@adhdev/daemon-core 0.9.82-rc.49 → 0.9.82-rc.491
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/daemon-lifecycle.d.ts +9 -0
- package/dist/boot/process-hardening.d.ts +50 -0
- package/dist/build-info.d.ts +37 -0
- package/dist/chat/source-machine.d.ts +166 -0
- package/dist/chat/source-resolver.d.ts +104 -0
- package/dist/chat/subscription-updates.d.ts +1 -0
- package/dist/cli-adapter-types.d.ts +68 -2
- package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
- package/dist/cli-adapters/cli-state-engine.d.ts +220 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +137 -64
- package/dist/cli-adapters/provider-cli-parse.d.ts +5 -0
- package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +74 -0
- package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
- package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
- package/dist/cli-adapters/resolve-executable.d.ts +69 -0
- package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
- package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
- package/dist/cli-adapters/terminal-screen.d.ts +4 -11
- package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
- package/dist/commands/chat-commands-read.d.ts +23 -0
- package/dist/commands/chat-commands-scope.d.ts +39 -0
- package/dist/commands/chat-commands-shared.d.ts +33 -0
- package/dist/commands/chat-commands-write.d.ts +14 -0
- package/dist/commands/chat-commands.d.ts +10 -23
- package/dist/commands/cli-manager.d.ts +71 -1
- package/dist/commands/handler.d.ts +125 -0
- package/dist/commands/high-family/index.d.ts +3 -0
- package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
- package/dist/commands/high-family/mesh-events.d.ts +2 -0
- package/dist/commands/high-family/mesh-status.d.ts +2 -0
- package/dist/commands/high-family/types.d.ts +64 -0
- package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
- package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
- package/dist/commands/low-family/diagnostics.d.ts +2 -0
- package/dist/commands/low-family/index.d.ts +3 -0
- package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
- package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
- package/dist/commands/low-family/notification.d.ts +2 -0
- package/dist/commands/low-family/refine-config.d.ts +2 -0
- package/dist/commands/low-family/session-host.d.ts +2 -0
- package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
- package/dist/commands/low-family/status-meta.d.ts +2 -0
- package/dist/commands/low-family/types.d.ts +33 -0
- package/dist/commands/med-family/cli-agent.d.ts +2 -0
- package/dist/commands/med-family/fast-forward.d.ts +2 -0
- package/dist/commands/med-family/ide.d.ts +10 -0
- package/dist/commands/med-family/index.d.ts +3 -0
- package/dist/commands/med-family/mesh-crud.d.ts +48 -0
- package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
- package/dist/commands/med-family/mesh-queue.d.ts +2 -0
- package/dist/commands/med-family/mesh-restart.d.ts +2 -0
- package/dist/commands/med-family/types.d.ts +152 -0
- package/dist/commands/mesh-coordinator.d.ts +85 -1
- package/dist/commands/router-aggregate-status.d.ts +24 -0
- package/dist/commands/router-mesh-session-owner.d.ts +59 -0
- package/dist/commands/router-refine.d.ts +151 -0
- package/dist/commands/router-worktree-cleanup.d.ts +133 -0
- package/dist/commands/router.d.ts +203 -15
- package/dist/commands/upgrade-helper.d.ts +41 -1
- package/dist/config/chat-history.d.ts +11 -0
- package/dist/config/config.d.ts +19 -0
- package/dist/config/mesh-config.d.ts +66 -0
- package/dist/config/mesh-json-config.d.ts +199 -0
- package/dist/config/registry-resolver.d.ts +54 -0
- package/dist/config/repo-settings.d.ts +77 -0
- package/dist/config/state-store.d.ts +30 -0
- package/dist/daemon/dev-server.d.ts +0 -2
- package/dist/detection/cli-detector.d.ts +17 -0
- package/dist/detection/ide-detector.d.ts +13 -0
- package/dist/detection/win32-ide-version.d.ts +37 -0
- package/dist/git/change-impact-config.d.ts +159 -0
- package/dist/git/git-commands.d.ts +26 -2
- package/dist/git/git-diff.d.ts +6 -0
- package/dist/git/git-executor.d.ts +11 -0
- package/dist/git/git-status.d.ts +80 -0
- package/dist/git/git-types.d.ts +2 -50
- package/dist/git/git-worktree.d.ts +71 -1
- package/dist/git/index.d.ts +3 -1
- package/dist/index.d.ts +70 -17
- package/dist/index.js +53556 -19074
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +53380 -19063
- package/dist/index.mjs.map +1 -1
- package/dist/installer.d.ts +1 -4
- package/dist/ipc/local-ipc-server.d.ts +91 -0
- package/dist/launch.d.ts +1 -1
- package/dist/logging/async-batch-writer.d.ts +10 -0
- package/dist/logging/debug-config.d.ts +16 -0
- package/dist/logging/log-redactor.d.ts +24 -0
- package/dist/logging/log-tail-reader.d.ts +81 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/mesh/contracts.d.ts +256 -0
- package/dist/mesh/coordinator-prompt.d.ts +76 -0
- package/dist/mesh/coordinator-registry.d.ts +59 -0
- package/dist/mesh/mesh-active-work.d.ts +203 -0
- package/dist/mesh/mesh-clone-grace.d.ts +8 -0
- package/dist/mesh/mesh-completion-synthesis.d.ts +4 -0
- package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +104 -0
- package/dist/mesh/mesh-event-classify.d.ts +7 -0
- package/dist/mesh/mesh-event-forwarding.d.ts +58 -0
- package/dist/mesh/mesh-event-trace.d.ts +21 -0
- package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
- package/dist/mesh/mesh-events-pending.d.ts +258 -0
- package/dist/mesh/mesh-events-stale.d.ts +61 -0
- package/dist/mesh/mesh-events-utils.d.ts +75 -0
- package/dist/mesh/mesh-events.d.ts +6 -49
- package/dist/mesh/mesh-fast-forward.d.ts +81 -0
- package/dist/mesh/mesh-host-ownership.d.ts +21 -1
- package/dist/mesh/mesh-init.d.ts +128 -0
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
- package/dist/mesh/mesh-ledger.d.ts +139 -1
- package/dist/mesh/mesh-magi-status.d.ts +63 -0
- package/dist/mesh/mesh-missions.d.ts +281 -0
- package/dist/mesh/mesh-node-identity.d.ts +310 -0
- package/dist/mesh/mesh-queue-assignment.d.ts +162 -0
- package/dist/mesh/mesh-reconcile-acked-hold.d.ts +17 -0
- package/dist/mesh/mesh-reconcile-config.d.ts +6 -0
- package/dist/mesh/mesh-reconcile-identity.d.ts +6 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +84 -0
- package/dist/mesh/mesh-reconcile-v2-backstop.d.ts +16 -0
- package/dist/mesh/mesh-refine-batch.d.ts +68 -0
- package/dist/mesh/mesh-refine-gates.d.ts +457 -0
- package/dist/mesh/mesh-refine-status.d.ts +62 -0
- package/dist/mesh/mesh-remote-event-pull.d.ts +15 -0
- package/dist/mesh/mesh-review-inbox.d.ts +90 -0
- package/dist/mesh/mesh-routing.d.ts +70 -0
- package/dist/mesh/mesh-runtime-store.d.ts +567 -0
- package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
- package/dist/mesh/mesh-task-inflight.d.ts +46 -0
- package/dist/mesh/mesh-task-stats.d.ts +49 -0
- package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +34 -0
- package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
- package/dist/mesh/mesh-work-queue.d.ts +392 -1
- package/dist/mesh/preview-freshness.d.ts +28 -0
- package/dist/mesh/refine-config.d.ts +97 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +219 -0
- package/dist/providers/acp-provider-instance.d.ts +5 -0
- package/dist/providers/approval-utils.d.ts +46 -0
- package/dist/providers/chat-message-normalization.d.ts +58 -1
- package/dist/providers/cli-provider-effect-format.d.ts +30 -0
- package/dist/providers/cli-provider-history-dedup.d.ts +17 -0
- package/dist/providers/cli-provider-input-prompt.d.ts +12 -0
- package/dist/providers/cli-provider-instance-types.d.ts +45 -0
- package/dist/providers/cli-provider-instance.d.ts +426 -30
- package/dist/providers/cli-provider-status-helpers.d.ts +46 -0
- package/dist/providers/cli-provider-transcript-merge.d.ts +7 -0
- package/dist/providers/contracts.d.ts +196 -6
- package/dist/providers/external-sources.d.ts +71 -0
- package/dist/providers/manual-attendance.d.ts +79 -0
- package/dist/providers/native-history/antigravity-claim-registry.d.ts +39 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +137 -0
- package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
- package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
- package/dist/providers/native-history/constants.d.ts +12 -0
- package/dist/providers/native-history/dispatcher.d.ts +44 -0
- package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
- package/dist/providers/native-history/index.d.ts +13 -0
- package/dist/providers/provider-instance-manager.d.ts +30 -0
- package/dist/providers/provider-instance.d.ts +31 -1
- package/dist/providers/provider-loader.d.ts +42 -5
- package/dist/providers/provider-trust.d.ts +31 -0
- package/dist/providers/read-chat-contract.d.ts +29 -0
- package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
- package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
- package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
- package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
- package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
- package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
- package/dist/providers/sdk/v1/index.d.ts +30 -0
- package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
- package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
- package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
- package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
- package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
- package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
- package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
- package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
- package/dist/providers/spec/adapter.d.ts +98 -0
- package/dist/providers/spec/cli-adapter.d.ts +223 -0
- package/dist/providers/spec/evaluator.d.ts +45 -0
- package/dist/providers/spec/fsm-driver.d.ts +449 -0
- package/dist/providers/spec/fsm-evaluator.d.ts +93 -0
- package/dist/providers/spec/fsm-loader.d.ts +14 -0
- package/dist/providers/spec/fsm-types.d.ts +202 -0
- package/dist/providers/spec/native-history-executor.d.ts +41 -0
- package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
- package/dist/providers/spec/route.d.ts +4 -0
- package/dist/providers/spec/types.d.ts +244 -0
- package/dist/providers/status-monitor.d.ts +7 -7
- package/dist/providers/transcript-v2.d.ts +176 -0
- package/dist/providers/types/interactive-prompt.d.ts +84 -0
- package/dist/providers/working-dir.d.ts +17 -0
- package/dist/repo-mesh-types.d.ts +616 -12
- package/dist/runtime-defaults.d.ts +2 -0
- package/dist/session-host/managed-host.d.ts +64 -0
- package/dist/session-host/runtime-surface.d.ts +10 -16
- package/dist/sessions/registry.d.ts +26 -0
- package/dist/shared-types-extra.d.ts +2 -4
- package/dist/shared-types.d.ts +110 -55
- package/dist/status/chat-tail-hot-sessions.d.ts +40 -0
- package/dist/status/normalize.d.ts +1 -1
- package/dist/status/normalize.js +1 -0
- package/dist/status/normalize.js.map +1 -1
- package/dist/status/normalize.mjs +1 -0
- package/dist/status/normalize.mjs.map +1 -1
- package/dist/status/reporter.d.ts +2 -0
- package/dist/status/snapshot.d.ts +27 -0
- package/dist/system/hash.d.ts +8 -0
- package/dist/system/load-better-sqlite3.d.ts +21 -0
- package/dist/types.d.ts +20 -6
- package/package.json +11 -4
- package/src/agent-stream/poller.ts +2 -3
- package/src/agent-stream/provider-adapter.ts +1 -1
- package/src/boot/daemon-lifecycle.ts +80 -14
- package/src/boot/process-hardening.ts +89 -0
- package/src/build-info.ts +73 -0
- package/src/chat/source-machine.ts +534 -0
- package/src/chat/source-resolver.ts +0 -0
- package/src/chat/subscription-updates.ts +20 -1
- package/src/cli-adapter-types.d.ts +3 -1
- package/src/cli-adapter-types.ts +68 -2
- package/src/cli-adapters/cli-script-runner.ts +421 -0
- package/src/cli-adapters/cli-state-engine.ts +1282 -0
- package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +1048 -1143
- package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
- package/src/cli-adapters/provider-cli-parse.ts +19 -0
- package/src/cli-adapters/provider-cli-runtime.ts +56 -7
- package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
- package/src/cli-adapters/provider-cli-shared.ts +121 -11
- package/src/cli-adapters/pty-transport.ts +2 -1
- package/src/cli-adapters/pty-write-chunking.ts +106 -0
- package/src/cli-adapters/raw-terminal-io.ts +252 -0
- package/src/cli-adapters/resolve-executable.ts +204 -0
- package/src/cli-adapters/session-host-transport.ts +2 -1
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
- package/src/cli-adapters/terminal-backends/types.ts +10 -3
- package/src/cli-adapters/terminal-screen.ts +16 -81
- package/src/commands/chat-commands-debug-bundle.ts +398 -0
- package/src/commands/chat-commands-read.ts +2788 -0
- package/src/commands/chat-commands-scope.ts +54 -0
- package/src/commands/chat-commands-shared.ts +114 -0
- package/src/commands/chat-commands-write.ts +891 -0
- package/src/commands/chat-commands.ts +19 -1841
- package/src/commands/cli-manager.d.ts +2 -0
- package/src/commands/cli-manager.ts +724 -27
- package/src/commands/handler.ts +865 -2
- package/src/commands/high-family/index.ts +28 -0
- package/src/commands/high-family/mesh-coordinator-launch.ts +696 -0
- package/src/commands/high-family/mesh-events.ts +102 -0
- package/src/commands/high-family/mesh-status.ts +857 -0
- package/src/commands/high-family/types.ts +80 -0
- package/src/commands/low-family/coordinator-prompt.ts +125 -0
- package/src/commands/low-family/daemon-lifecycle.ts +120 -0
- package/src/commands/low-family/diagnostics.ts +57 -0
- package/src/commands/low-family/index.ts +37 -0
- package/src/commands/low-family/mesh-ledger.ts +62 -0
- package/src/commands/low-family/mesh-node-logs.ts +87 -0
- package/src/commands/low-family/notification.ts +116 -0
- package/src/commands/low-family/refine-config.ts +106 -0
- package/src/commands/low-family/session-host.ts +282 -0
- package/src/commands/low-family/spec-providerdev.ts +217 -0
- package/src/commands/low-family/status-meta.ts +112 -0
- package/src/commands/low-family/types.ts +39 -0
- package/src/commands/med-family/cli-agent.ts +270 -0
- package/src/commands/med-family/fast-forward.ts +230 -0
- package/src/commands/med-family/ide.ts +163 -0
- package/src/commands/med-family/index.ts +37 -0
- package/src/commands/med-family/mesh-crud.ts +1309 -0
- package/src/commands/med-family/mesh-host-pairing.ts +234 -0
- package/src/commands/med-family/mesh-queue.ts +167 -0
- package/src/commands/med-family/mesh-restart.ts +92 -0
- package/src/commands/med-family/types.ts +153 -0
- package/src/commands/mesh-coordinator.ts +334 -124
- package/src/commands/router-aggregate-status.ts +209 -0
- package/src/commands/router-mesh-session-owner.ts +114 -0
- package/src/commands/router-refine.ts +1794 -0
- package/src/commands/router-worktree-cleanup.ts +870 -0
- package/src/commands/router.ts +567 -4149
- package/src/commands/stream-commands.ts +8 -0
- package/src/commands/upgrade-helper.ts +310 -45
- package/src/config/chat-history.ts +517 -24
- package/src/config/config.ts +30 -0
- package/src/config/mesh-config.ts +300 -24
- package/src/config/mesh-json-config.ts +376 -0
- package/src/config/recent-activity.ts +8 -2
- package/src/config/registry-resolver.ts +100 -0
- package/src/config/repo-settings.ts +111 -0
- package/src/config/state-store.ts +55 -0
- package/src/daemon/dev-auto-implement.ts +3 -2
- package/src/daemon/dev-cli-debug.ts +10 -1
- package/src/daemon/dev-server.ts +0 -541
- package/src/detection/cli-detector.ts +94 -9
- package/src/detection/ide-detector.ts +55 -16
- package/src/detection/win32-ide-version.ts +106 -0
- package/src/git/change-impact-config.ts +354 -0
- package/src/git/git-commands.ts +112 -16
- package/src/git/git-diff.ts +81 -11
- package/src/git/git-executor.ts +12 -0
- package/src/git/git-status.ts +822 -48
- package/src/git/git-types.ts +14 -62
- package/src/git/git-worktree.ts +261 -4
- package/src/git/index.ts +17 -0
- package/src/index.ts +190 -13
- package/src/installer.d.ts +1 -1
- package/src/installer.ts +8 -6
- package/src/ipc/local-ipc-server.ts +278 -0
- package/src/launch.d.ts +1 -1
- package/src/launch.ts +37 -28
- package/src/logging/async-batch-writer.ts +55 -0
- package/src/logging/command-log.ts +7 -5
- package/src/logging/debug-config.ts +25 -0
- package/src/logging/debug-trace.ts +7 -2
- package/src/logging/log-redactor.ts +100 -0
- package/src/logging/log-tail-reader.ts +341 -0
- package/src/logging/logger.ts +14 -7
- package/src/mesh/contracts.ts +510 -0
- package/src/mesh/coordinator-prompt.ts +627 -34
- package/src/mesh/coordinator-registry.ts +121 -0
- package/src/mesh/mesh-active-work.ts +706 -0
- package/src/mesh/mesh-clone-grace.ts +68 -0
- package/src/mesh/mesh-completion-synthesis.ts +416 -0
- package/src/mesh/mesh-coordinator-config.ts +97 -0
- package/src/mesh/mesh-delivery-policy.ts +284 -0
- package/src/mesh/mesh-event-classify.ts +78 -0
- package/src/mesh/mesh-event-forwarding.ts +2030 -0
- package/src/mesh/mesh-event-trace.ts +67 -0
- package/src/mesh/mesh-events-coordinator.ts +32 -0
- package/src/mesh/mesh-events-pending.ts +1548 -0
- package/src/mesh/mesh-events-stale.ts +452 -0
- package/src/mesh/mesh-events-utils.ts +443 -0
- package/src/mesh/mesh-events.ts +43 -1035
- package/src/mesh/mesh-fast-forward.ts +856 -0
- package/src/mesh/mesh-host-ownership.ts +41 -3
- package/src/mesh/mesh-init.ts +350 -0
- package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
- package/src/mesh/mesh-ledger.ts +897 -102
- package/src/mesh/mesh-magi-status.ts +223 -0
- package/src/mesh/mesh-missions.ts +695 -0
- package/src/mesh/mesh-node-identity.ts +2065 -0
- package/src/mesh/mesh-queue-assignment.ts +2498 -0
- package/src/mesh/mesh-reconcile-acked-hold.ts +230 -0
- package/src/mesh/mesh-reconcile-config.ts +66 -0
- package/src/mesh/mesh-reconcile-identity.ts +103 -0
- package/src/mesh/mesh-reconcile-loop.ts +1696 -0
- package/src/mesh/mesh-reconcile-v2-backstop.ts +62 -0
- package/src/mesh/mesh-refine-batch.ts +205 -0
- package/src/mesh/mesh-refine-gates.ts +1792 -0
- package/src/mesh/mesh-refine-status.ts +231 -0
- package/src/mesh/mesh-remote-event-pull.ts +279 -0
- package/src/mesh/mesh-review-inbox.ts +308 -0
- package/src/mesh/mesh-routing.ts +291 -0
- package/src/mesh/mesh-runtime-store.ts +2292 -0
- package/src/mesh/mesh-scheduling-runtime.ts +198 -0
- package/src/mesh/mesh-task-inflight.ts +70 -0
- package/src/mesh/mesh-task-stats.ts +161 -0
- package/src/mesh/mesh-unresolved-forward-outbox.ts +215 -0
- package/src/mesh/mesh-warmup-deadline.ts +152 -0
- package/src/mesh/mesh-work-queue.ts +1489 -145
- package/src/mesh/preview-freshness.ts +163 -0
- package/src/mesh/refine-config.ts +129 -12
- package/src/mesh/worktree-bootstrap-config.ts +599 -0
- package/src/providers/acp-provider-instance.ts +43 -10
- package/src/providers/approval-utils.d.ts +5 -0
- package/src/providers/approval-utils.ts +100 -6
- package/src/providers/chat-message-normalization.ts +126 -4
- package/src/providers/cli-provider-effect-format.ts +53 -0
- package/src/providers/cli-provider-history-dedup.ts +75 -0
- package/src/providers/cli-provider-input-prompt.ts +133 -0
- package/src/providers/cli-provider-instance-types.ts +131 -0
- package/src/providers/cli-provider-instance.ts +2771 -499
- package/src/providers/cli-provider-status-helpers.ts +123 -0
- package/src/providers/cli-provider-transcript-merge.ts +114 -0
- package/src/providers/contracts.d.ts +55 -0
- package/src/providers/contracts.ts +207 -6
- package/src/providers/extension-provider-instance.ts +12 -7
- package/src/providers/external-sources.ts +218 -0
- package/src/providers/ide-provider-instance.ts +35 -12
- package/src/providers/manual-attendance.ts +105 -0
- package/src/providers/native-history/antigravity-claim-registry.ts +138 -0
- package/src/providers/native-history/antigravity-cli-transcript.ts +1275 -0
- package/src/providers/native-history/claude-cli-transcript.ts +396 -0
- package/src/providers/native-history/codex-cli-transcript.ts +479 -0
- package/src/providers/native-history/constants.ts +19 -0
- package/src/providers/native-history/dispatcher.ts +617 -0
- package/src/providers/native-history/hermes-cli-transcript.ts +312 -0
- package/src/providers/native-history/index.ts +30 -0
- package/src/providers/provider-instance-manager.ts +71 -0
- package/src/providers/provider-instance.ts +24 -1
- package/src/providers/provider-loader.ts +700 -55
- package/src/providers/provider-schema.ts +93 -14
- package/src/providers/provider-trust.ts +114 -0
- package/src/providers/read-chat-contract.ts +76 -16
- package/src/providers/sdk/README.md +49 -0
- package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +261 -0
- package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
- package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
- package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
- package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
- package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
- package/src/providers/sdk/v1/index.ts +152 -0
- package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
- package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
- package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +538 -0
- package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
- package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
- package/src/providers/sdk/v1/types/cli/index.ts +365 -0
- package/src/providers/sdk/v1/types/common/index.ts +229 -0
- package/src/providers/sdk/v1/validators/index.ts +19 -0
- package/src/providers/sdk/v1/validators/manifest.ts +110 -0
- package/src/providers/sdk/v1/validators/taint.ts +309 -0
- package/src/providers/spec/adapter.ts +246 -0
- package/src/providers/spec/cli-adapter.ts +1064 -0
- package/src/providers/spec/evaluator.ts +407 -0
- package/src/providers/spec/fsm-driver.ts +1528 -0
- package/src/providers/spec/fsm-evaluator.ts +290 -0
- package/src/providers/spec/fsm-loader.ts +128 -0
- package/src/providers/spec/fsm-types.ts +301 -0
- package/src/providers/spec/native-history-executor.ts +1241 -0
- package/src/providers/spec/pre-launch-trust.ts +104 -0
- package/src/providers/spec/route.ts +51 -0
- package/src/providers/spec/types.ts +284 -0
- package/src/providers/status-monitor.d.ts +7 -7
- package/src/providers/status-monitor.ts +37 -22
- package/src/providers/transcript-v2.ts +567 -0
- package/src/providers/types/interactive-prompt.ts +536 -0
- package/src/providers/version-archive.ts +64 -24
- package/src/providers/working-dir.ts +23 -0
- package/src/repo-mesh-types.ts +861 -14
- package/src/runtime-defaults.ts +39 -0
- package/src/session-host/managed-host.ts +218 -0
- package/src/session-host/runtime-surface.ts +20 -80
- package/src/sessions/registry.ts +44 -0
- package/src/shared-types-extra.ts +2 -4
- package/src/shared-types.d.ts +8 -0
- package/src/shared-types.ts +148 -55
- package/src/status/builders.ts +69 -6
- package/src/status/chat-tail-hot-sessions.ts +117 -2
- package/src/status/normalize.ts +2 -0
- package/src/status/reporter.ts +19 -1
- package/src/status/snapshot.ts +100 -27
- package/src/system/hash.ts +23 -0
- package/src/system/host-memory.ts +29 -12
- package/src/system/load-better-sqlite3.ts +68 -0
- package/src/types.ts +26 -6
- package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
- package/dist/mesh/mesh-sync.d.ts +0 -53
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
- package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
- package/src/mesh/mesh-sync.ts +0 -111
|
@@ -9,315 +9,297 @@ import * as os from 'os';
|
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import * as crypto from 'crypto';
|
|
11
11
|
import * as fs from 'fs';
|
|
12
|
-
import {
|
|
13
|
-
import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope, type InputPart } from './contracts.js';
|
|
12
|
+
import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope } from './contracts.js';
|
|
14
13
|
import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
|
|
15
14
|
import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason, HotChatSessionState, SessionModalState } from './provider-instance.js';
|
|
15
|
+
import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
|
|
16
16
|
import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
|
|
17
|
+
import { shortHash } from '../system/hash.js';
|
|
17
18
|
import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
|
|
19
|
+
import { createCliAdapter } from './spec/route.js';
|
|
18
20
|
import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
|
|
19
21
|
import { StatusMonitor } from './status-monitor.js';
|
|
20
22
|
import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
|
|
23
|
+
import { loadPersistedProviderSessionPins } from '../config/state-store.js';
|
|
21
24
|
import { LOG } from '../logging/logger.js';
|
|
25
|
+
import { recordDebugTrace } from '../logging/debug-trace.js';
|
|
26
|
+
import { shouldCollectTraceCategory } from '../logging/debug-config.js';
|
|
27
|
+
import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
|
|
22
28
|
import type { ChatMessage } from '../types.js';
|
|
23
29
|
import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
|
|
24
|
-
import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
|
|
30
|
+
import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText, normalizeApprovalLabel } from './approval-utils.js';
|
|
25
31
|
import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
|
|
26
32
|
import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
|
|
27
33
|
import { normalizeProviderSessionId } from './provider-session-id.js';
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function safeInputImageBasename(index: number, mimeType: string): string {
|
|
78
|
-
const extension = extensionForImageMime(mimeType);
|
|
79
|
-
const suffix = crypto.randomBytes(6).toString('hex');
|
|
80
|
-
return `adhdev-input-image-${Date.now()}-${index}-${suffix}${extension}`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function materializeImageDataPart(part: Extract<InputPart, { type: 'image' }>, index: number, dir: string): string | null {
|
|
84
|
-
if (!part.data) return null;
|
|
85
|
-
const rawData = part.data.includes(',') ? part.data.split(',').pop() || '' : part.data;
|
|
86
|
-
if (!rawData) return null;
|
|
87
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
-
const filePath = path.join(dir, safeInputImageBasename(index, part.mimeType));
|
|
89
|
-
fs.writeFileSync(filePath, Buffer.from(rawData, 'base64'));
|
|
90
|
-
cleanupStaleMaterializedImages(dir);
|
|
91
|
-
return filePath;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const MATERIALIZED_IMAGE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
95
|
-
const MATERIALIZED_IMAGE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
96
|
-
let lastMaterializedImageCleanupAt = 0;
|
|
97
|
-
|
|
98
|
-
function cleanupStaleMaterializedImages(dir: string): void {
|
|
99
|
-
const now = Date.now();
|
|
100
|
-
if (now - lastMaterializedImageCleanupAt < MATERIALIZED_IMAGE_CLEANUP_INTERVAL_MS) return;
|
|
101
|
-
lastMaterializedImageCleanupAt = now;
|
|
102
|
-
try {
|
|
103
|
-
const entries = fs.readdirSync(dir);
|
|
104
|
-
for (const entry of entries) {
|
|
105
|
-
if (!entry.startsWith('adhdev-input-image-')) continue;
|
|
106
|
-
const fullPath = path.join(dir, entry);
|
|
107
|
-
try {
|
|
108
|
-
const stat = fs.statSync(fullPath);
|
|
109
|
-
if (now - stat.mtimeMs > MATERIALIZED_IMAGE_MAX_AGE_MS) {
|
|
110
|
-
fs.unlinkSync(fullPath);
|
|
111
|
-
}
|
|
112
|
-
} catch { /* file may have been removed concurrently */ }
|
|
113
|
-
}
|
|
114
|
-
} catch { /* dir may not exist or be inaccessible */ }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
|
|
118
|
-
const buttons = (activeModal as any)?.buttons;
|
|
119
|
-
return Array.isArray(buttons) && buttons.some((button) => String(button || '').trim().length > 0);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function isCliGeneratingLikeStatus(status: unknown): boolean {
|
|
123
|
-
return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function buildCliStructuredInputPrompt(
|
|
127
|
-
input: InputEnvelope,
|
|
128
|
-
options: { materializeDir?: string } = {},
|
|
129
|
-
): string {
|
|
130
|
-
const promptParts: string[] = [];
|
|
131
|
-
const imageRefs: string[] = [];
|
|
132
|
-
const resourceRefs: string[] = [];
|
|
133
|
-
const materializeDir = options.materializeDir || path.join(os.tmpdir(), 'adhdev-input-media');
|
|
134
|
-
|
|
135
|
-
input.parts.forEach((part, index) => {
|
|
136
|
-
if (part.type === 'text' && part.text.trim()) {
|
|
137
|
-
promptParts.push(part.text.trim());
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (part.type === 'image') {
|
|
142
|
-
const localPath = typeof part.uri === 'string' ? filePathFromUri(part.uri) : null;
|
|
143
|
-
const materializedPath = !localPath && part.data ? materializeImageDataPart(part, index, materializeDir) : null;
|
|
144
|
-
const ref = localPath || materializedPath || part.uri || '';
|
|
145
|
-
if (ref) imageRefs.push(ref);
|
|
146
|
-
if (part.alt?.trim()) promptParts.push(part.alt.trim());
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (part.type === 'resource_link') {
|
|
151
|
-
resourceRefs.push([part.title, part.name, part.description, part.uri].filter(Boolean).join('\n'));
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
34
|
+
import {
|
|
35
|
+
antigravityOwnerToken,
|
|
36
|
+
claimAntigravityConversation,
|
|
37
|
+
releaseAntigravityOwner,
|
|
38
|
+
} from './native-history/antigravity-claim-registry.js';
|
|
39
|
+
import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter, readChatMessageTimestampMs } from './chat-message-normalization.js';
|
|
40
|
+
import { workingDirBasename } from './working-dir.js';
|
|
41
|
+
import { ManualAttendanceTracker } from './manual-attendance.js';
|
|
42
|
+
import { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
|
|
43
|
+
import { type PersistableCliHistoryMessage, buildIncrementalHistoryAppendMessages } from './cli-provider-history-dedup.js';
|
|
44
|
+
import {
|
|
45
|
+
isIdleStatus,
|
|
46
|
+
getMessageTime,
|
|
47
|
+
hasNonEmptyCliModalButtons,
|
|
48
|
+
isCliGeneratingLikeStatus,
|
|
49
|
+
computeTurnAnchoredDurationMs,
|
|
50
|
+
getDatabaseSync,
|
|
51
|
+
getForcedNewSessionScriptName,
|
|
52
|
+
waitForCliAdapterReady,
|
|
53
|
+
} from './cli-provider-status-helpers.js';
|
|
54
|
+
import {
|
|
55
|
+
STATUS_HYDRATION_TAIL_LIMIT,
|
|
56
|
+
COMPLETED_FINALIZATION_RETRY_MS,
|
|
57
|
+
COMPLETED_FINALIZATION_MAX_WAIT_MS,
|
|
58
|
+
NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
|
|
59
|
+
USER_INPUT_ACK_DEDUP_WINDOW_MS,
|
|
60
|
+
STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS,
|
|
61
|
+
TERMINAL_MESH_EVENTS,
|
|
62
|
+
} from './cli-provider-instance-types.js';
|
|
63
|
+
import type {
|
|
64
|
+
CompletedDebouncePending,
|
|
65
|
+
CompletedFinalizationBlock,
|
|
66
|
+
CompletionFinalAssistantEvidence,
|
|
67
|
+
ExternalTranscriptProbe,
|
|
68
|
+
} from './cli-provider-instance-types.js';
|
|
69
|
+
import { mergeConversationMessages, buildExternalTranscriptProbe } from './cli-provider-transcript-merge.js';
|
|
70
|
+
import { getEffectDedupKey, formatApprovalRequestMessage, formatMarkerTimestamp } from './cli-provider-effect-format.js';
|
|
71
|
+
|
|
72
|
+
// Re-export moved public symbols so existing importers (index.ts, tests) keep
|
|
73
|
+
// their `./cli-provider-instance.js` path. Pure move — no behavior change.
|
|
74
|
+
export { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
|
|
75
|
+
export { buildIncrementalHistoryAppendMessages } from './cli-provider-history-dedup.js';
|
|
76
|
+
export {
|
|
77
|
+
computeTurnAnchoredDurationMs,
|
|
78
|
+
getForcedNewSessionScriptName,
|
|
79
|
+
waitForCliAdapterReady,
|
|
80
|
+
} from './cli-provider-status-helpers.js';
|
|
154
81
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const hasExplicitTextParts = input.parts.some((part) => part.type === 'text' && part.text.trim());
|
|
164
|
-
if (!hasExplicitTextParts && input.textFallback.trim()) {
|
|
165
|
-
promptParts.push(input.textFallback.trim());
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const ordered = [
|
|
169
|
-
...imageRefs,
|
|
170
|
-
...promptParts,
|
|
171
|
-
...resourceRefs,
|
|
172
|
-
].filter((value, index, values) => value.trim().length > 0 && values.indexOf(value) === index);
|
|
173
|
-
|
|
174
|
-
return ordered.join('\n');
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function normalizePersistableCliHistoryContent(content: unknown): string {
|
|
178
|
-
return flattenContent(content as any).replace(/\s+/g, ' ').trim();
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function buildPersistableCliHistorySignature(message: PersistableCliHistoryMessage): string {
|
|
182
|
-
return [
|
|
183
|
-
String(message.role || ''),
|
|
184
|
-
String(message.kind || ''),
|
|
185
|
-
String(message.senderName || ''),
|
|
186
|
-
normalizePersistableCliHistoryContent(message.content),
|
|
187
|
-
].join('|');
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function hasSamePersistableCliHistoryIdentity(a: PersistableCliHistoryMessage, b: PersistableCliHistoryMessage): boolean {
|
|
191
|
-
return String(a?.role || '') === String(b?.role || '')
|
|
192
|
-
&& String(a?.kind || '') === String(b?.kind || '')
|
|
193
|
-
&& String(a?.senderName || '') === String(b?.senderName || '')
|
|
194
|
-
&& String(a?.content || '') === String(b?.content || '');
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export function buildIncrementalHistoryAppendMessages(
|
|
198
|
-
previousMessages: PersistableCliHistoryMessage[],
|
|
199
|
-
currentMessages: PersistableCliHistoryMessage[],
|
|
200
|
-
): PersistableCliHistoryMessage[] {
|
|
201
|
-
if (!Array.isArray(currentMessages) || currentMessages.length === 0) return [];
|
|
202
|
-
if (!Array.isArray(previousMessages) || previousMessages.length === 0) return currentMessages;
|
|
203
|
-
|
|
204
|
-
const comparableLength = Math.min(previousMessages.length, currentMessages.length);
|
|
205
|
-
let sharedPrefixLength = 0;
|
|
206
|
-
while (
|
|
207
|
-
sharedPrefixLength < comparableLength
|
|
208
|
-
&& hasSamePersistableCliHistoryIdentity(previousMessages[sharedPrefixLength], currentMessages[sharedPrefixLength])
|
|
209
|
-
) {
|
|
210
|
-
sharedPrefixLength += 1;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (sharedPrefixLength === currentMessages.length) return [];
|
|
214
|
-
if (sharedPrefixLength === previousMessages.length) return currentMessages.slice(sharedPrefixLength);
|
|
215
|
-
|
|
216
|
-
// Rare fallback: preserve the older whitespace-normalized behavior only when
|
|
217
|
-
// the cheap identity check detects a changed prefix. Recomputing normalized
|
|
218
|
-
// signatures for the full transcript on every idle status poll was a CPU
|
|
219
|
-
// hot path for long CLI sessions.
|
|
220
|
-
while (
|
|
221
|
-
sharedPrefixLength < comparableLength
|
|
222
|
-
&& buildPersistableCliHistorySignature(previousMessages[sharedPrefixLength])
|
|
223
|
-
=== buildPersistableCliHistorySignature(currentMessages[sharedPrefixLength])
|
|
224
|
-
) {
|
|
225
|
-
sharedPrefixLength += 1;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (sharedPrefixLength === currentMessages.length) return [];
|
|
229
|
-
if (sharedPrefixLength === previousMessages.length) return currentMessages.slice(sharedPrefixLength);
|
|
230
|
-
return currentMessages;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
let CachedDatabaseSync: (new (path: string, options?: { readOnly?: boolean }) => {
|
|
234
|
-
prepare(sql: string): { get(...params: Array<string | number>): unknown };
|
|
235
|
-
close(): void;
|
|
236
|
-
}) | null = null;
|
|
237
|
-
|
|
238
|
-
function getDatabaseSync() {
|
|
239
|
-
if (CachedDatabaseSync) return CachedDatabaseSync;
|
|
240
|
-
const requireFn = typeof require === 'function'
|
|
241
|
-
? require
|
|
242
|
-
: createRequire(path.join(process.cwd(), '__adhdev_sqlite_loader__.js'));
|
|
243
|
-
const sqliteModule = requireFn(`node:${'sqlite'}`) as {
|
|
244
|
-
DatabaseSync: typeof CachedDatabaseSync;
|
|
245
|
-
};
|
|
246
|
-
CachedDatabaseSync = sqliteModule.DatabaseSync;
|
|
247
|
-
if (!CachedDatabaseSync) {
|
|
248
|
-
throw new Error('node:sqlite DatabaseSync unavailable');
|
|
249
|
-
}
|
|
250
|
-
return CachedDatabaseSync;
|
|
82
|
+
/**
|
|
83
|
+
* The auto-approve settle-gate identity signature: the modal's question line plus
|
|
84
|
+
* the NORMALIZED affirmative label, no volatile counters or raw button set. Shared
|
|
85
|
+
* by the fire path (maybeAutoApproveStatus) and the mask-stall nudge so both compare
|
|
86
|
+
* the SAME identity the settle clock tracks (AUTOAPPROVE-SETTLE-FLAP).
|
|
87
|
+
*/
|
|
88
|
+
function approvalModalSignature(message: unknown, affirmativeAnchor: string): string {
|
|
89
|
+
return [typeof message === 'string' ? message.trim() : '', affirmativeAnchor].join('::');
|
|
251
90
|
}
|
|
252
91
|
|
|
253
|
-
export
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
): string | null {
|
|
257
|
-
if (!provider || launchMode !== 'new') return null;
|
|
258
|
-
const resume = provider.resume;
|
|
259
|
-
if (!resume?.supported) return null;
|
|
260
|
-
if (Array.isArray(resume.newSessionArgs) && resume.newSessionArgs.length > 0) return null;
|
|
261
|
-
|
|
262
|
-
const controls = Array.isArray((provider as any).controls) ? (provider as any).controls : [];
|
|
263
|
-
for (const control of controls) {
|
|
264
|
-
if (control?.type !== 'action') continue;
|
|
265
|
-
if (typeof control?.confirmTitle === 'string' && control.confirmTitle.trim()) continue;
|
|
266
|
-
if (typeof control?.confirmMessage === 'string' && control.confirmMessage.trim()) continue;
|
|
267
|
-
if (typeof control?.confirmLabel === 'string' && control.confirmLabel.trim()) continue;
|
|
268
|
-
const invokeScript = typeof control?.invokeScript === 'string' ? control.invokeScript.trim() : '';
|
|
269
|
-
if (!invokeScript) continue;
|
|
270
|
-
const controlId = typeof control?.id === 'string' ? control.id.trim() : '';
|
|
271
|
-
if (controlId === 'new_session' || /^new.?session$/i.test(invokeScript)) {
|
|
272
|
-
return invokeScript;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
return null;
|
|
277
|
-
}
|
|
92
|
+
export class CliProviderInstance implements ProviderInstance {
|
|
93
|
+
readonly type: string;
|
|
94
|
+
readonly category = 'cli' as const;
|
|
278
95
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
):
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Quiet period an approval modal's signature must be stable before
|
|
98
|
+
* auto-approve sends the approve key. Guards against firing on a prompt
|
|
99
|
+
* that is still streaming into the PTY (the "resolves too fast" symptom):
|
|
100
|
+
* while the modal text/buttons are still changing, every frame yields a
|
|
101
|
+
* new signature and the settle clock restarts. Once the prompt finishes
|
|
102
|
+
* rendering the signature holds and the key is sent after this window.
|
|
103
|
+
* Bounded + small so genuine approvals stay timely. The FSM is already
|
|
104
|
+
* authoritative over the `waiting_approval` state; this only delays the
|
|
105
|
+
* keystroke until the modal *content* has settled.
|
|
106
|
+
*/
|
|
107
|
+
private static readonly AUTO_APPROVE_SETTLE_MS = 600;
|
|
286
108
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Busy-side hysteresis for the settle gate. A momentary `generating` flip
|
|
111
|
+
* while the SAME approval modal's button block is still on screen (its
|
|
112
|
+
* question line scrolled out of the captured frame, only the buttons + a
|
|
113
|
+
* residual `esc to interrupt` spinner remain) briefly reports
|
|
114
|
+
* status!=waiting_approval. Without hysteresis that flip wipes the settle
|
|
115
|
+
* clock, and the modal→generating→modal flap restarts the 600ms window
|
|
116
|
+
* every time so auto-approve never fires. We keep the in-progress settle
|
|
117
|
+
* gate warm across an inactive blip up to this bound; only once the modal
|
|
118
|
+
* has genuinely stayed gone this long (a real resolution → idle) is the
|
|
119
|
+
* gate cleared. Bounded so a genuinely new, later approval still re-settles
|
|
120
|
+
* from scratch rather than firing on a stale timestamp.
|
|
121
|
+
*/
|
|
122
|
+
private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS = 1500;
|
|
295
123
|
|
|
296
|
-
|
|
297
|
-
|
|
124
|
+
/**
|
|
125
|
+
* AUTOAPPROVE-FLAP-RECUR (Fix B): extended busy-side continuity window for a
|
|
126
|
+
* DELEGATED-WORKER auto-approve episode that is genuinely still cycling.
|
|
127
|
+
*
|
|
128
|
+
* The default AUTO_APPROVE_GATE_HYSTERESIS_MS (1500) absorbs a *momentary*
|
|
129
|
+
* `generating` blip. But a delegated worker running a Bash approval observed
|
|
130
|
+
* the FSM cycle the FULL state waiting_approval → busy → waiting_approval on a
|
|
131
|
+
* 2–5s period (the button set scrolls in/out AND the modal question repaints,
|
|
132
|
+
* so the adapter genuinely reports status=generating for whole seconds between
|
|
133
|
+
* approval frames). Each busy phase outran the 1500ms hysteresis, so the
|
|
134
|
+
* settle clock was WIPED (the genuine-resolution branch), the 600ms settle
|
|
135
|
+
* window never accumulated across the flap, resolveModal never fired
|
|
136
|
+
* (resolveModal count 0), and the mask-stall clock instead tripped at 4500ms →
|
|
137
|
+
* coordinator nudge → the flap the coordinator observed.
|
|
138
|
+
*
|
|
139
|
+
* A genuine resolution and a flap both start with a busy phase; they diverge
|
|
140
|
+
* only in whether waiting_approval RETURNS. So we cannot simply lengthen the
|
|
141
|
+
* blanket hysteresis (that would make every real resolution hold the gate
|
|
142
|
+
* open for seconds). Instead this longer window applies ONLY while an active
|
|
143
|
+
* mask episode is alive (autoApproveMaskSince > 0) AND the session is a
|
|
144
|
+
* delegated worker — i.e. exactly the never-resolving-flap case. A foreground
|
|
145
|
+
* / attended session keeps the tight 1500ms window unchanged. The mask-stall
|
|
146
|
+
* bound below still caps the episode, so a worker whose approval truly never
|
|
147
|
+
* returns is surfaced to the coordinator within AUTO_APPROVE_MASK_STALL_MS
|
|
148
|
+
* rather than held forever.
|
|
149
|
+
*
|
|
150
|
+
* INVARIANT (do not regress the ordering): this window must fully BRIDGE a
|
|
151
|
+
* single busy phase, and the mask-stall bound below must in turn exceed it —
|
|
152
|
+
* AUTO_APPROVE_MASK_STALL_MS > AUTO_APPROVE_FLAP_CONTINUITY_MS + max_busy_phase
|
|
153
|
+
* + AUTO_APPROVE_SETTLE_MS. Observed flap geometry (delegated-worker Bash
|
|
154
|
+
* approval): approval frames last ~1.5s, busy phases (modal=none) last
|
|
155
|
+
* ~4.3–4.5s. With the old 4000ms this window was SHORTER than a busy phase, so
|
|
156
|
+
* the settle clock was torn down every cycle and never accrued 600ms while the
|
|
157
|
+
* 4500ms mask-stall tripped INSIDE the first busy phase → a stalled-approval
|
|
158
|
+
* nudge leaked to the coordinator. 6000ms bridges the ~4.5s busy phase with
|
|
159
|
+
* margin so the returning approval frame survives to resume its settle clock.
|
|
160
|
+
*/
|
|
161
|
+
private static readonly AUTO_APPROVE_FLAP_CONTINUITY_MS = 6000;
|
|
298
162
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
163
|
+
/**
|
|
164
|
+
* STATUS-MISMATCH: upper bound on how long the auto-approve→`generating` SURFACE
|
|
165
|
+
* mask may hide a worker's `waiting_approval` (status + activeModal) before we give
|
|
166
|
+
* up and surface the real prompt. The mask exists because auto-approve is expected
|
|
167
|
+
* to resolve the modal momentarily; but if it STALLS without ever calling
|
|
168
|
+
* resolveModal — the modal signature never settles for AUTO_APPROVE_SETTLE_MS (a
|
|
169
|
+
* perpetually-flapping/streaming prompt), no concrete modal is ever captured, or the
|
|
170
|
+
* modal is a picker/non-affirmative we never auto-pick — the mask would persist
|
|
171
|
+
* forever and read_chat / mesh_status / the dashboard would NEVER see the pending
|
|
172
|
+
* approval (the coordinator cannot mesh_approve what it cannot see). Once an episode
|
|
173
|
+
* exceeds this bound we stop masking. Generously larger than
|
|
174
|
+
* AUTO_APPROVE_SETTLE_MS (600) + AUTO_APPROVE_GATE_HYSTERESIS_MS (1500) so a
|
|
175
|
+
* legitimately slow-settling / blip-flapping prompt is never unmasked early; a
|
|
176
|
+
* genuine never-resolving stall surfaces within this window. The settle gate keeps
|
|
177
|
+
* running underneath, so a prompt that finally stabilises still auto-approves, and
|
|
178
|
+
* mesh_approve (raw FSM, unmasked) works throughout.
|
|
179
|
+
*
|
|
180
|
+
* INVARIANT (do not regress): must be STRICTLY GREATER than
|
|
181
|
+
* AUTO_APPROVE_FLAP_CONTINUITY_MS + max_busy_phase + AUTO_APPROVE_SETTLE_MS so
|
|
182
|
+
* that during a flap the settle clock (which FLAP_CONTINUITY keeps alive across
|
|
183
|
+
* each busy phase) gets to accrue its 600ms on the RETURNING approval frame
|
|
184
|
+
* before this stall bound can trip. Observed geometry — worker: approval ~1.5s,
|
|
185
|
+
* busy ~4.3–4.5s; coordinator self-session: approval ~1.5s, busy ~2.85s. Both
|
|
186
|
+
* now use the extended window (isAutonomousMeshSession covers worker +
|
|
187
|
+
* meshCoordinatorFor). Worst case: CONTINUITY(6000) + busy(~4.5s) + SETTLE(600)
|
|
188
|
+
* = ~11100ms, so the stall bound must exceed that. 10500ms satisfies the invariant
|
|
189
|
+
* for coordinator (6000 + 2850 + 600 = 9450 < 10500) and was previously 9000ms
|
|
190
|
+
* (which failed for a worker busy phase of 4.5s: 6000+4500+600=11100 > 9000).
|
|
191
|
+
* the old 4500ms tripped inside the very first busy phase (while modal=none, so
|
|
192
|
+
* the nudge was NOT deferred) and leaked to the coordinator.
|
|
193
|
+
*/
|
|
194
|
+
private static readonly AUTO_APPROVE_MASK_STALL_MS = 10500;
|
|
302
195
|
|
|
303
196
|
private adapter: ProviderCliAdapter;
|
|
304
197
|
private context: InstanceContext | null = null;
|
|
305
198
|
private events: ProviderEvent[] = [];
|
|
306
199
|
private lastStatus: string = 'starting';
|
|
200
|
+
// Idempotency guard for the queue-claim agent:ready event. agent:ready is the
|
|
201
|
+
// sole signal the mesh coordinator's tryAssignQueueTask waits on to hand a
|
|
202
|
+
// queued task to this worker. It is emitted in two places: the boot-time
|
|
203
|
+
// starting→idle one-shot, and the readySeen re-arm below. This flag makes the
|
|
204
|
+
// event fire AT MOST ONCE per session so a worker is never claimed twice and a
|
|
205
|
+
// queued task is never double-dispatched/double-injected. Whichever path fires
|
|
206
|
+
// first sets it; the other becomes a no-op.
|
|
207
|
+
private agentReadyEmitted = false;
|
|
307
208
|
private generatingStartedAt: number = 0;
|
|
209
|
+
// FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
|
|
210
|
+
// phase (→generating or →waiting_approval). The completedDebouncePending snapshots
|
|
211
|
+
// this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
|
|
212
|
+
// — proving the session did not re-enter a busy phase (a momentary busy→idle blip
|
|
213
|
+
// in an inter-approval valley) between arming the debounce and flushing it. A
|
|
214
|
+
// single point-sample of status at flush time cannot see a generating phase that
|
|
215
|
+
// opened AND closed within the settle window; the epoch can. See
|
|
216
|
+
// flushCompletedDebounceIfFinalized.
|
|
217
|
+
private busyEpoch: number = 0;
|
|
218
|
+
// GENERATING-BOUNDARY (R4b): the per-turn taskId for which a startup-grace
|
|
219
|
+
// started+completed pair was already synthesized. Both fast-collapse callers
|
|
220
|
+
// (starting→idle transition AND the idle-stayed no-status-change poll) route
|
|
221
|
+
// through maybeSynthesizeStartupGraceCollapse; the idle-stayed caller re-polls
|
|
222
|
+
// steadily while the session sits idle, so this guard makes the synthesis fire
|
|
223
|
+
// AT MOST ONCE per collapsed turn.
|
|
224
|
+
private fastCollapseSynthesizedTaskId: string | null = null;
|
|
225
|
+
// GENERATING-BOUNDARY (R4c): the wall-clock moment the FSM's starting→idle
|
|
226
|
+
// startup-grace collapse was observed (set ONCE, on the first starting→idle
|
|
227
|
+
// transition this boot). The idle-stayed collapse window is anchored on THIS,
|
|
228
|
+
// not on instance/boot time (this.startedAt): the FSM spends the full 8s
|
|
229
|
+
// startup-grace sitting in 'starting' before collapsing, and a turn can be
|
|
230
|
+
// dispatched a few seconds AFTER the collapse — measuring the window from boot
|
|
231
|
+
// would have it already closed by the time that turn lands+completes (the live
|
|
232
|
+
// R4b miss: collapse at boot+8s, dispatch at boot+12.4s > the 12s boot window).
|
|
233
|
+
// Anchoring on the collapse moment makes the window cover dispatch-delay+turn.
|
|
234
|
+
private startupGraceCollapseAt: number | null = null;
|
|
308
235
|
private settings: Record<string, any> = {};
|
|
309
236
|
private monitor: StatusMonitor;
|
|
310
237
|
private generatingDebounceTimer: NodeJS.Timeout | null = null;
|
|
311
238
|
private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
|
|
312
|
-
private
|
|
239
|
+
private lastApprovalEventFingerprint = '';
|
|
313
240
|
private autoApproveBusy = false;
|
|
314
241
|
private autoApproveBusyTimer: NodeJS.Timeout | null = null;
|
|
242
|
+
private lastAutoApprovalSignature = '';
|
|
243
|
+
// Settle gate: the approval modal's signature + the wall-clock when this
|
|
244
|
+
// exact signature was first observed. Auto-approve only fires once the
|
|
245
|
+
// SAME signature has been stable for AUTO_APPROVE_SETTLE_MS, so a prompt
|
|
246
|
+
// still streaming into the PTY (its buttons/message changing frame to
|
|
247
|
+
// frame) keeps resetting the timer and is never approved half-rendered.
|
|
248
|
+
private pendingAutoApprovalSignature = '';
|
|
249
|
+
private pendingAutoApprovalSince = 0;
|
|
250
|
+
private autoApproveSettleTimer: NodeJS.Timeout | null = null;
|
|
251
|
+
// Wall-clock when auto-approve first observed status!=waiting_approval while
|
|
252
|
+
// a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS (or,
|
|
253
|
+
// for a delegated-worker flap episode, AUTO_APPROVE_FLAP_CONTINUITY_MS) so a
|
|
254
|
+
// brief generating flip does not immediately wipe the settle clock.
|
|
255
|
+
private autoApproveInactiveSince = 0;
|
|
256
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix A): wall-clock when the CURRENT waiting_approval
|
|
257
|
+
// episode last presented a concrete, captured modal (buttons.length > 0). The
|
|
258
|
+
// Claude TUI momentarily reports status=waiting_approval with activeModal=null
|
|
259
|
+
// / an empty button block while the button block scrolls out of the captured
|
|
260
|
+
// frame; the raw guard below (buttons.length===0) used to bail on that frame,
|
|
261
|
+
// never advancing the settle gate and leaving no re-check armed — so a modal
|
|
262
|
+
// that flapped modal=none ↔ N-buttons around the settle boundary never
|
|
263
|
+
// accumulated its 600ms. This tracks the last GOOD-modal frame so a short
|
|
264
|
+
// scroll-out blip is absorbed (settle keeps running against the last captured
|
|
265
|
+
// signature) while a genuinely closed modal — buttons empty continuously past
|
|
266
|
+
// the continuity window — is still recognised and resets the gate.
|
|
267
|
+
private autoApproveLastModalSeenAt = 0;
|
|
268
|
+
// STATUS-MISMATCH: wall-clock when the CURRENT auto-approve episode (waiting_approval
|
|
269
|
+
// + shouldAutoApprove) first began wanting to mask. Unlike pendingAutoApprovalSince it
|
|
270
|
+
// is NOT reset when the modal signature changes (a still-streaming/flapping prompt) and
|
|
271
|
+
// survives the same hysteresis blips the settle gate does, so it measures the TRUE age
|
|
272
|
+
// of an unresolved auto-approve. Once it exceeds AUTO_APPROVE_MASK_STALL_MS the surface
|
|
273
|
+
// mask is dropped so the real waiting_approval surfaces. Cleared when the episode ends
|
|
274
|
+
// (modal genuinely gone, manual attendance takes over, or auto-approve fires).
|
|
275
|
+
private autoApproveMaskSince = 0;
|
|
276
|
+
// NOTIF-APPROVAL-MASKED (Q1b): the autoApproveMaskSince episode value for which a
|
|
277
|
+
// stalled-approval coordinator nudge has already been emitted, so the nudge fires
|
|
278
|
+
// exactly once per stalled auto-approve episode (0 = none emitted). Reusing the
|
|
279
|
+
// per-episode mask-clock value as the key makes it provider-agnostic (no reliance on
|
|
280
|
+
// approvalEntrySeq) and self-resetting: each new episode gets a fresh
|
|
281
|
+
// autoApproveMaskSince timestamp, and the episode-end reset zeroes it.
|
|
282
|
+
private stalledApprovalNudgeEpisode = 0;
|
|
283
|
+
// Provider-common manual-attendance signal: while a human is actively driving
|
|
284
|
+
// this session from the dashboard, auto-approve holds so they can take manual
|
|
285
|
+
// control. Background mesh workers are never attended → delegated auto-approve
|
|
286
|
+
// is unaffected.
|
|
287
|
+
private readonly manualAttendance = new ManualAttendanceTracker();
|
|
315
288
|
private controlValues: Record<string, string | number | boolean> = {};
|
|
316
289
|
private summaryMetadata: unknown = undefined;
|
|
317
290
|
private appliedEffectKeys = new Set<string>();
|
|
318
291
|
private historyWriter: ChatHistoryWriter;
|
|
319
292
|
private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
|
|
320
293
|
private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
|
|
294
|
+
private lastAcknowledgedUserInputAt = 0;
|
|
295
|
+
// TASKBUBBLE-DUP: per-content last-ack timestamps so the same dispatched
|
|
296
|
+
// prompt acked twice in quick succession (the worker buffers the first
|
|
297
|
+
// send during bootstrap/busy, then a redelivery — dispatch-confirm-timeout
|
|
298
|
+
// requeue or a reconcile re-dispatch — fires a SECOND send_chat before the
|
|
299
|
+
// outbound queue drains) collapses to ONE user bubble. Keyed on the trimmed
|
|
300
|
+
// content; an entry older than USER_INPUT_ACK_DEDUP_WINDOW_MS is treated as
|
|
301
|
+
// a fresh, intentional resend and is NOT suppressed.
|
|
302
|
+
private recentUserInputAcks = new Map<string, number>();
|
|
321
303
|
private lastNativeSourceCanonicalCheckAt = 0;
|
|
322
304
|
private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
|
|
323
305
|
private cachedSqliteDb: {
|
|
@@ -330,10 +312,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
330
312
|
private suppressIdleHistoryReplay = false;
|
|
331
313
|
private errorMessage: string | undefined = undefined;
|
|
332
314
|
private errorReason: ProviderErrorReason | undefined = undefined;
|
|
315
|
+
private activeInteractivePrompt: InteractivePrompt | null = null;
|
|
333
316
|
|
|
334
317
|
private presentationMode: 'terminal' | 'chat';
|
|
335
318
|
private providerSessionId?: string;
|
|
336
319
|
private launchMode: 'new' | 'resume' | 'manual';
|
|
320
|
+
private initialThinkingLevel?: string;
|
|
337
321
|
private readonly startedAt = Date.now();
|
|
338
322
|
private onProviderSessionResolved?: (info: {
|
|
339
323
|
instanceId: string;
|
|
@@ -354,6 +338,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
354
338
|
providerSessionId?: string;
|
|
355
339
|
launchMode?: 'new' | 'resume' | 'manual';
|
|
356
340
|
extraEnv?: Record<string, string>;
|
|
341
|
+
/** BRAIN-ROUTING: standard thinking level to apply post-launch via the
|
|
342
|
+
* provider's thinkingControlId (runtime-control providers like hermes).
|
|
343
|
+
* Providers using thinkingLaunchArgs get it at spawn instead and ignore this. */
|
|
344
|
+
initialThinkingLevel?: string;
|
|
357
345
|
onProviderSessionResolved?: (info: {
|
|
358
346
|
instanceId: string;
|
|
359
347
|
providerType: string;
|
|
@@ -369,8 +357,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
369
357
|
this.presentationMode = 'chat';
|
|
370
358
|
this.providerSessionId = options?.providerSessionId;
|
|
371
359
|
this.launchMode = options?.launchMode || 'new';
|
|
360
|
+
this.initialThinkingLevel = options?.initialThinkingLevel;
|
|
372
361
|
this.onProviderSessionResolved = options?.onProviderSessionResolved;
|
|
373
|
-
this.adapter =
|
|
362
|
+
this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
|
|
363
|
+
if (this.providerSessionId) {
|
|
364
|
+
this.adapter.updateRuntimeMeta({ providerSessionId: this.providerSessionId });
|
|
365
|
+
}
|
|
374
366
|
this.monitor = new StatusMonitor();
|
|
375
367
|
this.historyWriter = new ChatHistoryWriter();
|
|
376
368
|
}
|
|
@@ -389,8 +381,8 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
389
381
|
this.adapter.updateRuntimeSettings?.(this.settings);
|
|
390
382
|
this.monitor.updateConfig({
|
|
391
383
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
392
|
-
|
|
393
|
-
|
|
384
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
385
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
394
386
|
});
|
|
395
387
|
|
|
396
388
|
// Server connection
|
|
@@ -411,15 +403,16 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
411
403
|
// PTY spawn
|
|
412
404
|
await this.adapter.spawn();
|
|
413
405
|
await this.enforceFreshSessionLaunchIfNeeded();
|
|
406
|
+
await this.applyInitialThinkingLevelViaControl();
|
|
414
407
|
this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
|
|
415
|
-
if (this.providerSessionId) {
|
|
408
|
+
if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
|
|
416
409
|
this.restorePersistedHistoryFromCurrentSession();
|
|
417
410
|
}
|
|
418
411
|
if (this.providerSessionId && this.launchMode === 'resume') {
|
|
419
412
|
const resumedAt = Date.now();
|
|
420
413
|
this.historyWriter.appendSystemMarker(
|
|
421
414
|
this.type,
|
|
422
|
-
`Resumed saved session at ${
|
|
415
|
+
`Resumed saved session at ${formatMarkerTimestamp(resumedAt)}`,
|
|
423
416
|
{
|
|
424
417
|
instanceId: this.instanceId,
|
|
425
418
|
historySessionId: this.providerSessionId,
|
|
@@ -486,14 +479,38 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
486
479
|
}
|
|
487
480
|
|
|
488
481
|
getState(): ProviderState {
|
|
482
|
+
// TODO(phase5-sandbox): JS override scripts (detectStatus, parseApproval,
|
|
483
|
+
// parseSession) are currently invoked by CliScriptRunner.invoke() via direct
|
|
484
|
+
// function calls — the scripts run in the daemon process with full Node.js
|
|
485
|
+
// access and no resource limits.
|
|
486
|
+
//
|
|
487
|
+
// When Phase 5 lands, CliScriptRunner should route these calls through a
|
|
488
|
+
// SandboxedScriptRunner (see providers/sdk/v1/sandbox/script-runner.ts) so
|
|
489
|
+
// that each call gets a fresh isolated-vm context with a 50 ms CPU limit and
|
|
490
|
+
// a 32 MB memory cap. The execution path to change is:
|
|
491
|
+
// CliScriptRunner.invoke() → SandboxedScriptRunner.run(scriptSource, context)
|
|
492
|
+
//
|
|
493
|
+
// This getState() call-site is NOT where the change goes — the wiring belongs
|
|
494
|
+
// in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
|
|
495
|
+
// parseSession), with provider-loader.ts updated to store script source strings
|
|
496
|
+
// alongside the loaded function references for extended-legacy providers.
|
|
489
497
|
const adapterStatus = this.adapter.getStatus();
|
|
498
|
+
if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
|
|
499
|
+
this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
|
|
500
|
+
}
|
|
490
501
|
let parsedStatus: any = null;
|
|
491
502
|
let parseErrorMessage: string | undefined;
|
|
492
503
|
if (typeof this.adapter.getScriptParsedStatus === 'function') {
|
|
493
504
|
try {
|
|
494
505
|
parsedStatus = this.adapter.getScriptParsedStatus() || null;
|
|
495
|
-
|
|
496
|
-
|
|
506
|
+
const parsedErrorMessage = typeof parsedStatus?.errorMessage === 'string' && parsedStatus.errorMessage.trim()
|
|
507
|
+
? parsedStatus.errorMessage.trim()
|
|
508
|
+
: undefined;
|
|
509
|
+
const parsedErrorReason = typeof parsedStatus?.errorReason === 'string' && parsedStatus.errorReason.trim()
|
|
510
|
+
? parsedStatus.errorReason.trim() as ProviderErrorReason
|
|
511
|
+
: undefined;
|
|
512
|
+
this.errorMessage = parsedErrorMessage;
|
|
513
|
+
this.errorReason = parsedErrorReason;
|
|
497
514
|
} catch (error: any) {
|
|
498
515
|
parseErrorMessage = error?.message || String(error);
|
|
499
516
|
this.errorMessage = parseErrorMessage;
|
|
@@ -503,22 +520,59 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
503
520
|
this.errorMessage = undefined;
|
|
504
521
|
this.errorReason = undefined;
|
|
505
522
|
}
|
|
506
|
-
const
|
|
507
|
-
const visibleStatus = parseErrorMessage
|
|
508
|
-
? 'error'
|
|
509
|
-
: (autoApproveActive ? 'generating' : adapterStatus.status);
|
|
510
|
-
const parsedProviderSessionId = normalizeProviderSessionId(
|
|
523
|
+
const adapterProviderSessionId = normalizeProviderSessionId(
|
|
511
524
|
this.provider,
|
|
512
|
-
typeof
|
|
525
|
+
typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
|
|
513
526
|
);
|
|
514
|
-
|
|
515
|
-
|
|
527
|
+
const nowMs = Date.now();
|
|
528
|
+
// STATUS-MISMATCH: maybeAutoApproveStatus still runs for its side effects (settle gate,
|
|
529
|
+
// resolveModal fire), but the SURFACE mask is dropped once the episode has stalled past
|
|
530
|
+
// AUTO_APPROVE_MASK_STALL_MS — otherwise a never-settling auto-approve hides the worker's
|
|
531
|
+
// waiting_approval + modal from read_chat/mesh_status/dashboard forever.
|
|
532
|
+
const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, nowMs)
|
|
533
|
+
&& !this.autoApproveMaskStalled(nowMs);
|
|
534
|
+
const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
|
|
535
|
+
let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
|
|
536
|
+
? 'error'
|
|
537
|
+
: (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
|
|
538
|
+
// getState() must agree with the status the FSM-driven detectStatusTransition()
|
|
539
|
+
// already committed to lastStatus. The adapter's own status is authoritative; we do
|
|
540
|
+
// not second-guess it with native-transcript shape. Only reconcile a generating-like
|
|
541
|
+
// read down to idle when our own lastStatus has already flipped idle (avoids a
|
|
542
|
+
// perpetual dashboard spinner during the brief window before the next getStatus()).
|
|
543
|
+
if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
|
|
544
|
+
visibleStatus = 'idle';
|
|
516
545
|
}
|
|
517
546
|
const runtime = this.adapter.getRuntimeMetadata();
|
|
518
547
|
this.maybeAppendRuntimeRecoveryMessage(runtime);
|
|
519
548
|
let parsedMessages = Array.isArray(parsedStatus?.messages)
|
|
520
549
|
? parsedStatus.messages
|
|
521
550
|
: [];
|
|
551
|
+
const parsedProviderSessionId = normalizeProviderSessionId(
|
|
552
|
+
this.provider,
|
|
553
|
+
typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
|
|
554
|
+
);
|
|
555
|
+
const suppressFreshLaunchStartupReplay = this.shouldSuppressFreshLaunchStartupReplay(
|
|
556
|
+
parsedMessages,
|
|
557
|
+
parsedStatus,
|
|
558
|
+
adapterStatus,
|
|
559
|
+
parsedProviderSessionId,
|
|
560
|
+
);
|
|
561
|
+
if (adapterProviderSessionId && !suppressFreshLaunchStartupReplay) {
|
|
562
|
+
this.promoteProviderSessionId(adapterProviderSessionId);
|
|
563
|
+
}
|
|
564
|
+
if (parsedProviderSessionId && !suppressFreshLaunchStartupReplay) {
|
|
565
|
+
this.promoteProviderSessionId(parsedProviderSessionId);
|
|
566
|
+
}
|
|
567
|
+
if (suppressFreshLaunchStartupReplay) {
|
|
568
|
+
parsedMessages = [];
|
|
569
|
+
}
|
|
570
|
+
// Adapter runtime metadata is transport-owned and is not guaranteed to
|
|
571
|
+
// identify this conversation. Spec adapters historically exposed the
|
|
572
|
+
// provider spec id (for example "codex-cli") as runtimeId, which made
|
|
573
|
+
// concurrent sessions share one activeChat identity until their native
|
|
574
|
+
// provider session ids were discovered.
|
|
575
|
+
const activeChatId = this.providerSessionId || this.instanceId;
|
|
522
576
|
const historyMessageCount = Number.isFinite(parsedStatus?.historyMessageCount)
|
|
523
577
|
? Math.max(0, Number(parsedStatus.historyMessageCount))
|
|
524
578
|
: null;
|
|
@@ -527,10 +581,57 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
527
581
|
? parsedMessages.slice(-historyMessageCount)
|
|
528
582
|
: [];
|
|
529
583
|
}
|
|
530
|
-
const mergedMessages = this.
|
|
531
|
-
const canonicalBackedHistory = this.
|
|
584
|
+
const mergedMessages = mergeConversationMessages(this.runtimeMessages, parsedMessages);
|
|
585
|
+
const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
|
|
586
|
+
? this.syncCanonicalSavedHistoryIfNeeded()
|
|
587
|
+
: false;
|
|
588
|
+
const statusMessages: any[] = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
|
|
589
|
+
? this.lastPersistedHistoryMessages.map((message) => ({
|
|
590
|
+
role: message.role,
|
|
591
|
+
content: message.content,
|
|
592
|
+
kind: message.kind,
|
|
593
|
+
senderName: message.senderName,
|
|
594
|
+
receivedAt: message.receivedAt,
|
|
595
|
+
}))
|
|
596
|
+
: mergedMessages;
|
|
597
|
+
|
|
598
|
+
// Dashboard-tail repair (native-source providers, e.g. antigravity): the
|
|
599
|
+
// assistant answer lives only in native-history, so the PTY-parsed
|
|
600
|
+
// statusMessages end on the user prompt / auto-approve system lines and the
|
|
601
|
+
// snapshot's preview / lastMessageRole / completionMarker never see the
|
|
602
|
+
// answer — the session looks stuck on the user turn. We already cached the
|
|
603
|
+
// real final assistant summary at completion time (lastCompletionSummary),
|
|
604
|
+
// so append it as the trailing assistant bubble when the current tail has no
|
|
605
|
+
// assistant message at/after it. Purely additive to the status view; no
|
|
606
|
+
// per-tick native read, no effect on providers whose PTY carries the
|
|
607
|
+
// assistant (they surface it themselves and the guard below is a no-op).
|
|
608
|
+
const adapterOwnsMessagesElsewhereForTail = (this.adapter as any)?.chatMessagesOwnedExternally === true;
|
|
609
|
+
if (adapterOwnsMessagesElsewhereForTail && this.lastCompletionSummary) {
|
|
610
|
+
const summary = this.lastCompletionSummary;
|
|
611
|
+
let hasTrailingAssistant = false;
|
|
612
|
+
for (let i = statusMessages.length - 1; i >= 0; i -= 1) {
|
|
613
|
+
const m = statusMessages[i] as { role?: string; kind?: string; receivedAt?: number };
|
|
614
|
+
const role = typeof m?.role === 'string' ? m.role : '';
|
|
615
|
+
if (role === 'system') continue;
|
|
616
|
+
if (typeof m?.kind === 'string' && m.kind === 'tool') continue;
|
|
617
|
+
// First non-system/non-tool message from the tail: if it's already an
|
|
618
|
+
// assistant reply not older than our cached summary, the tail is fine.
|
|
619
|
+
hasTrailingAssistant = role === 'assistant'
|
|
620
|
+
&& typeof m?.receivedAt === 'number'
|
|
621
|
+
&& m.receivedAt >= summary.receivedAt - 1000;
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
if (!hasTrailingAssistant) {
|
|
625
|
+
statusMessages.push({
|
|
626
|
+
role: 'assistant',
|
|
627
|
+
content: summary.content,
|
|
628
|
+
kind: 'standard',
|
|
629
|
+
receivedAt: summary.receivedAt,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
532
633
|
|
|
533
|
-
const dirName = this.workingDir
|
|
634
|
+
const dirName = workingDirBasename(this.workingDir);
|
|
534
635
|
const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
|
|
535
636
|
? parsedStatus.status.trim()
|
|
536
637
|
: undefined;
|
|
@@ -542,7 +643,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
542
643
|
&& adapterStatus.status === 'idle'
|
|
543
644
|
&& parsedStatus?.status === 'idle';
|
|
544
645
|
let messagesToSave = parsedMessages;
|
|
545
|
-
if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
|
|
646
|
+
if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'no_progress' || parsedChatStatus === 'long_generating')) {
|
|
546
647
|
const lastIdx = messagesToSave.length - 1;
|
|
547
648
|
if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
|
|
548
649
|
messagesToSave = messagesToSave.slice(0, lastIdx);
|
|
@@ -572,33 +673,48 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
572
673
|
}
|
|
573
674
|
}
|
|
574
675
|
|
|
575
|
-
this.applyProviderResponse(
|
|
676
|
+
this.applyProviderResponse(
|
|
677
|
+
suppressFreshLaunchStartupReplay && parsedStatus && typeof parsedStatus === 'object'
|
|
678
|
+
? { ...parsedStatus, providerSessionId: undefined }
|
|
679
|
+
: parsedStatus,
|
|
680
|
+
{ phase: 'immediate' },
|
|
681
|
+
);
|
|
576
682
|
const surface = resolveProviderStateSurface({
|
|
577
683
|
summaryMetadata: this.summaryMetadata as any,
|
|
578
684
|
controlValues: this.controlValues,
|
|
579
685
|
});
|
|
580
686
|
const activeChatStatus = parseErrorMessage
|
|
581
687
|
? 'error'
|
|
582
|
-
: autoApproveActive && parsedStatus?.status === 'waiting_approval'
|
|
688
|
+
: (autoApproveActive && parsedStatus?.status === 'waiting_approval') || autoApproveHoldIdle
|
|
583
689
|
? 'generating'
|
|
584
690
|
: (adapterStatus.status !== 'idle'
|
|
585
691
|
? visibleStatus
|
|
586
692
|
: (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
|
|
587
693
|
|
|
694
|
+
// If an AskUserQuestion prompt is awaiting user input, overlay status as
|
|
695
|
+
// waiting_choice. This is distinct from waiting_approval (tool-use consent)
|
|
696
|
+
// — the engine's isWaitingForResponse state is unchanged, so completion
|
|
697
|
+
// tracking continues normally once the user responds.
|
|
698
|
+
const hasInteractivePrompt = !!this.activeInteractivePrompt;
|
|
699
|
+
const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
|
|
700
|
+
const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
|
|
701
|
+
|
|
588
702
|
return {
|
|
589
703
|
type: this.type,
|
|
590
704
|
name: this.provider.name,
|
|
591
705
|
category: 'cli',
|
|
592
|
-
status:
|
|
706
|
+
status: finalStatus,
|
|
593
707
|
mode: this.presentationMode,
|
|
594
708
|
activeChat: {
|
|
595
|
-
id:
|
|
709
|
+
id: activeChatId,
|
|
596
710
|
title: parsedStatus?.title || dirName,
|
|
597
|
-
status:
|
|
598
|
-
messages:
|
|
599
|
-
activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
|
|
711
|
+
status: finalChatStatus,
|
|
712
|
+
messages: statusMessages,
|
|
713
|
+
activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
|
|
714
|
+
activeInteractivePrompt: this.activeInteractivePrompt,
|
|
600
715
|
inputContent: '',
|
|
601
716
|
},
|
|
717
|
+
activeInteractivePrompt: this.activeInteractivePrompt,
|
|
602
718
|
workspace: this.workingDir,
|
|
603
719
|
instanceId: this.instanceId,
|
|
604
720
|
providerSessionId: this.providerSessionId,
|
|
@@ -638,8 +754,12 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
638
754
|
|
|
639
755
|
getHotChatSessionState(): HotChatSessionState {
|
|
640
756
|
const adapterStatus = this.adapter.getStatus({ allowParse: false });
|
|
641
|
-
const
|
|
642
|
-
|
|
757
|
+
const nowMs = Date.now();
|
|
758
|
+
// STATUS-MISMATCH: drop the mask once the auto-approve episode has stalled (see getState).
|
|
759
|
+
const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status, nowMs)
|
|
760
|
+
&& !this.autoApproveMaskStalled(nowMs);
|
|
761
|
+
const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
|
|
762
|
+
const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
|
|
643
763
|
const runtime = this.adapter.getRuntimeMetadata();
|
|
644
764
|
return {
|
|
645
765
|
id: this.instanceId,
|
|
@@ -651,36 +771,279 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
651
771
|
};
|
|
652
772
|
}
|
|
653
773
|
|
|
654
|
-
getSessionModalState(): SessionModalState {
|
|
655
|
-
const adapterStatus = this.adapter.getStatus({ allowParse:
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
const
|
|
774
|
+
getSessionModalState(sessionId?: string): SessionModalState {
|
|
775
|
+
const adapterStatus = this.adapter.getStatus({ allowParse: true });
|
|
776
|
+
const nowMs = Date.now();
|
|
777
|
+
// STATUS-MISMATCH: drop the mask once the auto-approve episode has stalled (see getState).
|
|
778
|
+
const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status, nowMs)
|
|
779
|
+
&& !this.autoApproveMaskStalled(nowMs);
|
|
780
|
+
const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
|
|
781
|
+
const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
|
|
782
|
+
const dirName = workingDirBasename(this.workingDir);
|
|
659
783
|
return {
|
|
660
|
-
|
|
784
|
+
// Honor the caller-supplied sessionId — InstanceMgr rejects the
|
|
785
|
+
// projection when projected.id !== requested sessionId, and
|
|
786
|
+
// this.instanceId is the manager's internal key, not the public
|
|
787
|
+
// sessionId the dashboard subscribes by.
|
|
788
|
+
id: sessionId ?? this.instanceId,
|
|
661
789
|
status: visibleStatus,
|
|
662
790
|
title: dirName,
|
|
663
|
-
activeModal: autoApproveActive ? null : adapterStatus.activeModal,
|
|
791
|
+
activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : adapterStatus.activeModal,
|
|
664
792
|
};
|
|
665
793
|
}
|
|
666
794
|
|
|
667
795
|
updateSettings(newSettings: Record<string, any>): void {
|
|
668
|
-
|
|
796
|
+
// Merge semantics: a key omitted from newSettings preserves its existing
|
|
797
|
+
// value, a key present in newSettings (even as false) overrides it.
|
|
798
|
+
//
|
|
799
|
+
// This is required because updateSettings has two callers with opposite
|
|
800
|
+
// intent:
|
|
801
|
+
// 1. Full re-injection — the dashboard toggle path (handleSetProviderSetting
|
|
802
|
+
// → getSettings → updateInstanceSettings) sends the COMPLETE settings
|
|
803
|
+
// object, so an explicit autoApprove:false must win.
|
|
804
|
+
// 2. Partial stamp — the mesh relay-safety stamp (router.ts agent_command,
|
|
805
|
+
// buildMeshWorkerRelayStamp) sends ONLY {meshNodeFor, meshNodeId,
|
|
806
|
+
// meshCoordinatorDaemonId, launchedByCoordinator} on every coordinator
|
|
807
|
+
// re-dispatch. It carries no autoApprove, so a full replacement would
|
|
808
|
+
// wipe the launch-time autoApprove:true the worker was started with,
|
|
809
|
+
// silently dropping every later approval to a manual gate until the
|
|
810
|
+
// machine-page toggle re-injected the full settings.
|
|
811
|
+
//
|
|
812
|
+
// A plain merge satisfies both: undefined keys fall through to the existing
|
|
813
|
+
// value (preserving launch-stamp settings like autoApprove + the mesh routing
|
|
814
|
+
// keys), explicit keys override. This subsumes the previous mesh-key preserve
|
|
815
|
+
// list, which only protected the routing keys and not autoApprove.
|
|
816
|
+
this.settings = { ...this.settings, ...newSettings };
|
|
669
817
|
this.adapter.updateRuntimeSettings?.(this.settings);
|
|
670
818
|
this.monitor.updateConfig({
|
|
671
819
|
approvalAlert: this.settings.approvalAlert !== false,
|
|
672
|
-
|
|
673
|
-
|
|
820
|
+
noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
|
|
821
|
+
noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
|
|
674
822
|
});
|
|
675
823
|
}
|
|
676
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Stamp a direct-dispatch mesh assignment on this instance.
|
|
827
|
+
* setupMeshEventForwarding reads settings.meshNodeFor + meshActiveTaskId to
|
|
828
|
+
* route generating_completed back to the originating coordinator. Without
|
|
829
|
+
* this stamp, mesh_send_task --direct targets a plain CLI session whose
|
|
830
|
+
* completion events silently drop because the forwarder has nothing to
|
|
831
|
+
* match against.
|
|
832
|
+
*/
|
|
833
|
+
attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; dispatchNonce?: number; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void {
|
|
834
|
+
if (!assignment?.meshId) return;
|
|
835
|
+
this.settings = {
|
|
836
|
+
...this.settings,
|
|
837
|
+
meshNodeFor: assignment.meshId,
|
|
838
|
+
// WTCLAIM (A): track the bound node id under BOTH the active marker
|
|
839
|
+
// (meshNodeId, cleared on detach) and a sticky marker (meshLastNodeId,
|
|
840
|
+
// preserved across detach). The sticky marker lets a detached but still
|
|
841
|
+
// coordinator-owned session be re-picked ONLY for the SAME node it served
|
|
842
|
+
// — never auto-adopted for a sibling node (e.g. a cloned worktree) that
|
|
843
|
+
// shares this daemon. See isMeshOwnedDelegateSession's post-detach gate.
|
|
844
|
+
...(assignment.nodeId ? { meshNodeId: assignment.nodeId, meshLastNodeId: assignment.nodeId } : {}),
|
|
845
|
+
...(assignment.taskId ? { meshActiveTaskId: assignment.taskId } : {}),
|
|
846
|
+
// REDRIVE-DUP: task-level dispatch nonce, echoed on generating_started so the
|
|
847
|
+
// coordinator can reject a stale (reclaimed) dispatch. Cleared with meshActiveTaskId
|
|
848
|
+
// on detach so a subsequent unrelated turn never re-echoes a prior task's nonce.
|
|
849
|
+
...(typeof assignment.dispatchNonce === 'number' ? { meshActiveDispatchNonce: assignment.dispatchNonce } : {}),
|
|
850
|
+
...(assignment.coordinatorDaemonId ? { meshCoordinatorDaemonId: assignment.coordinatorDaemonId } : {}),
|
|
851
|
+
// Session-level routing anchor: the originating coordinator session, so this
|
|
852
|
+
// worker's completion events route back to the exact session that dispatched it.
|
|
853
|
+
...(assignment.coordinatorSessionId ? { meshCoordinatorSessionId: assignment.coordinatorSessionId } : {}),
|
|
854
|
+
};
|
|
855
|
+
this.adapter.updateRuntimeSettings?.(this.settings);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Clear a previously-attached mesh assignment after the task reaches a
|
|
860
|
+
* terminal state. Leaving meshNodeFor pinned would route this session's
|
|
861
|
+
* subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
|
|
862
|
+
* coordinator as if they were task completions.
|
|
863
|
+
*
|
|
864
|
+
* MESHID-DROP-ON-DETACH (Fix C): a coordinator-LAUNCHED worker session
|
|
865
|
+
* (launchedByCoordinator) holds its mesh membership (meshNodeFor / meshNodeId /
|
|
866
|
+
* meshCoordinatorDaemonId) at the SESSION level — set once at launch
|
|
867
|
+
* (mesh_launch_session / queue auto-launch), independent of any single task.
|
|
868
|
+
* The original detach wiped meshNodeFor + meshNodeId together with the
|
|
869
|
+
* task-level meshActiveTaskId, so the FIRST task completion stripped the
|
|
870
|
+
* membership and EVERY subsequent completion forwarded with meshId absent —
|
|
871
|
+
* resolveWorkerDelegateRouting fell to mesh_unresolved and the coordinator
|
|
872
|
+
* rejected the forward "meshId required". For a launched member we therefore
|
|
873
|
+
* clear ONLY the task-level marker (meshActiveTaskId) and preserve the
|
|
874
|
+
* session-level membership so its next task's completion still resolves.
|
|
875
|
+
* A task-less ad-hoc turn on a preserved-membership session is NOT misrouted:
|
|
876
|
+
* its completion carries no taskId and the session holds no active assignment,
|
|
877
|
+
* so the forwarder's WARMUPGAP guard skips the dispatch-row flip (it only
|
|
878
|
+
* injects a benign task-less notification). A NON-launched session (a plain CLI
|
|
879
|
+
* session adopted by mesh_send_task --direct, launchedByCoordinator falsy)
|
|
880
|
+
* keeps the original full clear so an ad-hoc session is never left pinned.
|
|
881
|
+
*/
|
|
882
|
+
detachMeshAssignment(): void {
|
|
883
|
+
if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
|
|
884
|
+
// Session-level member: keep membership, drop only the task-level markers.
|
|
885
|
+
if (this.settings.launchedByCoordinator === true) {
|
|
886
|
+
if (!this.settings.meshActiveTaskId) return;
|
|
887
|
+
// REDRIVE-DUP: clear the task-level dispatch nonce with the task marker.
|
|
888
|
+
const { meshActiveTaskId, meshActiveDispatchNonce, ...rest } = this.settings;
|
|
889
|
+
void meshActiveTaskId; void meshActiveDispatchNonce;
|
|
890
|
+
this.settings = rest;
|
|
891
|
+
this.adapter.updateRuntimeSettings?.(this.settings);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const { meshNodeFor, meshNodeId, meshActiveTaskId, meshActiveDispatchNonce, ...rest } = this.settings;
|
|
895
|
+
void meshNodeFor; void meshActiveTaskId; void meshActiveDispatchNonce;
|
|
896
|
+
// WTCLAIM (A): clear the active binding but PRESERVE the last bound node id
|
|
897
|
+
// (meshLastNodeId) so a later sessionless dispatch can re-adopt this idle
|
|
898
|
+
// session ONLY for the node it last served. Carry the id being cleared, or
|
|
899
|
+
// keep an already-present sticky marker if meshNodeId was absent.
|
|
900
|
+
const lastNodeId = (typeof meshNodeId === 'string' && meshNodeId.trim())
|
|
901
|
+
? meshNodeId.trim()
|
|
902
|
+
: (typeof rest.meshLastNodeId === 'string' && rest.meshLastNodeId.trim() ? rest.meshLastNodeId.trim() : undefined);
|
|
903
|
+
this.settings = lastNodeId ? { ...rest, meshLastNodeId: lastNodeId } : rest;
|
|
904
|
+
this.adapter.updateRuntimeSettings?.(this.settings);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* The resolved modal-park status of this session, or null when it is not
|
|
909
|
+
* parked on a modal awaiting a human answer. Mirrors the overlay logic in
|
|
910
|
+
* getState(): an active AskUserQuestion interactive prompt resolves to
|
|
911
|
+
* waiting_choice; otherwise the adapter's waiting_approval (tool consent)
|
|
912
|
+
* counts — UNLESS auto-approve will dismiss it, in which case the session is
|
|
913
|
+
* effectively generating and is NOT modal-parked. This is the single signal
|
|
914
|
+
* the mesh force-inject guard consults, and the same status string the
|
|
915
|
+
* reconcile loop reads off get_status_metadata. Lowercase literals only —
|
|
916
|
+
* the SessionStatus enum is forked across modules and waiting_choice is
|
|
917
|
+
* absent from some of them.
|
|
918
|
+
*/
|
|
919
|
+
resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null {
|
|
920
|
+
if (this.activeInteractivePrompt) return 'waiting_choice';
|
|
921
|
+
let adapterStatus: { status?: string };
|
|
922
|
+
try {
|
|
923
|
+
adapterStatus = this.adapter.getStatus({ allowParse: false });
|
|
924
|
+
} catch {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
// A session whose auto-approve is held by manual attendance IS parked on a
|
|
928
|
+
// modal awaiting the human — autoApproveEffectivelyActive folds that in, so
|
|
929
|
+
// the mesh force-inject guard correctly treats it as modal-parked. STATUS-MISMATCH:
|
|
930
|
+
// a STALLED auto-approve (never resolving) is likewise effectively parked — treat it
|
|
931
|
+
// as modal-parked so its events are held/surfaced rather than masked behind generating.
|
|
932
|
+
if (adapterStatus.status === 'waiting_approval'
|
|
933
|
+
&& (!this.autoApproveEffectivelyActive(adapterStatus.status) || this.autoApproveMaskStalled())) {
|
|
934
|
+
// NOTIF-HELD-DRAIN (Fix 1): an autonomous mesh session (coordinator or worker)
|
|
935
|
+
// that is actively progressing a turn — a tool call is in flight
|
|
936
|
+
// (hasAdapterPendingResponse) and NO human is attending it by hand — surfaces a
|
|
937
|
+
// routine tool-consent `waiting_approval` on EVERY tool call when auto-approve is
|
|
938
|
+
// off. That transient consent is part of the turn the harness/operator drives to
|
|
939
|
+
// completion, NOT a session genuinely wedged awaiting a human's modal answer.
|
|
940
|
+
// Classifying it modal-parked makes findLiveCoordinators hold the mesh's pending
|
|
941
|
+
// completion events under `modal_parked` across a busy coordinator's whole work
|
|
942
|
+
// batch, which is the multi-minute notification stall. Treat such a transient
|
|
943
|
+
// consent as NOT modal-parked so it is held as ordinary "generating" (released on
|
|
944
|
+
// the next idle) instead. A manually-attended session, a stalled auto-approve, or a
|
|
945
|
+
// non-progressing session (no turn in flight) still parks — those are the genuine
|
|
946
|
+
// human-await cases the guard must keep holding.
|
|
947
|
+
if (this.isTransientToolConsent()) {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
return 'waiting_approval';
|
|
951
|
+
}
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* NOTIF-HELD-DRAIN: true when this `waiting_approval` is a routine, transient tool-consent
|
|
957
|
+
* of an autonomously-progressing mesh session rather than a genuine human-await modal —
|
|
958
|
+
* i.e. it is a mesh coordinator/worker session, a turn is actively in flight
|
|
959
|
+
* (hasAdapterPendingResponse), and no human is attending it by hand. Such a consent is
|
|
960
|
+
* driven to resolution by the harness/operator as part of the in-flight turn, so holding
|
|
961
|
+
* the mesh's completion events behind it (modal_parked) is the false-positive that stalls
|
|
962
|
+
* delivery. Narrow by design: manual attendance or a non-progressing session falls through
|
|
963
|
+
* to the genuine-modal classification.
|
|
964
|
+
*/
|
|
965
|
+
private isTransientToolConsent(now = Date.now()): boolean {
|
|
966
|
+
return this.isAutonomousMeshSession()
|
|
967
|
+
&& this.hasAdapterPendingResponse()
|
|
968
|
+
&& !this.manualAttendance.isAttended(now);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** True when this session is parked on a modal awaiting a human answer. */
|
|
972
|
+
isModalParked(): boolean {
|
|
973
|
+
return this.resolveModalParkStatus() !== null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* PTY-OVERTRUST-DRAIN (Defect B). The deliverability/drain status the mesh
|
|
978
|
+
* reconcile loop must consult — the RAW adapter turn-state, with the
|
|
979
|
+
* auto-approve "hold-idle" visual mask STRIPPED.
|
|
980
|
+
*
|
|
981
|
+
* getState().status overlays `autoApproveHoldIdle`/`autoApproveActive` to paint a
|
|
982
|
+
* genuinely-idle adapter as `generating` (a UI-flicker suppression while an
|
|
983
|
+
* auto-approve key-press settles — see getState() ~:800). That mask is correct
|
|
984
|
+
* for the dashboard, but the reconcile loop trusts it as "the coordinator is
|
|
985
|
+
* busy" and therefore HOLDS a worker's completion under
|
|
986
|
+
* `generating_no_idle_coordinator` even though the coordinator's PTY is at a real
|
|
987
|
+
* turn end and would accept the inject as a turn — the completion is stranded.
|
|
988
|
+
*
|
|
989
|
+
* This accessor reports the drain truth instead:
|
|
990
|
+
* - 'modal_parked' — a GENUINE human-await modal (AskUserQuestion / a non-
|
|
991
|
+
* transient tool-consent). Still excluded from drain (a force-inject here
|
|
992
|
+
* writes raw keystrokes the modal eats → data corruption). Mirrors
|
|
993
|
+
* isModalParked(), evaluated first so a parked session never reads idle.
|
|
994
|
+
* - 'idle' — the RAW adapter is at a turn end (adapter.getStatus(allowParse:false)
|
|
995
|
+
* === 'idle') and the session is not modal-parked. Drain-eligible REGARDLESS
|
|
996
|
+
* of the auto-approve mask. This is the case the mask used to hide.
|
|
997
|
+
* - 'generating' — the raw adapter is genuinely mid-turn. Held (a raw PTY write
|
|
998
|
+
* into a generating claude-cli is not consumed as a turn → data loss). The
|
|
999
|
+
* intentional removal of force-inject-into-generating is preserved.
|
|
1000
|
+
* - 'other' — any other raw status (error / starting / waiting_choice handled by
|
|
1001
|
+
* modal-park above). Not a drain target.
|
|
1002
|
+
*
|
|
1003
|
+
* Uses allowParse:false (engine.activeModal only, side-effect-free) so it never
|
|
1004
|
+
* mutates the very auto-approve mask state the diagnostics read.
|
|
1005
|
+
*/
|
|
1006
|
+
getDrainStatus(): 'idle' | 'generating' | 'modal_parked' | 'other' {
|
|
1007
|
+
if (this.isModalParked()) return 'modal_parked';
|
|
1008
|
+
let rawStatus: string;
|
|
1009
|
+
try {
|
|
1010
|
+
const raw = this.adapter.getStatus({ allowParse: false })?.status;
|
|
1011
|
+
rawStatus = typeof raw === 'string' ? raw.trim() : '';
|
|
1012
|
+
} catch {
|
|
1013
|
+
return 'other';
|
|
1014
|
+
}
|
|
1015
|
+
if (rawStatus === 'idle') return 'idle';
|
|
1016
|
+
if (isCliGeneratingLikeStatus(rawStatus)) return 'generating';
|
|
1017
|
+
return 'other';
|
|
1018
|
+
}
|
|
1019
|
+
|
|
677
1020
|
onEvent(event: string, data?: any): void {
|
|
678
1021
|
if (event === 'send_message') {
|
|
679
1022
|
const input = normalizeInputEnvelope(data);
|
|
680
1023
|
assertProviderSupportsDeclaredInput(this.provider, input);
|
|
681
1024
|
const promptText = buildCliStructuredInputPrompt(input);
|
|
682
1025
|
if (promptText) {
|
|
683
|
-
|
|
1026
|
+
// force:true bypasses the busy/generating send guard so terminal mesh
|
|
1027
|
+
// events (completion/failure/bootstrap) land in a coordinator session that
|
|
1028
|
+
// is itself parked in `generating` while awaiting that very event.
|
|
1029
|
+
// Without it the message is queued and only flushed on the coordinator's
|
|
1030
|
+
// own idle transition — which never happens until it receives the message.
|
|
1031
|
+
const force = data?.force === true;
|
|
1032
|
+
// Modal guard: a force-inject still writes raw keystrokes into the PTY,
|
|
1033
|
+
// bypassing the busy send-guard. If the coordinator is parked on a
|
|
1034
|
+
// harness modal (claude-cli AskUserQuestion → waiting_choice, or a
|
|
1035
|
+
// tool-consent waiting_approval), those keystrokes are consumed by the
|
|
1036
|
+
// modal's key handler and silently select a choice the user never made
|
|
1037
|
+
// (data corruption). Hold the force-inject in that narrow window —
|
|
1038
|
+
// the event stays queued and the reconcile loop redelivers it on the
|
|
1039
|
+
// next tick once the modal is resolved. We ONLY hold for the two modal
|
|
1040
|
+
// states; generating is still force-injected (that is the deadlock the
|
|
1041
|
+
// force path exists to break — see mesh-events-coordinator).
|
|
1042
|
+
if (force && this.isModalParked()) {
|
|
1043
|
+
LOG.info('CLI', `[${this.type}] force send_message held — coordinator parked on modal (${this.resolveModalParkStatus()})`);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
|
|
684
1047
|
LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
|
|
685
1048
|
});
|
|
686
1049
|
}
|
|
@@ -690,14 +1053,130 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
690
1053
|
void this.adapter.resolveAction(data).catch((e: any) => {
|
|
691
1054
|
LOG.warn('CLI', `[${this.type}] resolve_action failed: ${e?.message || e}`);
|
|
692
1055
|
});
|
|
1056
|
+
} else if (event === 'interactive_prompt' && data) {
|
|
1057
|
+
const prompt = normalizeInteractivePrompt(data);
|
|
1058
|
+
if (prompt) {
|
|
1059
|
+
this.activeInteractivePrompt = prompt;
|
|
1060
|
+
this.events.push({
|
|
1061
|
+
event: 'interactive_prompt',
|
|
1062
|
+
timestamp: Date.now(),
|
|
1063
|
+
promptId: prompt.promptId,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
} else if (event === 'interactive_prompt_response' && data) {
|
|
1067
|
+
try {
|
|
1068
|
+
const response = normalizeInteractivePromptResponse(data);
|
|
1069
|
+
if (this.activeInteractivePrompt?.promptId === response.promptId) {
|
|
1070
|
+
this.activeInteractivePrompt = null;
|
|
1071
|
+
}
|
|
1072
|
+
if (typeof this.adapter.setInteractivePromptResponse !== 'function') {
|
|
1073
|
+
LOG.warn('CLI', `[${this.type}] interactive_prompt_response ignored: adapter does not support interactive prompts`);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
void this.adapter.setInteractivePromptResponse(response).catch((e: any) => {
|
|
1077
|
+
LOG.warn('CLI', `[${this.type}] interactive_prompt_response failed: ${e?.message || e}`);
|
|
1078
|
+
});
|
|
1079
|
+
} catch (e: any) {
|
|
1080
|
+
LOG.warn('CLI', `[${this.type}] invalid interactive_prompt_response: ${e?.message || e}`);
|
|
1081
|
+
}
|
|
693
1082
|
} else if (event === 'provider_state_patch' && data && typeof data === 'object') {
|
|
694
1083
|
this.applyProviderResponse(data, { phase: 'immediate' });
|
|
695
1084
|
}
|
|
696
1085
|
}
|
|
697
1086
|
|
|
1087
|
+
recordAcknowledgedUserInput(input: InputEnvelope | string): void {
|
|
1088
|
+
const content = typeof input === 'string'
|
|
1089
|
+
? input.trim()
|
|
1090
|
+
: buildCliStructuredInputPrompt(input).trim();
|
|
1091
|
+
if (!content) return;
|
|
1092
|
+
|
|
1093
|
+
const receivedAt = Date.now();
|
|
1094
|
+
|
|
1095
|
+
// TASKBUBBLE-DUP: collapse a redelivered dispatch to one bubble. A single
|
|
1096
|
+
// mesh_send_task can reach this instance as TWO send_chat calls when the
|
|
1097
|
+
// first injection is buffered during bootstrap/busy and a retry (dispatch-
|
|
1098
|
+
// confirm-timeout requeue, or a reconcile re-dispatch) fires before the
|
|
1099
|
+
// outbound queue drains. The previous dedupKey hashed receivedAt, so the
|
|
1100
|
+
// two acks produced different keys and BOTH bubbled. Suppress an identical
|
|
1101
|
+
// content ack seen within USER_INPUT_ACK_DEDUP_WINDOW_MS; a later resend of
|
|
1102
|
+
// the same text (beyond the window) is a genuine new turn and still shows.
|
|
1103
|
+
const ackContentKey = shortHash(`${this.instanceId}:${content}`, 24);
|
|
1104
|
+
const lastAckAt = this.recentUserInputAcks.get(ackContentKey);
|
|
1105
|
+
if (lastAckAt !== undefined && receivedAt - lastAckAt <= USER_INPUT_ACK_DEDUP_WINDOW_MS) {
|
|
1106
|
+
// Refresh the timestamp so a steady stream of redeliveries keeps
|
|
1107
|
+
// collapsing, and prune stale entries to bound the map size.
|
|
1108
|
+
this.recentUserInputAcks.set(ackContentKey, receivedAt);
|
|
1109
|
+
this.pruneRecentUserInputAcks(receivedAt);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
this.recentUserInputAcks.set(ackContentKey, receivedAt);
|
|
1113
|
+
this.pruneRecentUserInputAcks(receivedAt);
|
|
1114
|
+
|
|
1115
|
+
this.lastAcknowledgedUserInputAt = receivedAt;
|
|
1116
|
+
// The runtimeMessages dedupKey stays per-call unique (includes receivedAt)
|
|
1117
|
+
// so a genuine resend of the same text after the window appends a fresh
|
|
1118
|
+
// bubble; redelivery within the window is already suppressed above.
|
|
1119
|
+
const dedupKey = `user_input_ack:${shortHash(`${this.instanceId}:${content}:${receivedAt}`, 24)}`;
|
|
1120
|
+
this.appendRuntimeMessage(buildChatMessage({
|
|
1121
|
+
role: 'user',
|
|
1122
|
+
senderName: 'User',
|
|
1123
|
+
kind: 'standard',
|
|
1124
|
+
content,
|
|
1125
|
+
receivedAt,
|
|
1126
|
+
timestamp: receivedAt,
|
|
1127
|
+
source: 'runtime_input_ack',
|
|
1128
|
+
meta: {
|
|
1129
|
+
runtimeInputAck: true,
|
|
1130
|
+
provider: this.type,
|
|
1131
|
+
workspace: this.workingDir,
|
|
1132
|
+
},
|
|
1133
|
+
} as ChatMessage), dedupKey);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Drop user-input ack entries older than the dedup window so the map can't grow unbounded. */
|
|
1137
|
+
private pruneRecentUserInputAcks(now: number): void {
|
|
1138
|
+
if (this.recentUserInputAcks.size <= 1) return;
|
|
1139
|
+
for (const [key, at] of this.recentUserInputAcks) {
|
|
1140
|
+
if (now - at > USER_INPUT_ACK_DEDUP_WINDOW_MS) this.recentUserInputAcks.delete(key);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Owner token for this session in the antigravity conversation-claim
|
|
1146
|
+
* registry. Keyed on the daemon instance id — the SAME value the session
|
|
1147
|
+
* registry stores as this session's `sessionId` (see cli-manager
|
|
1148
|
+
* `sessionRegistry.register({ sessionId: cliInstance.instanceId })`) and the
|
|
1149
|
+
* read side hands the dispatcher as `instanceId`. Both sides therefore
|
|
1150
|
+
* derive the identical `iid:<instanceId>` token, so the claims the
|
|
1151
|
+
* dispatcher records under this session are exactly the ones dispose()
|
|
1152
|
+
* releases.
|
|
1153
|
+
*
|
|
1154
|
+
* This must NOT be derived from a spawn timestamp: the instance's
|
|
1155
|
+
* `startedAt`, the adapter's `spawnedAtMs`, and the session registry's
|
|
1156
|
+
* `spawnedAtMs` are three INDEPENDENT `Date.now()` samples for the one
|
|
1157
|
+
* session, so a workspace+spawn-time token computed here would never equal
|
|
1158
|
+
* the read side's — the claim isolation then silently collapses and two
|
|
1159
|
+
* concurrent antigravity sessions cross-bind each other's conversation .db
|
|
1160
|
+
* (coordinator+worker chat crosswire).
|
|
1161
|
+
*/
|
|
1162
|
+
private antigravityClaimOwner(): string {
|
|
1163
|
+
return antigravityOwnerToken(this.workingDir, this.startedAt, this.instanceId);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
698
1166
|
dispose(): void {
|
|
1167
|
+
// Release this session's antigravity conversation claims so the store
|
|
1168
|
+
// becomes available again (e.g. a later resume) and the registry doesn't
|
|
1169
|
+
// leak entries for dead sessions.
|
|
1170
|
+
if (this.type === 'antigravity-cli') {
|
|
1171
|
+
const owner = this.antigravityClaimOwner();
|
|
1172
|
+
if (owner) releaseAntigravityOwner(owner);
|
|
1173
|
+
}
|
|
699
1174
|
this.adapter.shutdown();
|
|
700
1175
|
this.monitor.reset();
|
|
1176
|
+
// Cancel any armed auto-approve timers so a pending settle re-check
|
|
1177
|
+
// can't fire resolveModal/detectStatusTransition against a dead adapter.
|
|
1178
|
+
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
1179
|
+
if (this.autoApproveBusyTimer) { clearTimeout(this.autoApproveBusyTimer); this.autoApproveBusyTimer = null; }
|
|
701
1180
|
this.appliedEffectKeys.clear();
|
|
702
1181
|
try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
|
|
703
1182
|
this.cachedSqliteDb = null;
|
|
@@ -706,6 +1185,19 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
706
1185
|
|
|
707
1186
|
private completedDebounceTimer: NodeJS.Timeout | null = null;
|
|
708
1187
|
private completedDebouncePending: CompletedDebouncePending | null = null;
|
|
1188
|
+
private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
|
|
1189
|
+
/**
|
|
1190
|
+
* The final assistant summary of the last completed turn, cached at
|
|
1191
|
+
* completion-emit time. For a native-source provider (antigravity) whose
|
|
1192
|
+
* assistant answer lives only in native-history — never in the PTY parse that
|
|
1193
|
+
* feeds activeChat.messages — the dashboard's preview / lastMessageRole /
|
|
1194
|
+
* completionMarker would otherwise never see the answer and show the session
|
|
1195
|
+
* stuck on the user prompt. getState() appends this cached assistant bubble to
|
|
1196
|
+
* the status messages when the PTY tail has none, so those fields reflect the
|
|
1197
|
+
* real last answer with ZERO per-tick native reads (the native read already ran
|
|
1198
|
+
* once at completion). Reset on the next turn's start.
|
|
1199
|
+
*/
|
|
1200
|
+
private lastCompletionSummary: { content: string; receivedAt: number } | null = null;
|
|
709
1201
|
|
|
710
1202
|
private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
|
|
711
1203
|
const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
|
|
@@ -734,13 +1226,353 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
734
1226
|
this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
|
|
735
1227
|
}
|
|
736
1228
|
|
|
737
|
-
|
|
1229
|
+
/**
|
|
1230
|
+
* BRAIN-ROUTING (runtime-control thinking axis): for a provider that selects
|
|
1231
|
+
* reasoning effort via a runtime control instead of a launch arg (e.g. hermes
|
|
1232
|
+
* `reasoning`), apply the requested initialThinkingLevel after spawn by invoking
|
|
1233
|
+
* that control's setScript. The provider names the control via thinkingControlId.
|
|
1234
|
+
* The standard level is mapped through thinkingLevelMap first (same as the
|
|
1235
|
+
* launch-arg path). Best-effort: any failure logs and never blocks launch.
|
|
1236
|
+
*/
|
|
1237
|
+
private async applyInitialThinkingLevelViaControl(): Promise<void> {
|
|
1238
|
+
const level = typeof this.initialThinkingLevel === 'string' ? this.initialThinkingLevel.trim() : '';
|
|
1239
|
+
if (!level) return;
|
|
1240
|
+
const controlId = (this.provider as any).thinkingControlId;
|
|
1241
|
+
if (!controlId) return; // provider uses thinkingLaunchArgs (or has no support)
|
|
1242
|
+
const controls: any[] = Array.isArray((this.provider as any).controls) ? (this.provider as any).controls : [];
|
|
1243
|
+
const control = controls.find(c => c && c.id === controlId);
|
|
1244
|
+
if (!control || !control.setScript) return;
|
|
1245
|
+
// Map the standard level to the provider's own vocabulary (unchanged if absent).
|
|
1246
|
+
const map = (this.provider as any).thinkingLevelMap as Record<string, string> | undefined;
|
|
1247
|
+
const mapped = (map && typeof map[level] === 'string' && map[level].trim()) ? map[level].trim() : level;
|
|
1248
|
+
try {
|
|
1249
|
+
await waitForCliAdapterReady(this.adapter);
|
|
1250
|
+
const raw = await this.adapter.invokeScript(control.setScript, { value: mapped });
|
|
1251
|
+
const parsed = parseCliScriptResult(raw);
|
|
1252
|
+
if (!parsed.success) {
|
|
1253
|
+
LOG.warn('CLI', `[${this.type}] thinking control '${controlId}' set to '${mapped}' failed: ${parsed.payload?.error || 'unknown'}`);
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
const cliCommand = getCliScriptCommand(parsed.payload);
|
|
1257
|
+
if (cliCommand?.type === 'send_message' && cliCommand.text) {
|
|
1258
|
+
await this.adapter.sendMessage(cliCommand.text);
|
|
1259
|
+
} else if (cliCommand?.type === 'pty_write' && cliCommand.text) {
|
|
1260
|
+
await this.adapter.writeRaw(cliCommand.text + '\r');
|
|
1261
|
+
}
|
|
1262
|
+
LOG.info('CLI', `[${this.type}] applied thinking level '${mapped}' via control '${controlId}'`);
|
|
1263
|
+
} catch (e: any) {
|
|
1264
|
+
LOG.warn('CLI', `[${this.type}] thinking control apply threw: ${e?.message || e}`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
private completionHasFinalAssistantMessage(messages: unknown, turnStartedAt?: number): boolean {
|
|
738
1269
|
const visibleMessages = (Array.isArray(messages) ? messages : [])
|
|
739
1270
|
.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
|
|
740
1271
|
const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
|
|
741
1272
|
const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
|
|
742
1273
|
const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
|
|
743
|
-
|
|
1274
|
+
if (role !== 'assistant' || !content) return false;
|
|
1275
|
+
// Guard: if the last assistant message looks like an active approval/input prompt,
|
|
1276
|
+
// it is not a real completion — the session is still awaiting user input.
|
|
1277
|
+
if (looksLikeActiveApprovalPromptText(content)) return false;
|
|
1278
|
+
// FALSE-IDLE turn-boundary evidence (Defect 1b): when a producing-turn start is
|
|
1279
|
+
// known, the final assistant bubble must POST-DATE it. A STALE mid-turn assistant
|
|
1280
|
+
// (predating this turn's start — e.g. the last bubble of a prior sub-turn observed
|
|
1281
|
+
// during an inter-approval valley) must NOT satisfy the finalization gate, or a
|
|
1282
|
+
// false-idle blip emits a completion carrying that stale summary. A bubble with no
|
|
1283
|
+
// parseable timestamp cannot be proven stale, so it is kept (fails open — behaviour
|
|
1284
|
+
// identical to before for providers/paths that carry no timestamps).
|
|
1285
|
+
if (typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0) {
|
|
1286
|
+
// readChatMessageTimestampMs mirrors the summary turn-scoping reader
|
|
1287
|
+
// (extractFinalSummaryFromMessagesAfter) — same seconds-vs-ms heuristic and
|
|
1288
|
+
// field precedence — so the present-check and the summary-scope agree on which
|
|
1289
|
+
// bubbles predate the turn.
|
|
1290
|
+
const ts = readChatMessageTimestampMs(lastVisible);
|
|
1291
|
+
if (typeof ts === 'number' && ts < turnStartedAt) return false;
|
|
1292
|
+
}
|
|
1293
|
+
return true;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
private recordPendingTranscriptProbe(pending: CompletedDebouncePending): ExternalTranscriptProbe | null {
|
|
1297
|
+
const probe = this.lastExternalCompletionProbe;
|
|
1298
|
+
if (!probe) return null;
|
|
1299
|
+
const history = pending.transcriptProbeHistory || [];
|
|
1300
|
+
const last = history[history.length - 1];
|
|
1301
|
+
if (!last || last.readAt !== probe.readAt || last.msgCount !== probe.msgCount || last.lastRole !== probe.lastRole || last.contentLen !== probe.contentLen) {
|
|
1302
|
+
history.push(probe);
|
|
1303
|
+
pending.transcriptProbeHistory = history.slice(-5);
|
|
1304
|
+
}
|
|
1305
|
+
return probe;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* The spawned CLI's env overrides (e.g. the mesh coordinator points hermes
|
|
1310
|
+
* at a per-coordinator HERMES_HOME so its state.db lives in a tmpdir instead
|
|
1311
|
+
* of ~/.hermes). The native-history executor expands `${HERMES_HOME:-~/.hermes}`
|
|
1312
|
+
* from this map, so the completion gate MUST pass it through — otherwise the
|
|
1313
|
+
* gate reads ~/.hermes, finds no coordinator-session transcript, and
|
|
1314
|
+
* false-fires missing_final_assistant on every coordinator turn.
|
|
1315
|
+
*/
|
|
1316
|
+
private spawnedEnvOverrides(): Record<string, string> | undefined {
|
|
1317
|
+
const meta = typeof (this.adapter as any)?.getRuntimeMetadata === 'function'
|
|
1318
|
+
? (this.adapter as any).getRuntimeMetadata()
|
|
1319
|
+
: undefined;
|
|
1320
|
+
const env = meta && typeof meta === 'object' ? (meta as Record<string, unknown>).spawnedEnv : undefined;
|
|
1321
|
+
return env && typeof env === 'object' ? env as Record<string, string> : undefined;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
private readExternalCompletionMessages(): unknown[] | null {
|
|
1325
|
+
const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
|
|
1326
|
+
if (!adapterOwnsMessagesElsewhere) return null;
|
|
1327
|
+
if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
|
|
1328
|
+
|
|
1329
|
+
// Resolve a CONCRETE native-history handle for this session's OWN
|
|
1330
|
+
// conversation. A provider that exposes its session id on the CLI
|
|
1331
|
+
// (codex/claude/hermes) sets this.providerSessionId. antigravity takes no
|
|
1332
|
+
// --session-id, so this.providerSessionId stays empty — recover the real
|
|
1333
|
+
// on-disk conversation id from the read pin persisted across restart
|
|
1334
|
+
// (state.json sessionProviderSessionPins, keyed by this session's
|
|
1335
|
+
// instanceId — the same map a binding read_chat / mesh_read_chat records
|
|
1336
|
+
// the resolved uuid into). The OLD `if (!this.providerSessionId) return
|
|
1337
|
+
// null` guard blocked antigravity's completion transcript entirely, so its
|
|
1338
|
+
// final-assistant evidence was permanently 'unavailable' and the turn
|
|
1339
|
+
// completion never emitted → the mesh reconcile loop reclaimed the
|
|
1340
|
+
// "delivered but no completion" task and re-dispatched the same MAGI prompt
|
|
1341
|
+
// (ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP, completion side).
|
|
1342
|
+
//
|
|
1343
|
+
// Prefer a concrete handle (providerSessionId, else the persisted pin). When
|
|
1344
|
+
// neither exists yet — a fresh antigravity turn whose conversation has not
|
|
1345
|
+
// been bound by any read_chat — fall through with an EMPTY handle and let the
|
|
1346
|
+
// dispatcher resolve this session's own conversations/<uuid>.db by the
|
|
1347
|
+
// spawn-floor + workspace + instanceId claim. This is safe now that the
|
|
1348
|
+
// claim owner token is single-form iid:<instanceId> (never collapses to '')
|
|
1349
|
+
// and pickUnboundConversationDb picks the oldest store born at/after THIS
|
|
1350
|
+
// session's floor: the probe resolves the session's OWN db under its own
|
|
1351
|
+
// owner token, so it can never steal a sibling's conversation (the earlier
|
|
1352
|
+
// theft required a floor=0 / owner='' collapse that no longer happens). It
|
|
1353
|
+
// means the completion summary is available on the FIRST completion — before
|
|
1354
|
+
// any read_chat has recorded a pin — which the dashboard tail-repair needs.
|
|
1355
|
+
let resolvedHandle = this.providerSessionId || '';
|
|
1356
|
+
if (!resolvedHandle) {
|
|
1357
|
+
try {
|
|
1358
|
+
const pinned = loadPersistedProviderSessionPins()[this.instanceId];
|
|
1359
|
+
if (typeof pinned === 'string' && pinned.trim()) resolvedHandle = pinned.trim();
|
|
1360
|
+
} catch { /* best-effort pin hydration */ }
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if (this.lastExternalCompletionProbe?.sourcePath) {
|
|
1364
|
+
try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
|
|
1365
|
+
}
|
|
1366
|
+
const restoredHistory = readProviderChatHistory(this.type, {
|
|
1367
|
+
canonicalHistory: this.provider.nativeHistory,
|
|
1368
|
+
historySessionId: resolvedHandle || undefined,
|
|
1369
|
+
workspace: this.workingDir,
|
|
1370
|
+
offset: 0,
|
|
1371
|
+
limit: Number.MAX_SAFE_INTEGER,
|
|
1372
|
+
historyBehavior: this.provider.historyBehavior,
|
|
1373
|
+
scripts: this.provider.scripts as any,
|
|
1374
|
+
sessionStartedAtMs: this.startedAt,
|
|
1375
|
+
// The claim owner token must match read_chat's so the exact-bind on our
|
|
1376
|
+
// own conversation stays idempotent rather than looking foreign, and so
|
|
1377
|
+
// the floor-based resolution above claims THIS session's db under its
|
|
1378
|
+
// own owner (never a sibling's).
|
|
1379
|
+
instanceId: this.instanceId,
|
|
1380
|
+
envOverrides: this.spawnedEnvOverrides(),
|
|
1381
|
+
forceRefresh: true,
|
|
1382
|
+
});
|
|
1383
|
+
if (restoredHistory.source !== 'provider-native') {
|
|
1384
|
+
this.lastExternalCompletionProbe = null;
|
|
1385
|
+
return null;
|
|
1386
|
+
}
|
|
1387
|
+
this.lastExternalCompletionProbe = buildExternalTranscriptProbe(
|
|
1388
|
+
restoredHistory.messages,
|
|
1389
|
+
restoredHistory.sourcePath,
|
|
1390
|
+
restoredHistory.sourceMtimeMs,
|
|
1391
|
+
);
|
|
1392
|
+
return restoredHistory.messages;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* The content of the LAST visible assistant bubble in a message list, or ''
|
|
1397
|
+
* when the tail is not an assistant reply. Skips trailing system/tool/activity
|
|
1398
|
+
* bubbles; stops (returns '') at the first user/human message. Used only for
|
|
1399
|
+
* the dashboard tail-repair cache — a display value, not a completion decision.
|
|
1400
|
+
*/
|
|
1401
|
+
private lastVisibleAssistantSummary(messages: unknown): string {
|
|
1402
|
+
if (!Array.isArray(messages)) return '';
|
|
1403
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
1404
|
+
const m = messages[i] as { role?: string; kind?: string; content?: unknown };
|
|
1405
|
+
const role = typeof m?.role === 'string' ? m.role : '';
|
|
1406
|
+
const kind = typeof m?.kind === 'string' ? m.kind : '';
|
|
1407
|
+
if (role === 'system') continue;
|
|
1408
|
+
if (kind === 'tool' || kind === 'activity') continue;
|
|
1409
|
+
if (role === 'user' || role === 'human') return '';
|
|
1410
|
+
if (role === 'assistant') return flattenContent(m.content as any).trim();
|
|
1411
|
+
return '';
|
|
1412
|
+
}
|
|
1413
|
+
return '';
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
|
|
1417
|
+
// (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
|
|
1418
|
+
// pure message-content check ("does the last visible bubble read as a finalized assistant
|
|
1419
|
+
// reply, post-dating the turn start?"). That LOWER bound alone treated the FIRST assistant
|
|
1420
|
+
// bubble of a turn that is STILL running — a tool call in flight between two assistant
|
|
1421
|
+
// bubbles — as proof the turn ended (RCA cases a & b). Require in ADDITION that the turn is
|
|
1422
|
+
// genuinely OVER: hasAdapterPendingResponse() folds the three upper-bound discriminators
|
|
1423
|
+
// into one — currentTurnScope closed, no in-flight tool (isProcessing false), and no partial
|
|
1424
|
+
// response buffer. So a mid-turn point-sample (short-gen / fast-collapse inline paths that
|
|
1425
|
+
// do NOT route through getCompletedFinalizationBlock) yields present=false and is held/settled
|
|
1426
|
+
// rather than fired. A genuinely-finished turn (adapter idle, no pending) is unaffected — the
|
|
1427
|
+
// gate stays open and the completion fires exactly as before. This mirrors the established
|
|
1428
|
+
// `completionHasFinalAssistantMessage(...) && !hasAdapterPendingResponse()` pairing already
|
|
1429
|
+
// used by the no-progress monitor reconcile path.
|
|
1430
|
+
const turnClosed = !this.hasAdapterPendingResponse();
|
|
1431
|
+
if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
|
|
1432
|
+
return {
|
|
1433
|
+
present: turnClosed,
|
|
1434
|
+
messages: Array.isArray(parsedMessages) ? parsedMessages : [],
|
|
1435
|
+
source: 'parsed',
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const externalMessages = this.readExternalCompletionMessages();
|
|
1440
|
+
if (externalMessages) {
|
|
1441
|
+
const present = turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt);
|
|
1442
|
+
// Dashboard tail-repair cache: this runs on EVERY completion check
|
|
1443
|
+
// (mesh AND non-mesh — the non-mesh path suppresses the
|
|
1444
|
+
// generating_completed emit, so completionFinalSummary never runs there
|
|
1445
|
+
// and cannot cache). Cache whenever the external transcript's LAST
|
|
1446
|
+
// visible bubble is an assistant reply — this is a display value only, so
|
|
1447
|
+
// it is intentionally looser than the strict `present` completion gate
|
|
1448
|
+
// (which also requires turnClosed and turn-scoping): the dashboard should
|
|
1449
|
+
// show the answer as soon as native-history has it, even if the FSM has
|
|
1450
|
+
// not yet ratified the turn end. getState() replaces it on the next turn.
|
|
1451
|
+
const lastVisibleAssistant = this.lastVisibleAssistantSummary(externalMessages);
|
|
1452
|
+
if (lastVisibleAssistant) {
|
|
1453
|
+
this.lastCompletionSummary = { content: lastVisibleAssistant, receivedAt: Date.now() };
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
present,
|
|
1457
|
+
messages: externalMessages,
|
|
1458
|
+
source: 'external-native',
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return {
|
|
1463
|
+
present: false,
|
|
1464
|
+
messages: Array.isArray(parsedMessages) ? parsedMessages : [],
|
|
1465
|
+
source: 'unavailable',
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
private completionFinalSummary(parsedMessages: unknown, turnStartedAt?: number): string | undefined {
|
|
1470
|
+
// For native-source providers (claude-cli: chatMessagesOwnedExternally), the PTY
|
|
1471
|
+
// screen parse is NOT the source of truth for the final summary — the terminal
|
|
1472
|
+
// wraps/scrolls/clips text, so a screen-parsed assistant message is often a partial
|
|
1473
|
+
// prefix (e.g. 76 chars of a 112-char turn). The append-only native transcript holds
|
|
1474
|
+
// the complete turn. Prefer it whenever it yields a longer/complete summary; fall back
|
|
1475
|
+
// to the parsed screen only when the transcript is unavailable. This is the real cause
|
|
1476
|
+
// of the truncated finalSummary — independent of cloud vs standalone (it surfaces on
|
|
1477
|
+
// any short, fast-completing task where screen parse wins the race).
|
|
1478
|
+
//
|
|
1479
|
+
// NOTIF Defect-B: `turnStartedAt` (the producing turn's start, snapshotted on the
|
|
1480
|
+
// completedDebouncePending record) turn-scopes the NATIVE transcript read. The native
|
|
1481
|
+
// transcript holds the WHOLE session filtered only by session start, so a debounce that
|
|
1482
|
+
// flushes before the producing turn's final assistant bubble has landed would otherwise
|
|
1483
|
+
// return the PRIOR task's last bubble (event taskId=B but summary=A). Filtering to bubbles
|
|
1484
|
+
// at/after turnStartedAt yields '' in that race instead of the stale tail; the weak/empty
|
|
1485
|
+
// summary is later upgraded by the mesh reconcile loop once the real bubble is written.
|
|
1486
|
+
const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
|
|
1487
|
+
// FALSE-IDLE Defect 1b: turn-scope the PARSED screen fallback too. Without this a stale
|
|
1488
|
+
// mid-turn assistant (predating turnStartedAt) that the turn-boundary gate already
|
|
1489
|
+
// rejected as evidence could still leak into the finalSummary via this parsed fallback
|
|
1490
|
+
// when the external transcript's turn-scoped read is empty — freezing the very stale text
|
|
1491
|
+
// the gate rejected. extractFinalSummaryFromMessagesAfter drops bubbles before the turn
|
|
1492
|
+
// start; with no boundary known (turnStartedAt falsy) it is identical to the unscoped read.
|
|
1493
|
+
const parsedSummary = extractFinalSummaryFromMessagesAfter(
|
|
1494
|
+
(this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)
|
|
1495
|
+
? (Array.isArray(parsedMessages) ? parsedMessages : [])
|
|
1496
|
+
: []) as any,
|
|
1497
|
+
turnStartedAt,
|
|
1498
|
+
);
|
|
1499
|
+
if (adapterOwnsMessagesElsewhere) {
|
|
1500
|
+
const externalMessages = this.readExternalCompletionMessages();
|
|
1501
|
+
// Turn-scope the external transcript: never return a bubble produced before this
|
|
1502
|
+
// turn started. With no boundary known (turnStartedAt falsy) behaviour is unchanged.
|
|
1503
|
+
const externalSummary = externalMessages
|
|
1504
|
+
? extractFinalSummaryFromMessagesAfter(externalMessages as any, turnStartedAt)
|
|
1505
|
+
: '';
|
|
1506
|
+
// The transcript is authoritative for native-source providers. Use it unless it is
|
|
1507
|
+
// empty (not yet written, or no in-turn bubble) — only then fall back to the screen
|
|
1508
|
+
// parse, which reflects the LIVE screen (this turn's output), not the stale tail.
|
|
1509
|
+
if (externalSummary) {
|
|
1510
|
+
// Cache the resolved final assistant for the dashboard tail-repair in
|
|
1511
|
+
// getState(). This runs on EVERY completion attempt (including non-mesh
|
|
1512
|
+
// sessions whose generating_completed is suppressed, and short-gen
|
|
1513
|
+
// settle paths), so the dashboard sees the answer even when no
|
|
1514
|
+
// agent:generating_completed is ever emitted. Native read already ran.
|
|
1515
|
+
this.lastCompletionSummary = { content: externalSummary, receivedAt: Date.now() };
|
|
1516
|
+
return externalSummary;
|
|
1517
|
+
}
|
|
1518
|
+
return parsedSummary || undefined;
|
|
1519
|
+
}
|
|
1520
|
+
return parsedSummary || undefined;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
private buildCompletedFinalizationDiagnostic(args: {
|
|
1524
|
+
blockReason: string;
|
|
1525
|
+
latestStatus?: any;
|
|
1526
|
+
latestVisibleStatus: string;
|
|
1527
|
+
waitedMs: number;
|
|
1528
|
+
pending: CompletedDebouncePending;
|
|
1529
|
+
emittedAfterFinalizationTimeout: boolean;
|
|
1530
|
+
}): Record<string, unknown> {
|
|
1531
|
+
let parsed: any = null;
|
|
1532
|
+
let parseError: string | undefined;
|
|
1533
|
+
try {
|
|
1534
|
+
parsed = this.adapter.getScriptParsedStatus();
|
|
1535
|
+
} catch (error: any) {
|
|
1536
|
+
parseError = error?.message || String(error);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const evidence = this.completionFinalAssistantEvidence(parsed?.messages);
|
|
1540
|
+
if (evidence.source === 'external-native') {
|
|
1541
|
+
this.recordPendingTranscriptProbe(args.pending);
|
|
1542
|
+
}
|
|
1543
|
+
const visibleMessages = (Array.isArray(evidence.messages) ? evidence.messages : [])
|
|
1544
|
+
.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
|
|
1545
|
+
const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
|
|
1546
|
+
const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
|
|
1547
|
+
const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
|
|
1548
|
+
const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
|
|
1549
|
+
|
|
1550
|
+
return {
|
|
1551
|
+
providerType: this.type,
|
|
1552
|
+
sessionId: this.instanceId,
|
|
1553
|
+
providerSessionId: this.providerSessionId || null,
|
|
1554
|
+
workspace: this.workingDir,
|
|
1555
|
+
blockReason: args.blockReason,
|
|
1556
|
+
emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
|
|
1557
|
+
waitedMs: args.waitedMs,
|
|
1558
|
+
maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
|
|
1559
|
+
adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
|
|
1560
|
+
latestVisibleStatus: args.latestVisibleStatus,
|
|
1561
|
+
parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
|
|
1562
|
+
parseError: parseError || undefined,
|
|
1563
|
+
finalAssistantPresent: evidence.present,
|
|
1564
|
+
finalAssistantEvidenceSource: evidence.source,
|
|
1565
|
+
visibleMessageCount: visibleMessages.length,
|
|
1566
|
+
lastVisibleRole,
|
|
1567
|
+
lastVisibleKind,
|
|
1568
|
+
lastVisibleContentLength,
|
|
1569
|
+
pendingStartedAt: this.generatingStartedAt || null,
|
|
1570
|
+
pendingFirstObservedAt: args.pending.firstObservedAt,
|
|
1571
|
+
pendingTimestamp: args.pending.timestamp,
|
|
1572
|
+
pendingDurationSec: args.pending.duration,
|
|
1573
|
+
previousBlockReason: args.pending.loggedBlockReason || null,
|
|
1574
|
+
transcriptProbeHistory: args.pending.transcriptProbeHistory || [],
|
|
1575
|
+
};
|
|
744
1576
|
}
|
|
745
1577
|
|
|
746
1578
|
private hasAdapterPendingResponse(): boolean {
|
|
@@ -765,41 +1597,310 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
765
1597
|
if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
|
|
766
1598
|
if (adapterRawStatus !== 'idle') return false;
|
|
767
1599
|
if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
|
|
768
|
-
|
|
1600
|
+
if (this.hasAdapterPendingResponse()) return false;
|
|
1601
|
+
// Do not suppress when the adapter's raw response buffer is still non-empty.
|
|
1602
|
+
// This catches the case where isWaitingForResponse has already flipped to false
|
|
1603
|
+
// (so getPartialResponse() returns '') but the provider's native parser still
|
|
1604
|
+
// reports generating because it's parsing buffered content. Suppressing the
|
|
1605
|
+
// finalization block here would emit a false completion event while the provider
|
|
1606
|
+
// session is still actively processing its response stream.
|
|
1607
|
+
const adapterAny = this.adapter as any;
|
|
1608
|
+
if (typeof adapterAny?.responseBuffer === 'string' && adapterAny.responseBuffer.trim()) return false;
|
|
1609
|
+
return true;
|
|
769
1610
|
}
|
|
770
1611
|
|
|
771
|
-
private
|
|
772
|
-
if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}
|
|
1612
|
+
private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
|
|
1613
|
+
if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
|
|
773
1614
|
|
|
774
1615
|
const adapterAny = this.adapter as any;
|
|
775
|
-
|
|
776
|
-
|
|
1616
|
+
const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
|
|
1617
|
+
// (FALSEIDLE-a FixA) The adapter pending-response checks run UNCONDITIONALLY.
|
|
1618
|
+
// Previously they were SKIPPED when approvalResolvedIdle, on the assumption that
|
|
1619
|
+
// a waiting_approval→idle transition proved the approval's turn was over. But
|
|
1620
|
+
// auto-approve RESOLVES the modal and the agent RESUMES the same turn — currentTurnScope
|
|
1621
|
+
// / isWaitingForResponse stay set, or a tool runs — so skipping the guard let the FIRST
|
|
1622
|
+
// assistant bubble of the still-running turn be mistaken for the last and fired an early
|
|
1623
|
+
// completion the coordinator could never correct (RCA case a). Keep the guard live for the
|
|
1624
|
+
// approval path too: when the resumed turn genuinely ends these clear and the completion
|
|
1625
|
+
// fires. Approval-resolved holds are NON-terminal (bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS)
|
|
1626
|
+
// so a provider that never closes its turn-scope still force-fires a weak completion rather
|
|
1627
|
+
// than wedging; the non-approval path keeps its terminal hold (a genuinely-busy adapter must
|
|
1628
|
+
// never force a completion out).
|
|
1629
|
+
if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: !approvalResolvedIdle };
|
|
1630
|
+
if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: !approvalResolvedIdle };
|
|
1631
|
+
if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: !approvalResolvedIdle };
|
|
777
1632
|
|
|
778
1633
|
const partial = typeof this.adapter.getPartialResponse === 'function'
|
|
779
1634
|
? this.adapter.getPartialResponse()
|
|
780
1635
|
: '';
|
|
781
|
-
if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
|
|
1636
|
+
if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
|
|
782
1637
|
|
|
783
1638
|
let parsed: any;
|
|
784
1639
|
try {
|
|
785
1640
|
parsed = this.adapter.getScriptParsedStatus();
|
|
786
1641
|
} catch (error: any) {
|
|
787
|
-
return `parse_error:${error?.message || String(error)}
|
|
1642
|
+
return { reason: `parse_error:${error?.message || String(error)}` };
|
|
788
1643
|
}
|
|
789
1644
|
|
|
790
1645
|
const parsedStatus = typeof parsed?.status === 'string' ? parsed.status : 'unknown';
|
|
791
|
-
if (parsedStatus !== 'idle')
|
|
792
|
-
|
|
793
|
-
|
|
1646
|
+
if (parsedStatus !== 'idle') {
|
|
1647
|
+
const adapterStatus = this.adapter.getStatus({ allowParse: false });
|
|
1648
|
+
if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
|
|
1649
|
+
return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
|
|
1650
|
+
}
|
|
1651
|
+
if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
|
|
1652
|
+
const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
|
|
1653
|
+
// FALSE-IDLE turn-boundary evidence (Defect 1b): turn-scope the present-check so a
|
|
1654
|
+
// STALE mid-turn assistant (predating pending.turnStartedAt) cannot satisfy the
|
|
1655
|
+
// finalization gate. Only the confirming final-assistant bubble that POST-DATES this
|
|
1656
|
+
// turn's start counts as evidence the turn genuinely ended.
|
|
1657
|
+
const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages, pending.turnStartedAt);
|
|
1658
|
+
const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
|
|
1659
|
+
LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
|
|
1660
|
+
if (!finalAssistantEvidence.present) {
|
|
1661
|
+
if (adapterOwnsMessagesElsewhere) {
|
|
1662
|
+
if (finalAssistantEvidence.source === 'external-native') {
|
|
1663
|
+
const probe = this.recordPendingTranscriptProbe(pending);
|
|
1664
|
+
if (probe && !pending.loggedTranscriptProbe) {
|
|
1665
|
+
LOG.info('CLI', `[${this.type}] external transcript probe: msgCount=${probe.msgCount} lastRole=${probe.lastRole || 'none'} lastKind=${probe.lastKind || 'none'} contentLen=${probe.contentLen} sourceMtime=${probe.sourceMtimeMs ?? 'unknown'} mtimeAge=${probe.mtimeAgeMs ?? 'unknown'}ms`);
|
|
1666
|
+
pending.loggedTranscriptProbe = true;
|
|
1667
|
+
}
|
|
1668
|
+
LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
|
|
1669
|
+
if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
|
|
1670
|
+
return null;
|
|
1671
|
+
}
|
|
1672
|
+
if (this.type === 'antigravity-cli') {
|
|
1673
|
+
return null;
|
|
1674
|
+
}
|
|
1675
|
+
// (SETTLE-VALLEY) The inter-approval idle valley: a native-history mesh worker
|
|
1676
|
+
// that resolved an approval and fell briefly idle (waiting_approval→idle) BEFORE
|
|
1677
|
+
// the next approval turn resumes. The live valley (~3s) is mostly covered by the
|
|
1678
|
+
// 4000ms NATIVE_HISTORY_MESH_IDLE_SETTLE_MS settle window, but a longer valley can
|
|
1679
|
+
// still let the flush run while the transcript's final assistant turn is not yet
|
|
1680
|
+
// written (source still the screen parse → finalAssistantPresent=false,
|
|
1681
|
+
// workerResult.source='default'). CANON-C would emit immediately here, freezing a
|
|
1682
|
+
// truncated preamble summary as evidenceLevel=insufficient. Instead HOLD: this
|
|
1683
|
+
// waiting_approval hold complements the settle window. Retry until the transcript finalizes
|
|
1684
|
+
// (block clears → genuine emit) or the worker resumes (resume guard cancels),
|
|
1685
|
+
// bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS. Scoped to the approval-resolved
|
|
1686
|
+
// idle so a genuinely-finished background-child turn keeps the CANON-C immediate
|
|
1687
|
+
// emit (its transcript trails by a write, not by a whole resume).
|
|
1688
|
+
if (allowMissingAssistantTimeout && pending.previousStatus === 'waiting_approval') {
|
|
1689
|
+
return { reason: 'missing_final_assistant', terminal: false, holdForTranscript: true };
|
|
1690
|
+
}
|
|
1691
|
+
return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
|
|
1692
|
+
}
|
|
1693
|
+
if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
|
|
1694
|
+
return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
|
|
1695
|
+
}
|
|
1696
|
+
} else {
|
|
1697
|
+
LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
|
|
1698
|
+
return {
|
|
1699
|
+
reason: 'missing_final_assistant',
|
|
1700
|
+
terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
|
|
1701
|
+
allowTimeout: allowMissingAssistantTimeout,
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// (FALSEIDLE-a) Structural approval-resolution gate. Runs BEFORE the brittle
|
|
1707
|
+
// screen-text heuristic below so it also catches modals whose text does not match
|
|
1708
|
+
// looksLikeActiveApprovalPromptText (e.g. claude-cli's cd / "untrusted hooks" prompt).
|
|
1709
|
+
const approvalResolutionBlock = this.approvalResolutionFinalizationBlock(pending);
|
|
1710
|
+
if (approvalResolutionBlock) return approvalResolutionBlock;
|
|
1711
|
+
|
|
1712
|
+
// Guard: if the screen still shows an approval/choice prompt as the last visible text,
|
|
1713
|
+
// the turn is not complete even if the parsed status says idle and there is an assistant
|
|
1714
|
+
// message. This catches the case where waiting_approval→idle transitions occur before
|
|
1715
|
+
// the modal has been resolved (e.g. the PTY rendered the prompt but no button press fired).
|
|
1716
|
+
try {
|
|
1717
|
+
const screenText = typeof (this.adapter as any).getScreenText === 'function'
|
|
1718
|
+
? String((this.adapter as any).getScreenText() || '')
|
|
1719
|
+
: '';
|
|
1720
|
+
if (screenText) {
|
|
1721
|
+
const tailLines = screenText.split(/\r?\n/).slice(-16).join('\n');
|
|
1722
|
+
if (looksLikeActiveApprovalPromptText(tailLines)) {
|
|
1723
|
+
return { reason: 'screen_shows_approval_prompt', terminal: approvalResolvedIdle };
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
} catch { /* defensive: screen text read is best-effort */ }
|
|
794
1727
|
|
|
795
1728
|
return null;
|
|
796
1729
|
}
|
|
797
1730
|
|
|
1731
|
+
// (FALSEIDLE-a) Positive, structural proof that the latest approval entry was resolved
|
|
1732
|
+
// through ADHDev. resolveModal() — driven by auto-approve, dashboard/mesh_approve, and
|
|
1733
|
+
// dev-cli-debug alike — advances the engine's lastResolvedEntrySeq to the current
|
|
1734
|
+
// approvalEntrySeq. So `lastResolvedEntrySeq >= approvalEntrySeq` (with a real entry,
|
|
1735
|
+
// approvalEntrySeq > 0) means the modal we last saw was actually answered. Absence of this
|
|
1736
|
+
// evidence after a waiting_approval→idle transition means the idle is suspect: the spec's
|
|
1737
|
+
// text-based approval→idle rule false-tripped while the modal is still unresolved.
|
|
1738
|
+
// Fails OPEN (returns true) when the seq fields are unavailable, so the gate can never wedge
|
|
1739
|
+
// a session on a provider/adapter that does not surface the counters.
|
|
1740
|
+
private hasApprovalResolutionEvidence(): boolean {
|
|
1741
|
+
try {
|
|
1742
|
+
const status = this.adapter.getStatus({ allowParse: false }) as any;
|
|
1743
|
+
const entrySeq = typeof status?.approvalEntrySeq === 'number' ? status.approvalEntrySeq : 0;
|
|
1744
|
+
if (entrySeq <= 0) return true;
|
|
1745
|
+
const resolvedSeq = typeof status?.lastResolvedEntrySeq === 'number' ? status.lastResolvedEntrySeq : undefined;
|
|
1746
|
+
if (resolvedSeq === undefined) return true;
|
|
1747
|
+
return resolvedSeq >= entrySeq;
|
|
1748
|
+
} catch {
|
|
1749
|
+
return true;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// (FALSEIDLE-a) Hold a completion that is the anomalous DIRECT waiting_approval→idle
|
|
1754
|
+
// transition with no positive resolution evidence. A genuinely resolved approval routes
|
|
1755
|
+
// through resolveModal → setStatus('generating'), so its completion's previousStatus is
|
|
1756
|
+
// 'generating' (not 'waiting_approval') and this gate never fires for it. Scoped to
|
|
1757
|
+
// delegated mesh/coordinator sessions — whose only modal-resolution path is auto-approve /
|
|
1758
|
+
// mesh_approve (both advance lastResolvedEntrySeq) — so an interactive local session, where
|
|
1759
|
+
// a human may answer the PTY prompt directly and leave no resolveModal record, is untouched.
|
|
1760
|
+
// Non-terminal: the hold is bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS (30s), giving a
|
|
1761
|
+
// settling auto-approve time to fire and advance the seq, and guaranteeing no permanent wedge
|
|
1762
|
+
// if resolution ever happens via a path that does not record evidence.
|
|
1763
|
+
private approvalResolutionFinalizationBlock(pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
|
|
1764
|
+
if (pending.previousStatus !== 'waiting_approval') return null;
|
|
1765
|
+
const meshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
|
|
1766
|
+
if (!meshContext) return null;
|
|
1767
|
+
if (this.hasApprovalResolutionEvidence()) return null;
|
|
1768
|
+
return { reason: 'approval_resolution_unconfirmed', terminal: false };
|
|
1769
|
+
}
|
|
1770
|
+
|
|
798
1771
|
private scheduleCompletedDebounceFlush(delayMs: number): void {
|
|
799
1772
|
if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
|
|
800
1773
|
this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
|
|
801
1774
|
}
|
|
802
1775
|
|
|
1776
|
+
// EVTTRACE (observation-only): is this a mesh worker session whose completion
|
|
1777
|
+
// events must route to a coordinator? Used purely to gate trace logging so a
|
|
1778
|
+
// non-mesh CLI session's completions don't add EvtTrace noise. No decision logic.
|
|
1779
|
+
private isMeshWorkerSession(): boolean {
|
|
1780
|
+
return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId
|
|
1781
|
+
|| this.settings.meshNodeId || this.settings.launchedByCoordinator);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
|
|
1786
|
+
* persist before the in-progress settle gate is torn down. For a delegated
|
|
1787
|
+
* worker whose auto-approve episode is genuinely still cycling (mask clock
|
|
1788
|
+
* alive), the FSM's full waiting_approval → busy → waiting_approval flap runs
|
|
1789
|
+
* on a multi-second period, so the settle continuity window is extended to
|
|
1790
|
+
* AUTO_APPROVE_FLAP_CONTINUITY_MS to bridge it (still bounded, and still capped
|
|
1791
|
+
* by AUTO_APPROVE_MASK_STALL_MS). Every other case — foreground/attended
|
|
1792
|
+
* session, or no active mask episode — keeps the tight default hysteresis so a
|
|
1793
|
+
* genuine resolution frees the gate promptly.
|
|
1794
|
+
*/
|
|
1795
|
+
private autoApproveContinuityWindowMs(): number {
|
|
1796
|
+
return this.autoApproveMaskSince > 0 && this.isAutonomousMeshSession()
|
|
1797
|
+
? CliProviderInstance.AUTO_APPROVE_FLAP_CONTINUITY_MS
|
|
1798
|
+
: CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* The settle-gate identity signature for a raw activeModal, or null when the
|
|
1803
|
+
* modal is NOT a concrete auto-approvable consent prompt (no captured buttons,
|
|
1804
|
+
* a picker/confirm kind, or no reliable affirmative+decline anchor). Mirrors the
|
|
1805
|
+
* gates the auto-approve fire path applies before computing modalSignature, so
|
|
1806
|
+
* the mask-stall nudge can ask the SAME question the settle gate is tracking —
|
|
1807
|
+
* "is THIS frame's modal the identity the settle clock is accruing against?" —
|
|
1808
|
+
* without duplicating the button-pick logic. The signature is message +
|
|
1809
|
+
* normalized affirmative label only (no volatile counters/button set), matching
|
|
1810
|
+
* the fire path exactly (AUTOAPPROVE-SETTLE-FLAP).
|
|
1811
|
+
*/
|
|
1812
|
+
private approvableModalSignature(modal: any): string | null {
|
|
1813
|
+
const buttons = Array.isArray(modal?.buttons)
|
|
1814
|
+
? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
|
|
1815
|
+
: [];
|
|
1816
|
+
if (!modal || buttons.length === 0) return null;
|
|
1817
|
+
const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
|
|
1818
|
+
if (modalKind !== 'approval') return null;
|
|
1819
|
+
const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
|
|
1820
|
+
const hasReliableConsentAnchor = hasNegativeApprovalOption(buttons)
|
|
1821
|
+
|| hasReliableApprovalAffirmative(buttons);
|
|
1822
|
+
if (buttonIndex < 0 || !hasReliableConsentAnchor) return null;
|
|
1823
|
+
return approvalModalSignature(modal?.message, normalizeApprovalLabel(buttonLabel));
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// FALSE-IDLE (self-coordinator settle): an autonomously-progressing mesh session
|
|
1827
|
+
// is either a delegated worker (isMeshWorkerSession) OR the coordinator's OWN
|
|
1828
|
+
// claude-cli session (meshCoordinatorFor). Both run auto-approved tool turns whose
|
|
1829
|
+
// inter-approval valley (busy→idle blip→generating re-entry ~0.5s later) must be
|
|
1830
|
+
// absorbed by the completedDebounce settle window, not flushed on the first idle
|
|
1831
|
+
// sample. The worker branch already gets NATIVE_HISTORY_MESH_IDLE_SETTLE_MS; the
|
|
1832
|
+
// self-coordinator session (worker markers absent, meshCoordinatorFor present) was
|
|
1833
|
+
// taking flushDelay=0 — no settle window — so its busyEpoch/lastOutputAt continuity
|
|
1834
|
+
// guard had no window to observe the valley and fired mid-turn "next-step" previews
|
|
1835
|
+
// as a finalSummary. Mirrors the isAutonomousMeshSession notion in isTransientToolConsent.
|
|
1836
|
+
private isAutonomousMeshSession(): boolean {
|
|
1837
|
+
return this.isMeshWorkerSession() || !!this.settings.meshCoordinatorFor;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
|
|
1842
|
+
* Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
|
|
1843
|
+
* submitted and surviving until the next turn starts) over the last-write-wins
|
|
1844
|
+
* session scalar (settings.meshActiveTaskId). The scalar is retained only as a
|
|
1845
|
+
* backward-compat alias for the "current/last assignment" and is the source of the
|
|
1846
|
+
* NOTIF-MISDELIVER / TASK-MSG-MISROUTE race: a second task attaching before this
|
|
1847
|
+
* turn completes overwrites it. Returns undefined for a non-task ad-hoc turn.
|
|
1848
|
+
*/
|
|
1849
|
+
private completingTurnTaskId(): string | undefined {
|
|
1850
|
+
const turnTaskId = this.adapter?.currentTurnTaskId;
|
|
1851
|
+
if (typeof turnTaskId === 'string' && turnTaskId.trim()) return turnTaskId;
|
|
1852
|
+
const scalar = this.settings.meshActiveTaskId;
|
|
1853
|
+
return typeof scalar === 'string' && scalar.trim() ? scalar : undefined;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// EVTTRACE correlation context for this session's completion lifecycle. taskId is
|
|
1857
|
+
// the primary grep anchor; instanceId is the session fallback.
|
|
1858
|
+
private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {
|
|
1859
|
+
return {
|
|
1860
|
+
// ARCH-REFACTOR R1: trace the per-turn taskId (falling back to the scalar) so
|
|
1861
|
+
// EvtTrace anchors on the same id the completion event actually carries.
|
|
1862
|
+
taskId: this.completingTurnTaskId(),
|
|
1863
|
+
sessionId: this.instanceId,
|
|
1864
|
+
nodeId: this.settings.meshNodeId,
|
|
1865
|
+
meshId: this.settings.meshNodeFor,
|
|
1866
|
+
event,
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// COMPLETION-EARLYNOTIFY instrumentation. A session-keyed FSM-transition +
|
|
1871
|
+
// completion-gate snapshot recorded into the shared debug-trace ring buffer
|
|
1872
|
+
// (secret-safe, length/role/pattern-name only — never screen or bubble text).
|
|
1873
|
+
// Retrieved via getRecentDebugTrace (chat_debug_bundle). Both categories are a
|
|
1874
|
+
// no-op unless collectDebugTrace is on AND the category is selected, so the
|
|
1875
|
+
// hot-path guards below (completionTraceOn / fsmTraceOn) keep production cost
|
|
1876
|
+
// at a single boolean check.
|
|
1877
|
+
private completionTraceOn(): boolean {
|
|
1878
|
+
return shouldCollectTraceCategory('completion-gate');
|
|
1879
|
+
}
|
|
1880
|
+
private fsmTraceOn(): boolean {
|
|
1881
|
+
return shouldCollectTraceCategory('fsm-transition');
|
|
1882
|
+
}
|
|
1883
|
+
private recordCompletionGateTrace(stage: string, payload: Record<string, unknown>): void {
|
|
1884
|
+
recordDebugTrace({
|
|
1885
|
+
category: 'completion-gate',
|
|
1886
|
+
stage,
|
|
1887
|
+
level: 'debug',
|
|
1888
|
+
sessionId: this.instanceId,
|
|
1889
|
+
providerType: this.type,
|
|
1890
|
+
payload,
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
private recordFsmTransitionTrace(payload: Record<string, unknown>): void {
|
|
1894
|
+
recordDebugTrace({
|
|
1895
|
+
category: 'fsm-transition',
|
|
1896
|
+
stage: 'transition',
|
|
1897
|
+
level: 'debug',
|
|
1898
|
+
sessionId: this.instanceId,
|
|
1899
|
+
providerType: this.type,
|
|
1900
|
+
payload,
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
803
1904
|
private flushCompletedDebounceIfFinalized(): void {
|
|
804
1905
|
const pending = this.completedDebouncePending;
|
|
805
1906
|
if (!pending) {
|
|
@@ -809,97 +1910,763 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
809
1910
|
|
|
810
1911
|
const latestStatus = this.adapter.getStatus({ allowParse: false });
|
|
811
1912
|
const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
|
|
812
|
-
const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
|
|
1913
|
+
const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
|
|
1914
|
+
LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
|
|
813
1915
|
if (latestVisibleStatus !== 'idle') {
|
|
814
1916
|
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
|
|
1917
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
|
|
1918
|
+
blockReason: 'resumed_status',
|
|
1919
|
+
latestVisibleStatus,
|
|
1920
|
+
previousStatus: pending.previousStatus,
|
|
1921
|
+
busyEpochAtArm: pending.busyEpochAtArm,
|
|
1922
|
+
busyEpoch: this.busyEpoch,
|
|
1923
|
+
});
|
|
815
1924
|
this.completedDebouncePending = null;
|
|
816
1925
|
this.completedDebounceTimer = null;
|
|
817
1926
|
return;
|
|
818
1927
|
}
|
|
819
1928
|
|
|
820
|
-
|
|
821
|
-
|
|
1929
|
+
// FALSE-IDLE continuity guard (Defect 1a): the point-sample above only proves the
|
|
1930
|
+
// session is idle at THIS instant. A momentary busy→idle blip inside an inter-approval
|
|
1931
|
+
// valley (auto-approved tool turns) opens AND closes a generating phase entirely within
|
|
1932
|
+
// the settle window — so the single sample reads 'idle' even though the turn is still in
|
|
1933
|
+
// flight (it re-enters generating ~0.5s later). Require instead that the session stayed
|
|
1934
|
+
// CONTINUOUSLY idle since the debounce was armed: (1) no entry into a busy phase
|
|
1935
|
+
// (busyEpoch unchanged), and (2) no new raw PTY output (lastOutputAt did not advance).
|
|
1936
|
+
// Either signal ⇒ the idle was not continuous ⇒ cancel; the still-live turn re-arms its
|
|
1937
|
+
// own completion when it genuinely finishes. This only ever cancels (never emits more),
|
|
1938
|
+
// so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
|
|
1939
|
+
if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
|
|
1940
|
+
LOG.info('CLI', `[${this.type}] cancelled pending completed (busy re-entry during settle: epoch ${pending.busyEpochAtArm}→${this.busyEpoch})`);
|
|
1941
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
|
|
1942
|
+
blockReason: 'busy_reentry',
|
|
1943
|
+
latestVisibleStatus,
|
|
1944
|
+
previousStatus: pending.previousStatus,
|
|
1945
|
+
busyEpochAtArm: pending.busyEpochAtArm,
|
|
1946
|
+
busyEpoch: this.busyEpoch,
|
|
1947
|
+
busyEpochDelta: this.busyEpoch - pending.busyEpochAtArm,
|
|
1948
|
+
});
|
|
1949
|
+
this.completedDebouncePending = null;
|
|
1950
|
+
this.completedDebounceTimer = null;
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
const latestOutputAt = typeof (latestStatus as any)?.lastOutputAt === 'number' ? (latestStatus as any).lastOutputAt as number : undefined;
|
|
1954
|
+
if (typeof pending.lastOutputAtArm === 'number'
|
|
1955
|
+
&& typeof latestOutputAt === 'number'
|
|
1956
|
+
&& latestOutputAt > pending.lastOutputAtArm) {
|
|
1957
|
+
LOG.info('CLI', `[${this.type}] cancelled pending completed (new PTY output during settle: ${pending.lastOutputAtArm}→${latestOutputAt})`);
|
|
1958
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
|
|
1959
|
+
blockReason: 'new_pty_output',
|
|
1960
|
+
latestVisibleStatus,
|
|
1961
|
+
previousStatus: pending.previousStatus,
|
|
1962
|
+
lastOutputAtArm: pending.lastOutputAtArm,
|
|
1963
|
+
lastOutputAt: latestOutputAt,
|
|
1964
|
+
lastOutputAtDelta: latestOutputAt - pending.lastOutputAtArm,
|
|
1965
|
+
});
|
|
1966
|
+
this.completedDebouncePending = null;
|
|
1967
|
+
this.completedDebounceTimer = null;
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
|
|
1972
|
+
if (block) {
|
|
1973
|
+
const blockReason = block.reason;
|
|
822
1974
|
const waitedMs = Date.now() - pending.firstObservedAt;
|
|
823
|
-
|
|
1975
|
+
// CANON-C (completion-gate decouple): a block carrying `allowTimeout` is the
|
|
1976
|
+
// transcript-evidence gate — the worker FSM has ALREADY reached idle and the only
|
|
1977
|
+
// thing missing is the append-only transcript's final assistant turn (a native-source
|
|
1978
|
+
// race: claude-cli owns its history externally and the file write trails the idle
|
|
1979
|
+
// transition). `allowTimeout` is set ONLY on the missing_final_assistant block, and
|
|
1980
|
+
// ONLY for mesh worker sessions (meshNodeFor / meshActiveTaskId / launchedByCoordinator).
|
|
1981
|
+
// The coordinator's sole path to learn this session is idle is agent:generating_completed,
|
|
1982
|
+
// so holding it up to COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) leaves the coordinator
|
|
1983
|
+
// false-generating while the worker is done. Decouple the idle NOTIFICATION from the
|
|
1984
|
+
// transcript evidence: emit the completion immediately, marked weak
|
|
1985
|
+
// (completionDiagnostic.blockReason=missing_final_assistant, finalAssistantPresent=false).
|
|
1986
|
+
// The finalSummary is enriched on a SEPARATE path — the mesh reconcile loop reads the
|
|
1987
|
+
// transcript once written and re-emits a GENUINE completion (CANON-B weak→genuine
|
|
1988
|
+
// upgrade; buildPendingEventFingerprint keeps weak and genuine distinct so the enriched
|
|
1989
|
+
// one still surfaces, and isFalseIdleCompletion keeps the direct dispatch active until
|
|
1990
|
+
// then). All OTHER blocks (genuinely-busy adapter/partial/parsed states, transient
|
|
1991
|
+
// parse_error) keep the existing terminal-hold / 30s-retry behavior unchanged.
|
|
1992
|
+
//
|
|
1993
|
+
// (SETTLE-VALLEY) Exception: a `holdForTranscript` block is the inter-approval idle
|
|
1994
|
+
// valley of a native-history mesh worker (waiting_approval→idle that will resume into
|
|
1995
|
+
// the next approval). It deliberately does NOT carry allowTimeout, so it falls into the
|
|
1996
|
+
// hold-and-retry path below (terminal:false) rather than the CANON-C immediate emit —
|
|
1997
|
+
// the retry loop re-runs the resume guard each cycle, so when the worker resumes the
|
|
1998
|
+
// pending completion is cancelled, and when the transcript's final assistant arrives the
|
|
1999
|
+
// block clears for a GENUINE emit. This blocks the truncated weak (insufficient) summary
|
|
2000
|
+
// from ever being emitted during the valley, without depending on the valley's length.
|
|
2001
|
+
const isTranscriptEvidenceGate = block.allowTimeout === true;
|
|
2002
|
+
LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} allowTimeout=${isTranscriptEvidenceGate} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
|
|
2003
|
+
if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
|
|
824
2004
|
if (pending.loggedBlockReason !== blockReason) {
|
|
825
2005
|
LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
|
|
2006
|
+
// EVTTRACE: completion held by the finalization gate (CANON-C). Observation
|
|
2007
|
+
// only — does not change the hold decision above.
|
|
2008
|
+
if (this.isMeshWorkerSession()) {
|
|
2009
|
+
traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
|
|
2010
|
+
}
|
|
2011
|
+
// COMPLETION-EARLYNOTIFY: a hold is the CORRECT outcome when the turn is not
|
|
2012
|
+
// yet proven done (the FixA/FixB gates route here); trace it so an early-notify
|
|
2013
|
+
// investigation can see the gate holding rather than firing.
|
|
2014
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
|
|
2015
|
+
blockReason,
|
|
2016
|
+
latestVisibleStatus,
|
|
2017
|
+
terminal: block.terminal === true,
|
|
2018
|
+
holdForTranscript: block.holdForTranscript === true,
|
|
2019
|
+
approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
|
|
2020
|
+
waitedMs,
|
|
2021
|
+
});
|
|
826
2022
|
pending.loggedBlockReason = blockReason;
|
|
827
2023
|
}
|
|
828
2024
|
this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
|
|
829
2025
|
return;
|
|
830
2026
|
}
|
|
831
|
-
|
|
2027
|
+
const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
|
|
2028
|
+
const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
|
|
2029
|
+
blockReason,
|
|
2030
|
+
latestStatus,
|
|
2031
|
+
latestVisibleStatus,
|
|
2032
|
+
waitedMs,
|
|
2033
|
+
pending,
|
|
2034
|
+
emittedAfterFinalizationTimeout,
|
|
2035
|
+
});
|
|
2036
|
+
// Surface the CANON-C immediate-emit path distinctly so a delegated worker's idle
|
|
2037
|
+
// notification (transcript still pending) is not mistaken for a 30s-timeout fallback.
|
|
2038
|
+
(completionDiagnostic as Record<string, unknown>).decoupledImmediateEmit = isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout;
|
|
2039
|
+
LOG.warn('CLI', `[${this.type}] emitting completed event (${isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'CANON-C decoupled-immediate, transcript pending' : `after ${waitedMs}ms`}) without finalized assistant turn (${blockReason})`);
|
|
2040
|
+
// EVTTRACE: completion fired (forced past the finalization timeout / CANON-C decoupled-immediate).
|
|
2041
|
+
if (this.isMeshWorkerSession()) {
|
|
2042
|
+
traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
|
|
2043
|
+
}
|
|
2044
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
|
|
2045
|
+
path: isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'canon_c_decoupled' : 'forced_timeout',
|
|
2046
|
+
blockReason,
|
|
2047
|
+
latestVisibleStatus,
|
|
2048
|
+
approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
|
|
2049
|
+
finalAssistantPresent: (completionDiagnostic as any).finalAssistantPresent === true,
|
|
2050
|
+
evidenceSource: (completionDiagnostic as any).finalAssistantEvidenceSource ?? null,
|
|
2051
|
+
lastVisibleRole: (completionDiagnostic as any).lastVisibleRole ?? null,
|
|
2052
|
+
lastVisibleContentLen: (completionDiagnostic as any).lastVisibleContentLength ?? null,
|
|
2053
|
+
emittedAfterFinalizationTimeout,
|
|
2054
|
+
waitedMs,
|
|
2055
|
+
busyEpoch: this.busyEpoch,
|
|
2056
|
+
});
|
|
2057
|
+
this.emitGeneratingCompleted({
|
|
2058
|
+
chatTitle: pending.chatTitle,
|
|
2059
|
+
duration: pending.duration,
|
|
2060
|
+
timestamp: pending.timestamp,
|
|
2061
|
+
taskId: pending.taskId,
|
|
2062
|
+
// When finalization is forced past the timeout on a `parsed_status:` block
|
|
2063
|
+
// (the parser never confirmed a final assistant turn) we previously rode an
|
|
2064
|
+
// empty `finalSummary` unconditionally. That empty value propagates to the
|
|
2065
|
+
// mesh coordinator's mirror preview (meshSessionLastMessagePreview), leaving a
|
|
2066
|
+
// delegated session's inbox preview blank — or, for a LOCAL worktree session,
|
|
2067
|
+
// stuck on the dispatched user task. If the parser DID surface assistant text,
|
|
2068
|
+
// prefer it; only fall back to '' when no assistant summary can be derived.
|
|
2069
|
+
finalSummary: blockReason.startsWith('parsed_status:')
|
|
2070
|
+
? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) ?? '')
|
|
2071
|
+
: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
|
|
2072
|
+
completionDiagnostic,
|
|
2073
|
+
});
|
|
832
2074
|
this.completedDebouncePending = null;
|
|
833
2075
|
this.completedDebounceTimer = null;
|
|
834
2076
|
this.generatingStartedAt = 0;
|
|
2077
|
+
this.lastApprovalEventFingerprint = '';
|
|
835
2078
|
return;
|
|
836
2079
|
}
|
|
837
2080
|
|
|
838
2081
|
LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
|
|
839
|
-
|
|
840
|
-
|
|
2082
|
+
// EVTTRACE: completion fired (transcript finalized cleanly).
|
|
2083
|
+
if (this.isMeshWorkerSession()) {
|
|
2084
|
+
traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
|
|
2085
|
+
}
|
|
2086
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
|
|
2087
|
+
path: 'clean',
|
|
2088
|
+
latestVisibleStatus,
|
|
2089
|
+
approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
|
|
2090
|
+
finalAssistantPresent: true,
|
|
2091
|
+
duration: pending.duration,
|
|
2092
|
+
busyEpoch: this.busyEpoch,
|
|
2093
|
+
});
|
|
2094
|
+
this.emitGeneratingCompleted({
|
|
841
2095
|
chatTitle: pending.chatTitle,
|
|
842
2096
|
duration: pending.duration,
|
|
843
2097
|
timestamp: pending.timestamp,
|
|
844
|
-
|
|
2098
|
+
taskId: pending.taskId,
|
|
2099
|
+
finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
|
|
845
2100
|
});
|
|
846
2101
|
this.completedDebouncePending = null;
|
|
847
2102
|
this.completedDebounceTimer = null;
|
|
848
2103
|
this.generatingStartedAt = 0;
|
|
2104
|
+
this.lastApprovalEventFingerprint = '';
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* A-3 (CLI 완료판정 통합): single authoritative emit for a CLI turn's
|
|
2109
|
+
* `agent:generating_completed` event. The completion JUDGMENT — WHETHER and
|
|
2110
|
+
* WHEN a turn is done (settle windows, continuity guards, the finalization
|
|
2111
|
+
* gate, the short-gen / startup-grace / no-progress-monitor discriminators) —
|
|
2112
|
+
* stays at each call site, where it is legitimately path-specific. What used
|
|
2113
|
+
* to be duplicated across all five completion paths was the EVENT-SHAPE
|
|
2114
|
+
* assembly: the `event` name, the conditional `taskId` spread, and the
|
|
2115
|
+
* optional `finalSummary` / `evidenceLevel` / `completionDiagnostic` fields.
|
|
2116
|
+
* Folding that assembly here removes the divergence (e.g. one site spreading
|
|
2117
|
+
* taskId, others omitting it) without touching any judgment. Callers pass the
|
|
2118
|
+
* values they have already computed; omitted optionals are simply absent from
|
|
2119
|
+
* the emitted event, exactly as each inline builder produced before.
|
|
2120
|
+
*/
|
|
2121
|
+
private emitGeneratingCompleted(opts: {
|
|
2122
|
+
chatTitle: string;
|
|
2123
|
+
duration: number | undefined;
|
|
2124
|
+
timestamp: number;
|
|
2125
|
+
taskId?: string;
|
|
2126
|
+
finalSummary?: string;
|
|
2127
|
+
evidenceLevel?: string;
|
|
2128
|
+
completionDiagnostic?: Record<string, unknown>;
|
|
2129
|
+
}): void {
|
|
2130
|
+
// Cache the final assistant summary so the dashboard snapshot can surface it
|
|
2131
|
+
// for native-source providers whose assistant answer is absent from the PTY
|
|
2132
|
+
// parse (antigravity). completionFinalSummary already read native-history to
|
|
2133
|
+
// produce this, so nothing extra is read here.
|
|
2134
|
+
const summary = typeof opts.finalSummary === 'string' ? opts.finalSummary.trim() : '';
|
|
2135
|
+
if (summary) {
|
|
2136
|
+
this.lastCompletionSummary = { content: summary, receivedAt: opts.timestamp };
|
|
2137
|
+
}
|
|
2138
|
+
this.pushEvent({
|
|
2139
|
+
event: 'agent:generating_completed',
|
|
2140
|
+
chatTitle: opts.chatTitle,
|
|
2141
|
+
duration: opts.duration,
|
|
2142
|
+
timestamp: opts.timestamp,
|
|
2143
|
+
// ARCH-REFACTOR R1: attribute to the turn captured at idle-transition.
|
|
2144
|
+
...(opts.taskId ? { taskId: opts.taskId } : {}),
|
|
2145
|
+
// finalSummary is always carried (value may be undefined) — every prior
|
|
2146
|
+
// inline builder included the key, so downstream consumers see the same shape.
|
|
2147
|
+
finalSummary: opts.finalSummary,
|
|
2148
|
+
...(opts.evidenceLevel !== undefined ? { evidenceLevel: opts.evidenceLevel } : {}),
|
|
2149
|
+
...(opts.completionDiagnostic !== undefined ? { completionDiagnostic: opts.completionDiagnostic } : {}),
|
|
2150
|
+
});
|
|
849
2151
|
}
|
|
850
2152
|
|
|
851
2153
|
private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
|
|
2154
|
+
// Manual-attendance suppression (provider-common): when a human is
|
|
2155
|
+
// Manual-attendance suppression (provider-common): when a human is
|
|
2156
|
+
// actively driving this session from the dashboard, hold auto-approve so
|
|
2157
|
+
// the modal stays visible and they can pick a button / use the controlbar
|
|
2158
|
+
// themselves. Return false (NOT auto-approving) so getState keeps the
|
|
2159
|
+
// modal surfaced. Clear any in-progress settle gate — a genuine fire
|
|
2160
|
+
// after the window lapses must re-settle from scratch — and arm a
|
|
2161
|
+
// re-check for the lapse moment, because the PTY may have gone silent and
|
|
2162
|
+
// would otherwise never re-drive this decision. Background mesh workers
|
|
2163
|
+
// are never attended, so their delegated auto-approve is untouched.
|
|
2164
|
+
if (adapterStatus?.status === 'waiting_approval'
|
|
2165
|
+
&& this.shouldAutoApprove()
|
|
2166
|
+
&& this.manualAttendance.isAttended(now)) {
|
|
2167
|
+
this.lastAutoApprovalSignature = '';
|
|
2168
|
+
this.pendingAutoApprovalSignature = '';
|
|
2169
|
+
this.pendingAutoApprovalSince = 0;
|
|
2170
|
+
this.autoApproveInactiveSince = 0;
|
|
2171
|
+
// Manual attendance takes over — the modal stays surfaced (maybeAutoApproveStatus
|
|
2172
|
+
// returns false), so end the mask episode.
|
|
2173
|
+
this.autoApproveMaskSince = 0;
|
|
2174
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2175
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
2176
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2177
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2178
|
+
this.autoApproveSettleTimer = null;
|
|
2179
|
+
this.recheckAutoApproveSettled();
|
|
2180
|
+
}, this.manualAttendance.remainingMs(now) + 20);
|
|
2181
|
+
return false;
|
|
2182
|
+
}
|
|
852
2183
|
const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
|
|
853
2184
|
// Guard re-entry: onStatusChange/getState can observe the same modal multiple
|
|
854
2185
|
// times while the PTY absorbs the approval key. Without this flag, repeated
|
|
855
2186
|
// snapshots would write stray keys into the input once the modal dismisses.
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
2187
|
+
// However, Claude Code can present a second approval immediately after the
|
|
2188
|
+
// first. Resolve a changed modal signature even while the previous write is
|
|
2189
|
+
// still inside the short busy window.
|
|
2190
|
+
if (!autoApproveActive) {
|
|
2191
|
+
this.lastAutoApprovalSignature = '';
|
|
2192
|
+
// Hysteresis: if a settle gate is mid-progress, a momentary
|
|
2193
|
+
// status!=waiting_approval blip (a generating flip while the same
|
|
2194
|
+
// modal's button block is still on screen) must NOT wipe the settle
|
|
2195
|
+
// clock — otherwise the modal→generating→modal flap restarts the
|
|
2196
|
+
// 600ms window every time and auto-approve never fires. Keep the
|
|
2197
|
+
// gate warm for AUTO_APPROVE_GATE_HYSTERESIS_MS; re-arm a timer so
|
|
2198
|
+
// that if the modal does NOT come back the gate is cleared on the
|
|
2199
|
+
// re-check (a genuine resolution → idle frees the gate normally).
|
|
2200
|
+
if (this.pendingAutoApprovalSince) {
|
|
2201
|
+
if (!this.autoApproveInactiveSince) this.autoApproveInactiveSince = now;
|
|
2202
|
+
const goneForMs = now - this.autoApproveInactiveSince;
|
|
2203
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix B): a delegated-worker flap cycles the
|
|
2204
|
+
// FULL waiting_approval → busy → waiting_approval state on a
|
|
2205
|
+
// multi-second period, outrunning the tight default hysteresis and
|
|
2206
|
+
// wiping the settle clock before 600ms ever accumulates. For an
|
|
2207
|
+
// active worker mask episode the continuity window is widened (still
|
|
2208
|
+
// capped by AUTO_APPROVE_MASK_STALL_MS) so the settle clock survives
|
|
2209
|
+
// the busy phase and the returning approval keeps accruing settle time.
|
|
2210
|
+
const continuityMs = this.autoApproveContinuityWindowMs();
|
|
2211
|
+
if (goneForMs < continuityMs) {
|
|
2212
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2213
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2214
|
+
this.autoApproveSettleTimer = null;
|
|
2215
|
+
this.recheckAutoApproveSettled();
|
|
2216
|
+
}, continuityMs - goneForMs + 20);
|
|
2217
|
+
return autoApproveActive;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
// Clear the settle gate so the next approval starts its own quiet
|
|
2221
|
+
// window from scratch (a stale timestamp would let it fire instantly).
|
|
2222
|
+
this.pendingAutoApprovalSignature = '';
|
|
2223
|
+
this.pendingAutoApprovalSince = 0;
|
|
2224
|
+
this.autoApproveInactiveSince = 0;
|
|
2225
|
+
// Modal has genuinely been gone past the hysteresis window → the episode ended;
|
|
2226
|
+
// end the mask episode too (a later approval starts a fresh stall clock).
|
|
2227
|
+
this.autoApproveMaskSince = 0;
|
|
2228
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2229
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
2230
|
+
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
2231
|
+
return autoApproveActive;
|
|
2232
|
+
}
|
|
2233
|
+
// Active approval observed — reset the inactivity tracker so a later
|
|
2234
|
+
// blip starts its hysteresis window fresh.
|
|
2235
|
+
this.autoApproveInactiveSince = 0;
|
|
2236
|
+
// STATUS-MISMATCH: start (or keep) the mask-stall clock for this episode. Set ONLY
|
|
2237
|
+
// when zero so it survives modal-signature changes and hysteresis blips — it measures
|
|
2238
|
+
// the true age of the unresolved auto-approve, not the per-signature settle window.
|
|
2239
|
+
if (!this.autoApproveMaskSince) this.autoApproveMaskSince = now;
|
|
2240
|
+
// NOTIF-APPROVAL-MASKED (Q1b): once this episode has stalled past the mask threshold,
|
|
2241
|
+
// surface the raw waiting_approval to the mesh coordinator (decoupled from the dashboard
|
|
2242
|
+
// mask). Placed on the active-approval path here — the single choke point that owns the
|
|
2243
|
+
// mask-stall clock and is re-driven throughout a silent stall (getState heartbeat,
|
|
2244
|
+
// detectStatusTransition, recheckAutoApproveSettled). No-op until the stall threshold trips.
|
|
2245
|
+
this.maybeEmitStalledApprovalNudge(adapterStatus, now);
|
|
2246
|
+
const modal = adapterStatus.activeModal;
|
|
2247
|
+
// (fix) Do not auto-approve when no concrete modal/buttons are present.
|
|
2248
|
+
// Claude TUI flaps between paints; without this guard adapterStatus
|
|
2249
|
+
// could report status=waiting_approval with activeModal=null (or with
|
|
2250
|
+
// an empty buttons array briefly) and we'd still call
|
|
2251
|
+
// resolveModal(-1) — which used to type "1" into the prompt
|
|
2252
|
+
// repeatedly. Skip until a real modal is captured.
|
|
2253
|
+
const buttons = Array.isArray(modal?.buttons)
|
|
2254
|
+
? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
|
|
2255
|
+
: [];
|
|
2256
|
+
if (!modal || buttons.length === 0) {
|
|
2257
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix A): the button block momentarily scrolled
|
|
2258
|
+
// out of the captured frame (status is still waiting_approval — we are
|
|
2259
|
+
// on the active path). Do NOT tear the settle gate down on this frame:
|
|
2260
|
+
// if a concrete modal was captured within the continuity window and a
|
|
2261
|
+
// settle gate is in progress, this is a short scroll-out blip — keep the
|
|
2262
|
+
// gate warm against the last-captured signature and arm a re-check so a
|
|
2263
|
+
// silent PTY still re-drives the decision when the buttons repaint. Only
|
|
2264
|
+
// once the modal has stayed empty PAST the continuity window is it a
|
|
2265
|
+
// genuine close, and the gate is cleared here so a later approval
|
|
2266
|
+
// re-settles from scratch (never fires on a stale timestamp).
|
|
2267
|
+
const blipForMs = this.autoApproveLastModalSeenAt ? now - this.autoApproveLastModalSeenAt : Infinity;
|
|
2268
|
+
if (this.pendingAutoApprovalSince && blipForMs < this.autoApproveContinuityWindowMs()) {
|
|
2269
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2270
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2271
|
+
this.autoApproveSettleTimer = null;
|
|
2272
|
+
this.recheckAutoApproveSettled();
|
|
2273
|
+
}, this.autoApproveContinuityWindowMs() - blipForMs + 20);
|
|
2274
|
+
return autoApproveActive;
|
|
2275
|
+
}
|
|
2276
|
+
if (blipForMs >= this.autoApproveContinuityWindowMs()) {
|
|
2277
|
+
// Buttons empty continuously past the window → the modal genuinely
|
|
2278
|
+
// closed (or never captured). Reset the per-signature settle gate so
|
|
2279
|
+
// a later approval re-settles cleanly. The mask-stall clock keeps
|
|
2280
|
+
// running underneath so a never-captured worker modal still surfaces
|
|
2281
|
+
// to the coordinator within AUTO_APPROVE_MASK_STALL_MS.
|
|
2282
|
+
this.pendingAutoApprovalSignature = '';
|
|
2283
|
+
this.pendingAutoApprovalSince = 0;
|
|
2284
|
+
}
|
|
2285
|
+
return autoApproveActive;
|
|
2286
|
+
}
|
|
2287
|
+
// Concrete modal captured this frame — mark the last-good-modal timestamp so
|
|
2288
|
+
// a subsequent scroll-out blip (buttons.length===0) can be told apart from a
|
|
2289
|
+
// genuine close by how long it persists (Fix A above).
|
|
2290
|
+
this.autoApproveLastModalSeenAt = now;
|
|
2291
|
+
// Picker/confirm exclusion (provider-common). A /model or /mode picker is
|
|
2292
|
+
// surfaced with status=waiting_approval so the dashboard shows it, but it
|
|
2293
|
+
// has no "correct" answer to auto-pick — blindly selecting the first
|
|
2294
|
+
// option silently switches the model (the "always Opus, before I even
|
|
2295
|
+
// choose" bug). Two independent gates, BOTH must pass to fire:
|
|
2296
|
+
//
|
|
2297
|
+
// (1) modal_kind — the spec/FSM tells us this is an 'approval' modal,
|
|
2298
|
+
// not a 'picker'/'confirm'. A modal whose kind is unknown (legacy
|
|
2299
|
+
// adapter, or a spec that predates modal_kind) reads as 'approval'
|
|
2300
|
+
// so genuine approvals keep auto-approving; only an explicit
|
|
2301
|
+
// 'picker'/'confirm' is excluded here.
|
|
2302
|
+
// (2) structural anchor — a real approval offers an affirmative AND a
|
|
2303
|
+
// decline option (pickApprovalButton finds a positive that isn't a
|
|
2304
|
+
// decline, and hasNegativeApprovalOption confirms a No/Cancel/Deny
|
|
2305
|
+
// is present). A model picker ("1. Default 2. Opus 3. Sonnet")
|
|
2306
|
+
// has no decline, so even an un-migrated picker is caught here.
|
|
2307
|
+
//
|
|
2308
|
+
// Mirrors the SDK v1 detect-status approval heuristic (detect-status.ts).
|
|
2309
|
+
const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
|
|
2310
|
+
if (modalKind !== 'approval') {
|
|
2311
|
+
// Picker/confirm — leave it for the user; keep the modal surfaced.
|
|
2312
|
+
return autoApproveActive;
|
|
2313
|
+
}
|
|
2314
|
+
const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
|
|
2315
|
+
// Structural decline anchor. A real approval offers BOTH an affirmative
|
|
2316
|
+
// and a decline — but on a TALL Write/Edit diff the trailing "3. No"
|
|
2317
|
+
// scrolls off the captured frame, leaving only "1. Yes" + "2. Yes, allow
|
|
2318
|
+
// … this session" so hasNegativeApprovalOption reads false (#137). A
|
|
2319
|
+
// scoped grant-affirmative ("Yes, allow … during this session" / "…don't
|
|
2320
|
+
// ask again") ONLY appears in a genuine consent modal (never a picker),
|
|
2321
|
+
// so it stands in for the off-frame decline as a reliable second anchor.
|
|
2322
|
+
// Conservative by construction: a picker without a grant-scope option
|
|
2323
|
+
// still bails here, and the fire below still picks the plain allow-once
|
|
2324
|
+
// "Yes" via pickApprovalButton, not the broader grant.
|
|
2325
|
+
const hasReliableConsentAnchor = hasNegativeApprovalOption(buttons)
|
|
2326
|
+
|| hasReliableApprovalAffirmative(buttons);
|
|
2327
|
+
if (buttonIndex < 0 || !hasReliableConsentAnchor) {
|
|
2328
|
+
// No affirmative matched, or no decline / reliable grant option present
|
|
2329
|
+
// (→ not a real consent prompt, e.g. a picker that slipped past the
|
|
2330
|
+
// kind gate). Surface the modal so the user decides; never pick blindly.
|
|
2331
|
+
return autoApproveActive;
|
|
2332
|
+
}
|
|
2333
|
+
// Modal *identity* signature — the question plus the STABLE affirmative
|
|
2334
|
+
// anchor only, NO volatile counters and NO raw button set. This is what
|
|
2335
|
+
// the settle gate tracks: the FSM bumps approvalEntrySeq on every fresh
|
|
2336
|
+
// waiting_approval entry, and a modal→generating→modal flap (the question
|
|
2337
|
+
// line scrolled out of the captured frame while the button block stays)
|
|
2338
|
+
// re-enters and bumps it again. Folding that seq into the settle signature
|
|
2339
|
+
// made the 600ms settle clock restart on every flap, so the modal never
|
|
2340
|
+
// stayed stable long enough to fire — the gate was never satisfied.
|
|
2341
|
+
// Identity excludes the seq so seq flap of the SAME modal keeps one clock.
|
|
2342
|
+
//
|
|
2343
|
+
// The raw button set is also excluded: on a TALL Write/Edit diff claude's
|
|
2344
|
+
// TUI repaints the button block 3↔5↔none between frames (buttons scroll in
|
|
2345
|
+
// and out of the captured region), which flipped both buttons.join('|') and
|
|
2346
|
+
// the positional buttonIndex every frame → signature flap → the settle
|
|
2347
|
+
// clock reset 4–9s and only mask-stalled episodes leaked to the coordinator
|
|
2348
|
+
// (AUTOAPPROVE-SETTLE-FLAP). The affirmative the auto-approve will actually
|
|
2349
|
+
// press is the invariant across those repaints, so we anchor on its
|
|
2350
|
+
// NORMALIZED label (numbers/bullets/punctuation stripped, so "1. Yes" and
|
|
2351
|
+
// "3. Yes" collapse to "yes"). Message + affirmative label uniquely
|
|
2352
|
+
// identifies the consent question without tracking the volatile button
|
|
2353
|
+
// positions.
|
|
2354
|
+
const affirmativeAnchor = normalizeApprovalLabel(buttonLabel);
|
|
2355
|
+
const modalSignature = approvalModalSignature(modal?.message, affirmativeAnchor);
|
|
2356
|
+
// Busy-window re-entry guard still needs the seq: two DISTINCT
|
|
2357
|
+
// back-to-back approvals can carry identical message/buttons (common
|
|
2358
|
+
// with claude-cli). Without the seq their busy signatures collide and
|
|
2359
|
+
// the 5s busy-window guard below would swallow the second auto-approve,
|
|
2360
|
+
// leaving it stuck. The seq is bumped by the FSM on every fresh
|
|
2361
|
+
// waiting_approval entry, so a new approval always yields a new busy
|
|
2362
|
+
// signature and fires through.
|
|
2363
|
+
const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
|
|
2364
|
+
? adapterStatus.approvalEntrySeq
|
|
2365
|
+
: 0;
|
|
2366
|
+
const busySignature = `${approvalEntrySeq}::${modalSignature}`;
|
|
2367
|
+
// Already fired for this exact modal entry and still inside the busy
|
|
2368
|
+
// window — nothing to do (re-entry guard for repeated snapshots of one
|
|
2369
|
+
// modal).
|
|
2370
|
+
if (this.autoApproveBusy && busySignature === this.lastAutoApprovalSignature) {
|
|
2371
|
+
return autoApproveActive;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// Settle gate: only fire once this modal identity has been stable for
|
|
2375
|
+
// AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
|
|
2376
|
+
// message/buttons each frame → new identity → clock restarts, so we
|
|
2377
|
+
// never approve a half-rendered prompt (the "resolves too fast" bug).
|
|
2378
|
+
if (modalSignature !== this.pendingAutoApprovalSignature) {
|
|
2379
|
+
this.pendingAutoApprovalSignature = modalSignature;
|
|
2380
|
+
this.pendingAutoApprovalSince = now;
|
|
2381
|
+
}
|
|
2382
|
+
const settledForMs = now - this.pendingAutoApprovalSince;
|
|
2383
|
+
if (settledForMs < CliProviderInstance.AUTO_APPROVE_SETTLE_MS) {
|
|
2384
|
+
// Not yet settled. Arm a timer to re-check after the remaining quiet
|
|
2385
|
+
// window — the PTY may go silent once the prompt finishes painting,
|
|
2386
|
+
// so there is no guaranteed status-change frame to re-drive us.
|
|
2387
|
+
if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
|
|
2388
|
+
this.autoApproveSettleTimer = setTimeout(() => {
|
|
2389
|
+
this.autoApproveSettleTimer = null;
|
|
2390
|
+
this.recheckAutoApproveSettled();
|
|
2391
|
+
}, CliProviderInstance.AUTO_APPROVE_SETTLE_MS - settledForMs + 20);
|
|
2392
|
+
return autoApproveActive;
|
|
869
2393
|
}
|
|
2394
|
+
|
|
2395
|
+
// Settled — fire the approve key.
|
|
2396
|
+
if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
|
|
2397
|
+
this.autoApproveBusy = true;
|
|
2398
|
+
this.lastAutoApprovalSignature = busySignature;
|
|
2399
|
+
this.pendingAutoApprovalSignature = '';
|
|
2400
|
+
this.pendingAutoApprovalSince = 0;
|
|
2401
|
+
this.autoApproveInactiveSince = 0;
|
|
2402
|
+
// Fired (resolveModal in flight) — the episode resolved; end the mask-stall clock.
|
|
2403
|
+
this.autoApproveMaskSince = 0;
|
|
2404
|
+
this.stalledApprovalNudgeEpisode = 0;
|
|
2405
|
+
this.autoApproveLastModalSeenAt = 0;
|
|
2406
|
+
if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
|
|
2407
|
+
this.autoApproveBusyTimer = setTimeout(() => {
|
|
2408
|
+
this.autoApproveBusy = false;
|
|
2409
|
+
this.autoApproveBusyTimer = null;
|
|
2410
|
+
this.lastAutoApprovalSignature = '';
|
|
2411
|
+
}, 5000);
|
|
2412
|
+
this.recordAutoApproval(modal?.message, buttonLabel, now);
|
|
2413
|
+
setTimeout(() => {
|
|
2414
|
+
this.adapter.resolveModal(buttonIndex);
|
|
2415
|
+
}, 0);
|
|
870
2416
|
return autoApproveActive;
|
|
871
2417
|
}
|
|
872
2418
|
|
|
2419
|
+
/**
|
|
2420
|
+
* Re-drive the auto-approve check after the settle quiet window elapses.
|
|
2421
|
+
* The PTY may have gone silent once the approval prompt finished painting,
|
|
2422
|
+
* so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
|
|
2423
|
+
* — this timer-driven re-check picks up the now-settled modal and fires.
|
|
2424
|
+
* Deliberately lighter than detectStatusTransition(): it only re-evaluates
|
|
2425
|
+
* the approval decision; the next real PTY frame refreshes visible status.
|
|
2426
|
+
*/
|
|
2427
|
+
private recheckAutoApproveSettled(): void {
|
|
2428
|
+
try {
|
|
2429
|
+
// APPROVAL Defect-C (auto-approve gap): re-probe with a LIVE parse, not the cached
|
|
2430
|
+
// engine snapshot. This timer is the ONLY re-drive when the PTY goes silent after the
|
|
2431
|
+
// approval prompt finishes painting (its whole reason to exist) — but with
|
|
2432
|
+
// allowParse:false it read only engine.activeModal, which the engine's per-frame settle
|
|
2433
|
+
// pass can leave null/stale when the modal arrived between writes. The re-check then saw
|
|
2434
|
+
// no modal and never fired, so the delegated worker's transient/quiet approval was missed
|
|
2435
|
+
// and the coordinator had to step in with a manual mesh_approve. allowParse:true makes
|
|
2436
|
+
// getStatus re-run runDetectStatus/runParseApproval on the current screen buffer (the same
|
|
2437
|
+
// live re-probe the coordinator's resolveAction path uses), recovering the modal so
|
|
2438
|
+
// auto-approve fires on its own. The half-rendered-frame guard (buttons.length===0) and
|
|
2439
|
+
// the settle gate in maybeAutoApproveStatus still protect against firing on a partial modal.
|
|
2440
|
+
const adapterStatus = this.adapter.getStatus({ allowParse: true });
|
|
2441
|
+
this.maybeAutoApproveStatus(adapterStatus, Date.now());
|
|
2442
|
+
} catch { /* adapter gone / transient — next frame retries */ }
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
/**
|
|
2446
|
+
* Emit the queue-claim agent:ready event at most once per session. Both the
|
|
2447
|
+
* boot-time starting→idle one-shot and the fsmReadySeen re-arm call this; the
|
|
2448
|
+
* agentReadyEmitted guard ensures the second caller is a no-op so a worker is
|
|
2449
|
+
* never claimed twice and a queued task is never double-dispatched.
|
|
2450
|
+
*/
|
|
2451
|
+
private emitAgentReadyOnce(chatTitle: string, now: number): void {
|
|
2452
|
+
if (this.agentReadyEmitted) return;
|
|
2453
|
+
this.agentReadyEmitted = true;
|
|
2454
|
+
this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/**
|
|
2458
|
+
* GENERATING-BOUNDARY synthetic emit for a first turn that started AND finished
|
|
2459
|
+
* inside the startup-grace window without the FSM ever observing a 'generating'
|
|
2460
|
+
* frame (an unobservably-fast turn). Synthesizes the agent:generating_started +
|
|
2461
|
+
* agent:generating_completed pair so the mesh coordinator learns the worker went
|
|
2462
|
+
* idle. Returns true iff it synthesized.
|
|
2463
|
+
*
|
|
2464
|
+
* Two callers share this defense line:
|
|
2465
|
+
* - 'startup_grace_fast_collapse' — the starting→idle transition (the FSM whose
|
|
2466
|
+
* spec lacks/hadn't-synced the starting→busy edge collapses starting→idle
|
|
2467
|
+
* directly, with no intervening 'generating' frame).
|
|
2468
|
+
* - 'startup_grace_idle_turn_collapse' — the idle→idle no-status-change poll
|
|
2469
|
+
* (the launch settle already drained starting→idle BEFORE the first turn, so
|
|
2470
|
+
* the turn runs+completes while status stays 'idle' the whole time and
|
|
2471
|
+
* detectStatusTransition never re-enters its change block — the live rc.403
|
|
2472
|
+
* Probe1 miss).
|
|
2473
|
+
*
|
|
2474
|
+
* False-positive safety (must NOT fire on a benign boot): currentTurnTaskId is
|
|
2475
|
+
* set ONLY by onTurnStarted (a real turn STARTED this boot) and persists past
|
|
2476
|
+
* completion, so it excludes a true idle boot (null) and a queued-pending first
|
|
2477
|
+
* turn that only runs after grace (onTurnStarted not yet called → null).
|
|
2478
|
+
* !hasAdapterPendingResponse() excludes a turn still mid-flight. generating
|
|
2479
|
+
* never armed (generatingStartedAt===0 && !generatingDebouncePending) means the
|
|
2480
|
+
* whole idle→busy→idle never happened for this turn. The once-per-turn guard
|
|
2481
|
+
* (fastCollapseSynthesizedTaskId) keeps the re-polled idle-stayed caller from
|
|
2482
|
+
* re-emitting every poll.
|
|
2483
|
+
*/
|
|
2484
|
+
private maybeSynthesizeStartupGraceCollapse(
|
|
2485
|
+
chatTitle: string,
|
|
2486
|
+
now: number,
|
|
2487
|
+
reason: 'startup_grace_fast_collapse' | 'startup_grace_idle_turn_collapse',
|
|
2488
|
+
): boolean {
|
|
2489
|
+
const startedTurnTaskId = typeof (this.adapter as any)?.currentTurnTaskId === 'string'
|
|
2490
|
+
&& (this.adapter as any).currentTurnTaskId.trim()
|
|
2491
|
+
? (this.adapter as any).currentTurnTaskId as string
|
|
2492
|
+
: undefined;
|
|
2493
|
+
const fastCollapsed = !!startedTurnTaskId
|
|
2494
|
+
&& !this.hasAdapterPendingResponse()
|
|
2495
|
+
&& !this.generatingStartedAt
|
|
2496
|
+
&& !this.generatingDebouncePending;
|
|
2497
|
+
if (!fastCollapsed) return false;
|
|
2498
|
+
// Once-per-turn: the idle-stayed caller re-polls steadily while the session
|
|
2499
|
+
// sits idle; without this guard it would re-emit the pair every poll.
|
|
2500
|
+
if (this.fastCollapseSynthesizedTaskId === startedTurnTaskId) return false;
|
|
2501
|
+
|
|
2502
|
+
let fcFinalSummary: string | undefined;
|
|
2503
|
+
let fcEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
|
|
2504
|
+
try {
|
|
2505
|
+
const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
|
|
2506
|
+
const evidence = this.completionFinalAssistantEvidence(parsedMessages);
|
|
2507
|
+
fcEvidenceSource = evidence.source;
|
|
2508
|
+
fcFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
|
|
2509
|
+
} catch { /* best-effort */ }
|
|
2510
|
+
const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || fcEvidenceSource === 'external-native') && !fcFinalSummary;
|
|
2511
|
+
// Mirror the short-generating idle path's suppression: a provider that
|
|
2512
|
+
// requires a final assistant (or external-native history) with NO confirmed
|
|
2513
|
+
// summary and NO mesh context emits nothing — the session is idle with no
|
|
2514
|
+
// confirmed turn, matching startup-blip semantics. With mesh context we still
|
|
2515
|
+
// emit so the coordinator can apply its own timeout/retry logic. Left UNMARKED
|
|
2516
|
+
// so a later poll can retry once the transcript's final assistant lands.
|
|
2517
|
+
const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
|
|
2518
|
+
if (missingEvidence && !hasMeshContext) {
|
|
2519
|
+
LOG.info('CLI', `[${this.type}] ${reason} suppressed: missing final assistant evidence, no mesh context (source=${fcEvidenceSource})`);
|
|
2520
|
+
return false;
|
|
2521
|
+
}
|
|
2522
|
+
// Mark BEFORE emitting so a re-entrant poll cannot double-fire.
|
|
2523
|
+
this.fastCollapseSynthesizedTaskId = startedTurnTaskId;
|
|
2524
|
+
LOG.info('CLI', `[${this.type}] ${reason}: synthesizing started+completed (taskId=${startedTurnTaskId} source=${fcEvidenceSource} hadFinalSummary=${!!fcFinalSummary})`);
|
|
2525
|
+
// Retroactive started so the started→completed pair (and the chat bubble) is
|
|
2526
|
+
// well-formed; pushEvent stamps the per-turn taskId for CANON-B ack.
|
|
2527
|
+
this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now });
|
|
2528
|
+
if (this.isMeshWorkerSession()) {
|
|
2529
|
+
traceMeshEventStage('fired', this.meshTraceCtx(), `${reason} (source=${fcEvidenceSource})`);
|
|
2530
|
+
}
|
|
2531
|
+
// EARLYNOTIFY-GATEBYPASS (c): a startup-grace fast-collapse never OBSERVED the turn's
|
|
2532
|
+
// generating phase — its evidence is a plain transcript tail, never a self-attributing
|
|
2533
|
+
// final_summary_json — so this synth is TENTATIVE by default. Mark it WEAK
|
|
2534
|
+
// (evidenceLevel:'weak') so buildPendingEventFingerprint keys it `…::weak` and any later
|
|
2535
|
+
// genuine agent:generating_completed for the same task can still surface (CANON-B). The
|
|
2536
|
+
// stronger missing_final_assistant marker is preserved when there is also no summary.
|
|
2537
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('synth-fire', {
|
|
2538
|
+
path: 'startup_grace_fast_collapse',
|
|
2539
|
+
reason,
|
|
2540
|
+
evidenceSource: fcEvidenceSource,
|
|
2541
|
+
hadFinalSummary: !!fcFinalSummary,
|
|
2542
|
+
missingEvidence,
|
|
2543
|
+
evidenceLevel: 'weak',
|
|
2544
|
+
});
|
|
2545
|
+
this.emitGeneratingCompleted({
|
|
2546
|
+
chatTitle,
|
|
2547
|
+
duration: 0,
|
|
2548
|
+
timestamp: now,
|
|
2549
|
+
finalSummary: fcFinalSummary,
|
|
2550
|
+
evidenceLevel: 'weak',
|
|
2551
|
+
completionDiagnostic: {
|
|
2552
|
+
reason,
|
|
2553
|
+
finalAssistantEvidenceSource: fcEvidenceSource,
|
|
2554
|
+
...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
|
|
2555
|
+
},
|
|
2556
|
+
});
|
|
2557
|
+
return true;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
873
2560
|
private detectStatusTransition(): void {
|
|
874
2561
|
const now = Date.now();
|
|
875
2562
|
// Status-change handling is a hot path: PTY output can fire it many times
|
|
876
2563
|
// during long-running CLI sessions. Keep this path on adapter-owned light
|
|
877
2564
|
// state only; rich provider parsing is reserved for getState/read_chat.
|
|
878
2565
|
const adapterStatus = this.adapter.getStatus({ allowParse: false });
|
|
2566
|
+
const adapterProviderSessionId = normalizeProviderSessionId(
|
|
2567
|
+
this.provider,
|
|
2568
|
+
typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
|
|
2569
|
+
);
|
|
2570
|
+
if (adapterProviderSessionId) {
|
|
2571
|
+
this.promoteProviderSessionId(adapterProviderSessionId);
|
|
2572
|
+
}
|
|
879
2573
|
const parsedStatus = null;
|
|
880
2574
|
const rawStatus = adapterStatus.status;
|
|
881
2575
|
const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
|
|
882
|
-
|
|
883
|
-
|
|
2576
|
+
// During the autoApproveBusy window (2s after firing approval key), the PTY
|
|
2577
|
+
// can briefly report 'idle' before the next generating phase starts. Treat that
|
|
2578
|
+
// transient idle as 'generating' to suppress a spurious agent:generating_completed
|
|
2579
|
+
// push notification. The adapter's status is otherwise authoritative — native
|
|
2580
|
+
// transcript shape does NOT override the FSM's busy/idle decision.
|
|
2581
|
+
const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
|
|
2582
|
+
const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
|
|
2583
|
+
const dirName = workingDirBasename(this.workingDir);
|
|
884
2584
|
const chatTitle = `${this.provider.name} · ${dirName}`;
|
|
885
2585
|
const partial = this.adapter.getPartialResponse();
|
|
2586
|
+
// Liveness fingerprint for the no-progress watchdog. The parsed
|
|
2587
|
+
// assistant buffer (`partial`) alone goes static while a tool/build runs
|
|
2588
|
+
// — the assistant emits no tokens even though the PTY is actively
|
|
2589
|
+
// printing tool output — which made the watchdog false-fire a "stuck"
|
|
2590
|
+
// alert mid-turn. Fold in the adapter's raw-activity timestamps so any
|
|
2591
|
+
// visible terminal progress (lastScreenChangeAt) or raw PTY byte
|
|
2592
|
+
// (lastOutputAt) keeps the fingerprint moving. The watchdog then only
|
|
2593
|
+
// survives a genuine stall where nothing at all is happening.
|
|
886
2594
|
const progressFingerprint = newStatus === 'generating'
|
|
887
|
-
? `${partial || ''}`.slice(-2000)
|
|
2595
|
+
? `${`${partial || ''}`.slice(-2000)}::scr=${adapterStatus.lastScreenChangeAt ?? 0}::out=${adapterStatus.lastOutputAt ?? 0}`
|
|
888
2596
|
: undefined;
|
|
889
2597
|
|
|
890
2598
|
const previousStatus = this.lastStatus;
|
|
891
2599
|
if (newStatus !== this.lastStatus) {
|
|
892
2600
|
LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
|
|
893
|
-
|
|
2601
|
+
// COMPLETION-EARLYNOTIFY: snapshot every FSM status transition (the arm/fire/cancel
|
|
2602
|
+
// decisions downstream all hang off these edges). Guarded so production pays only a
|
|
2603
|
+
// boolean check; payload carries the visibility/auto-approve flags and the continuity
|
|
2604
|
+
// clocks (busyEpoch / lastOutputAt / lastScreenChangeAt) that the completion gate reads.
|
|
2605
|
+
if (this.fsmTraceOn()) this.recordFsmTransitionTrace({
|
|
2606
|
+
from: this.lastStatus,
|
|
2607
|
+
to: newStatus,
|
|
2608
|
+
rawStatus,
|
|
2609
|
+
autoApproveActive,
|
|
2610
|
+
autoApproveHoldIdle,
|
|
2611
|
+
autoApproveBusy: this.autoApproveBusy,
|
|
2612
|
+
hasPending: this.hasAdapterPendingResponse(),
|
|
2613
|
+
busyEpoch: this.busyEpoch,
|
|
2614
|
+
lastOutputAt: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
|
|
2615
|
+
lastScreenChangeAt: typeof adapterStatus?.lastScreenChangeAt === 'number' ? adapterStatus.lastScreenChangeAt : null,
|
|
2616
|
+
});
|
|
2617
|
+
// GENERATING-MISSING (win32 fresh-worktree first-turn): a freshly-launched session
|
|
2618
|
+
// is in 'starting' until its startup-grace settles to idle. When the FIRST inject
|
|
2619
|
+
// lands inside that grace window, the adapter can report status DIRECTLY
|
|
2620
|
+
// starting → generating without an intervening 'idle' frame for
|
|
2621
|
+
// detectStatusTransition() to observe. Previously only the idle→generating arm
|
|
2622
|
+
// armed the bookkeeping, so a starting→generating frame fell straight through to the
|
|
2623
|
+
// bare `this.lastStatus = newStatus` update: generatingStartedAt stayed 0 and no
|
|
2624
|
+
// generating_started was queued. The fast turn's generating→idle completion was then
|
|
2625
|
+
// suppressed by the startup-blip guard below (generatingStartedAt===0 &&
|
|
2626
|
+
// !generatingDebouncePending) — so NO generating_started AND NO generating_completed
|
|
2627
|
+
// ever fired and the mesh coordinator never learned the worker went idle.
|
|
2628
|
+
//
|
|
2629
|
+
// We extend the idle→generating arm to also fire on starting→generating, BUT ONLY
|
|
2630
|
+
// when a real turn is in flight. The adapter script can also report 'generating' from
|
|
2631
|
+
// pure startup PTY noise (no task dispatched) — antigravity/codex/hermes-cli all
|
|
2632
|
+
// exercise that benign starting→generating→idle blip, which must NOT emit a
|
|
2633
|
+
// completion (see "startup-phase spurious completion suppression" tests).
|
|
2634
|
+
// hasAdapterPendingResponse() is the discriminator: a genuine inject sets the
|
|
2635
|
+
// adapter's isWaitingForResponse / currentTurnScope (or leaves a partial response),
|
|
2636
|
+
// whereas startup repaint noise leaves all of them empty. So an armed
|
|
2637
|
+
// starting→generating means "the worker actually started its first turn", and a
|
|
2638
|
+
// bare one stays a suppressed blip via the existing fall-through.
|
|
2639
|
+
const startingToGeneratingWithActiveTurn = this.lastStatus === 'starting'
|
|
2640
|
+
&& newStatus === 'generating'
|
|
2641
|
+
&& this.hasAdapterPendingResponse();
|
|
2642
|
+
if (((this.lastStatus === 'idle' && newStatus === 'generating') || startingToGeneratingWithActiveTurn)) {
|
|
2643
|
+
// If a completion event is already pending and the turn has ended
|
|
2644
|
+
// (generatingStartedAt===0), the PTY is painting its prompt area
|
|
2645
|
+
// after completing. Ignore this blip — do not cancel the pending
|
|
2646
|
+
// completion and do not advance lastStatus to generating. (On a true
|
|
2647
|
+
// starting→generating the session is fresh: completedDebouncePending is
|
|
2648
|
+
// null, so this blip guard is a no-op and we arm normally below.)
|
|
2649
|
+
if (this.completedDebouncePending && this.generatingStartedAt === 0) {
|
|
2650
|
+
LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
894
2653
|
this.suppressIdleHistoryReplay = false;
|
|
895
2654
|
// Cancel any pending completed event (multi-step: idle→generating resume)
|
|
896
2655
|
if (this.completedDebouncePending) {
|
|
897
|
-
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
|
|
2656
|
+
LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
|
|
898
2657
|
if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
|
|
899
2658
|
this.completedDebouncePending = null;
|
|
900
2659
|
}
|
|
901
2660
|
|
|
902
2661
|
if (!this.generatingStartedAt) this.generatingStartedAt = now;
|
|
2662
|
+
// A genuinely new turn is underway — drop the previous turn's cached
|
|
2663
|
+
// final-summary so the dashboard does not keep showing the old answer
|
|
2664
|
+
// as "done" while the new turn generates. Re-populated when this turn
|
|
2665
|
+
// completes.
|
|
2666
|
+
this.lastCompletionSummary = null;
|
|
2667
|
+
// FALSE-IDLE continuity: entering a busy phase invalidates any
|
|
2668
|
+
// completedDebouncePending armed earlier in this settle window.
|
|
2669
|
+
this.busyEpoch++;
|
|
903
2670
|
// Defer the generating_started event — if idle comes back within 3s,
|
|
904
2671
|
// the whole started→completed pair was a false positive from PTY noise
|
|
905
2672
|
if (this.generatingDebounceTimer) clearTimeout(this.generatingDebounceTimer);
|
|
@@ -924,14 +2691,38 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
924
2691
|
this.completedDebouncePending = null;
|
|
925
2692
|
|
|
926
2693
|
if (!this.generatingStartedAt) this.generatingStartedAt = now;
|
|
2694
|
+
// FALSE-IDLE continuity: waiting_approval is a busy phase (the agent
|
|
2695
|
+
// resumes into it), so bump the epoch too — the completedDebouncePending
|
|
2696
|
+
// cancel above covers the currently-armed pending, and the epoch covers
|
|
2697
|
+
// a pending that re-arms and flushes across this same valley.
|
|
2698
|
+
this.busyEpoch++;
|
|
927
2699
|
const modal = adapterStatus.activeModal;
|
|
928
2700
|
LOG.info('CLI', `[${this.type}] approval modal: "${modal?.message?.slice(0, 80) ?? 'none'}"`);
|
|
929
|
-
//
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
2701
|
+
// Include the FSM's approval entry seq, mirroring the auto-approve
|
|
2702
|
+
// path (maybeAutoApproveStatus) and resolveModal's sameEntryReResolve
|
|
2703
|
+
// guard. Two distinct back-to-back approvals can carry identical
|
|
2704
|
+
// message/buttons (very common with claude-cli's "Allow Bash
|
|
2705
|
+
// command?"). Without the seq their fingerprints collide and the dedup
|
|
2706
|
+
// below silently drops the second waiting_approval event — it is never
|
|
2707
|
+
// emitted, so it cannot even land in the pending inbox for a later
|
|
2708
|
+
// read_chat reconcile to recover. The seq is bumped by the FSM on every
|
|
2709
|
+
// fresh waiting_approval entry, so a new approval always yields a new
|
|
2710
|
+
// fingerprint and emits.
|
|
2711
|
+
const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
|
|
2712
|
+
? adapterStatus.approvalEntrySeq
|
|
2713
|
+
: 0;
|
|
2714
|
+
const approvalFingerprint = JSON.stringify({
|
|
2715
|
+
message: typeof modal?.message === 'string' ? modal.message.trim() : '',
|
|
2716
|
+
buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
|
|
2717
|
+
seq: approvalEntrySeq,
|
|
2718
|
+
});
|
|
2719
|
+
// PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
|
|
2720
|
+
// Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
|
|
2721
|
+
// (e.g. antigravity-cli resolves one prompt and immediately shows the next) and would be silently dropped.
|
|
2722
|
+
if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
|
|
2723
|
+
this.lastApprovalEventFingerprint = approvalFingerprint;
|
|
933
2724
|
this.appendRuntimeSystemMessage(
|
|
934
|
-
|
|
2725
|
+
formatApprovalRequestMessage(modal?.message, modal?.buttons),
|
|
935
2726
|
`approval_request:${now}`,
|
|
936
2727
|
now,
|
|
937
2728
|
);
|
|
@@ -941,22 +2732,286 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
941
2732
|
modalButtons: modal?.buttons,
|
|
942
2733
|
});
|
|
943
2734
|
}
|
|
2735
|
+
} else if (newStatus === 'generating' && this.lastStatus === 'waiting_approval') {
|
|
2736
|
+
// Approval resolved and the agent resumed work. Defense-in-depth:
|
|
2737
|
+
// clear the approval emit fingerprint here too (not only on
|
|
2738
|
+
// completion at scheduleCompletedDebounceFlush). A subsequent
|
|
2739
|
+
// waiting_approval with the same modal content as the one just
|
|
2740
|
+
// resolved would otherwise collide with the stale fingerprint and be
|
|
2741
|
+
// dropped. The seq in the fingerprint already separates entries; this
|
|
2742
|
+
// reset is a belt-and-suspenders guard for the re-entry case.
|
|
2743
|
+
this.lastApprovalEventFingerprint = '';
|
|
944
2744
|
} else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
|
|
945
2745
|
const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
|
|
946
|
-
//
|
|
2746
|
+
// Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
|
|
2747
|
+
// was entered from 'starting' state (startup PTY noise), not from a real idle→generating
|
|
2748
|
+
// task dispatch. The idle→generating handler is the only code path that sets
|
|
2749
|
+
// generatingStartedAt and generatingDebouncePending, so both being absent means no
|
|
2750
|
+
// task was ever dispatched. Suppress the spurious completion event and fall through
|
|
2751
|
+
// to a simple lastStatus update.
|
|
2752
|
+
if (!this.generatingStartedAt && !this.generatingDebouncePending) {
|
|
2753
|
+
LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
|
|
2754
|
+
} else
|
|
2755
|
+
// If debounce still pending (generating lasted < 1s), cancel both UI events.
|
|
2756
|
+
// Still emit agent:generating_completed so mesh orchestration can record
|
|
2757
|
+
// task_completed for direct dispatches that complete faster than the debounce.
|
|
947
2758
|
if (this.generatingDebouncePending) {
|
|
948
|
-
|
|
2759
|
+
// NOTIF Defect-2a: shortDurationMs is the REPORTED turn duration, so it must be
|
|
2760
|
+
// measured from the IMMUTABLE turn start — not generatingStartedAt, which is
|
|
2761
|
+
// reset to 0 on every mid-turn waiting_approval/idle blip (see :1864/1885/2397/
|
|
2762
|
+
// 2503/2521) and re-armed on the next →generating, so a long turn that blipped
|
|
2763
|
+
// would measure only the final 1.5-2.5s sliver. engine.currentTurnStartedAt is
|
|
2764
|
+
// stamped once at onTurnStarted and persists past mid-turn blips until the next
|
|
2765
|
+
// turn starts, so it captures the true turn length. generatingStartedAt remains
|
|
2766
|
+
// the fallback (and the debounce itself stays a pure UI-suppression signal,
|
|
2767
|
+
// decoupled from the reported duration).
|
|
2768
|
+
const { durationMs: shortDurationMs, anchor: durationAnchor } = computeTurnAnchoredDurationMs(
|
|
2769
|
+
(this.adapter as any)?.currentTurnStartedAt,
|
|
2770
|
+
this.generatingStartedAt,
|
|
2771
|
+
now,
|
|
2772
|
+
);
|
|
2773
|
+
LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms, anchor=${durationAnchor})`);
|
|
949
2774
|
if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
|
|
2775
|
+
// Emit completion for mesh task association even though the UI generating
|
|
2776
|
+
// started/completed pair is suppressed (too short for visible UI update).
|
|
2777
|
+
let shortFinalSummary: string | undefined;
|
|
2778
|
+
let shortEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
|
|
2779
|
+
try {
|
|
2780
|
+
const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
|
|
2781
|
+
const evidence = this.completionFinalAssistantEvidence(parsedMessages);
|
|
2782
|
+
shortEvidenceSource = evidence.source;
|
|
2783
|
+
shortFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
|
|
2784
|
+
} catch { /* best-effort */ }
|
|
2785
|
+
// If a real response is confirmed, retroactively emit started so the chat
|
|
2786
|
+
// bubble appears even though the debounce suppressed the original event.
|
|
2787
|
+
if (shortFinalSummary) {
|
|
2788
|
+
this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now - shortDurationMs });
|
|
2789
|
+
}
|
|
2790
|
+
// FALSE-IDLE short-gen settle: snapshot the producing turn's start + taskId NOW,
|
|
2791
|
+
// before `generatingStartedAt` is reset below — the settle-arm path (mesh sessions,
|
|
2792
|
+
// see the mesh branch further down) needs the same turn anchor the normal
|
|
2793
|
+
// completedDebounce branch captures, and generatingStartedAt is the fallback for it.
|
|
2794
|
+
const shortEngineTurnStart = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
|
|
2795
|
+
&& Number.isFinite((this.adapter as any).currentTurnStartedAt)
|
|
2796
|
+
? (this.adapter as any).currentTurnStartedAt as number
|
|
2797
|
+
: 0;
|
|
2798
|
+
const shortTurnStartedAt = shortEngineTurnStart || this.generatingStartedAt || 0;
|
|
2799
|
+
const shortTaskId = this.completingTurnTaskId();
|
|
950
2800
|
this.generatingDebouncePending = null;
|
|
951
2801
|
this.generatingStartedAt = 0;
|
|
2802
|
+
// FALSE-IDLE short-gen: a short-generating completion with NO transcript
|
|
2803
|
+
// backing at all (shortEvidenceSource === 'unavailable': both the screen parse
|
|
2804
|
+
// AND the external-native transcript failed to yield a final assistant) is just
|
|
2805
|
+
// as unproven as an 'external-native' source that returned no final assistant.
|
|
2806
|
+
// Fold 'unavailable' into the missing-evidence predicate so a zero-evidence dip
|
|
2807
|
+
// (the mid-turn point-sample that triggered this whole false-idle bug) is treated
|
|
2808
|
+
// as weak/held, not fired as a genuine completion. A real shortFinalSummary being
|
|
2809
|
+
// present still clears the gate (the !shortFinalSummary guard is unchanged).
|
|
2810
|
+
const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true
|
|
2811
|
+
|| shortEvidenceSource === 'external-native'
|
|
2812
|
+
|| shortEvidenceSource === 'unavailable') && !shortFinalSummary;
|
|
2813
|
+
if (missingEvidence) {
|
|
2814
|
+
LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
|
|
2815
|
+
}
|
|
2816
|
+
if (this.isAutonomousMeshSession()) {
|
|
2817
|
+
// FALSE-IDLE short-gen settle (the core fix): for an autonomously-progressing
|
|
2818
|
+
// mesh session (delegated worker OR self-coordinator), the short-generating
|
|
2819
|
+
// branch was a POINT-SAMPLE — a single idle read from getStatus({allowParse:false})
|
|
2820
|
+
// that fires the completion INLINE with zero continuity backing. When the worker
|
|
2821
|
+
// is merely mid-turn (a sub-3s dip between two tool calls), this synchronously
|
|
2822
|
+
// emitted a false agent:generating_completed the coordinator can never correct.
|
|
2823
|
+
//
|
|
2824
|
+
// Route it through the SAME settle + continuity machinery as the normal
|
|
2825
|
+
// completedDebounce branch: arm completedDebouncePending capturing busyEpochAtArm
|
|
2826
|
+
// and lastOutputAtArm, then scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS)
|
|
2827
|
+
// so flushCompletedDebounceIfFinalized() re-verifies CONTINUOUS idle before emitting.
|
|
2828
|
+
// A busy re-entry (busyEpoch bump) or new PTY output within the settle window —
|
|
2829
|
+
// exactly what happens when the worker resumes its next tool call — CANCELS the
|
|
2830
|
+
// false completion. Genuine completions (real final assistant) still emit at the
|
|
2831
|
+
// end of the (short, 4s) settle window; missingEvidence flows through the
|
|
2832
|
+
// finalization block (missing_final_assistant → CANON-C weak/held) rather than
|
|
2833
|
+
// being frozen as a genuine inline fire.
|
|
2834
|
+
this.completedDebouncePending = {
|
|
2835
|
+
chatTitle,
|
|
2836
|
+
duration: Math.round(shortDurationMs / 1000),
|
|
2837
|
+
timestamp: now,
|
|
2838
|
+
firstObservedAt: now,
|
|
2839
|
+
// Short-gen enters from generating→idle (or waiting_approval→idle); the
|
|
2840
|
+
// completedDebounce finalization gate treats previousStatus for its
|
|
2841
|
+
// approval-resolution / inter-approval-valley handling. lastStatus is the
|
|
2842
|
+
// status we transitioned FROM here.
|
|
2843
|
+
previousStatus: this.lastStatus,
|
|
2844
|
+
...(shortTaskId ? { taskId: shortTaskId } : {}),
|
|
2845
|
+
...(shortTurnStartedAt ? { turnStartedAt: shortTurnStartedAt } : {}),
|
|
2846
|
+
// FALSE-IDLE continuity: same arm-time snapshots as the normal branch so the
|
|
2847
|
+
// flush guard can prove continuous idle across the settle window.
|
|
2848
|
+
busyEpochAtArm: this.busyEpoch,
|
|
2849
|
+
...(typeof adapterStatus?.lastOutputAt === 'number' && Number.isFinite(adapterStatus.lastOutputAt)
|
|
2850
|
+
? { lastOutputAtArm: adapterStatus.lastOutputAt as number }
|
|
2851
|
+
: {}),
|
|
2852
|
+
};
|
|
2853
|
+
LOG.info('CLI', `[${this.type}] short-generating routed through settle window (${shortDurationMs}ms, source=${shortEvidenceSource}, missingEvidence=${missingEvidence}) — arming completedDebouncePending instead of inline fire`);
|
|
2854
|
+
// EVTTRACE: now traces the settle ARM (not an inline fire) for mesh sessions,
|
|
2855
|
+
// so logs show the short-gen path deferring to continuity re-check.
|
|
2856
|
+
if (this.isMeshWorkerSession()) {
|
|
2857
|
+
traceMeshEventStage('arm', this.meshTraceCtx(), `short-generating settle-arm (source=${shortEvidenceSource}, missingEvidence=${missingEvidence})`);
|
|
2858
|
+
}
|
|
2859
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
|
|
2860
|
+
branch: 'short_generating',
|
|
2861
|
+
previousStatus: this.lastStatus,
|
|
2862
|
+
turnStartedAt: shortTurnStartedAt || null,
|
|
2863
|
+
busyEpochAtArm: this.busyEpoch,
|
|
2864
|
+
lastOutputAtArm: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
|
|
2865
|
+
flushDelay: NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
|
|
2866
|
+
evidenceSource: shortEvidenceSource,
|
|
2867
|
+
missingEvidence,
|
|
2868
|
+
hasFinalSummary: !!shortFinalSummary,
|
|
2869
|
+
});
|
|
2870
|
+
this.scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS);
|
|
2871
|
+
} else if (missingEvidence) {
|
|
2872
|
+
// NON-MESH, missing evidence: suppress the completion event entirely (the
|
|
2873
|
+
// original !hasMeshContext suppression). A genuinely non-mesh session has no
|
|
2874
|
+
// coordinator to notify, and firing a completion with no confirmed final
|
|
2875
|
+
// assistant would just surface an empty/unconfirmed turn. Leave
|
|
2876
|
+
// completedDebouncePending null — the session is now idle with no confirmed
|
|
2877
|
+
// turn, matching the startup-blip suppression semantics. (No EvtTrace: not a
|
|
2878
|
+
// mesh session, so nothing routes to a coordinator.)
|
|
2879
|
+
LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, non-mesh session (source=${shortEvidenceSource})`);
|
|
2880
|
+
} else {
|
|
2881
|
+
// NON-MESH interactive fast-path with a CONFIRMED summary: keep the existing
|
|
2882
|
+
// inline fire. The dashboard UX reason for the short path (a fast turn should
|
|
2883
|
+
// surface its completion promptly without a 4s settle) still holds, and a
|
|
2884
|
+
// non-mesh session has no coordinator to be falsely notified — the false-idle
|
|
2885
|
+
// bug being fixed is specifically the mesh worker/coordinator misfire above.
|
|
2886
|
+
this.emitGeneratingCompleted({
|
|
2887
|
+
chatTitle,
|
|
2888
|
+
duration: 0,
|
|
2889
|
+
timestamp: now,
|
|
2890
|
+
finalSummary: shortFinalSummary,
|
|
2891
|
+
completionDiagnostic: {
|
|
2892
|
+
reason: 'short_generating_suppressed',
|
|
2893
|
+
shortDurationMs,
|
|
2894
|
+
finalAssistantEvidenceSource: shortEvidenceSource,
|
|
2895
|
+
},
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
952
2898
|
} else {
|
|
953
2899
|
// Debounce completed, then require the rich transcript path that read_chat
|
|
954
2900
|
// uses to show an idle turn whose last user-facing message is assistant.
|
|
955
|
-
this.completedDebouncePending = {
|
|
956
|
-
|
|
2901
|
+
this.completedDebouncePending = {
|
|
2902
|
+
chatTitle,
|
|
2903
|
+
duration,
|
|
2904
|
+
timestamp: now,
|
|
2905
|
+
firstObservedAt: now,
|
|
2906
|
+
previousStatus: this.lastStatus,
|
|
2907
|
+
// ARCH-REFACTOR R1: snapshot the completing turn's taskId NOW (sync),
|
|
2908
|
+
// before any follow-up task's flush can start a new turn and move
|
|
2909
|
+
// engine.currentTurnTaskId.
|
|
2910
|
+
...(this.completingTurnTaskId() ? { taskId: this.completingTurnTaskId() } : {}),
|
|
2911
|
+
// NOTIF Defect-B: snapshot the producing turn's START instant NOW, for the
|
|
2912
|
+
// same reason as taskId — a follow-up turn moves engine.currentTurnStartedAt.
|
|
2913
|
+
// Prefer the engine's per-turn start (set at onTurnStarted, earliest reliable
|
|
2914
|
+
// anchor) and fall back to generatingStartedAt (when generating was observed).
|
|
2915
|
+
...((() => {
|
|
2916
|
+
const engineTurnStart = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
|
|
2917
|
+
&& Number.isFinite((this.adapter as any).currentTurnStartedAt)
|
|
2918
|
+
? (this.adapter as any).currentTurnStartedAt as number
|
|
2919
|
+
: 0;
|
|
2920
|
+
const turnStartedAt = engineTurnStart || this.generatingStartedAt || 0;
|
|
2921
|
+
return turnStartedAt ? { turnStartedAt } : {};
|
|
2922
|
+
})()),
|
|
2923
|
+
// FALSE-IDLE continuity: snapshot the busy epoch + raw PTY output
|
|
2924
|
+
// clock at arm time so the flush guard can prove the session stayed
|
|
2925
|
+
// continuously idle (no busy re-entry, no new PTY output) through the
|
|
2926
|
+
// settle window rather than merely reading 'idle' once at flush.
|
|
2927
|
+
busyEpochAtArm: this.busyEpoch,
|
|
2928
|
+
...(typeof adapterStatus?.lastOutputAt === 'number' && Number.isFinite(adapterStatus.lastOutputAt)
|
|
2929
|
+
? { lastOutputAtArm: adapterStatus.lastOutputAt as number }
|
|
2930
|
+
: {}),
|
|
2931
|
+
};
|
|
2932
|
+
const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
|
|
2933
|
+
// (FALSEIDLE-BGCHILD-a) Native-history providers flush immediately (the
|
|
2934
|
+
// transcript is authoritative). For autonomously-progressing mesh sessions,
|
|
2935
|
+
// give the generating→idle transition a short settle window so a background-child
|
|
2936
|
+
// false idle (quiet after a backgrounded test/command while the parent turn
|
|
2937
|
+
// continues) or an inter-approval auto-approve valley gets caught by the resume
|
|
2938
|
+
// guard in flushCompletedDebounceIfFinalized instead of firing an early completion
|
|
2939
|
+
// the coordinator can never correct.
|
|
2940
|
+
//
|
|
2941
|
+
// (FALSE-IDLE self-coordinator settle) The settle window now covers BOTH mesh
|
|
2942
|
+
// worker sessions AND the coordinator's own claude-cli session (meshCoordinatorFor):
|
|
2943
|
+
// isAutonomousMeshSession(). Previously only isMeshWorkerSession() qualified, so a
|
|
2944
|
+
// self-coordinating daemon (worker + coordinator on the same daemon) ran the
|
|
2945
|
+
// coordinator's own turn at flushDelay=0 — no settle window at all — and the
|
|
2946
|
+
// busyEpoch/lastOutputAt continuity guard, being a flush-time point-check, had no
|
|
2947
|
+
// window in which to observe the ~0.5s auto-approve valley. Its mid-turn
|
|
2948
|
+
// "next-step" sentence was flushed as finalSummary. A genuinely non-mesh session
|
|
2949
|
+
// (neither worker nor self-coordinator) still flushes immediately (delay=0), so
|
|
2950
|
+
// no non-mesh behaviour changes; this only ADDS a settle window (strictly
|
|
2951
|
+
// stricter — the guard can only ever CANCEL a pending flush, never emit more).
|
|
2952
|
+
const meshSettleSession = this.isAutonomousMeshSession();
|
|
2953
|
+
const flushDelay = ownsExternalHistory
|
|
2954
|
+
? (meshSettleSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
|
|
2955
|
+
: 3000;
|
|
2956
|
+
LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshSettle=${meshSettleSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
|
|
2957
|
+
if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
|
|
2958
|
+
branch: 'normal',
|
|
2959
|
+
previousStatus: this.completedDebouncePending.previousStatus,
|
|
2960
|
+
turnStartedAt: this.completedDebouncePending.turnStartedAt ?? null,
|
|
2961
|
+
busyEpochAtArm: this.completedDebouncePending.busyEpochAtArm ?? null,
|
|
2962
|
+
lastOutputAtArm: this.completedDebouncePending.lastOutputAtArm ?? null,
|
|
2963
|
+
flushDelay,
|
|
2964
|
+
ownsExternalHistory,
|
|
2965
|
+
meshSettle: meshSettleSession,
|
|
2966
|
+
});
|
|
2967
|
+
this.scheduleCompletedDebounceFlush(flushDelay);
|
|
957
2968
|
}
|
|
958
2969
|
} else if (newStatus === 'idle' && this.lastStatus === 'starting') {
|
|
959
|
-
this.
|
|
2970
|
+
this.emitAgentReadyOnce(chatTitle, now);
|
|
2971
|
+
// GENERATING-BOUNDARY (R4c): stamp the collapse moment ONCE so the
|
|
2972
|
+
// idle-stayed window below is anchored on the startup-grace collapse,
|
|
2973
|
+
// not on boot. Set only the first time so a later starting re-entry
|
|
2974
|
+
// cannot slide the window forward.
|
|
2975
|
+
if (this.startupGraceCollapseAt === null) this.startupGraceCollapseAt = now;
|
|
2976
|
+
// GENERATING-BOUNDARY fast-collapse (R4, win32 startup-grace first turn):
|
|
2977
|
+
// a turn dispatched into the startup-grace window can START and FINISH while
|
|
2978
|
+
// the FSM is still in 'starting'. On a daemon whose claude-cli spec has NOT yet
|
|
2979
|
+
// synced the starting→busy edge (the primary cure lives in the spec's
|
|
2980
|
+
// idle→busy.from), the FSM never reaches 'busy'/generating, so
|
|
2981
|
+
// detectStatusTransition observes starting→idle DIRECTLY with no intervening
|
|
2982
|
+
// 'generating' frame. The idle→generating arm — the only path that sets
|
|
2983
|
+
// generatingStartedAt and arms the completion — never fired, so the completing
|
|
2984
|
+
// turn's agent:generating_completed is never emitted and the mesh coordinator
|
|
2985
|
+
// never learns the worker went idle. Synthesize the started+completed pair here.
|
|
2986
|
+
//
|
|
2987
|
+
// Discriminator (false-positive safe — must NOT fire on a benign boot):
|
|
2988
|
+
// adapter.currentTurnTaskId is set ONLY by onTurnStarted (a real turn STARTED
|
|
2989
|
+
// this boot) and persists past completion, so it cleanly separates the three
|
|
2990
|
+
// non-firing cases — a true idle boot (no turn → null), a queued-pending
|
|
2991
|
+
// first turn that only runs AFTER startup-grace drains the composer (onTurnStarted
|
|
2992
|
+
// not yet called → null; it completes normally later via idle→busy→idle), and a
|
|
2993
|
+
// turn STILL running at the 8s mark (hasAdapterPendingResponse() still true →
|
|
2994
|
+
// excluded so we don't fire a premature mid-turn completion; idle→busy self-
|
|
2995
|
+
// corrects once the FSM reaches idle). We fire only when a turn started AND has
|
|
2996
|
+
// already finished: started-this-boot && !still-in-flight.
|
|
2997
|
+
this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_fast_collapse');
|
|
2998
|
+
} else if (newStatus === 'error') {
|
|
2999
|
+
if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
|
|
3000
|
+
this.generatingDebouncePending = null;
|
|
3001
|
+
if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
|
|
3002
|
+
this.completedDebouncePending = null;
|
|
3003
|
+
this.errorMessage = adapterStatus.errorMessage || this.errorMessage;
|
|
3004
|
+
this.errorReason = (adapterStatus.errorReason as ProviderErrorReason) || this.errorReason;
|
|
3005
|
+
this.pushEvent({
|
|
3006
|
+
event: 'agent:stopped',
|
|
3007
|
+
chatTitle,
|
|
3008
|
+
timestamp: now,
|
|
3009
|
+
finalSummary: adapterStatus.errorMessage || adapterStatus.errorReason || 'Provider reported an error',
|
|
3010
|
+
completionDiagnostic: {
|
|
3011
|
+
reason: adapterStatus.errorReason || 'provider_error',
|
|
3012
|
+
errorMessage: adapterStatus.errorMessage || undefined,
|
|
3013
|
+
},
|
|
3014
|
+
});
|
|
960
3015
|
} else if (newStatus === 'stopped') {
|
|
961
3016
|
// Cancel any pending debounce
|
|
962
3017
|
if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
|
|
@@ -968,6 +3023,79 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
968
3023
|
this.lastStatus = newStatus;
|
|
969
3024
|
}
|
|
970
3025
|
|
|
3026
|
+
// GENERATING-BOUNDARY idle-stayed collapse (R4b): the starting→idle
|
|
3027
|
+
// fast-collapse arm above only fires when the FIRST turn itself drives the
|
|
3028
|
+
// starting→idle transition. When the launch settle already drained
|
|
3029
|
+
// starting→idle BEFORE the first turn arrives, the session is already 'idle'
|
|
3030
|
+
// and a turn that runs+completes inside the startup-grace window — too fast
|
|
3031
|
+
// for any poll to observe a 'generating' frame — produces NO status change
|
|
3032
|
+
// at all (idle→idle). detectStatusTransition's change block above is skipped
|
|
3033
|
+
// entirely, so neither the idle→generating arm nor the starting→idle
|
|
3034
|
+
// fast-collapse arm ever runs, and the mesh coordinator never learns the
|
|
3035
|
+
// worker went idle (the live rc.403 Probe1 miss). Catch it here: an
|
|
3036
|
+
// already-idle poll (no status change), still inside the startup-grace
|
|
3037
|
+
// window, where a turn started this boot and has already finished without
|
|
3038
|
+
// ever arming generating. The same false-positive-safe discriminators apply
|
|
3039
|
+
// (currentTurnTaskId set && !pending && generating never armed), plus a
|
|
3040
|
+
// once-per-turn guard since this branch is re-polled while the session sits
|
|
3041
|
+
// idle. Normal turns that DO reach 'busy' set generatingStartedAt and are
|
|
3042
|
+
// excluded; a queued-pending first turn that only runs after grace falls
|
|
3043
|
+
// outside the window and completes normally via idle→busy→idle.
|
|
3044
|
+
//
|
|
3045
|
+
// R4d (the live rc.405 Probe2 miss): R4c anchored the window on the collapse
|
|
3046
|
+
// moment but still measured its END against `now` (the poll/completion time). The
|
|
3047
|
+
// helper only fires once the turn has FINISHED (!hasAdapterPendingResponse()), so
|
|
3048
|
+
// the first eligible poll happens at completion. When the first turn is dispatched
|
|
3049
|
+
// a few seconds after the collapse AND runs for a non-trivial duration, that
|
|
3050
|
+
// completion lands PAST the 12s now-anchored window even though the turn was a
|
|
3051
|
+
// genuine startup-grace first turn (live: collapse→dispatch +5.2s, turn ~11s →
|
|
3052
|
+
// completion at collapse+16.2s > 12s). Anchor the window on when the first turn
|
|
3053
|
+
// STARTED (engine.currentTurnStartedAt, set by onTurnStarted) instead: a turn that
|
|
3054
|
+
// STARTED within the collapse window is a startup-grace first turn no matter how
|
|
3055
|
+
// long it then ran. The now-anchored check is retained as a union so a fast turn
|
|
3056
|
+
// (dispatched+completed quickly within 12s of collapse) keeps firing too; both
|
|
3057
|
+
// close for a much-later turn, preserving the "don't mislabel a late fast turn"
|
|
3058
|
+
// honesty the window exists for.
|
|
3059
|
+
const firstTurnStartedAt = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
|
|
3060
|
+
? (this.adapter as any).currentTurnStartedAt as number
|
|
3061
|
+
: 0;
|
|
3062
|
+
const collapsedAt = this.startupGraceCollapseAt;
|
|
3063
|
+
const turnStartedWithinCollapseWindow = collapsedAt !== null
|
|
3064
|
+
&& firstTurnStartedAt > 0
|
|
3065
|
+
&& firstTurnStartedAt >= collapsedAt
|
|
3066
|
+
&& (firstTurnStartedAt - collapsedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS;
|
|
3067
|
+
const nowWithinCollapseWindow = collapsedAt !== null
|
|
3068
|
+
&& (now - collapsedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS;
|
|
3069
|
+
if (
|
|
3070
|
+
newStatus === 'idle'
|
|
3071
|
+
&& previousStatus === 'idle'
|
|
3072
|
+
&& (turnStartedWithinCollapseWindow || nowWithinCollapseWindow)
|
|
3073
|
+
) {
|
|
3074
|
+
this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_idle_turn_collapse');
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
// Re-arm the queue-claim agent:ready on the FSM's first GENUINE ready.
|
|
3078
|
+
//
|
|
3079
|
+
// The boot-time starting→idle one-shot above is the historical claim
|
|
3080
|
+
// trigger, but it is consumed too early for FSM-spec providers whose
|
|
3081
|
+
// INITIAL state already reports status 'idle' (e.g. antigravity-cli): the
|
|
3082
|
+
// adapter reports idle before maybeMarkReady has fired, so lastStatus
|
|
3083
|
+
// advances starting→idle while the prompt is not yet drawn and the worker
|
|
3084
|
+
// cannot yet claim. Subsequent state-driven idle frames are idle→idle (no
|
|
3085
|
+
// status change), so the one-shot never re-fires and the worker strands its
|
|
3086
|
+
// queued task — the coordinator then relaunch-loops every ~90s.
|
|
3087
|
+
//
|
|
3088
|
+
// The adapter surfaces fsmReadySeen=true exactly when the FSM reaches its
|
|
3089
|
+
// first non-initial idle (the prompt is genuinely up). On that signal we
|
|
3090
|
+
// emit agent:ready once more. emitAgentReadyOnce is idempotent (guarded by
|
|
3091
|
+
// agentReadyEmitted), so providers whose boot one-shot already landed on a
|
|
3092
|
+
// real ready (claude-cli / codex-cli / hermes-cli, which use a startup
|
|
3093
|
+
// grace and whose initial state is not idle) treat this as a no-op — no
|
|
3094
|
+
// double claim, no double task injection.
|
|
3095
|
+
if (newStatus === 'idle' && adapterStatus.fsmReadySeen === true && !this.agentReadyEmitted) {
|
|
3096
|
+
this.emitAgentReadyOnce(chatTitle, now);
|
|
3097
|
+
}
|
|
3098
|
+
|
|
971
3099
|
this.applyProviderResponse(parsedStatus, {
|
|
972
3100
|
phase: (newStatus === 'idle' && (previousStatus === 'generating' || previousStatus === 'waiting_approval'))
|
|
973
3101
|
? 'turn_completed'
|
|
@@ -976,8 +3104,42 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
976
3104
|
|
|
977
3105
|
// Monitor check (cooldown based notification, IDE/CLI common)
|
|
978
3106
|
const agentKey = `${this.type}:cli`;
|
|
979
|
-
|
|
3107
|
+
// Approval pending is detected from the raw adapter status, not `newStatus`:
|
|
3108
|
+
// auto-approve synthesizes `waiting_approval` → 'generating', which would
|
|
3109
|
+
// otherwise let the no-progress watchdog accumulate the approval wait.
|
|
3110
|
+
const approvalPending = rawStatus === 'waiting_approval';
|
|
3111
|
+
const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
|
|
3112
|
+
const monitorParsedStatus: any = parsedStatus;
|
|
980
3113
|
for (const me of monitorEvents) {
|
|
3114
|
+
if (
|
|
3115
|
+
me.type === 'monitor:no_progress'
|
|
3116
|
+
&& this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
|
|
3117
|
+
&& !this.hasAdapterPendingResponse()
|
|
3118
|
+
&& !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
|
|
3119
|
+
) {
|
|
3120
|
+
// EVTTRACE: completion fired (no-progress monitor reconciled to completion).
|
|
3121
|
+
if (this.isMeshWorkerSession()) {
|
|
3122
|
+
traceMeshEventStage('fired', this.meshTraceCtx(), 'no_progress_monitor_final_summary');
|
|
3123
|
+
}
|
|
3124
|
+
this.emitGeneratingCompleted({
|
|
3125
|
+
chatTitle,
|
|
3126
|
+
duration: this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : undefined,
|
|
3127
|
+
timestamp: me.timestamp,
|
|
3128
|
+
finalSummary: extractFinalSummaryFromMessages(monitorParsedStatus?.messages),
|
|
3129
|
+
completionDiagnostic: {
|
|
3130
|
+
providerType: this.type,
|
|
3131
|
+
sessionId: this.instanceId,
|
|
3132
|
+
providerSessionId: this.providerSessionId || null,
|
|
3133
|
+
reconciliationReason: 'no_progress_monitor_final_summary',
|
|
3134
|
+
finalAssistantPresent: true,
|
|
3135
|
+
},
|
|
3136
|
+
});
|
|
3137
|
+
this.generatingStartedAt = 0;
|
|
3138
|
+
// Cancel any pending debounce flush — monitor already fired completion.
|
|
3139
|
+
if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
|
|
3140
|
+
this.completedDebouncePending = null;
|
|
3141
|
+
continue;
|
|
3142
|
+
}
|
|
981
3143
|
this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
|
|
982
3144
|
}
|
|
983
3145
|
}
|
|
@@ -997,15 +3159,67 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
997
3159
|
workspaceName: typeof event.workspaceName === 'string' && event.workspaceName.trim()
|
|
998
3160
|
? event.workspaceName
|
|
999
3161
|
: this.workingDir,
|
|
3162
|
+
// Carry the workspace under BOTH `workspace` and `workspaceName` so the
|
|
3163
|
+
// downstream mesh forward/merge path — which reads `workspace` — can
|
|
3164
|
+
// propagate it to the coordinator snapshot. Without `workspace` the live
|
|
3165
|
+
// event path delivers an empty workspace and the dashboard falls back to
|
|
3166
|
+
// the generic "Terminal (Mesh Node)" title.
|
|
3167
|
+
workspace: typeof event.workspace === 'string' && event.workspace.trim()
|
|
3168
|
+
? event.workspace
|
|
3169
|
+
: this.workingDir,
|
|
1000
3170
|
providerSessionId: typeof event.providerSessionId === 'string' && event.providerSessionId.trim()
|
|
1001
3171
|
? event.providerSessionId
|
|
1002
3172
|
: this.providerSessionId,
|
|
1003
3173
|
};
|
|
3174
|
+
// TASKIDLESS: stamp the mesh task primary key on lifecycle events emitted by
|
|
3175
|
+
// a mesh worker session. The consumer (updateDirectDispatchStatus) was switched
|
|
3176
|
+
// to key on task_id (CANON-B), but the producer never carried it — so every
|
|
3177
|
+
// forwarded metadataEvent.taskId arrived undefined and the coordinator fell back
|
|
3178
|
+
// to a session_id match, which can flip a sibling dispatch row. Surface it here so
|
|
3179
|
+
// updateDirectDispatchStatus hits the exact PK row and the session_id fallback is
|
|
3180
|
+
// never exercised. Non-mesh sessions get no taskId (regression guard) —
|
|
3181
|
+
// isMeshWorkerSession() gates the injection.
|
|
3182
|
+
//
|
|
3183
|
+
// ARCH-REFACTOR R1 (per-turn identity): resolution order is
|
|
3184
|
+
// (1) an explicit taskId already on the event — the debounce-flush completion
|
|
3185
|
+
// path stamps the taskId captured at the generating→idle transition (the
|
|
3186
|
+
// turn that actually produced this completion);
|
|
3187
|
+
// (2) the per-turn binding (engine.currentTurnTaskId) for synchronously-emitted
|
|
3188
|
+
// events whose turn is still the current one;
|
|
3189
|
+
// (3) the legacy session scalar (settings.meshActiveTaskId) as a last-resort
|
|
3190
|
+
// backward-compat alias.
|
|
3191
|
+
// The scalar is last because it is last-write-wins: a second task attaching while
|
|
3192
|
+
// this turn was still running overwrites it, which is the exact NOTIF-MISDELIVER /
|
|
3193
|
+
// TASK-MSG-MISROUTE race this refactor removes.
|
|
3194
|
+
if (this.isMeshWorkerSession()) {
|
|
3195
|
+
const existingTaskId = typeof enrichedEvent.taskId === 'string' && enrichedEvent.taskId.trim()
|
|
3196
|
+
? enrichedEvent.taskId
|
|
3197
|
+
: undefined;
|
|
3198
|
+
if (!existingTaskId) {
|
|
3199
|
+
const resolved = this.completingTurnTaskId();
|
|
3200
|
+
if (resolved) enrichedEvent.taskId = resolved;
|
|
3201
|
+
}
|
|
3202
|
+
// REDRIVE-DUP: echo the dispatch nonce this session's active task was stamped with
|
|
3203
|
+
// so the coordinator's generating_started handler can reject a stale (reclaimed)
|
|
3204
|
+
// dispatch and stop this worker before it double-executes the reclaimed task.
|
|
3205
|
+
if (enrichedEvent.dispatchNonce === undefined && typeof this.settings.meshActiveDispatchNonce === 'number') {
|
|
3206
|
+
enrichedEvent.dispatchNonce = this.settings.meshActiveDispatchNonce;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
1004
3209
|
if (this.context?.emitProviderEvent) {
|
|
1005
3210
|
this.context.emitProviderEvent(enrichedEvent);
|
|
1006
|
-
|
|
3211
|
+
} else {
|
|
3212
|
+
this.events.push(enrichedEvent);
|
|
3213
|
+
}
|
|
3214
|
+
// Auto-detach a direct-dispatch mesh assignment once the dispatched
|
|
3215
|
+
// task reaches a terminal state. Leaving meshNodeFor pinned would
|
|
3216
|
+
// route this session's next unrelated turn (a dashboard chat) into
|
|
3217
|
+
// the coordinator as if it were the completion of another task.
|
|
3218
|
+
// We schedule after the emit so the originating coordinator still
|
|
3219
|
+
// observes the completion event with its routing marker intact.
|
|
3220
|
+
if (TERMINAL_MESH_EVENTS.has(event.event) && this.settings.meshActiveTaskId) {
|
|
3221
|
+
try { this.detachMeshAssignment(); } catch { /* best-effort */ }
|
|
1007
3222
|
}
|
|
1008
|
-
this.events.push(enrichedEvent);
|
|
1009
3223
|
}
|
|
1010
3224
|
|
|
1011
3225
|
private flushEvents(): ProviderEvent[] {
|
|
@@ -1022,7 +3236,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1022
3236
|
typeof data.providerSessionId === 'string' ? data.providerSessionId : '',
|
|
1023
3237
|
);
|
|
1024
3238
|
if (patchedProviderSessionId) {
|
|
1025
|
-
|
|
3239
|
+
// A provider-response id is authoritative when it carries an
|
|
3240
|
+
// explicit `new_session` marker (the CLI genuinely started a new
|
|
3241
|
+
// conversation). Without that marker it's just an observed id and
|
|
3242
|
+
// must not hijack an existing binding (see promoteProviderSessionId).
|
|
3243
|
+
this.promoteProviderSessionId(patchedProviderSessionId, {
|
|
3244
|
+
authoritative: data.sessionEvent === 'new_session',
|
|
3245
|
+
});
|
|
1026
3246
|
}
|
|
1027
3247
|
|
|
1028
3248
|
if (data.sessionEvent === 'new_session') {
|
|
@@ -1047,7 +3267,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1047
3267
|
if (effectWhen === 'turn_completed' && options.phase !== 'turn_completed') continue;
|
|
1048
3268
|
if (effectWhen === 'immediate' && options.phase === 'turn_completed') continue;
|
|
1049
3269
|
|
|
1050
|
-
const effectKey =
|
|
3270
|
+
const effectKey = getEffectDedupKey(effect);
|
|
1051
3271
|
if (this.appliedEffectKeys.has(effectKey)) continue;
|
|
1052
3272
|
this.appliedEffectKeys.add(effectKey);
|
|
1053
3273
|
|
|
@@ -1097,40 +3317,6 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1097
3317
|
this.appliedEffectKeys = new Set(Array.from(this.appliedEffectKeys).slice(-100));
|
|
1098
3318
|
}
|
|
1099
3319
|
}
|
|
1100
|
-
|
|
1101
|
-
private getEffectDedupKey(effect: { id?: string; type: string; message?: { content?: unknown }; toast?: { message?: string }; notification?: { title?: string; body?: string } }): string {
|
|
1102
|
-
if (effect.id) return `provider_effect:${effect.id}`;
|
|
1103
|
-
if (effect.type === 'message') {
|
|
1104
|
-
const content = typeof effect.message?.content === 'string'
|
|
1105
|
-
? effect.message.content
|
|
1106
|
-
: JSON.stringify(effect.message?.content || '');
|
|
1107
|
-
return `provider_effect:message:${content}`;
|
|
1108
|
-
}
|
|
1109
|
-
if (effect.type === 'notification') {
|
|
1110
|
-
return `provider_effect:notification:${effect.notification?.title || ''}:${effect.notification?.body || ''}`;
|
|
1111
|
-
}
|
|
1112
|
-
return `provider_effect:toast:${effect.toast?.message || ''}`;
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
private getPersistedEffectContent(effect: { type: string; message?: { content?: unknown }; toast?: { message?: string }; notification?: { title?: string; body?: string; bubbleContent?: unknown } }): string | null {
|
|
1116
|
-
if (effect.type === 'message') {
|
|
1117
|
-
return typeof effect.message?.content === 'string'
|
|
1118
|
-
? effect.message.content
|
|
1119
|
-
: JSON.stringify(effect.message?.content || '');
|
|
1120
|
-
}
|
|
1121
|
-
if (effect.type === 'toast') {
|
|
1122
|
-
return effect.toast?.message || null;
|
|
1123
|
-
}
|
|
1124
|
-
if (effect.type === 'notification') {
|
|
1125
|
-
if (typeof effect.notification?.bubbleContent === 'string') return effect.notification.bubbleContent;
|
|
1126
|
-
if (typeof effect.notification?.title === 'string' && effect.notification.title.trim()) {
|
|
1127
|
-
return `${effect.notification.title}\n${effect.notification.body || ''}`.trim();
|
|
1128
|
-
}
|
|
1129
|
-
return effect.notification?.body || null;
|
|
1130
|
-
}
|
|
1131
|
-
return null;
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
3320
|
// ─── Adapter access (backward compat) ──────────────────
|
|
1135
3321
|
|
|
1136
3322
|
getAdapter(): ProviderCliAdapter {
|
|
@@ -1151,6 +3337,118 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1151
3337
|
return false;
|
|
1152
3338
|
}
|
|
1153
3339
|
|
|
3340
|
+
/** @see ProviderInstance.noteManualInteraction */
|
|
3341
|
+
noteManualInteraction(now = Date.now(), opts?: { passive?: boolean }): void {
|
|
3342
|
+
// P1b (#137 secondary): a DELEGATED worker session must not treat a
|
|
3343
|
+
// passive dashboard view (foreground tab selection / panel open) as
|
|
3344
|
+
// manual attendance. A coordinator merely peeking at a worker's panel
|
|
3345
|
+
// would otherwise suppress that worker's delegated auto-approve for the
|
|
3346
|
+
// whole 60s window. Only explicit input/intervention (controlbar,
|
|
3347
|
+
// resolve_action, pty_input) attends a worker. Non-worker (foreground)
|
|
3348
|
+
// sessions keep noting on passive views so a user foregrounding their own
|
|
3349
|
+
// session still holds auto-approve to act on the modal themselves.
|
|
3350
|
+
if (opts?.passive && this.isMeshWorkerSession()) return;
|
|
3351
|
+
this.manualAttendance.note(now);
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
/**
|
|
3355
|
+
* Whether auto-approve should be treated as active *right now* for display
|
|
3356
|
+
* and firing decisions: the configured intent AND the user is not currently
|
|
3357
|
+
* attending this session by hand. When a human is attending, auto-approve is
|
|
3358
|
+
* held so the modal stays visible and they can drive it via the controlbar.
|
|
3359
|
+
* Provider-agnostic — the attendance signal is the command set, never any
|
|
3360
|
+
* CLI-specific modal text.
|
|
3361
|
+
*/
|
|
3362
|
+
private autoApproveEffectivelyActive(status: string | undefined, now = Date.now()): boolean {
|
|
3363
|
+
return status === 'waiting_approval'
|
|
3364
|
+
&& this.shouldAutoApprove()
|
|
3365
|
+
&& !this.manualAttendance.isAttended(now);
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
// STATUS-MISMATCH: true once the current auto-approve episode has been masking
|
|
3369
|
+
// waiting_approval behind `generating` for longer than AUTO_APPROVE_MASK_STALL_MS without
|
|
3370
|
+
// resolving (the settle gate never fired). When stalled, the surface mask must be dropped
|
|
3371
|
+
// so read_chat / mesh_status / the dashboard see the real waiting_approval + modal (and a
|
|
3372
|
+
// coordinator can mesh_approve it). autoApproveMaskSince is maintained by
|
|
3373
|
+
// maybeAutoApproveStatus (driven by getState + the recheck timer during a waiting episode);
|
|
3374
|
+
// this read is side-effect-free so getStatusMetadata can consult it too.
|
|
3375
|
+
private autoApproveMaskStalled(now = Date.now()): boolean {
|
|
3376
|
+
return this.autoApproveMaskSince > 0
|
|
3377
|
+
&& now - this.autoApproveMaskSince > CliProviderInstance.AUTO_APPROVE_MASK_STALL_MS;
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/**
|
|
3381
|
+
* NOTIF-APPROVAL-MASKED (Q1b): surface a delegated worker's STALLED auto-approve modal
|
|
3382
|
+
* to the mesh COORDINATOR, decoupled from the dashboard visible-status mask.
|
|
3383
|
+
*
|
|
3384
|
+
* When auto-approve is configured but the episode never settles (modal parse miss / the
|
|
3385
|
+
* settle gate never satisfied), getState()/detectStatusTransition() fold the raw
|
|
3386
|
+
* `waiting_approval` into `generating` to suppress dashboard flicker — so
|
|
3387
|
+
* detectStatusTransition()'s `waiting_approval` arm never runs and NO agent:waiting_approval
|
|
3388
|
+
* event is emitted. The coordinator's real-time approval-nudge delivery then has no input and
|
|
3389
|
+
* the worker's stuck modal is never surfaced (the live ~25s stall). The dashboard mask is
|
|
3390
|
+
* intentional and stays; this emits the coordinator nudge exactly ONCE, gated on the SAME
|
|
3391
|
+
* raw-waiting_approval + mask-stalled signal resolveModalParkStatus() distinguishes, the
|
|
3392
|
+
* instant the mask-stall threshold trips (the same moment getState un-folds the mask).
|
|
3393
|
+
*
|
|
3394
|
+
* Only delegated worker sessions qualify: a foreground session has no coordinator to notify,
|
|
3395
|
+
* and its own dashboard mask already reveals the modal on stall. A normally-resolving
|
|
3396
|
+
* auto-approve never reaches AUTO_APPROVE_MASK_STALL_MS, so it emits nothing here; and if a
|
|
3397
|
+
* masked approval clears just as this fires, rc.455's isApprovalNudgeResolved stale-drop
|
|
3398
|
+
* discards the nudge coordinator-side without noise. Dedup is per-episode (keyed on the
|
|
3399
|
+
* mask-clock value) so a modal that flaps between parsed/unparsed states is announced once.
|
|
3400
|
+
*/
|
|
3401
|
+
private maybeEmitStalledApprovalNudge(adapterStatus: any, now: number): void {
|
|
3402
|
+
if (!this.isMeshWorkerSession()) return;
|
|
3403
|
+
if (adapterStatus?.status !== 'waiting_approval') return;
|
|
3404
|
+
if (!this.autoApproveMaskStalled(now)) return;
|
|
3405
|
+
// AUTOAPPROVE-FLAP-RECUR (Fix C, redesigned): the mask-stall bound tripped.
|
|
3406
|
+
// If auto-approve is genuinely about to fire on its own, defer to that fire —
|
|
3407
|
+
// the nudge would race a resolveModal landing milliseconds later, producing
|
|
3408
|
+
// the coordinator flap this fix targets.
|
|
3409
|
+
//
|
|
3410
|
+
// The ORIGINAL guard deferred on `pendingAutoApprovalSince && buttons > 0`
|
|
3411
|
+
// alone. That is FALSE-POSITIVE for a FLAPPING modal: a modal whose identity
|
|
3412
|
+
// changes every frame (message streaming) keeps pendingAutoApprovalSince
|
|
3413
|
+
// nonzero (it is re-stamped to `now` on every signature change) and keeps
|
|
3414
|
+
// buttons present, yet its settle clock is wiped back to ~0 each frame and
|
|
3415
|
+
// NEVER reaches SETTLE_MS — so auto-approve can never fire and this is exactly
|
|
3416
|
+
// the stuck episode the coordinator MUST be paged about. The old guard
|
|
3417
|
+
// silenced it permanently (the live rc.466 regression).
|
|
3418
|
+
//
|
|
3419
|
+
// Discriminator: a settle is genuinely PROGRESSING only if THIS frame's modal
|
|
3420
|
+
// carries the SAME identity the settle clock is already accruing against
|
|
3421
|
+
// (pendingAutoApprovalSignature). A stable modal keeps its signature across
|
|
3422
|
+
// frames → the clock climbs → resolveModal is imminent → defer. A flapping
|
|
3423
|
+
// modal presents a DIFFERENT signature this frame (or none — a non-concrete /
|
|
3424
|
+
// picker / never-captured modal) → the clock is about to reset (or never
|
|
3425
|
+
// engaged) → do NOT defer, page the coordinator. This is evaluated for the
|
|
3426
|
+
// CURRENT frame (not a stale prior-frame value), so a stable modal polled only
|
|
3427
|
+
// twice — start, then past the stall bound — is still correctly deferred.
|
|
3428
|
+
const currentSignature = this.approvableModalSignature(adapterStatus.activeModal);
|
|
3429
|
+
const settleProgressing = !!currentSignature
|
|
3430
|
+
&& this.pendingAutoApprovalSince > 0
|
|
3431
|
+
&& currentSignature === this.pendingAutoApprovalSignature;
|
|
3432
|
+
if (settleProgressing) return;
|
|
3433
|
+
// Exactly once per stalled episode (autoApproveMaskSince uniquely identifies it).
|
|
3434
|
+
if (this.stalledApprovalNudgeEpisode === this.autoApproveMaskSince) return;
|
|
3435
|
+
this.stalledApprovalNudgeEpisode = this.autoApproveMaskSince;
|
|
3436
|
+
const modal = adapterStatus.activeModal;
|
|
3437
|
+
const dirName = workingDirBasename(this.workingDir);
|
|
3438
|
+
const chatTitle = `${this.provider.name} · ${dirName}`;
|
|
3439
|
+
this.appendRuntimeSystemMessage(
|
|
3440
|
+
formatApprovalRequestMessage(modal?.message, modal?.buttons),
|
|
3441
|
+
`approval_request:${now}`,
|
|
3442
|
+
now,
|
|
3443
|
+
);
|
|
3444
|
+
this.pushEvent({
|
|
3445
|
+
event: 'agent:waiting_approval', chatTitle, timestamp: now,
|
|
3446
|
+
modalMessage: modal?.message,
|
|
3447
|
+
modalButtons: modal?.buttons,
|
|
3448
|
+
});
|
|
3449
|
+
LOG.info('CLI', `[${this.type}] stalled auto-approve nudge → coordinator (masked ${Math.round((now - this.autoApproveMaskSince) / 1000)}s)`);
|
|
3450
|
+
}
|
|
3451
|
+
|
|
1154
3452
|
private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
|
|
1155
3453
|
this.appendRuntimeSystemMessage(
|
|
1156
3454
|
formatAutoApprovalMessage(modalMessage, buttonLabel),
|
|
@@ -1170,12 +3468,6 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1170
3468
|
);
|
|
1171
3469
|
}
|
|
1172
3470
|
|
|
1173
|
-
private formatMarkerTimestamp(timestamp: number): string {
|
|
1174
|
-
const date = new Date(timestamp);
|
|
1175
|
-
const pad = (value: number) => String(value).padStart(2, '0');
|
|
1176
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
3471
|
private maybeAppendRuntimeRecoveryMessage(runtime: PtyRuntimeMetadata | null): void {
|
|
1180
3472
|
if (!runtime?.restoredFromStorage || !runtime.runtimeId) return;
|
|
1181
3473
|
|
|
@@ -1238,7 +3530,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1238
3530
|
receivedAt: normalizedMessage.receivedAt || normalizedMessage.timestamp,
|
|
1239
3531
|
historyDedupKey: dedupKey,
|
|
1240
3532
|
}],
|
|
1241
|
-
this.adapter.getScriptParsedStatus?.()?.title || this.workingDir
|
|
3533
|
+
this.adapter.getScriptParsedStatus?.()?.title || workingDirBasename(this.workingDir),
|
|
1242
3534
|
this.instanceId,
|
|
1243
3535
|
this.providerSessionId,
|
|
1244
3536
|
);
|
|
@@ -1246,102 +3538,46 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1246
3538
|
}
|
|
1247
3539
|
|
|
1248
3540
|
mergeRuntimeChatMessages(parsedMessages: ChatMessage[]): ChatMessage[] {
|
|
1249
|
-
return this.
|
|
3541
|
+
return mergeConversationMessages(this.runtimeMessages, parsedMessages);
|
|
1250
3542
|
}
|
|
1251
3543
|
|
|
1252
|
-
private
|
|
1253
|
-
if (this.runtimeMessages.length === 0) return normalizeChatMessages(parsedMessages);
|
|
1254
|
-
|
|
1255
|
-
type MergeEntry = { message: ChatMessage; index: number; source: 'parsed' | 'runtime'; runtimeKey?: string };
|
|
1256
|
-
const parsedEntries: MergeEntry[] = parsedMessages.map((message, index) => ({
|
|
1257
|
-
message,
|
|
1258
|
-
index,
|
|
1259
|
-
source: 'parsed',
|
|
1260
|
-
}));
|
|
1261
|
-
const runtimeEntries: MergeEntry[] = this.runtimeMessages.map((entry, index) => ({
|
|
1262
|
-
message: entry.message,
|
|
1263
|
-
index: parsedMessages.length + index,
|
|
1264
|
-
source: 'runtime',
|
|
1265
|
-
runtimeKey: entry.key,
|
|
1266
|
-
}));
|
|
1267
|
-
const getTime = (message: ChatMessage): number => {
|
|
1268
|
-
const value = typeof message.receivedAt === 'number'
|
|
1269
|
-
? message.receivedAt
|
|
1270
|
-
: typeof message.timestamp === 'number'
|
|
1271
|
-
? message.timestamp
|
|
1272
|
-
: 0;
|
|
1273
|
-
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
1274
|
-
};
|
|
1275
|
-
|
|
1276
|
-
const getRole = (message: ChatMessage): string => typeof message.role === 'string'
|
|
1277
|
-
? message.role.trim().toLowerCase()
|
|
1278
|
-
: '';
|
|
1279
|
-
const isRuntimeOverlay = (entry: MergeEntry): boolean => {
|
|
1280
|
-
if (entry.source !== 'runtime') return false;
|
|
1281
|
-
const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
|
|
1282
|
-
if (key.startsWith('auto_approval:')) return true;
|
|
1283
|
-
return !isUserFacingChatMessage(entry.message);
|
|
1284
|
-
};
|
|
1285
|
-
const shouldKeepParsedBeforeUntimedRuntime = (message: ChatMessage): boolean => {
|
|
1286
|
-
const role = getRole(message);
|
|
1287
|
-
return role === 'user' || role === 'human';
|
|
1288
|
-
};
|
|
1289
|
-
const shouldKeepParsedAfterUntimedRuntime = (message: ChatMessage): boolean => {
|
|
1290
|
-
const role = getRole(message);
|
|
1291
|
-
if (role !== 'assistant') return false;
|
|
1292
|
-
const kind = resolveChatMessageKind(message);
|
|
1293
|
-
return kind === 'standard' || kind === 'terminal';
|
|
1294
|
-
};
|
|
1295
|
-
|
|
1296
|
-
return normalizeChatMessages([...parsedEntries, ...runtimeEntries]
|
|
1297
|
-
.sort((a, b) => {
|
|
1298
|
-
const aTime = getTime(a.message);
|
|
1299
|
-
const bTime = getTime(b.message);
|
|
1300
|
-
if (aTime && bTime && aTime !== bTime) return aTime - bTime;
|
|
1301
|
-
if (a.source !== b.source && aTime !== bTime) {
|
|
1302
|
-
const parsedEntry = a.source === 'parsed' ? a : b.source === 'parsed' ? b : null;
|
|
1303
|
-
const runtimeEntry = a.source === 'runtime' ? a : b.source === 'runtime' ? b : null;
|
|
1304
|
-
if (parsedEntry && runtimeEntry && isRuntimeOverlay(runtimeEntry) && getTime(parsedEntry.message) === 0 && getTime(runtimeEntry.message) > 0) {
|
|
1305
|
-
if (shouldKeepParsedBeforeUntimedRuntime(parsedEntry.message)) {
|
|
1306
|
-
return a.source === 'parsed' ? -1 : 1;
|
|
1307
|
-
}
|
|
1308
|
-
if (shouldKeepParsedAfterUntimedRuntime(parsedEntry.message)) {
|
|
1309
|
-
return a.source === 'parsed' ? 1 : -1;
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
// Many provider-owned CLI transcripts (including Hermes CLI in debug bundles)
|
|
1314
|
-
// do not carry timestamps on parsed messages. In that case there is no safe
|
|
1315
|
-
// clock basis for interleaving timestamped runtime/system messages into the
|
|
1316
|
-
// provider transcript. Keep user prompts before runtime overlays, but do not
|
|
1317
|
-
// let timed runtime/system/tool/internal overlays become the final chat turns
|
|
1318
|
-
// after an untimed parsed assistant transcript.
|
|
1319
|
-
return a.index - b.index;
|
|
1320
|
-
})
|
|
1321
|
-
.map((entry) => entry.message));
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
private formatApprovalRequestMessage(modalMessage?: string, buttons?: string[]): string {
|
|
1325
|
-
const lines = ['Approval requested'];
|
|
1326
|
-
const cleanMessage = String(modalMessage || '').trim();
|
|
1327
|
-
if (cleanMessage) lines.push(cleanMessage);
|
|
1328
|
-
const labels = (buttons || []).map((button) => String(button || '').trim()).filter(Boolean);
|
|
1329
|
-
if (labels.length > 0) {
|
|
1330
|
-
lines.push(labels.map((label) => `[${label}]`).join(' '));
|
|
1331
|
-
}
|
|
1332
|
-
return lines.join('\n');
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
private promoteProviderSessionId(sessionId: string): void {
|
|
3544
|
+
private promoteProviderSessionId(sessionId: string, opts: { authoritative?: boolean } = {}): void {
|
|
1336
3545
|
const nextSessionId = String(sessionId || '').trim();
|
|
1337
3546
|
if (!nextSessionId || nextSessionId === this.providerSessionId) return;
|
|
1338
3547
|
|
|
3548
|
+
// Sticky binding: once this instance is bound to a provider session,
|
|
3549
|
+
// an *observed* id (one discovered from a status parse or the native
|
|
3550
|
+
// history reader) must NOT hijack the live binding. hermes ≥0.14
|
|
3551
|
+
// spawns a fresh `sessions` row per internal sub-session, so a
|
|
3552
|
+
// newest-wins native read surfaces a different id mid-turn on every
|
|
3553
|
+
// poll; accepting it would re-bind the instance, re-hydrate unbounded
|
|
3554
|
+
// history (daemon saturation) and reset completion detection so the
|
|
3555
|
+
// turn never finalizes. Only an *authoritative* change — the first
|
|
3556
|
+
// bind (no id yet) or an explicit provider `new_session`/resume — may
|
|
3557
|
+
// replace an existing binding. Legitimate resume/new-session paths
|
|
3558
|
+
// pass authoritative:true and are unaffected.
|
|
3559
|
+
if (this.providerSessionId && !opts.authoritative) {
|
|
3560
|
+
LOG.debug('CLI', `[${this.type}] ignoring non-authoritative session id ${nextSessionId} (bound to ${this.providerSessionId})`);
|
|
3561
|
+
return;
|
|
3562
|
+
}
|
|
3563
|
+
|
|
1339
3564
|
const previousHistorySessionId = this.providerSessionId || this.instanceId;
|
|
1340
3565
|
const previousProviderSessionId = this.providerSessionId;
|
|
1341
3566
|
this.providerSessionId = nextSessionId;
|
|
3567
|
+
// Conversation-binding lock (antigravity): the moment this session is
|
|
3568
|
+
// authoritatively bound to a conversation uuid, claim it so a concurrent
|
|
3569
|
+
// sibling session's newest-on-disk discovery can never resolve to the
|
|
3570
|
+
// same .db (RCA: two antigravity sessions ~94ms apart shared one store
|
|
3571
|
+
// and cross-routed completions). Released on dispose().
|
|
3572
|
+
if (this.type === 'antigravity-cli') {
|
|
3573
|
+
const owner = this.antigravityClaimOwner();
|
|
3574
|
+
if (owner) claimAntigravityConversation(nextSessionId, owner);
|
|
3575
|
+
}
|
|
1342
3576
|
this.historyWriter.promoteHistorySession(this.type, previousHistorySessionId, nextSessionId);
|
|
1343
3577
|
this.historyWriter.writeSessionStart(this.type, nextSessionId, this.workingDir, this.instanceId);
|
|
1344
|
-
this.
|
|
3578
|
+
if (this.shouldHydrateExistingProviderHistory()) {
|
|
3579
|
+
this.restorePersistedHistoryFromCurrentSession();
|
|
3580
|
+
}
|
|
1345
3581
|
this.adapter.updateRuntimeMeta({ providerSessionId: nextSessionId });
|
|
1346
3582
|
this.onProviderSessionResolved?.({
|
|
1347
3583
|
instanceId: this.instanceId,
|
|
@@ -1354,13 +3590,39 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1354
3590
|
LOG.info('CLI', `[${this.type}] discovered provider session id: ${nextSessionId}`);
|
|
1355
3591
|
}
|
|
1356
3592
|
|
|
1357
|
-
private
|
|
3593
|
+
private shouldHydrateExistingProviderHistory(): boolean {
|
|
3594
|
+
return this.launchMode === 'resume' || this.launchMode === 'manual';
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
private shouldSuppressFreshLaunchStartupReplay(parsedMessages: unknown[], parsedStatus: any, adapterStatus: any, parsedProviderSessionId = ''): boolean {
|
|
3598
|
+
if (this.launchMode !== 'new') return false;
|
|
3599
|
+
if (this.providerSessionId) return false;
|
|
3600
|
+
if (!Array.isArray(parsedMessages) || parsedMessages.length === 0) return false;
|
|
3601
|
+
if (!isIdleStatus(adapterStatus?.status) || !isIdleStatus(parsedStatus?.status)) return false;
|
|
3602
|
+
if (parsedProviderSessionId) return true;
|
|
3603
|
+
|
|
3604
|
+
const newestMessageAt = parsedMessages.reduce<number>((newest, message) => Math.max(newest, getMessageTime(message)), 0);
|
|
3605
|
+
|
|
3606
|
+
// Untimestamped idle parser output during a fresh launch is usually the
|
|
3607
|
+
// provider's last workspace transcript before a new turn exists.
|
|
3608
|
+
return newestMessageAt === 0;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
private syncCanonicalSavedHistoryIfNeeded(options: { full?: boolean } = {}): boolean {
|
|
1358
3612
|
if (!this.providerSessionId) return false;
|
|
1359
|
-
const canonicalHistory = this.provider.
|
|
3613
|
+
const canonicalHistory = this.provider.nativeHistory;
|
|
1360
3614
|
if (!canonicalHistory) return false;
|
|
1361
3615
|
|
|
3616
|
+
// Per-status-report hydration reads only a bounded tail (snapshot needs at
|
|
3617
|
+
// most the newest 60). The once-per-resume restore path passes full:true
|
|
3618
|
+
// because seedSessionHistory needs the COMPLETE transcript to seed dedup
|
|
3619
|
+
// state. The read-cache key encodes the window so the bounded and full
|
|
3620
|
+
// reads don't share/clobber each other's 2s cache entry.
|
|
3621
|
+
const limit = options.full ? Number.MAX_SAFE_INTEGER : STATUS_HYDRATION_TAIL_LIMIT;
|
|
3622
|
+
const windowTag = options.full ? 'full' : `tail:${STATUS_HYDRATION_TAIL_LIMIT}`;
|
|
3623
|
+
|
|
1362
3624
|
if (isNativeSourceCanonicalHistory(canonicalHistory)) {
|
|
1363
|
-
const cacheKey = [this.type, this.providerSessionId, this.workingDir].join('\0');
|
|
3625
|
+
const cacheKey = [this.type, this.providerSessionId, this.workingDir, windowTag].join('\0');
|
|
1364
3626
|
const now = Date.now();
|
|
1365
3627
|
if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
|
|
1366
3628
|
return true;
|
|
@@ -1373,7 +3635,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1373
3635
|
historySessionId: this.providerSessionId,
|
|
1374
3636
|
workspace: this.workingDir,
|
|
1375
3637
|
offset: 0,
|
|
1376
|
-
limit
|
|
3638
|
+
limit,
|
|
1377
3639
|
historyBehavior: this.provider.historyBehavior,
|
|
1378
3640
|
scripts: this.provider.scripts as any,
|
|
1379
3641
|
});
|
|
@@ -1390,7 +3652,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1390
3652
|
}
|
|
1391
3653
|
|
|
1392
3654
|
try {
|
|
1393
|
-
const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror'].join('\0');
|
|
3655
|
+
const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror', windowTag].join('\0');
|
|
1394
3656
|
const now = Date.now();
|
|
1395
3657
|
if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
|
|
1396
3658
|
return true;
|
|
@@ -1401,7 +3663,14 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1401
3663
|
if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
|
|
1402
3664
|
return false;
|
|
1403
3665
|
}
|
|
1404
|
-
|
|
3666
|
+
// Bounded by default: the per-status-report path only needs the newest
|
|
3667
|
+
// STATUS_HYDRATION_TAIL_LIMIT messages because the snapshot caps
|
|
3668
|
+
// activeChat.messages to the last 60 (status/normalize.ts) and loads
|
|
3669
|
+
// the rest lazily via read_chat on subscribe. The once-per-resume
|
|
3670
|
+
// restore path passes full:true so seedSessionHistory still sees the
|
|
3671
|
+
// COMPLETE transcript for prefix-dedup seeding. readChatHistory serves
|
|
3672
|
+
// a bounded limit as an O(tail) read.
|
|
3673
|
+
const restoredHistory = readChatHistory(this.type, 0, limit, this.providerSessionId, 0, this.provider.historyBehavior);
|
|
1405
3674
|
this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
|
|
1406
3675
|
role: message.role,
|
|
1407
3676
|
content: message.content,
|
|
@@ -1417,10 +3686,13 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1417
3686
|
|
|
1418
3687
|
private restorePersistedHistoryFromCurrentSession(): void {
|
|
1419
3688
|
if (!this.providerSessionId) return;
|
|
1420
|
-
|
|
1421
|
-
|
|
3689
|
+
// Restore is the once-per-resume seeding path: it needs the COMPLETE
|
|
3690
|
+
// transcript so seedSessionHistory can prime dedup state. Pass full so the
|
|
3691
|
+
// hydration read is unbounded here (and only here).
|
|
3692
|
+
this.syncCanonicalSavedHistoryIfNeeded({ full: true });
|
|
3693
|
+
const restoredHistory = isNativeSourceCanonicalHistory(this.provider.nativeHistory)
|
|
1422
3694
|
? readProviderChatHistory(this.type, {
|
|
1423
|
-
canonicalHistory: this.provider.
|
|
3695
|
+
canonicalHistory: this.provider.nativeHistory,
|
|
1424
3696
|
historySessionId: this.providerSessionId,
|
|
1425
3697
|
workspace: this.workingDir,
|
|
1426
3698
|
offset: 0,
|