@adhdev/daemon-core 0.9.82-rc.50 → 0.9.82-rc.501
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 +85 -2
- package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
- package/dist/cli-adapters/cli-state-engine.d.ts +246 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +139 -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 +71 -17
- package/dist/index.js +56455 -21586
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +53763 -19064
- 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-idle-reminder.d.ts +68 -0
- 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 +584 -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 +47 -0
- package/dist/providers/cli-provider-instance.d.ts +461 -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/background-task-detector.d.ts +29 -0
- package/dist/providers/spec/cli-adapter.d.ts +224 -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 +50 -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 +629 -14
- 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 +79 -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 +85 -2
- package/src/cli-adapters/cli-script-runner.ts +421 -0
- package/src/cli-adapters/cli-state-engine.ts +1379 -0
- package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +1059 -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 +191 -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 +626 -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 +2042 -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-idle-reminder.ts +153 -0
- 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 +1709 -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 +286 -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 +2318 -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/p2p-relay-failure.ts +2 -2
- 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 +150 -0
- package/src/providers/cli-provider-instance.ts +2914 -498
- 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/background-task-detector.ts +178 -0
- package/src/providers/spec/cli-adapter.ts +1090 -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 +1256 -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 +877 -16
- 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
|
@@ -0,0 +1,1528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FsmDriver — runs an adhdev:cli/spec@4 finite state machine against a PTY.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in compatible with SpecDriver's public surface (subscribe / dispatch /
|
|
5
|
+
* start / snapshot / getStateHistory / getDebugState / …) so the cli-adapter
|
|
6
|
+
* can use either driver behind one interface. The difference is entirely
|
|
7
|
+
* internal: instead of a stack of hard-coded debounce layers, this driver
|
|
8
|
+
* holds ONE piece of state — the current FSM node — and on every screen change
|
|
9
|
+
* asks the FSM evaluator "which outgoing transition fires?". All timing
|
|
10
|
+
* (startup grace, busy hold, completion stability) is expressed in the spec as
|
|
11
|
+
* transition guards (min_hold_ms) and time conditions (elapsed_ms / stable_ms).
|
|
12
|
+
*
|
|
13
|
+
* Because the engine carries no CLI knowledge, the only way it can be "wrong"
|
|
14
|
+
* is if a spec's transitions are wrong — and that is fully inspectable via
|
|
15
|
+
* getFsmDebug(), which reports every outgoing transition with its per-condition
|
|
16
|
+
* match result and countdown. That is the contract: debug the spec, not the
|
|
17
|
+
* engine.
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as os from 'node:os';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import { TerminalAdapter, type TerminalAdapterOpts, type SpecPtyEvent } from './adapter.js';
|
|
25
|
+
import { resolveCliSpawnPlanFromParts } from '../../cli-adapters/provider-cli-runtime.js';
|
|
26
|
+
import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
|
|
27
|
+
import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
|
|
28
|
+
import {
|
|
29
|
+
resolveSections, sectionText, extractTitle, extractButtonsFromRule,
|
|
30
|
+
type ResolvedSection, type TraceEntry,
|
|
31
|
+
} from './evaluator.js';
|
|
32
|
+
import { evaluateFsm, stableRegionKey, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
|
|
33
|
+
import {
|
|
34
|
+
type CliSpecV4, type FsmState, type FsmTransition,
|
|
35
|
+
initialState, stateById, statusForState, modalKindForState, outgoingTransitions,
|
|
36
|
+
} from './fsm-types.js';
|
|
37
|
+
import { loadFsmSpec } from './fsm-loader.js';
|
|
38
|
+
import { applyPreLaunchTrust } from './pre-launch-trust.js';
|
|
39
|
+
import type { Control, DelegateTrigger } from './types.js';
|
|
40
|
+
import { LOG } from '../../logging/logger.js';
|
|
41
|
+
import { recordDebugTrace } from '../../logging/debug-trace.js';
|
|
42
|
+
import { shouldCollectTraceCategory } from '../../logging/debug-config.js';
|
|
43
|
+
import {
|
|
44
|
+
WIN32_PTY_WRITE_CHUNK_CHARS,
|
|
45
|
+
WIN32_PTY_WRITE_CHUNK_GAP_MS,
|
|
46
|
+
chunkPreservingSurrogates as chunkPreservingSurrogatesShared,
|
|
47
|
+
} from '../../cli-adapters/pty-write-chunking.js';
|
|
48
|
+
|
|
49
|
+
// ── Shared driver types (formerly in driver.ts) ───────────────────────────
|
|
50
|
+
|
|
51
|
+
export type DashboardEvent =
|
|
52
|
+
| { kind: 'pty_data'; chunk: string }
|
|
53
|
+
| { kind: 'state_changed'; state: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' };
|
|
54
|
+
modal: { title: string | null; buttons: { index: number; label: string }[]; kind: 'approval' | 'picker' | 'confirm' | null } | null;
|
|
55
|
+
controls: { id: string; label: string; action_type: string }[] }
|
|
56
|
+
| { kind: 'notification'; id: string; title: string; body: string }
|
|
57
|
+
| { kind: 'delegate'; id: string; task: string }
|
|
58
|
+
| { kind: 'spec_trace'; entries: TraceEntry[] }
|
|
59
|
+
| { kind: 'exit'; exit_code: number }
|
|
60
|
+
| { kind: 'spec_error'; errors: string[] };
|
|
61
|
+
|
|
62
|
+
export type DashboardCommand =
|
|
63
|
+
| { kind: 'send_message'; text: string }
|
|
64
|
+
| { kind: 'pty_write'; data: string }
|
|
65
|
+
| { kind: 'click_control'; control_id: string; payload?: unknown }
|
|
66
|
+
| { kind: 'click_modal_button'; index: number }
|
|
67
|
+
| { kind: 'attach_image'; blob: string; mime: string }
|
|
68
|
+
| { kind: 'resize'; cols: number; rows: number }
|
|
69
|
+
| { kind: 'cancel' }
|
|
70
|
+
| { kind: 'shutdown' };
|
|
71
|
+
|
|
72
|
+
export interface DriverHistoryEntry {
|
|
73
|
+
stateId: string;
|
|
74
|
+
label: string;
|
|
75
|
+
at: number;
|
|
76
|
+
durationMs: number;
|
|
77
|
+
reason: string;
|
|
78
|
+
matchedStateId?: string;
|
|
79
|
+
matchedRules?: string[];
|
|
80
|
+
debounceKind?: string;
|
|
81
|
+
idleHoldMs?: number;
|
|
82
|
+
busyHoldMs?: number;
|
|
83
|
+
via?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A frozen snapshot of the FULL FSM evaluation captured at the instant a
|
|
88
|
+
* transition fired — the rich `transitions[]` table (per-transition eligible /
|
|
89
|
+
* hold countdown / per-condition CondResult + remainingMs) that `getFsmDebug()`
|
|
90
|
+
* otherwise only computes live for the current instant. Kept in a separate ring
|
|
91
|
+
* buffer from `stateHistory` (which stays intentionally lightweight) so the
|
|
92
|
+
* "why did this rule fire just before the transition" question is answerable
|
|
93
|
+
* after the fact. before-only: this is the evaluation that PRODUCED the
|
|
94
|
+
* transition, not the post-transition state.
|
|
95
|
+
*/
|
|
96
|
+
export interface FsmSnapshotEntry {
|
|
97
|
+
/** State we transitioned out of. */
|
|
98
|
+
stateFrom: string;
|
|
99
|
+
/** State we transitioned into (the fired transition's destination). */
|
|
100
|
+
stateTo: string;
|
|
101
|
+
/** Wall-clock time the transition committed (ms). */
|
|
102
|
+
at: number;
|
|
103
|
+
/** The fired transition's destination state id (== stateTo; kept explicit
|
|
104
|
+
* to mirror the rule that fired). */
|
|
105
|
+
firedTo: string;
|
|
106
|
+
/** Human label of the fired transition (e.g. "approval→busy"). */
|
|
107
|
+
firedLabel: string;
|
|
108
|
+
/** Why-it-fired summary, same shape produced for stateHistory.matchedRules. */
|
|
109
|
+
reason: string[];
|
|
110
|
+
/** Every outgoing transition from `stateFrom` as evaluated at `at`, each
|
|
111
|
+
* with its eligible / hold / per-condition CondResult + remainingMs. This
|
|
112
|
+
* is the full pre-transition evaluation table — the whole point of the
|
|
113
|
+
* snapshot. */
|
|
114
|
+
transitions: TransitionEval[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ISpecDriver {
|
|
118
|
+
subscribe(listener: (ev: DashboardEvent) => void): () => void;
|
|
119
|
+
start(): void;
|
|
120
|
+
dispatch(cmd: DashboardCommand): void;
|
|
121
|
+
updateMeta(meta: Record<string, unknown>, replace?: boolean): void;
|
|
122
|
+
snapshot(): string;
|
|
123
|
+
getCursorPosition(): { row: number; col: number };
|
|
124
|
+
getScreen(): string;
|
|
125
|
+
getSpecPath(): string;
|
|
126
|
+
shutdown(): void;
|
|
127
|
+
getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
|
|
128
|
+
getSections(): Array<{ id: string; text: string }> | null;
|
|
129
|
+
getLastBusyAt(): number;
|
|
130
|
+
hasIdleHoldPending(): boolean;
|
|
131
|
+
hasSeenReady(): boolean;
|
|
132
|
+
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
|
|
133
|
+
getFsmDebug?(): unknown;
|
|
134
|
+
getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
|
|
135
|
+
getEventTimeline?(limit?: number): ReadonlyArray<SpecPtyEvent>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SpecDriverOpts {
|
|
139
|
+
specPath: string;
|
|
140
|
+
workingDir: string;
|
|
141
|
+
extraEnv?: Record<string, string>;
|
|
142
|
+
cols?: number;
|
|
143
|
+
rows?: number;
|
|
144
|
+
hotReload?: boolean;
|
|
145
|
+
emitTrace?: boolean;
|
|
146
|
+
transportFactory?: PtyTransportFactory;
|
|
147
|
+
extraCliArgs?: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function countNewlines(s: string): number {
|
|
151
|
+
let n = 0;
|
|
152
|
+
for (let i = 0; i < s.length; i += 1) if (s.charCodeAt(i) === 10) n += 1;
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SUBMIT_DELAY_FLOOR_MS = 200;
|
|
157
|
+
|
|
158
|
+
// win32 ConPTY submit reliability — TWO independent concerns, do not conflate:
|
|
159
|
+
//
|
|
160
|
+
// (A) WHEN the single real submit CR fires. The first CR must not fire until
|
|
161
|
+
// the WHOLE body has echoed into the composer; a MULTILINE body also opens
|
|
162
|
+
// an Ink paste/newline-accumulation window during which a lone CR can be
|
|
163
|
+
// absorbed as a literal newline rather than submitting, with a
|
|
164
|
+
// nondeterministic length (observed 0–~2s, driven by ConPTY byte timing).
|
|
165
|
+
// So we VERIFY instead of guessing: hold the CR behind the head+tail
|
|
166
|
+
// echo-gate (scheduleWin32Submit phase 1), then resend the submit key on a
|
|
167
|
+
// fixed cadence until the FSM observes the agent has actually left the idle
|
|
168
|
+
// composer (status flips away from 'idle' — submitted / generating / modal),
|
|
169
|
+
// bounded by a retry budget (phase 2). Once submission is observed we stop
|
|
170
|
+
// so we don't spam Enter into the next turn. Single-line messages satisfy
|
|
171
|
+
// the check after the first CR, so their behaviour is unchanged.
|
|
172
|
+
//
|
|
173
|
+
// (B) HOW the body's OWN embedded newlines are written (FIX-B-v2). On the real
|
|
174
|
+
// win32 Ink/ConPTY composer each embedded '\n' in the body SUBMITS the
|
|
175
|
+
// preceding line as a separate composer entry, so writing the raw body
|
|
176
|
+
// (writeWin32Body) submitted every line but the last BEFORE the trailing
|
|
177
|
+
// echo-gated CR (A) ever ran — the prompt was truncated to only the tail
|
|
178
|
+
// fragment after the last '\n' (failure_category=per_newline_submit). The
|
|
179
|
+
// body must therefore land atomically as composer TEXT with ZERO per-line
|
|
180
|
+
// submits. writeWin32Body now does that: it wraps a newline-bearing body in
|
|
181
|
+
// a bracketed-paste (ESC[200~ … ESC[201~) so the composer takes the whole
|
|
182
|
+
// thing — embedded newlines and all — as pasted text (PRIMARY mode), or in
|
|
183
|
+
// the soft_newline fallback rewrites each embedded newline as a
|
|
184
|
+
// non-submitting Shift+Enter sequence. EITHER way the trailing submit CR is
|
|
185
|
+
// NOT part of this write — it stays separate and is fired later by (A).
|
|
186
|
+
//
|
|
187
|
+
// NOTE on the old "bracketed-paste wrapping does NOT help" claim that used to live
|
|
188
|
+
// here: that A/B fused the submit CR *inside* the paste (…body\r…201~), so the
|
|
189
|
+
// paste-closing still carried a submit and was never a clean text-only paste. The
|
|
190
|
+
// correct shape — paste wraps ONLY the body, CR stays separate — is what FIX-B-v2
|
|
191
|
+
// implements; it was never actually tested by that earlier A/B.
|
|
192
|
+
const WIN32_SUBMIT_RESEND_GAP_MS = 350;
|
|
193
|
+
const WIN32_SUBMIT_MAX_RESENDS = 14;
|
|
194
|
+
// Quiet window the win32 echo-gate (below) requires AFTER the body is seen in the
|
|
195
|
+
// composer, so a CR fires only once the FULL (possibly multi-KB / multiline) body has
|
|
196
|
+
// finished arriving and echoing — not mid-arrival. POLL_MS is the gate's recheck
|
|
197
|
+
// cadence while it waits for the body to echo.
|
|
198
|
+
const WIN32_SUBMIT_SETTLE_MS = 500;
|
|
199
|
+
const WIN32_SUBMIT_SETTLE_POLL_MS = 120;
|
|
200
|
+
// Defensive paced PTY write tuning (WIN32_PTY_WRITE_CHUNK_CHARS / _GAP_MS) and the
|
|
201
|
+
// surrogate-safe splitter now live in the shared pty-write-chunking module so this
|
|
202
|
+
// driver and the legacy ProviderCliAdapter cannot drift apart — see the import above.
|
|
203
|
+
// Echo-gate for the win32 FIRST submit CR (supersedes the bare output-quiet settle).
|
|
204
|
+
// The body write can race claude-cli's boot — its stdin reader is not wired until the
|
|
205
|
+
// composer renders (~5–7s in, later under load), so a too-early write is buffered and
|
|
206
|
+
// the output goes quiet with a still-EMPTY composer; a quiet-only gate then fires a CR
|
|
207
|
+
// into nothing. Instead, hold the CR until the body text has actually ECHOED into the
|
|
208
|
+
// composer (effect-confirmed, not readiness-signal-guessed) — the buffered write lands
|
|
209
|
+
// once claude wires up, just late. WIN32_ECHO_MAX_WAIT_MS bounds the wait so a body
|
|
210
|
+
// that truly never confirms still fires a blind CR (resend net) rather than hanging; it
|
|
211
|
+
// is set generously so even a slow/contended boot lands its body before the blind fire.
|
|
212
|
+
const WIN32_ECHO_PROBE_CHARS = 16;
|
|
213
|
+
const WIN32_ECHO_MAX_WAIT_MS = 20_000;
|
|
214
|
+
|
|
215
|
+
// FIX-B-v2 — how a newline-bearing win32 body's OWN embedded newlines are written
|
|
216
|
+
// (see concern (B) above). 'paste' (default) wraps the body in a bracketed-paste so
|
|
217
|
+
// the Ink composer absorbs the whole thing as text; 'soft_newline' rewrites each
|
|
218
|
+
// embedded newline as a non-submitting Shift+Enter. Whether THIS ConPTY honors
|
|
219
|
+
// bracketed-paste can only be confirmed by the live deploy A/B (we cannot A/B it via
|
|
220
|
+
// delegation — win32 truncates any newline-bearing task), so both modes ship and the
|
|
221
|
+
// fallback is selectable at runtime via ADHDEV_WIN32_SUBMIT_MODE.
|
|
222
|
+
export type Win32SubmitMode = 'paste' | 'soft_newline';
|
|
223
|
+
const WIN32_BRACKETED_PASTE_OPEN = '\x1b[200~';
|
|
224
|
+
const WIN32_BRACKETED_PASTE_CLOSE = '\x1b[201~';
|
|
225
|
+
// Non-submitting soft-newline for the claude-cli Ink composer. The spec
|
|
226
|
+
// (cli/claude-cli/specs/4.0.json) declares no soft-newline keycode, so we use the
|
|
227
|
+
// CSI-u encoding of Shift+Enter (modifyOtherKeys form: keycode 13 = Enter, modifier
|
|
228
|
+
// 2 = Shift). This inserts a literal newline into the composer WITHOUT submitting,
|
|
229
|
+
// unlike a bare CR (\r) which is reserved for the single real submit via (A).
|
|
230
|
+
const WIN32_SOFT_NEWLINE = '\x1b[27;2;13~';
|
|
231
|
+
|
|
232
|
+
export function resolveWin32SubmitMode(env: NodeJS.ProcessEnv = process.env): Win32SubmitMode {
|
|
233
|
+
return env.ADHDEV_WIN32_SUBMIT_MODE === 'soft_newline' ? 'soft_newline' : 'paste';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Collapse a string to its non-whitespace characters for echo comparison: the
|
|
237
|
+
* composer wraps, indents, and prefixes the body (with the `❯ ` prompt), so a raw
|
|
238
|
+
* substring test against the rendered screen fails. Stripping all whitespace makes
|
|
239
|
+
* "❯ hello world" reliably contain the probe "helloworld". */
|
|
240
|
+
function normalizeForEcho(s: string): string {
|
|
241
|
+
return s.replace(/\s+/g, '');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number {
|
|
245
|
+
const lines = countNewlines(text);
|
|
246
|
+
const linesBonus = Math.min(800, lines * 80);
|
|
247
|
+
const spec = typeof specBeforeSubmit === 'number' && specBeforeSubmit > 0 ? specBeforeSubmit : 0;
|
|
248
|
+
return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Re-export of the shared surrogate-safe splitter so existing imports of
|
|
252
|
+
* `chunkPreservingSurrogates` from this module keep working. The implementation
|
|
253
|
+
* lives in ../../cli-adapters/pty-write-chunking so the spec driver and the
|
|
254
|
+
* legacy adapter share one definition. */
|
|
255
|
+
export const chunkPreservingSurrogates = chunkPreservingSurrogatesShared;
|
|
256
|
+
|
|
257
|
+
export function guessExt(mime: string): string {
|
|
258
|
+
if (/png/i.test(mime)) return '.png';
|
|
259
|
+
if (/jpe?g/i.test(mime)) return '.jpg';
|
|
260
|
+
if (/gif/i.test(mime)) return '.gif';
|
|
261
|
+
if (/webp/i.test(mime)) return '.webp';
|
|
262
|
+
return '.bin';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
interface ModalSnapshot {
|
|
268
|
+
title: string | null;
|
|
269
|
+
buttons: { index: number; label: string; key: string; current: boolean }[];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface VisibleControl {
|
|
273
|
+
id: string;
|
|
274
|
+
label: string;
|
|
275
|
+
actionType: 'send_keys' | 'open_picker' | 'attach_image';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
type HistoryEntry = DriverHistoryEntry;
|
|
279
|
+
|
|
280
|
+
/** Per-state evaluation snapshot (mirrors v3 SpecEvaluation shape for the
|
|
281
|
+
* parts the cli-adapter / panel consume). */
|
|
282
|
+
interface CurrentEval {
|
|
283
|
+
state: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' };
|
|
284
|
+
modal: ModalSnapshot | null;
|
|
285
|
+
controls: VisibleControl[];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export class FsmDriver implements ISpecDriver {
|
|
289
|
+
private spec!: CliSpecV4;
|
|
290
|
+
private adapter: TerminalAdapter;
|
|
291
|
+
private listeners = new Set<(ev: DashboardEvent) => void>();
|
|
292
|
+
|
|
293
|
+
// ── The entire FSM state: which node we're in, and when we entered it.
|
|
294
|
+
private currentStateId = '';
|
|
295
|
+
private stateEnteredAt = 0;
|
|
296
|
+
|
|
297
|
+
// ── Clock bookkeeping for time conditions.
|
|
298
|
+
private startedAtMs = 0;
|
|
299
|
+
private prevScreenLines: string[] = [];
|
|
300
|
+
/** Per stable region → last time that region's content changed. Drives
|
|
301
|
+
* stable_ms conditions. Key = stableRegionKey(cond): numeric cursor_above
|
|
302
|
+
* (−1 = whole screen), or a `section:<id>` / `<region>#ignore:<pat>` string
|
|
303
|
+
* when the clause scopes to a section or declares an ignore_lines filter. */
|
|
304
|
+
private regionLastChangedAt = new Map<number | string, number>();
|
|
305
|
+
/** COMPLETION-EARLYNOTIFY stable-eval trace: last stable/not-stable verdict
|
|
306
|
+
* recorded per stable region, so the trace fires only when the verdict FLIPS
|
|
307
|
+
* (not every quiet frame). Cleared on every transition alongside
|
|
308
|
+
* regionLastChangedAt. Diagnostic-only — never consulted by the FSM. */
|
|
309
|
+
private stableVerdictCache = new Map<number | string, boolean>();
|
|
310
|
+
/** Timer that re-runs evaluate() when a time-condition would flip true
|
|
311
|
+
* with no PTY frame to trigger it. */
|
|
312
|
+
private wakeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
313
|
+
/** Timer driving the focus-gated stall watchdog (refocus_when_stalled_ms).
|
|
314
|
+
* Re-arms itself while the machine is generating so a re-prime can fire
|
|
315
|
+
* even when the PTY has gone completely quiet. */
|
|
316
|
+
private stallTimer: ReturnType<typeof setTimeout> | null = null;
|
|
317
|
+
/** Wall-clock time the last stall re-prime was injected. The cooldown gate:
|
|
318
|
+
* after a re-prime we don't re-inject until the screen changes (which
|
|
319
|
+
* resets the stall reference) or another full stall window lapses. */
|
|
320
|
+
private lastRefocusAt = 0;
|
|
321
|
+
/** Timer driving the win32 verification-based submit resend loop (see
|
|
322
|
+
* WIN32_SUBMIT_* and scheduleWin32Submit). Re-arms itself until the FSM
|
|
323
|
+
* leaves idle (submitted) or the resend budget is spent. */
|
|
324
|
+
private win32SubmitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
325
|
+
/** Wall-clock (ms) of the most recent raw PTY output chunk. Advances on every
|
|
326
|
+
* on_pty_data — including the echo of text written into the composer — so the
|
|
327
|
+
* win32 submit settle-gate can tell when input has finished landing. */
|
|
328
|
+
private lastPtyDataAt = 0;
|
|
329
|
+
/** Wall-clock (ms) of the most recent win32 message-body input write. Bridges
|
|
330
|
+
* the gap between writing a chunk and its echo so the settle-gate does not
|
|
331
|
+
* declare "quiet" mid-write. */
|
|
332
|
+
private lastWin32WriteAt = 0;
|
|
333
|
+
/** Pending paced chunk-write timer for a large win32 body (see writeWin32Body). */
|
|
334
|
+
private win32WriteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
335
|
+
/** Timer driving the win32 verification-based modal-confirm CR resend loop (see
|
|
336
|
+
* scheduleWin32ModalConfirm). A lone CR that confirms an approval/picker choice
|
|
337
|
+
* is absorbed by ConPTY the same way a send_message submit CR is, so the confirm
|
|
338
|
+
* must be resent until the modal actually resolves (status leaves 'approval'). */
|
|
339
|
+
private win32ModalConfirmTimer: ReturnType<typeof setTimeout> | null = null;
|
|
340
|
+
|
|
341
|
+
private currentEval: CurrentEval | null = null;
|
|
342
|
+
private stateHistory: HistoryEntry[] = [];
|
|
343
|
+
private prevStateAt = 0;
|
|
344
|
+
|
|
345
|
+
// ── send_message queueing until the machine first reaches a non-initial,
|
|
346
|
+
// non-busy ("ready") state — same contract as v3's idleSeenOnce.
|
|
347
|
+
private readySeenOnce = false;
|
|
348
|
+
private pendingSends: string[] = [];
|
|
349
|
+
|
|
350
|
+
private pickerInProgress: { control_id: string; spec: Control } | null = null;
|
|
351
|
+
private delegateTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
352
|
+
private specWatcher: fs.FSWatcher | null = null;
|
|
353
|
+
/** Last full FSM evaluation, kept for the debugger. */
|
|
354
|
+
private lastFsmEval: ReturnType<typeof evaluateFsm> | null = null;
|
|
355
|
+
/** Ring buffer (max 20) of the full FSM evaluation captured at each
|
|
356
|
+
* transition — the rich pre-transition table that lastFsmEval only keeps
|
|
357
|
+
* for the single most recent evaluation. Separate from stateHistory. */
|
|
358
|
+
private fsmSnapshotHistory: FsmSnapshotEntry[] = [];
|
|
359
|
+
|
|
360
|
+
constructor(private readonly opts: SpecDriverOpts) {
|
|
361
|
+
this.loadSpecOrThrow();
|
|
362
|
+
this.adapter = new TerminalAdapter(
|
|
363
|
+
this.buildAdapterOpts(),
|
|
364
|
+
{
|
|
365
|
+
init: () => this.emitInitialState(),
|
|
366
|
+
on_pty_data: (chunk) => { this.lastPtyDataAt = Date.now(); this.emit({ kind: 'pty_data', chunk }); },
|
|
367
|
+
on_screen_changed: () => this.reevaluate(),
|
|
368
|
+
on_exit: ({ exitCode }) => this.handleExit(exitCode),
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
if (this.opts.hotReload !== false) this.armSpecWatcher();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
subscribe(listener: (ev: DashboardEvent) => void): () => void {
|
|
375
|
+
this.listeners.add(listener);
|
|
376
|
+
return () => { this.listeners.delete(listener); };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
start(): void {
|
|
380
|
+
const now = Date.now();
|
|
381
|
+
this.startedAtMs = now;
|
|
382
|
+
const init = initialState(this.spec);
|
|
383
|
+
this.currentStateId = init.id;
|
|
384
|
+
this.stateEnteredAt = now;
|
|
385
|
+
this.prevStateAt = now;
|
|
386
|
+
// Pre-trust the workspace before spawning so a first-run folder-trust
|
|
387
|
+
// prompt never appears (best-effort; failures fall back to the FSM's
|
|
388
|
+
// trust-modal detection). Only runs for specs that declare it.
|
|
389
|
+
if (this.spec.pre_launch_trust) {
|
|
390
|
+
applyPreLaunchTrust(this.spec.pre_launch_trust, this.opts.workingDir);
|
|
391
|
+
}
|
|
392
|
+
this.adapter.start();
|
|
393
|
+
// Prime focus-gated TUIs (see CliSpecV4.send_on_spawn). Written once,
|
|
394
|
+
// shortly after spawn, so the input stream is awake before the first
|
|
395
|
+
// delegated message — without this, a focus-event CLI like antigravity
|
|
396
|
+
// drops the first programmatic write until a manual keystroke.
|
|
397
|
+
this.scheduleSpawnPrime();
|
|
398
|
+
// The initial state may have a purely time-based exit (elapsed_ms);
|
|
399
|
+
// schedule a wake so we leave it even if the PTY goes quiet.
|
|
400
|
+
this.scheduleWakeForState();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private scheduleSpawnPrime(): void {
|
|
404
|
+
const seqs = this.spec.send_on_spawn;
|
|
405
|
+
if (!Array.isArray(seqs) || seqs.length === 0) return;
|
|
406
|
+
const delay = Math.max(0, this.spec.send_on_spawn_delay_ms ?? 250);
|
|
407
|
+
setTimeout(() => this.sendSpawnPrime(), delay);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Write the declared `send_on_spawn` sequences to the PTY once. Used both
|
|
411
|
+
* at spawn (scheduleSpawnPrime) and, for focus-gated TUIs, by the stall
|
|
412
|
+
* watchdog to re-inject the focus-in wake mid-turn. No-op when the spec
|
|
413
|
+
* declares no prime, so non-focus-gated CLIs are never poked. */
|
|
414
|
+
private sendSpawnPrime(): void {
|
|
415
|
+
const seqs = this.spec.send_on_spawn;
|
|
416
|
+
if (!Array.isArray(seqs) || seqs.length === 0) return;
|
|
417
|
+
for (const seq of seqs) {
|
|
418
|
+
if (typeof seq === 'string' && seq.length > 0) this.adapter.send_keys(seq);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
dispatch(cmd: DashboardCommand): void {
|
|
423
|
+
switch (cmd.kind) {
|
|
424
|
+
case 'send_message': this.handleSendMessage(cmd.text); return;
|
|
425
|
+
case 'pty_write': this.adapter.send_keys(cmd.data); return;
|
|
426
|
+
case 'click_control': this.handleClickControl(cmd.control_id, cmd.payload); return;
|
|
427
|
+
case 'click_modal_button': this.handleClickModalButton(cmd.index); return;
|
|
428
|
+
case 'attach_image': this.handleAttachImage(cmd.blob, cmd.mime); return;
|
|
429
|
+
case 'resize': this.adapter.resize(cmd.cols, cmd.rows); return;
|
|
430
|
+
case 'cancel': this.adapter.send_keys('\x03'); return;
|
|
431
|
+
case 'shutdown': this.shutdown(); return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Forward runtime metadata to the terminal transport so mesh binding
|
|
436
|
+
* fields (meshNodeId / meshNodeFor / workspaceLabel / lifecycle) reach
|
|
437
|
+
* the session registry. Not a DashboardCommand — this is a control-plane
|
|
438
|
+
* update, not user input. */
|
|
439
|
+
updateMeta(meta: Record<string, unknown>, replace = false): void {
|
|
440
|
+
this.adapter.updateMeta(meta, replace);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
snapshot(): string { return this.adapter.snapshot(); }
|
|
444
|
+
getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
|
|
445
|
+
getScreen(): string { return this.adapter.snapshot(); }
|
|
446
|
+
|
|
447
|
+
/** Scrollback-inclusive screen as line array — used only for modal/button
|
|
448
|
+
* content extraction so a tall prompt's off-screen anchors stay matchable.
|
|
449
|
+
* Falls back to the viewport snapshot if scrollback read is unavailable. */
|
|
450
|
+
private scrollbackLines(): string[] {
|
|
451
|
+
let screen = '';
|
|
452
|
+
try {
|
|
453
|
+
screen = this.adapter.snapshotWithScrollback();
|
|
454
|
+
} catch { /* fall through to viewport */ }
|
|
455
|
+
if (!screen) screen = this.adapter.snapshot();
|
|
456
|
+
return screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
457
|
+
}
|
|
458
|
+
getSpecPath(): string { return this.opts.specPath; }
|
|
459
|
+
|
|
460
|
+
shutdown(): void {
|
|
461
|
+
for (const t of this.delegateTimers.values()) clearTimeout(t);
|
|
462
|
+
this.delegateTimers.clear();
|
|
463
|
+
if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
|
|
464
|
+
if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
|
|
465
|
+
if (this.win32SubmitTimer) { clearTimeout(this.win32SubmitTimer); this.win32SubmitTimer = null; }
|
|
466
|
+
if (this.win32WriteTimer) { clearTimeout(this.win32WriteTimer); this.win32WriteTimer = null; }
|
|
467
|
+
if (this.win32ModalConfirmTimer) { clearTimeout(this.win32ModalConfirmTimer); this.win32ModalConfirmTimer = null; }
|
|
468
|
+
this.specWatcher?.close();
|
|
469
|
+
this.adapter.kill();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Debug surface (the whole reason for the rewrite) ──────────────────
|
|
473
|
+
//
|
|
474
|
+
// getFsmDebug() answers, for the CURRENT instant: which state am I in,
|
|
475
|
+
// how long have I been here, and for every outgoing transition — does its
|
|
476
|
+
// guard pass right now, and if not, which sub-condition is blocking and
|
|
477
|
+
// how many ms until it would flip. No screenshots required.
|
|
478
|
+
|
|
479
|
+
getFsmDebug(): {
|
|
480
|
+
currentState: string;
|
|
481
|
+
label: string;
|
|
482
|
+
stateAgeMs: number;
|
|
483
|
+
status: string;
|
|
484
|
+
cursor: { row: number; col: number };
|
|
485
|
+
transitions: TransitionEval[];
|
|
486
|
+
} {
|
|
487
|
+
const now = Date.now();
|
|
488
|
+
const cursor = this.adapter.getCursorPosition();
|
|
489
|
+
const screen = this.adapter.snapshot();
|
|
490
|
+
const ev = this.evalFsmNow(screen, cursor, now);
|
|
491
|
+
const state = stateById(this.spec, this.currentStateId);
|
|
492
|
+
return {
|
|
493
|
+
currentState: this.currentStateId,
|
|
494
|
+
label: state?.label ?? this.currentStateId,
|
|
495
|
+
stateAgeMs: now - this.stateEnteredAt,
|
|
496
|
+
status: state ? statusForState(state) : 'idle',
|
|
497
|
+
cursor,
|
|
498
|
+
transitions: ev.transitions,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
getStateHistory(): ReadonlyArray<HistoryEntry> { return this.stateHistory; }
|
|
503
|
+
getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry> { return this.fsmSnapshotHistory; }
|
|
504
|
+
/** Debug-only PTY input/output/resize/cursor timeline from the adapter. */
|
|
505
|
+
getEventTimeline(limit?: number): ReadonlyArray<SpecPtyEvent> {
|
|
506
|
+
return this.adapter.getEventTimeline(limit);
|
|
507
|
+
}
|
|
508
|
+
getSections(): Array<{ id: string; text: string }> | null {
|
|
509
|
+
try {
|
|
510
|
+
const screen = this.adapter.snapshot();
|
|
511
|
+
const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
512
|
+
return resolveSections(this.spec.sections ?? {}, lines).map(s => ({ id: s.id, text: s.text }));
|
|
513
|
+
} catch { return null; }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** v3-compat shims so the cli-adapter's existing debug snapshot keeps
|
|
517
|
+
* working without branching on driver type. */
|
|
518
|
+
getLastBusyAt(): number {
|
|
519
|
+
// Approximate: time we entered a generating-status state.
|
|
520
|
+
const st = stateById(this.spec, this.currentStateId);
|
|
521
|
+
return st && statusForState(st) === 'generating' ? this.stateEnteredAt : 0;
|
|
522
|
+
}
|
|
523
|
+
hasIdleHoldPending(): boolean {
|
|
524
|
+
// FSM has no separate idle-hold timer; min_hold_ms is the analog.
|
|
525
|
+
// Report true while any outgoing transition is hold-blocked.
|
|
526
|
+
return (this.lastFsmEval?.transitions ?? []).some(t => !t.holdSatisfied && t.condResult);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* True once the machine has reached its first non-initial idle state (the
|
|
530
|
+
* prompt is genuinely drawn — see maybeMarkReady). The cli-adapter surfaces
|
|
531
|
+
* this on its idle status so CliProviderInstance can re-arm the queue-claim
|
|
532
|
+
* agent:ready on the first genuine ready, independent of the boot-time
|
|
533
|
+
* starting→idle one-shot (which is consumed too early for specs whose
|
|
534
|
+
* initial state already reports idle).
|
|
535
|
+
*/
|
|
536
|
+
hasSeenReady(): boolean {
|
|
537
|
+
return this.readySeenOnce;
|
|
538
|
+
}
|
|
539
|
+
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null {
|
|
540
|
+
// Surface the busy→ready transition's stable countdown, if any, so the
|
|
541
|
+
// existing panel field stays meaningful.
|
|
542
|
+
const out = outgoingTransitions(this.spec, this.currentStateId);
|
|
543
|
+
const toReady = this.lastFsmEval?.transitions.find((t, i) => {
|
|
544
|
+
const st = stateById(this.spec, out[i]?.to ?? '');
|
|
545
|
+
return st && statusForState(st) === 'idle';
|
|
546
|
+
});
|
|
547
|
+
if (!toReady || !toReady.cond) return null;
|
|
548
|
+
const stable = findStable(toReady.cond);
|
|
549
|
+
if (!stable) return null;
|
|
550
|
+
return { active: true, ageMs: 0, holdMs: stable.totalMs, forceAfterMs: 0 };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ────────────────────────────────────────────────────────────────────
|
|
554
|
+
// Loading & adapter wiring
|
|
555
|
+
// ────────────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
private loadSpecOrThrow(): void {
|
|
558
|
+
const res = loadFsmSpec(this.opts.specPath);
|
|
559
|
+
if (!res.ok) throw new Error(`fsm spec invalid: ${res.errors.join('; ')}`);
|
|
560
|
+
this.spec = res.spec;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private buildAdapterOpts(): TerminalAdapterOpts {
|
|
564
|
+
// Single-source spawn resolution: route the spec's binary/args/env
|
|
565
|
+
// through the SAME planner the legacy ProviderCliAdapter uses
|
|
566
|
+
// (resolveCliSpawnPlanFromParts). This gives the spec/FSM path
|
|
567
|
+
// findBinary (PATH + npm-global / Node-dir fallback so an off-PATH
|
|
568
|
+
// `codex`/`claude` resolves), `{{workingDir}}` token substitution, shell
|
|
569
|
+
// wrapping for script-shims / non-absolute / non-native binaries, and a
|
|
570
|
+
// sanitized env with TERMINAL_CWD — none of which it had when it passed
|
|
571
|
+
// `this.spec.binary` straight to the PTY.
|
|
572
|
+
const cols = this.opts.cols ?? DEFAULT_SESSION_HOST_COLS;
|
|
573
|
+
const rows = this.opts.rows ?? DEFAULT_SESSION_HOST_ROWS;
|
|
574
|
+
const plan = resolveCliSpawnPlanFromParts({
|
|
575
|
+
command: this.spec.binary,
|
|
576
|
+
baseArgs: this.spec.spawn_args ?? [],
|
|
577
|
+
baseEnv: this.spec.env ?? {},
|
|
578
|
+
workingDir: this.opts.workingDir,
|
|
579
|
+
extraArgs: this.opts.extraCliArgs ?? [],
|
|
580
|
+
extraEnv: this.opts.extraEnv ?? {},
|
|
581
|
+
geometry: { cols, rows },
|
|
582
|
+
});
|
|
583
|
+
return {
|
|
584
|
+
binary: plan.shellCmd,
|
|
585
|
+
args: plan.shellArgs,
|
|
586
|
+
cwd: plan.ptyOptions.cwd,
|
|
587
|
+
// plan.ptyOptions.env is already a complete, sanitized environment —
|
|
588
|
+
// pass it verbatim, do not overlay process.env (see envIsComplete).
|
|
589
|
+
env: plan.ptyOptions.env,
|
|
590
|
+
envIsComplete: true,
|
|
591
|
+
cols,
|
|
592
|
+
rows,
|
|
593
|
+
transportFactory: this.opts.transportFactory,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private armSpecWatcher(): void {
|
|
598
|
+
try {
|
|
599
|
+
const dir = path.dirname(this.opts.specPath);
|
|
600
|
+
const base = path.basename(this.opts.specPath);
|
|
601
|
+
this.specWatcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
|
|
602
|
+
if (filename && filename !== base) return;
|
|
603
|
+
const res = loadFsmSpec(this.opts.specPath);
|
|
604
|
+
if (!res.ok) {
|
|
605
|
+
this.emit({ kind: 'spec_error', errors: res.errors });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
this.spec = res.spec;
|
|
609
|
+
LOG.info('FsmDriver', `[${this.specTag()}] spec hot-reloaded`);
|
|
610
|
+
// Re-evaluate immediately with the new transitions.
|
|
611
|
+
this.reevaluate(true);
|
|
612
|
+
});
|
|
613
|
+
} catch { /* best-effort */ }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private emitInitialState(): void {
|
|
617
|
+
this.reevaluate(true);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ────────────────────────────────────────────────────────────────────
|
|
621
|
+
// Core: evaluate the FSM and commit transitions
|
|
622
|
+
// ────────────────────────────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
private buildClock(now: number): FsmClock {
|
|
625
|
+
return {
|
|
626
|
+
now,
|
|
627
|
+
stateEnteredAt: this.stateEnteredAt,
|
|
628
|
+
regionLastChangedAt: this.regionLastChangedAt,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private evalFsmNow(screen: string, cursor: { row: number; col: number }, now: number) {
|
|
633
|
+
const prev = this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined;
|
|
634
|
+
return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private reevaluate(forceEmit = false): void {
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
const screen = this.adapter.snapshot();
|
|
640
|
+
const cursor = this.adapter.getCursorPosition();
|
|
641
|
+
const currentLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
642
|
+
|
|
643
|
+
// Track per-region change timestamps for stable_ms conditions BEFORE
|
|
644
|
+
// we overwrite prevScreenLines.
|
|
645
|
+
this.trackRegionChanges(currentLines, cursor, now);
|
|
646
|
+
|
|
647
|
+
const ev = this.evalFsmNow(screen, cursor, now);
|
|
648
|
+
this.lastFsmEval = ev;
|
|
649
|
+
this.prevScreenLines = currentLines;
|
|
650
|
+
|
|
651
|
+
if (ev.fired) {
|
|
652
|
+
this.commitTransition(ev.fired, now, ev);
|
|
653
|
+
// After a transition, immediately re-derive controls/modal for the
|
|
654
|
+
// new state and emit. Re-run once so a chain like approval→busy
|
|
655
|
+
// that's already satisfied doesn't wait for the next PTY frame.
|
|
656
|
+
this.emitStateChanged(forceEmit);
|
|
657
|
+
this.scheduleWakeForState();
|
|
658
|
+
this.scheduleStallWatchdog();
|
|
659
|
+
// Drain queued sends on the SAME frame the machine reaches "ready".
|
|
660
|
+
// The first delegated message is queued in pendingSends until the
|
|
661
|
+
// FSM first enters a non-initial idle state (the prompt is drawn).
|
|
662
|
+
// That readiness is normally reached BY a transition (e.g.
|
|
663
|
+
// signing_in→idle / starting→idle) — and an idle state has no
|
|
664
|
+
// pending time-condition, so scheduleWakeForState() arms no timer
|
|
665
|
+
// and, agy being quiet at the prompt, no further PTY frame arrives.
|
|
666
|
+
// Without this call the queued first message would strand here
|
|
667
|
+
// forever (the "first input never processed" bug). maybeMarkReady is
|
|
668
|
+
// idempotent (guarded by readySeenOnce) so calling it on both
|
|
669
|
+
// branches is safe.
|
|
670
|
+
this.maybeMarkReady();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// No transition — refresh modal/controls (content inside the same
|
|
675
|
+
// state can still change, e.g. modal title/buttons) and emit if changed.
|
|
676
|
+
this.emitStateChanged(forceEmit);
|
|
677
|
+
// Schedule a wake for the soonest pending time-condition.
|
|
678
|
+
this.scheduleWakeForState();
|
|
679
|
+
this.scheduleStallWatchdog();
|
|
680
|
+
// Drain queued sends once we first reach a "ready" state.
|
|
681
|
+
this.maybeMarkReady();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private commitTransition(fired: TransitionEval, now: number, ev: FsmEvaluation): void {
|
|
685
|
+
const from = this.currentStateId;
|
|
686
|
+
// Capture the full pre-transition evaluation BEFORE we mutate state, so
|
|
687
|
+
// the snapshot records why this transition fired from `from`.
|
|
688
|
+
this.pushFsmSnapshot(from, fired, now, ev);
|
|
689
|
+
this.currentStateId = fired.to;
|
|
690
|
+
this.stateEnteredAt = now;
|
|
691
|
+
// Region change timestamps are relative to the previous state's
|
|
692
|
+
// activity; reset so stable_ms in the new state measures from entry.
|
|
693
|
+
this.regionLastChangedAt.clear();
|
|
694
|
+
this.stableVerdictCache.clear();
|
|
695
|
+
this.pushHistory(fired.to, stateById(this.spec, fired.to)?.label ?? fired.to, {
|
|
696
|
+
reason: 'transition',
|
|
697
|
+
via: `${from}→${fired.to}`,
|
|
698
|
+
matchedRules: summarizeTransition(fired),
|
|
699
|
+
});
|
|
700
|
+
LOG.info('FsmDriver', `[${this.specTag()}] ${from} → ${fired.to} (${fired.label})`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Snapshot the full FSM evaluation that produced a transition into the
|
|
704
|
+
* separate fsmSnapshotHistory ring buffer (max 20). The transitions[]
|
|
705
|
+
* table is captured by reference — it is freshly built per evaluation in
|
|
706
|
+
* evaluateFsm and never mutated after, so no clone is needed. */
|
|
707
|
+
private pushFsmSnapshot(from: string, fired: TransitionEval, now: number, ev: FsmEvaluation): void {
|
|
708
|
+
this.fsmSnapshotHistory.push({
|
|
709
|
+
stateFrom: from,
|
|
710
|
+
stateTo: fired.to,
|
|
711
|
+
at: now,
|
|
712
|
+
firedTo: fired.to,
|
|
713
|
+
firedLabel: fired.label,
|
|
714
|
+
reason: summarizeTransition(fired),
|
|
715
|
+
transitions: ev.transitions,
|
|
716
|
+
});
|
|
717
|
+
if (this.fsmSnapshotHistory.length > 20) this.fsmSnapshotHistory.shift();
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Re-derive the visible modal + controls for the current state and emit a
|
|
721
|
+
* state_changed if anything differs from the last emit. */
|
|
722
|
+
private emitStateChanged(forceEmit: boolean): void {
|
|
723
|
+
const state = stateById(this.spec, this.currentStateId);
|
|
724
|
+
if (!state) return;
|
|
725
|
+
const screen = this.adapter.snapshot();
|
|
726
|
+
const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
727
|
+
const sections = resolveSections(this.spec.sections ?? {}, lines);
|
|
728
|
+
|
|
729
|
+
// Modal states (approval / picker) extract their buttons + title from a
|
|
730
|
+
// SCROLLBACK-INCLUSIVE buffer: claude-cli renders an approval as a box,
|
|
731
|
+
// and when the prompt body (a big diff / long explanation) is tall the
|
|
732
|
+
// top of the box — including the `─────` separator that anchors the
|
|
733
|
+
// `modal` section — scrolls above the viewport. Matching only the
|
|
734
|
+
// viewport then yields < min_count buttons, deriveModal returns null,
|
|
735
|
+
// and auto-approve never fires (the "long prompts never auto-approve"
|
|
736
|
+
// bug). The viewport stays authoritative for transitions / cursor
|
|
737
|
+
// conditions; only this content-pattern extraction reads scrollback.
|
|
738
|
+
const modalLines = state.modal
|
|
739
|
+
? this.scrollbackLines()
|
|
740
|
+
: lines;
|
|
741
|
+
const modalSections = state.modal
|
|
742
|
+
? resolveSections(this.spec.sections ?? {}, modalLines)
|
|
743
|
+
: sections;
|
|
744
|
+
const modalFullScreen = modalLines.join('\n');
|
|
745
|
+
|
|
746
|
+
const modal = this.deriveModal(state, modalSections, modalFullScreen);
|
|
747
|
+
const controls = this.deriveControls(state.id);
|
|
748
|
+
const title = modal?.title ?? this.deriveTitle(state, modalSections, modalFullScreen);
|
|
749
|
+
|
|
750
|
+
const next: CurrentEval = {
|
|
751
|
+
// status is derived from the FSM state itself (statusForState), NOT from
|
|
752
|
+
// whether a modal was parsed this frame. A modal state whose buttons briefly
|
|
753
|
+
// fail to parse (PTY repaint → deriveModal returns null) must still report
|
|
754
|
+
// its authoritative status (e.g. 'approval'), so the adapter never collapses
|
|
755
|
+
// an approval/busy state to idle on a transient modal-parse miss.
|
|
756
|
+
state: { id: state.id, label: state.label, title, status: statusForState(state) },
|
|
757
|
+
modal,
|
|
758
|
+
controls,
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const changed = forceEmit
|
|
762
|
+
|| !this.currentEval
|
|
763
|
+
|| this.currentEval.state.id !== next.state.id
|
|
764
|
+
|| this.currentEval.state.title !== next.state.title
|
|
765
|
+
|| !sameModal(this.currentEval.modal, next.modal)
|
|
766
|
+
|| !sameControls(this.currentEval.controls, next.controls);
|
|
767
|
+
|
|
768
|
+
this.currentEval = next;
|
|
769
|
+
if (this.pickerInProgress) this.tryAdvancePicker(screen);
|
|
770
|
+
|
|
771
|
+
if (changed) {
|
|
772
|
+
this.emit({
|
|
773
|
+
kind: 'state_changed',
|
|
774
|
+
state: next.state,
|
|
775
|
+
// kind is the SEMANTIC modal class (approval vs picker/confirm)
|
|
776
|
+
// derived from the FSM state, NOT from the parsed buttons — the
|
|
777
|
+
// status field already collapsed it to 'approval' so the modal is
|
|
778
|
+
// surfaced. The auto-approve worker needs the distinction back to
|
|
779
|
+
// avoid answering a /model picker on the user's behalf.
|
|
780
|
+
modal: next.modal ? { title: next.modal.title, buttons: next.modal.buttons.map(b => ({ index: b.index, label: b.label })), kind: modalKindForState(state) } : null,
|
|
781
|
+
controls: next.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
|
|
782
|
+
});
|
|
783
|
+
this.fireNotifications(state.id, title);
|
|
784
|
+
this.armOrCancelDelegateTimers(state.id);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private deriveModal(state: FsmState, sections: ResolvedSection[], fullScreen: string): ModalSnapshot | null {
|
|
789
|
+
const rule = state.extract?.buttons;
|
|
790
|
+
if (!rule) return null;
|
|
791
|
+
const hay = sectionText(sections, rule.section, fullScreen);
|
|
792
|
+
const minCount = rule.min_count ?? 2;
|
|
793
|
+
const buttons = extractButtonsFromRule(rule, hay);
|
|
794
|
+
if (buttons.length < minCount) return null;
|
|
795
|
+
const title = this.deriveTitle(state, sections, fullScreen);
|
|
796
|
+
return { title, buttons };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private deriveTitle(state: FsmState, sections: ResolvedSection[], fullScreen: string): string | null {
|
|
800
|
+
const rule = state.extract?.title;
|
|
801
|
+
if (!rule) return null;
|
|
802
|
+
return extractTitle(rule, sections, fullScreen);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private deriveControls(stateId: string): VisibleControl[] {
|
|
806
|
+
const out: VisibleControl[] = [];
|
|
807
|
+
for (const c of this.spec.control_bar ?? []) {
|
|
808
|
+
if (c.visible_when_state && !c.visible_when_state.includes(stateId)) continue;
|
|
809
|
+
out.push({ id: c.id, label: c.label, actionType: c.action.type });
|
|
810
|
+
}
|
|
811
|
+
return out;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Track which stable regions changed since the previous frame so
|
|
815
|
+
* stable_ms conditions can measure quiet time. We record every distinct
|
|
816
|
+
* stable region referenced in the current state (numeric cursor_above /
|
|
817
|
+
* whole-screen -1, and named `section:<id>` regions) plus, for each, the
|
|
818
|
+
* optional `ignore_lines` filter that folds into its key.
|
|
819
|
+
*
|
|
820
|
+
* `ignore_lines` is the content-aware fix for the busy→idle wedge: lines
|
|
821
|
+
* matching it are stripped from BOTH frames before the comparison, so a
|
|
822
|
+
* benign residual ticker (bare token counter / elapsed timer that repaints
|
|
823
|
+
* every frame post-generation) no longer resets the clock — while an active
|
|
824
|
+
* spinner line, which does NOT match the benign pattern, still does (the
|
|
825
|
+
* FALSEIDLE2 / FALSEBUSY-B whole-screen invariant is preserved). */
|
|
826
|
+
private trackRegionChanges(currentLines: string[], cursor: { row: number; col: number }, now: number): void {
|
|
827
|
+
if (this.prevScreenLines.length === 0) return;
|
|
828
|
+
const descs = this.stableRegionDescriptors();
|
|
829
|
+
// COMPLETION-EARLYNOTIFY hook 4: record the stable/not-stable verdict for each
|
|
830
|
+
// tracked region, but ONLY when the verdict flips (see stableVerdictCache) so a
|
|
831
|
+
// quiet screen does not spam the ring buffer. This is the case-b diagnostic — an
|
|
832
|
+
// ignore_lines-scoped stable clause declaring a tool-execution screen "stable-idle"
|
|
833
|
+
// shows up here as verdict:true with a short fingerprint. Payload carries lengths
|
|
834
|
+
// and the pattern SOURCE only — never screen text.
|
|
835
|
+
const stableTraceOn = shouldCollectTraceCategory('fsm-transition');
|
|
836
|
+
// Section ranges depend on screen content, so resolve per-frame for both
|
|
837
|
+
// frames — but only when some tracked region is actually section-scoped.
|
|
838
|
+
const needsSections = descs.some(d => !!d.section);
|
|
839
|
+
const curSections = needsSections ? resolveSections(this.spec.sections ?? {}, currentLines) : [];
|
|
840
|
+
const prevSections = needsSections ? resolveSections(this.spec.sections ?? {}, this.prevScreenLines) : [];
|
|
841
|
+
for (const d of descs) {
|
|
842
|
+
let curLines: string[]; let prevLines: string[];
|
|
843
|
+
if (d.section) {
|
|
844
|
+
curLines = sliceSectionLines(currentLines, curSections, d.section);
|
|
845
|
+
prevLines = sliceSectionLines(this.prevScreenLines, prevSections, d.section);
|
|
846
|
+
} else if (!d.cursor_above || d.cursor_above <= 0) {
|
|
847
|
+
curLines = currentLines;
|
|
848
|
+
prevLines = this.prevScreenLines;
|
|
849
|
+
} else {
|
|
850
|
+
const start = Math.max(0, cursor.row - d.cursor_above);
|
|
851
|
+
curLines = currentLines.slice(start, cursor.row);
|
|
852
|
+
prevLines = this.prevScreenLines.slice(start, cursor.row);
|
|
853
|
+
}
|
|
854
|
+
const cur = filterIgnoredLines(curLines, d.ignoreRe).join('\n');
|
|
855
|
+
const prev = filterIgnoredLines(prevLines, d.ignoreRe).join('\n');
|
|
856
|
+
if (cur !== prev) this.regionLastChangedAt.set(d.key, now);
|
|
857
|
+
if (stableTraceOn && typeof d.holdMs === 'number') {
|
|
858
|
+
const lastChanged = this.regionLastChangedAt.get(d.key) ?? this.stateEnteredAt;
|
|
859
|
+
const ageMs = now - lastChanged;
|
|
860
|
+
const verdict = ageMs >= d.holdMs;
|
|
861
|
+
if (this.stableVerdictCache.get(d.key) !== verdict) {
|
|
862
|
+
this.stableVerdictCache.set(d.key, verdict);
|
|
863
|
+
recordDebugTrace({
|
|
864
|
+
category: 'fsm-transition',
|
|
865
|
+
stage: 'stable-eval',
|
|
866
|
+
level: 'debug',
|
|
867
|
+
payload: {
|
|
868
|
+
state: this.currentStateId,
|
|
869
|
+
regionKey: String(d.key),
|
|
870
|
+
ignorePattern: d.ignoreRe?.source ?? null,
|
|
871
|
+
fingerprintLen: cur.length,
|
|
872
|
+
ageMs,
|
|
873
|
+
holdMs: d.holdMs,
|
|
874
|
+
verdict,
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Every distinct stable-region descriptor referenced by stable_ms
|
|
883
|
+
* conditions in the current state's outgoing transitions, plus the plain
|
|
884
|
+
* whole-screen key (-1) that other machinery (stall watchdog) reads.
|
|
885
|
+
* De-duplicated by key. Cached lazily per spec load would be nicer but the
|
|
886
|
+
* set is tiny. */
|
|
887
|
+
private stableRegionDescriptors(): StableRegionDescriptor[] {
|
|
888
|
+
const byKey = new Map<number | string, StableRegionDescriptor>();
|
|
889
|
+
byKey.set(-1, { key: -1 });
|
|
890
|
+
for (const t of outgoingTransitions(this.spec, this.currentStateId)) {
|
|
891
|
+
collectStableDescriptors(t.when, byKey);
|
|
892
|
+
}
|
|
893
|
+
return [...byKey.values()];
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Schedule a re-evaluation for the soonest pending time-condition on any
|
|
897
|
+
* outgoing transition (elapsed_ms / stable_ms / min_hold_ms). Without
|
|
898
|
+
* this, a state whose only exit is time-based would never leave once the
|
|
899
|
+
* PTY goes quiet. */
|
|
900
|
+
private scheduleWakeForState(): void {
|
|
901
|
+
if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
|
|
902
|
+
const ev = this.lastFsmEval;
|
|
903
|
+
if (!ev) return;
|
|
904
|
+
let soonest = Infinity;
|
|
905
|
+
for (const t of ev.transitions) {
|
|
906
|
+
if (t.fires) continue;
|
|
907
|
+
if (!t.holdSatisfied) soonest = Math.min(soonest, t.holdRemainingMs);
|
|
908
|
+
const condRemain = t.cond ? t.cond.remainingMs ?? Infinity : Infinity;
|
|
909
|
+
// Only treat the cond countdown as a wake source when the rest of
|
|
910
|
+
// the guard (hold) is already or will be satisfied.
|
|
911
|
+
if (Number.isFinite(condRemain) && condRemain > 0) soonest = Math.min(soonest, condRemain);
|
|
912
|
+
}
|
|
913
|
+
if (!Number.isFinite(soonest)) return;
|
|
914
|
+
this.wakeTimer = setTimeout(() => { this.wakeTimer = null; this.reevaluate(); }, Math.max(soonest + 30, 50));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ── Focus-gated stall watchdog (refocus_when_stalled_ms) ──────────────────
|
|
918
|
+
//
|
|
919
|
+
// A focus-event TUI (antigravity's `agy`) freezes its render loop the moment
|
|
920
|
+
// it thinks it has lost focus mid-turn: the screen stops updating and only
|
|
921
|
+
// repaints on the next keypress, which the daemon never sends. The output
|
|
922
|
+
// pump is wired entirely to PTY onData (no PTY data → no reevaluate, no
|
|
923
|
+
// on_screen_changed), so a normal time-wake that only re-reads the screen
|
|
924
|
+
// can't help — there is nothing new to read. The fix is to re-inject the
|
|
925
|
+
// focus-in wake (`send_on_spawn`) so the CLI flushes the held output itself.
|
|
926
|
+
|
|
927
|
+
/** True when this spec opts into stall recovery (declares a positive
|
|
928
|
+
* refocus window AND a wake sequence to re-inject). */
|
|
929
|
+
private stallRecoveryEnabled(): boolean {
|
|
930
|
+
const ms = this.spec.refocus_when_stalled_ms;
|
|
931
|
+
return typeof ms === 'number' && ms > 0
|
|
932
|
+
&& Array.isArray(this.spec.send_on_spawn) && this.spec.send_on_spawn.length > 0;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Wall-clock time the screen last changed in the current state, falling
|
|
936
|
+
* back to state entry when it has not changed since (i.e. fully stalled
|
|
937
|
+
* from the start of the state). */
|
|
938
|
+
private lastScreenChangeAt(): number {
|
|
939
|
+
return this.regionLastChangedAt.get(-1) ?? this.stateEnteredAt;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Arm a timer to re-inject the focus-in prime if the screen stays frozen
|
|
943
|
+
* through a `generating` state. Only active for opted-in focus-gated specs;
|
|
944
|
+
* a no-op (and cleared) for every other CLI and every non-generating state.
|
|
945
|
+
* Re-arms itself so it keeps watching while the PTY is quiet. */
|
|
946
|
+
private scheduleStallWatchdog(): void {
|
|
947
|
+
if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
|
|
948
|
+
if (!this.stallRecoveryEnabled()) return;
|
|
949
|
+
const st = stateById(this.spec, this.currentStateId);
|
|
950
|
+
if (!st || statusForState(st) !== 'generating') return;
|
|
951
|
+
const windowMs = this.spec.refocus_when_stalled_ms as number;
|
|
952
|
+
// Cooldown reference: a re-prime defers the next one by a full window,
|
|
953
|
+
// even if the screen has not yet repainted, so we don't tight-loop.
|
|
954
|
+
const since = Math.max(this.lastScreenChangeAt(), this.lastRefocusAt);
|
|
955
|
+
const remaining = windowMs - (Date.now() - since);
|
|
956
|
+
this.stallTimer = setTimeout(
|
|
957
|
+
() => { this.stallTimer = null; this.onStallTick(); },
|
|
958
|
+
Math.max(remaining + 30, 50),
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Fire when the stall window elapses: if the screen is still frozen and we
|
|
963
|
+
* are still generating, re-inject the focus-in wake once, then re-arm. */
|
|
964
|
+
private onStallTick(): void {
|
|
965
|
+
if (!this.stallRecoveryEnabled()) return;
|
|
966
|
+
const st = stateById(this.spec, this.currentStateId);
|
|
967
|
+
if (!st || statusForState(st) !== 'generating') return;
|
|
968
|
+
const windowMs = this.spec.refocus_when_stalled_ms as number;
|
|
969
|
+
const now = Date.now();
|
|
970
|
+
const stalledFor = now - this.lastScreenChangeAt();
|
|
971
|
+
const sinceLastRefocus = now - this.lastRefocusAt;
|
|
972
|
+
if (stalledFor >= windowMs && sinceLastRefocus >= windowMs) {
|
|
973
|
+
LOG.info('FsmDriver', `[${this.specTag()}] stall detected (${stalledFor}ms quiet, generating) — re-injecting focus-in`);
|
|
974
|
+
this.sendSpawnPrime();
|
|
975
|
+
this.lastRefocusAt = now;
|
|
976
|
+
}
|
|
977
|
+
// Keep watching: the re-prime may not flush instantly, and a still-quiet
|
|
978
|
+
// screen needs the next window to come around.
|
|
979
|
+
this.scheduleStallWatchdog();
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
private maybeMarkReady(): void {
|
|
983
|
+
if (this.readySeenOnce) return;
|
|
984
|
+
const st = stateById(this.spec, this.currentStateId);
|
|
985
|
+
if (!st) return;
|
|
986
|
+
// "Ready" = a non-initial state whose status is idle (the prompt is up).
|
|
987
|
+
if (!st.initial && statusForState(st) === 'idle') {
|
|
988
|
+
this.readySeenOnce = true;
|
|
989
|
+
const queued = this.pendingSends.splice(0);
|
|
990
|
+
for (const text of queued) setTimeout(() => this.actuallySendMessage(text), 50);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ────────────────────────────────────────────────────────────────────
|
|
995
|
+
// Notifications & delegates
|
|
996
|
+
// ────────────────────────────────────────────────────────────────────
|
|
997
|
+
|
|
998
|
+
private fireNotifications(stateId: string, title: string | null): void {
|
|
999
|
+
for (const n of this.spec.notifications ?? []) {
|
|
1000
|
+
if (n.when_state !== stateId) continue;
|
|
1001
|
+
const body = (n.body ?? '').replace(/\{state\.title\}/g, title ?? '');
|
|
1002
|
+
this.emit({ kind: 'notification', id: n.id, title: n.title, body });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private armOrCancelDelegateTimers(currentStateId: string): void {
|
|
1007
|
+
for (const d of this.spec.delegate ?? []) {
|
|
1008
|
+
const armed = this.delegateTimers.has(d.id);
|
|
1009
|
+
const shouldFire = d.when_state === currentStateId;
|
|
1010
|
+
if (shouldFire && !armed) {
|
|
1011
|
+
const delay = d.after_duration_ms ?? 0;
|
|
1012
|
+
const t = setTimeout(() => { this.fireDelegate(d); this.delegateTimers.delete(d.id); }, delay);
|
|
1013
|
+
this.delegateTimers.set(d.id, t);
|
|
1014
|
+
} else if (!shouldFire && armed) {
|
|
1015
|
+
clearTimeout(this.delegateTimers.get(d.id)!);
|
|
1016
|
+
this.delegateTimers.delete(d.id);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private fireDelegate(d: DelegateTrigger): void {
|
|
1022
|
+
const ev = this.currentEval;
|
|
1023
|
+
const task = d.task_template
|
|
1024
|
+
.replace(/\{node\}/g, os.hostname())
|
|
1025
|
+
.replace(/\{state\.label\}/g, ev?.state.label ?? '')
|
|
1026
|
+
.replace(/\{state\.title\}/g, ev?.state.title ?? '')
|
|
1027
|
+
.replace(/\{duration_ms\}/g, String(d.after_duration_ms ?? 0));
|
|
1028
|
+
this.emit({ kind: 'delegate', id: d.id, task });
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1032
|
+
// Dashboard commands (identical semantics to v3)
|
|
1033
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1034
|
+
|
|
1035
|
+
private handleSendMessage(text: string): void {
|
|
1036
|
+
if (!this.readySeenOnce) { this.pendingSends.push(text); return; }
|
|
1037
|
+
this.actuallySendMessage(text);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private actuallySendMessage(text: string): void {
|
|
1041
|
+
const sm = this.spec.send_message;
|
|
1042
|
+
const perChar = sm.delay_ms_per_char ?? 0;
|
|
1043
|
+
const beforeSubmit = resolveSubmitDelayMs(sm.delay_ms_before_submit, text);
|
|
1044
|
+
|
|
1045
|
+
// win32 ConPTY submit: the text and the submit key (CR) must NOT be
|
|
1046
|
+
// combined into one PTY write — Ink-based TUIs (claude-cli) treat a
|
|
1047
|
+
// single write that carries text + a trailing CR as a bracketed/multi-line
|
|
1048
|
+
// paste and absorb the CR as a literal newline. So we write the text on
|
|
1049
|
+
// its own, then resend the submit key on a fixed cadence, VERIFYING after
|
|
1050
|
+
// each that the agent actually left the idle composer (status flipped away
|
|
1051
|
+
// from 'idle'). This handles the nondeterministic multiline
|
|
1052
|
+
// paste-accumulation window where a variable number of CRs is needed; a
|
|
1053
|
+
// fixed double-CR fails for multiline (see WIN32_SUBMIT_* above). perChar
|
|
1054
|
+
// typing simulation is skipped on win32; correctness of submission wins
|
|
1055
|
+
// over the typing visual there.
|
|
1056
|
+
if (process.platform === 'win32') {
|
|
1057
|
+
this.writeWin32Body(text);
|
|
1058
|
+
this.scheduleWin32Submit(sm.submit_key, beforeSubmit, text);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (perChar === 0) {
|
|
1063
|
+
this.adapter.send_keys(text);
|
|
1064
|
+
if (beforeSubmit > 0) setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
|
|
1065
|
+
else this.adapter.send_keys(sm.submit_key);
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
let i = 0;
|
|
1069
|
+
const iv = setInterval(() => {
|
|
1070
|
+
if (i >= text.length) {
|
|
1071
|
+
clearInterval(iv);
|
|
1072
|
+
setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
this.adapter.send_keys(text[i]);
|
|
1076
|
+
i += 1;
|
|
1077
|
+
}, perChar);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** The agent's current coarse status, derived from the FSM node we're in. */
|
|
1081
|
+
private currentStatus(): 'idle' | 'generating' | 'approval' {
|
|
1082
|
+
const st = stateById(this.spec, this.currentStateId);
|
|
1083
|
+
return st ? statusForState(st) : 'idle';
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Record a win32 body write so the settle-gate counts it as input activity
|
|
1087
|
+
* even before the echo arrives. */
|
|
1088
|
+
private markWin32Write(): void {
|
|
1089
|
+
this.lastWin32WriteAt = Date.now();
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/** Most recent win32 input activity — a write we issued OR a PTY output chunk
|
|
1093
|
+
* (echo). The submit settle-gate waits for this to go quiet. */
|
|
1094
|
+
private lastWin32InputActivityAt(): number {
|
|
1095
|
+
return Math.max(this.lastPtyDataAt, this.lastWin32WriteAt);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Write the message body to the PTY for win32, paced into bounded chunks. A
|
|
1100
|
+
* single unbounded ConPTY write can overflow the input pipe and drop leading
|
|
1101
|
+
* bytes; splitting it with a short inter-chunk gap keeps the console input
|
|
1102
|
+
* buffer from overflowing. Small bodies still go out in a single write. Each
|
|
1103
|
+
* write advances lastWin32WriteAt so the submit settle-gate keeps waiting until
|
|
1104
|
+
* the final segment is out and echoed.
|
|
1105
|
+
*
|
|
1106
|
+
* FIX-B-v2: a body that contains an embedded newline cannot be written raw —
|
|
1107
|
+
* on the real win32 Ink/ConPTY composer each '\n' SUBMITS the preceding line as
|
|
1108
|
+
* its own entry, truncating the prompt to only the tail fragment. So a
|
|
1109
|
+
* newline-bearing body is rewritten so its embedded newlines never submit:
|
|
1110
|
+
* - 'paste' (default): wrap the body in a bracketed-paste (ESC[200~ … ESC[201~)
|
|
1111
|
+
* — the composer takes the whole thing, newlines and all, as pasted text.
|
|
1112
|
+
* - 'soft_newline': replace each embedded newline with a non-submitting
|
|
1113
|
+
* Shift+Enter (CSI-u) so the body is typed as one multi-line entry.
|
|
1114
|
+
* The trailing submit CR is NOT written here — it stays separate and is fired
|
|
1115
|
+
* later by scheduleWin32Submit (concern (A)). The bracketed-paste markers are
|
|
1116
|
+
* written as their own atomic segments (never chunked), so chunking can never
|
|
1117
|
+
* split ESC[200~ / ESC[201~ mid-sequence regardless of body length.
|
|
1118
|
+
*/
|
|
1119
|
+
private writeWin32Body(text: string): void {
|
|
1120
|
+
if (this.win32WriteTimer) { clearTimeout(this.win32WriteTimer); this.win32WriteTimer = null; }
|
|
1121
|
+
|
|
1122
|
+
const hasNewline = /\r?\n/.test(text);
|
|
1123
|
+
const mode = resolveWin32SubmitMode();
|
|
1124
|
+
|
|
1125
|
+
// Build the ordered list of segments to write. Bracketed-paste markers are
|
|
1126
|
+
// their OWN segments so chunking only ever splits the body, never a marker.
|
|
1127
|
+
let segments: string[];
|
|
1128
|
+
if (!hasNewline) {
|
|
1129
|
+
// Single-line: unchanged behaviour — just (chunk and) write the body.
|
|
1130
|
+
segments = chunkPreservingSurrogates(text, WIN32_PTY_WRITE_CHUNK_CHARS);
|
|
1131
|
+
} else if (mode === 'soft_newline') {
|
|
1132
|
+
// Rewrite embedded newlines as non-submitting soft-newlines, THEN chunk.
|
|
1133
|
+
// The soft-newline sequence (ESC[27;2;13~) contains no '\n', so it is
|
|
1134
|
+
// never re-interpreted as a submit, and chunking it like ordinary text is
|
|
1135
|
+
// safe (a split mid-sequence is avoided below by chunking the whole
|
|
1136
|
+
// rewritten string — see the marker-safety note for paste; for soft_newline
|
|
1137
|
+
// the only ESC seq is short and self-contained, so we keep it simple and
|
|
1138
|
+
// chunk the rewritten body, accepting that the 1024-char chunk boundary is
|
|
1139
|
+
// astronomically unlikely to land inside a 9-byte CSI-u seq — and even if
|
|
1140
|
+
// it did, ConPTY reassembles the byte stream, the composer parses the full
|
|
1141
|
+
// sequence across the boundary).
|
|
1142
|
+
const rewritten = text.split(/\r?\n/).join(WIN32_SOFT_NEWLINE);
|
|
1143
|
+
segments = chunkPreservingSurrogates(rewritten, WIN32_PTY_WRITE_CHUNK_CHARS);
|
|
1144
|
+
} else {
|
|
1145
|
+
// paste: [OPEN marker] [body chunks…] [CLOSE marker]. Markers are atomic
|
|
1146
|
+
// segments — never merged with body bytes — so they cannot be split.
|
|
1147
|
+
segments = [
|
|
1148
|
+
WIN32_BRACKETED_PASTE_OPEN,
|
|
1149
|
+
...chunkPreservingSurrogates(text, WIN32_PTY_WRITE_CHUNK_CHARS),
|
|
1150
|
+
WIN32_BRACKETED_PASTE_CLOSE,
|
|
1151
|
+
];
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (segments.length <= 1) {
|
|
1155
|
+
this.markWin32Write();
|
|
1156
|
+
this.adapter.send_keys(segments[0] ?? text);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
let idx = 0;
|
|
1160
|
+
const writeNext = (): void => {
|
|
1161
|
+
this.win32WriteTimer = null;
|
|
1162
|
+
if (idx >= segments.length) return;
|
|
1163
|
+
this.markWin32Write();
|
|
1164
|
+
this.adapter.send_keys(segments[idx]);
|
|
1165
|
+
idx += 1;
|
|
1166
|
+
if (idx < segments.length) {
|
|
1167
|
+
this.win32WriteTimer = setTimeout(writeNext, WIN32_PTY_WRITE_CHUNK_GAP_MS);
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
writeNext();
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* win32 submit. Two phases:
|
|
1175
|
+
*
|
|
1176
|
+
* Phase 1 (echo-gate): hold the first CR until the body text is CONFIRMED in the
|
|
1177
|
+
* composer (its whitespace-collapsed tail appears in the rendered screen) AND the
|
|
1178
|
+
* PTY output is then quiet for WIN32_SUBMIT_SETTLE_MS (the full, possibly multi-KB
|
|
1179
|
+
* / multiline body has finished arriving). This replaces a bare output-quiet
|
|
1180
|
+
* settle: an early write that races claude's boot is buffered, not dropped, so the
|
|
1181
|
+
* screen can go quiet with the body NOT YET in the composer — a quiet-only gate
|
|
1182
|
+
* would then fire a CR into an empty composer (no submit). Waiting on the echo
|
|
1183
|
+
* closes that. Honors an initial minimum delay and a generous WIN32_ECHO_MAX_WAIT_MS
|
|
1184
|
+
* last-resort blind fire so a body that truly never confirms still submits (carried
|
|
1185
|
+
* by phase 2) rather than hanging. A settled session echoes immediately → no delay.
|
|
1186
|
+
*
|
|
1187
|
+
* Phase 2 (verified resend — unchanged): send the submit key, wait a gap, and
|
|
1188
|
+
* if the FSM is still 'idle' (the CR was absorbed as a multiline-paste
|
|
1189
|
+
* newline) resend, up to WIN32_SUBMIT_MAX_RESENDS. The first CR always fires
|
|
1190
|
+
* (a stale/edge status never suppresses it); resends are gated on still being
|
|
1191
|
+
* idle and stop the instant the agent leaves idle (submitted → generating /
|
|
1192
|
+
* approval). This preserves the win32 lone-CR-swallow handling.
|
|
1193
|
+
*/
|
|
1194
|
+
private scheduleWin32Submit(submitKey: string, initialDelayMs: number, body: string): void {
|
|
1195
|
+
if (this.win32SubmitTimer) { clearTimeout(this.win32SubmitTimer); this.win32SubmitTimer = null; }
|
|
1196
|
+
const startedAt = Date.now();
|
|
1197
|
+
|
|
1198
|
+
// FULL-BODY echo confirmation. A tail-only probe (slice(-N)) was insufficient
|
|
1199
|
+
// for multi-line prompts: a multiline body echoes line-by-line, so its TAIL can
|
|
1200
|
+
// appear in the composer (and the screen settle) while the HEAD / middle is still
|
|
1201
|
+
// arriving. Releasing the first CR on the tail alone then submits a PARTIAL body:
|
|
1202
|
+
// the early CR commits whatever has accumulated and the remainder is lost, so only
|
|
1203
|
+
// the trailing segment survives (the cross-machine MAGI replica receiving only the
|
|
1204
|
+
// prompt's boilerplate suffix). We now require BOTH the head probe AND the tail
|
|
1205
|
+
// probe to be present in the echo before releasing the CR.
|
|
1206
|
+
//
|
|
1207
|
+
// Crucially, the head check runs against snapshotWithScrollback(), NOT the visible
|
|
1208
|
+
// viewport: a tall body scrolls its leading lines off-screen (the very reason the
|
|
1209
|
+
// original gate used the tail), so the head is only ever observable in the
|
|
1210
|
+
// scrollback-inclusive buffer. The tail check stays on the visible snapshot (the
|
|
1211
|
+
// cursor / body end is always on screen). Both present ⇒ the whole body, head
|
|
1212
|
+
// through tail, has echoed — not just its end with a still-streaming middle.
|
|
1213
|
+
// Single-line / short bodies have overlapping head and tail probes and the
|
|
1214
|
+
// scrollback buffer is a superset of the viewport, so behaviour is unchanged. The
|
|
1215
|
+
// WIN32_ECHO_MAX_WAIT_MS blind-fire backstop below still bounds the wait so a body
|
|
1216
|
+
// that never fully confirms cannot hang.
|
|
1217
|
+
const normBody = normalizeForEcho(body);
|
|
1218
|
+
const headProbe = normBody.slice(0, WIN32_ECHO_PROBE_CHARS);
|
|
1219
|
+
const tailProbe = normBody.slice(-WIN32_ECHO_PROBE_CHARS);
|
|
1220
|
+
const bodyEchoed = (): boolean => {
|
|
1221
|
+
if (!normBody) return true;
|
|
1222
|
+
// Tail on the visible viewport (cursor end is always on screen); head on the
|
|
1223
|
+
// scrollback-inclusive buffer (leading lines of a tall body scroll off-screen).
|
|
1224
|
+
const visible = normalizeForEcho(this.adapter.snapshot());
|
|
1225
|
+
if (!visible.includes(tailProbe)) return false;
|
|
1226
|
+
const full = normalizeForEcho(this.adapter.snapshotWithScrollback());
|
|
1227
|
+
return full.includes(headProbe);
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
const fire = (attempt: number): void => {
|
|
1231
|
+
this.win32SubmitTimer = null;
|
|
1232
|
+
this.adapter.send_keys(submitKey);
|
|
1233
|
+
if (attempt + 1 >= WIN32_SUBMIT_MAX_RESENDS) return;
|
|
1234
|
+
this.win32SubmitTimer = setTimeout(() => {
|
|
1235
|
+
// Left the idle composer → it submitted; stop resending.
|
|
1236
|
+
if (this.currentStatus() !== 'idle') { this.win32SubmitTimer = null; return; }
|
|
1237
|
+
fire(attempt + 1);
|
|
1238
|
+
}, WIN32_SUBMIT_RESEND_GAP_MS);
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
// Echo-gate: hold the first CR until the body is CONFIRMED in the composer, not
|
|
1242
|
+
// merely until output goes quiet — a quiet screen with a not-yet-arrived body is
|
|
1243
|
+
// the exact empty-submit failure this replaces. The body is NOT re-written: on
|
|
1244
|
+
// the session-host IPC transport an early write is buffered, not dropped, so it
|
|
1245
|
+
// lands once claude's stdin reader wires up (just late on a slow/contended boot);
|
|
1246
|
+
// re-writing would risk a duplicated body when several buffered writes drain
|
|
1247
|
+
// together. So we simply WAIT for the echo. The hard upper bound only fires a
|
|
1248
|
+
// blind CR if the body never confirms at all (resend net carries it), set
|
|
1249
|
+
// generously so a slow boot still lands its body first.
|
|
1250
|
+
const waitForEcho = (): void => {
|
|
1251
|
+
this.win32SubmitTimer = null;
|
|
1252
|
+
const now = Date.now();
|
|
1253
|
+
const quietFor = now - this.lastWin32InputActivityAt();
|
|
1254
|
+
const waited = now - startedAt;
|
|
1255
|
+
const settled = quietFor >= WIN32_SUBMIT_SETTLE_MS;
|
|
1256
|
+
// Body present in the composer AND output quiet (full body arrived) → submit.
|
|
1257
|
+
if (bodyEchoed() && settled) { fire(0); return; }
|
|
1258
|
+
// Last-resort blind fire so a body that truly never confirms cannot hang.
|
|
1259
|
+
if (waited >= WIN32_ECHO_MAX_WAIT_MS) { fire(0); return; }
|
|
1260
|
+
this.win32SubmitTimer = setTimeout(waitForEcho, WIN32_SUBMIT_SETTLE_POLL_MS);
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
if (initialDelayMs > 0) this.win32SubmitTimer = setTimeout(waitForEcho, initialDelayMs);
|
|
1264
|
+
else waitForEcho();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
private handleClickControl(controlId: string, payload?: unknown): void {
|
|
1268
|
+
const ctl = (this.spec.control_bar ?? []).find(c => c.id === controlId);
|
|
1269
|
+
if (!ctl) return;
|
|
1270
|
+
if (ctl.visible_when_state && !ctl.visible_when_state.includes(this.currentStateId)) return;
|
|
1271
|
+
const a = ctl.action;
|
|
1272
|
+
switch (a.type) {
|
|
1273
|
+
case 'send_keys': this.adapter.send_keys(a.keys); return;
|
|
1274
|
+
case 'open_picker':
|
|
1275
|
+
// Some TUIs (e.g. codex) don't register a slash command if its
|
|
1276
|
+
// text and the submitting Enter arrive in the same write — the
|
|
1277
|
+
// composer needs a beat to recognise the command before the CR.
|
|
1278
|
+
// Split a trailing CR/LF off the trigger and send it after a
|
|
1279
|
+
// short delay, mirroring send_message's delay_ms_before_submit.
|
|
1280
|
+
{
|
|
1281
|
+
const m = /^([\s\S]*?)([\r\n]+)$/.exec(a.trigger_keys);
|
|
1282
|
+
if (m && m[1]) {
|
|
1283
|
+
this.adapter.send_keys(m[1]);
|
|
1284
|
+
setTimeout(() => this.adapter.send_keys(m[2]), 200);
|
|
1285
|
+
} else {
|
|
1286
|
+
this.adapter.send_keys(a.trigger_keys);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
this.pickerInProgress = { control_id: ctl.id, spec: ctl };
|
|
1290
|
+
return;
|
|
1291
|
+
case 'attach_image': {
|
|
1292
|
+
const p = typeof payload === 'object' && payload && (payload as any).path;
|
|
1293
|
+
if (typeof p === 'string') this.adapter.send_keys(a.keys_template.replace(/\{path\}/g, p));
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
private handleClickModalButton(index: number): void {
|
|
1300
|
+
const m = this.currentEval?.modal;
|
|
1301
|
+
if (!m) return;
|
|
1302
|
+
const btn = m.buttons.find(b => b.index === index);
|
|
1303
|
+
if (!btn) return;
|
|
1304
|
+
|
|
1305
|
+
const rule = stateById(this.spec, this.currentStateId)?.extract?.buttons;
|
|
1306
|
+
if (rule?.select_mode === 'arrow_keys') {
|
|
1307
|
+
// Cursor-list approval modal (claude-cli new TUI): number keys are
|
|
1308
|
+
// IGNORED — sending `btn.key` ("1\r") types a literal "1" into the
|
|
1309
|
+
// composer and the trailing CR submits it as a chat message. Drive
|
|
1310
|
+
// the cursor from its current row to the target row with arrows,
|
|
1311
|
+
// then confirm. The cursor opens on the first option, so when the
|
|
1312
|
+
// marker isn't detected we step down from row 1 (index - 1).
|
|
1313
|
+
const from = m.buttons.find(b => b.current)?.index ?? 1;
|
|
1314
|
+
const up = rule.cursor_keys?.up ?? '\x1b[A';
|
|
1315
|
+
const down = rule.cursor_keys?.down ?? '\x1b[B';
|
|
1316
|
+
const delta = btn.index - from;
|
|
1317
|
+
const step = delta >= 0 ? down : up;
|
|
1318
|
+
const nav = step.repeat(Math.abs(delta));
|
|
1319
|
+
// Confirm = key_for_index with the (now unused) {index} stripped:
|
|
1320
|
+
// `{index}\r` → `\r`.
|
|
1321
|
+
const confirm = (rule.key_for_index || '\r').replace(/\{index\}/g, '') || '\r';
|
|
1322
|
+
if (nav) this.adapter.send_keys(nav);
|
|
1323
|
+
this.submitModalConfirm(confirm);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
this.submitModalConfirm(btn.key);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Submit a modal-confirm key sequence (the choice key + its trailing CR).
|
|
1331
|
+
*
|
|
1332
|
+
* On win32 the trailing CR is the SAME lone-CR-swallow case as a send_message
|
|
1333
|
+
* submit: ConPTY can absorb a single CR as a literal newline instead of a
|
|
1334
|
+
* confirm, so the approval/picker modal never resolves and the FSM flaps
|
|
1335
|
+
* approval↔busy while auto-approve keeps firing into the void (APPROVESTUCK).
|
|
1336
|
+
* So we split any non-CR prefix (e.g. the "1" of "1\r") off, write it once, and
|
|
1337
|
+
* resend the CR on a fixed cadence until the modal actually resolves (status
|
|
1338
|
+
* leaves 'approval'). Non-win32 keeps the single direct write — its CR submits
|
|
1339
|
+
* on the first try.
|
|
1340
|
+
*/
|
|
1341
|
+
private submitModalConfirm(keys: string): void {
|
|
1342
|
+
if (process.platform !== 'win32') {
|
|
1343
|
+
this.adapter.send_keys(keys);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const m = /^([\s\S]*?)([\r\n]+)$/.exec(keys);
|
|
1347
|
+
const prefix = m ? m[1] : keys;
|
|
1348
|
+
const cr = m ? m[2] : '';
|
|
1349
|
+
if (prefix) this.adapter.send_keys(prefix);
|
|
1350
|
+
if (!cr) return;
|
|
1351
|
+
this.scheduleWin32ModalConfirm(cr);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* win32 modal-confirm CR resend loop. Mirrors scheduleWin32Submit's phase-2
|
|
1356
|
+
* verified resend, but gated on still being IN a modal (status 'approval')
|
|
1357
|
+
* rather than still idle: the first CR fires immediately, then resends every
|
|
1358
|
+
* WIN32_SUBMIT_RESEND_GAP_MS while the FSM is still showing the modal, up to
|
|
1359
|
+
* WIN32_SUBMIT_MAX_RESENDS. The instant the modal resolves (status flips to
|
|
1360
|
+
* generating/idle) we stop, so no stray CR leaks into the next turn's composer.
|
|
1361
|
+
*/
|
|
1362
|
+
private scheduleWin32ModalConfirm(submitKey: string): void {
|
|
1363
|
+
if (this.win32ModalConfirmTimer) { clearTimeout(this.win32ModalConfirmTimer); this.win32ModalConfirmTimer = null; }
|
|
1364
|
+
const fire = (attempt: number): void => {
|
|
1365
|
+
this.win32ModalConfirmTimer = null;
|
|
1366
|
+
this.adapter.send_keys(submitKey);
|
|
1367
|
+
if (attempt + 1 >= WIN32_SUBMIT_MAX_RESENDS) return;
|
|
1368
|
+
this.win32ModalConfirmTimer = setTimeout(() => {
|
|
1369
|
+
// Left the modal → it resolved; stop resending.
|
|
1370
|
+
if (this.currentStatus() !== 'approval') { this.win32ModalConfirmTimer = null; return; }
|
|
1371
|
+
fire(attempt + 1);
|
|
1372
|
+
}, WIN32_SUBMIT_RESEND_GAP_MS);
|
|
1373
|
+
};
|
|
1374
|
+
fire(0);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
private handleAttachImage(blob: string, mime: string): void {
|
|
1378
|
+
const ctl = (this.spec.control_bar ?? []).find(c => c.action.type === 'attach_image');
|
|
1379
|
+
if (!ctl || ctl.action.type !== 'attach_image') return;
|
|
1380
|
+
const ext = guessExt(mime);
|
|
1381
|
+
const tmp = path.join(os.tmpdir(), `adhdev-attach-${Date.now()}${ext}`);
|
|
1382
|
+
try { fs.writeFileSync(tmp, Buffer.from(blob, 'base64')); } catch { return; }
|
|
1383
|
+
this.adapter.send_keys(ctl.action.keys_template.replace(/\{path\}/g, tmp));
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
private tryAdvancePicker(screen: string): void {
|
|
1387
|
+
const picker = this.pickerInProgress;
|
|
1388
|
+
if (!picker) return;
|
|
1389
|
+
const action = picker.spec.action;
|
|
1390
|
+
if (action.type !== 'open_picker' || !action.wait_for.regex) return;
|
|
1391
|
+
const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
|
1392
|
+
const sections = resolveSections(this.spec.sections ?? {}, lines);
|
|
1393
|
+
const hay = sectionText(sections, action.wait_for.section, lines.join('\n'));
|
|
1394
|
+
const re = new RegExp(action.wait_for.regex, action.wait_for.flags ?? 'i');
|
|
1395
|
+
if (!re.test(hay)) return;
|
|
1396
|
+
this.pickerInProgress = null;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
private handleExit(exitCode: number): void {
|
|
1400
|
+
this.emit({ kind: 'exit', exit_code: exitCode });
|
|
1401
|
+
this.shutdown();
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
private pushHistory(stateId: string, label: string, meta: { reason: string; via?: string; matchedRules?: string[] }): void {
|
|
1405
|
+
const now = Date.now();
|
|
1406
|
+
const durationMs = this.prevStateAt > 0 ? now - this.prevStateAt : 0;
|
|
1407
|
+
this.prevStateAt = now;
|
|
1408
|
+
this.stateHistory.push({
|
|
1409
|
+
stateId, label, at: now, durationMs,
|
|
1410
|
+
reason: meta.reason,
|
|
1411
|
+
...(meta.via ? { via: meta.via } : {}),
|
|
1412
|
+
...(meta.matchedRules ? { matchedRules: meta.matchedRules } : {}),
|
|
1413
|
+
});
|
|
1414
|
+
if (this.stateHistory.length > 50) this.stateHistory.shift();
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
private specTag(): string {
|
|
1418
|
+
return this.opts.specPath.split('/').slice(-3).join('/');
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
private emit(ev: DashboardEvent): void {
|
|
1422
|
+
for (const l of this.listeners) {
|
|
1423
|
+
try { l(ev); } catch { /* listener side */ }
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
1429
|
+
|
|
1430
|
+
function sameModal(a: ModalSnapshot | null, b: ModalSnapshot | null): boolean {
|
|
1431
|
+
if (!a && !b) return true;
|
|
1432
|
+
if (!a || !b) return false;
|
|
1433
|
+
if (a.title !== b.title) return false;
|
|
1434
|
+
if (a.buttons.length !== b.buttons.length) return false;
|
|
1435
|
+
for (let i = 0; i < a.buttons.length; i += 1) {
|
|
1436
|
+
if (a.buttons[i].label !== b.buttons[i].label) return false;
|
|
1437
|
+
}
|
|
1438
|
+
return true;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function sameControls(a: VisibleControl[], b: VisibleControl[]): boolean {
|
|
1442
|
+
if (a.length !== b.length) return false;
|
|
1443
|
+
for (let i = 0; i < a.length; i += 1) if (a[i].id !== b[i].id) return false;
|
|
1444
|
+
return true;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** A compact one-line-per-condition summary of why a transition fired. */
|
|
1448
|
+
function summarizeTransition(t: TransitionEval): string[] {
|
|
1449
|
+
const out: string[] = [`${t.label} fired`];
|
|
1450
|
+
if (t.cond) flattenCond(t.cond, out, 1);
|
|
1451
|
+
return out;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function flattenCond(c: import('./fsm-evaluator.js').CondResult, out: string[], depth: number): void {
|
|
1455
|
+
const matched = c.matchedText ? ` matched=${JSON.stringify(c.matchedText)}` : '';
|
|
1456
|
+
out.push(`${' '.repeat(depth)}${c.kind} ${c.detail} = ${c.result}${c.remainingMs ? ` (${c.remainingMs}ms left)` : ''}${matched}`);
|
|
1457
|
+
for (const child of c.children ?? []) flattenCond(child, out, depth + 1);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function findStable(c: import('./fsm-evaluator.js').CondResult): { totalMs: number } | null {
|
|
1461
|
+
if (c.kind === 'stable') {
|
|
1462
|
+
const m = /\/ (\d+)ms/.exec(c.detail);
|
|
1463
|
+
return { totalMs: m ? Number(m[1]) : 0 };
|
|
1464
|
+
}
|
|
1465
|
+
for (const child of c.children ?? []) {
|
|
1466
|
+
const r = findStable(child);
|
|
1467
|
+
if (r) return r;
|
|
1468
|
+
}
|
|
1469
|
+
return null;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Resolved description of one stable region the driver must track: its map
|
|
1473
|
+
* key, the geometry (section / cursor_above / whole-screen), and a compiled
|
|
1474
|
+
* `ignore_lines` matcher. */
|
|
1475
|
+
interface StableRegionDescriptor {
|
|
1476
|
+
key: number | string;
|
|
1477
|
+
section?: string;
|
|
1478
|
+
cursor_above?: number;
|
|
1479
|
+
ignoreRe?: RegExp;
|
|
1480
|
+
/** The stable_ms threshold the FIRST clause on this region declares. Used
|
|
1481
|
+
* only by the COMPLETION-EARLYNOTIFY stable-eval trace to report the
|
|
1482
|
+
* stable/not-stable verdict; the FSM decision itself is owned by the
|
|
1483
|
+
* evaluator against the live clause. */
|
|
1484
|
+
holdMs?: number;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function collectStableDescriptors(when: FsmTransition['when'], byKey: Map<number | string, StableRegionDescriptor>): void {
|
|
1488
|
+
if (!when) return;
|
|
1489
|
+
const w = when as any;
|
|
1490
|
+
if ('stable_ms' in w) {
|
|
1491
|
+
const key = stableRegionKey(w);
|
|
1492
|
+
const existing = byKey.get(key);
|
|
1493
|
+
if (!existing) {
|
|
1494
|
+
let ignoreRe: RegExp | undefined;
|
|
1495
|
+
if (w.ignore_lines) {
|
|
1496
|
+
// Compile once here; a bad pattern is validated at load time, so
|
|
1497
|
+
// this is best-effort and simply skips the filter if it throws.
|
|
1498
|
+
try { ignoreRe = new RegExp(w.ignore_lines, 'm'); } catch { /* validated at load */ }
|
|
1499
|
+
}
|
|
1500
|
+
byKey.set(key, { key, section: w.section, cursor_above: w.cursor_above, ignoreRe, holdMs: typeof w.stable_ms === 'number' ? w.stable_ms : undefined });
|
|
1501
|
+
} else if (existing.holdMs === undefined && typeof w.stable_ms === 'number') {
|
|
1502
|
+
// Enrich the -1 whole-screen seed (or an earlier clause) with a threshold
|
|
1503
|
+
// so its verdict can be traced. Geometry/ignoreRe from the first set win.
|
|
1504
|
+
existing.holdMs = w.stable_ms;
|
|
1505
|
+
}
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if ('all' in w) { for (const c of w.all) collectStableDescriptors(c, byKey); return; }
|
|
1509
|
+
if ('any' in w) { for (const c of w.any) collectStableDescriptors(c, byKey); return; }
|
|
1510
|
+
if ('not' in w) { collectStableDescriptors(w.not, byKey); return; }
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Lines of section `id` on the given frame, or [] if that section is absent
|
|
1514
|
+
* this frame. Used to compute per-frame change of a section-scoped stable
|
|
1515
|
+
* region. */
|
|
1516
|
+
function sliceSectionLines(lines: string[], sections: ResolvedSection[], id: string): string[] {
|
|
1517
|
+
const sec = sections.find(s => s.id === id);
|
|
1518
|
+
if (!sec) return [];
|
|
1519
|
+
return lines.slice(sec.fromLine, sec.toLine);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/** Drop lines matching `ignoreRe` so a per-frame repaint confined to them does
|
|
1523
|
+
* not register as a region change. No filter → lines returned unchanged.
|
|
1524
|
+
* Exported for unit tests of the stable_ms `ignore_lines` change-detection. */
|
|
1525
|
+
export function filterIgnoredLines(lines: string[], ignoreRe: RegExp | undefined): string[] {
|
|
1526
|
+
if (!ignoreRe) return lines;
|
|
1527
|
+
return lines.filter(l => !ignoreRe.test(l));
|
|
1528
|
+
}
|