@north-light/crouter 0.3.173 → 0.3.175
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/bin/runtime-selector.mjs +8 -1
- package/dist/api/__tests__/{client.test.js → serial/client.test.js} +2 -135
- package/dist/api/client.d.ts +12 -21
- package/dist/api/client.js +22 -31
- package/dist/api/dto/broker-ops.d.ts +158 -0
- package/dist/api/dto/broker-ops.js +6 -0
- package/dist/api/dto/crons.d.ts +6 -0
- package/dist/api/dto/human.d.ts +7 -1
- package/dist/api/dto/inbox.d.ts +1 -71
- package/dist/api/dto/inbox.js +1 -9
- package/dist/api/dto/nodes.d.ts +6 -7
- package/dist/api/dto/reviews.d.ts +1 -11
- package/dist/api/routes.d.ts +7 -4
- package/dist/api/routes.js +8 -5
- package/dist/build-root.js +3 -3
- package/dist/builtin-memory/04-base-worker.md +1 -4
- package/dist/builtin-memory/05-kinds/advisor/00-base.md +1 -5
- package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +2 -7
- package/dist/builtin-memory/05-kinds/design/00-base.md +1 -6
- package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -6
- package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -4
- package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +1 -2
- package/dist/builtin-memory/05-kinds/general/00-base.md +1 -3
- package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -6
- package/dist/builtin-memory/05-kinds/plan/reviewers/00-base.md +1 -2
- package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +1 -3
- package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +1 -3
- package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +1 -3
- package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +1 -3
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -4
- package/dist/builtin-memory/05-kinds/review/companion/00-base.md +2 -3
- package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -2
- package/dist/builtin-memory/advisor/council.md +1 -6
- package/dist/builtin-memory/design.md +2 -7
- package/dist/builtin-memory/development.md +9 -14
- package/dist/builtin-memory/internal/marketplaces.md +2 -6
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/plugins.md +2 -8
- package/dist/builtin-memory/internal/storage-tiers.md +1 -2
- package/dist/builtin-memory/testing.md +41 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/{provider-rotation.test.ts → serial/provider-rotation.test.ts} +27 -179
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +5 -15
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +1 -28
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +43 -88
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +7 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +9 -1
- package/dist/cli.js +2 -2
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +37 -131
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +71 -152
- package/dist/clients/attach/__tests__/context-message.test.js +16 -174
- package/dist/clients/attach/__tests__/crtr-output.test.js +36 -2069
- package/dist/clients/attach/__tests__/diagram.test.js +2 -2
- package/dist/clients/attach/__tests__/edit-diff.test.js +0 -4
- package/dist/clients/attach/__tests__/file-review-focus.test.js +2 -1
- package/dist/clients/attach/__tests__/group-activity.test.js +40 -229
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +26 -76
- package/dist/clients/attach/__tests__/prompt-preview.test.js +4 -70
- package/dist/clients/attach/__tests__/ref-autocomplete.test.js +11 -125
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +14 -205
- package/dist/clients/attach/command.d.ts +2 -1
- package/dist/clients/attach/command.js +81 -76
- package/dist/clients/attach/input/capabilities.d.ts +27 -0
- package/dist/clients/attach/input/capabilities.js +96 -0
- package/dist/clients/attach/input/controller.d.ts +20 -182
- package/dist/clients/attach/input/controller.js +128 -537
- package/dist/clients/attach/input/overlay-owner.d.ts +41 -0
- package/dist/clients/attach/input/overlay-owner.js +199 -0
- package/dist/clients/attach/overlays/file-review.d.ts +0 -4
- package/dist/clients/attach/overlays/file-review.js +13 -5
- package/dist/clients/attach/overlays/graph.js +2 -10
- package/dist/clients/attach/render/chat-view.d.ts +4 -3
- package/dist/clients/attach/render/chat-view.js +39 -33
- package/dist/clients/attach/render/group-activity.d.ts +1 -0
- package/dist/clients/attach/render/group-activity.js +20 -13
- package/dist/clients/attach/render/group-recap.js +12 -3
- package/dist/clients/attach/session/connection.js +9 -15
- package/dist/clients/attach/session/editor-inventory.d.ts +6 -5
- package/dist/clients/attach/session/editor-inventory.js +7 -8
- package/dist/clients/attach/session/file-links.d.ts +13 -0
- package/dist/clients/attach/session/file-links.js +107 -0
- package/dist/clients/attach/session/frame.d.ts +3 -0
- package/dist/clients/attach/session/frame.js +10 -1
- package/dist/clients/attach/session/frames.js +2 -3
- package/dist/clients/attach/session/input-wiring.d.ts +4 -1
- package/dist/clients/attach/session/input-wiring.js +15 -14
- package/dist/clients/attach/session/mouse.d.ts +6 -1
- package/dist/clients/attach/session/mouse.js +8 -5
- package/dist/clients/attach/session/profile-files.js +18 -12
- package/dist/clients/attach/session/reconnect.js +1 -1
- package/dist/clients/attach/session/state-sync.js +1 -2
- package/dist/clients/attach/session/surface-host.d.ts +3 -2
- package/dist/clients/attach/session/surface-host.js +4 -4
- package/dist/clients/attach/slash/dispatch.d.ts +8 -31
- package/dist/clients/attach/slash/dispatch.js +34 -81
- package/dist/clients/attach/viewer.js +820 -860
- package/dist/clients/conversation/projection.d.ts +79 -0
- package/dist/clients/conversation/projection.js +341 -0
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +159 -0
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +151 -0
- package/dist/clients/inbox/controller.d.ts +0 -4
- package/dist/clients/inbox/controller.js +0 -2
- package/dist/clients/inbox/review/__tests__/review-core.test.js +4 -293
- package/dist/clients/inbox/review/__tests__/teardown-notice.test.js +100 -0
- package/dist/clients/inbox/review/comments-client.d.ts +3 -2
- package/dist/clients/inbox/review/comments-client.js +12 -12
- package/dist/clients/inbox/review/companion-pane.d.ts +30 -0
- package/dist/clients/inbox/review/companion-pane.js +86 -0
- package/dist/clients/inbox/review/document-surface.d.ts +182 -0
- package/dist/clients/inbox/review/{document-region.js → document-surface.js} +252 -48
- package/dist/clients/inbox/review/frame.js +5 -4
- package/dist/clients/inbox/review/launch.d.ts +8 -8
- package/dist/clients/inbox/review/launch.js +52 -92
- package/dist/clients/inbox/review/nvim.d.ts +2 -0
- package/dist/clients/inbox/review/nvim.js +14 -12
- package/dist/clients/inbox/review/review-client.d.ts +12 -8
- package/dist/clients/inbox/review/review-client.js +46 -35
- package/dist/clients/inbox/review-adapter.d.ts +3 -10
- package/dist/clients/inbox/review-adapter.js +27 -19
- package/dist/clients/inbox/surface.d.ts +0 -4
- package/dist/clients/inbox/surface.js +0 -2
- package/dist/clients/surfaces/host.d.ts +11 -3
- package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -34
- package/dist/commands/__tests__/human.test.js +11 -322
- package/dist/commands/api-client.d.ts +5 -5
- package/dist/commands/api-client.js +6 -85
- package/dist/commands/canvas-browse.js +3 -29
- package/dist/commands/canvas-config.js +1 -1
- package/dist/commands/canvas-use.js +3 -3
- package/dist/commands/canvas.js +1 -1
- package/dist/commands/cron.js +5 -1
- package/dist/commands/human/queue.d.ts +0 -4
- package/dist/commands/human/queue.js +7 -40
- package/dist/commands/memory/list.d.ts +14 -0
- package/dist/commands/memory/list.js +35 -31
- package/dist/commands/memory/read.d.ts +7 -0
- package/dist/commands/memory/read.js +14 -8
- package/dist/commands/memory/write.js +13 -12
- package/dist/commands/node/bash.d.ts +1 -0
- package/dist/commands/node/bash.js +198 -0
- package/dist/commands/node/create.d.ts +23 -0
- package/dist/commands/node/create.js +207 -0
- package/dist/commands/node/inspect.d.ts +2 -0
- package/dist/commands/node/inspect.js +302 -0
- package/dist/commands/node/lifecycle.d.ts +4 -0
- package/dist/commands/node/lifecycle.js +415 -0
- package/dist/commands/node/message.d.ts +1 -0
- package/dist/commands/node/message.js +164 -0
- package/dist/commands/node/pyramid.d.ts +2 -0
- package/dist/commands/node/pyramid.js +79 -0
- package/dist/commands/node/subscription.d.ts +1 -0
- package/dist/commands/node/subscription.js +82 -0
- package/dist/commands/node/wait.d.ts +1 -0
- package/dist/commands/node/wait.js +159 -0
- package/dist/commands/node-lifecycle-revive.js +10 -57
- package/dist/commands/node.d.ts +0 -42
- package/dist/commands/node.js +11 -2065
- package/dist/commands/search.js +1 -1
- package/dist/commands/surface/node/cycle.d.ts +1 -0
- package/dist/commands/surface/node/cycle.js +83 -0
- package/dist/commands/surface/node/focus.d.ts +1 -0
- package/dist/commands/surface/node/focus.js +67 -0
- package/dist/commands/surface/node/id.d.ts +1 -0
- package/dist/commands/surface/node/id.js +38 -0
- package/dist/commands/surface/node/pin.d.ts +1 -0
- package/dist/commands/surface/node/pin.js +42 -0
- package/dist/commands/surface/node/placement.d.ts +46 -0
- package/dist/commands/surface/node/placement.js +124 -0
- package/dist/commands/surface-node.js +4 -1
- package/dist/commands/surface-reload.js +1 -2
- package/dist/commands/surface-tmux-spread.js +1 -1
- package/dist/commands/surface.js +5 -20
- package/dist/commands/sys/__tests__/setup-core.test.js +18 -271
- package/dist/commands/sys/config.js +6 -6
- package/dist/commands/sys/logs.js +1 -1
- package/dist/commands/sys/panels/broker-limits-panel.d.ts +17 -0
- package/dist/commands/sys/panels/broker-limits-panel.js +104 -0
- package/dist/commands/sys/panels/dependencies-panel.d.ts +14 -0
- package/dist/commands/sys/panels/dependencies-panel.js +80 -0
- package/dist/commands/sys/panels/keybindings-panel.d.ts +56 -0
- package/dist/commands/sys/panels/keybindings-panel.js +703 -0
- package/dist/commands/sys/panels/keys-panel.d.ts +16 -0
- package/dist/commands/sys/panels/keys-panel.js +60 -0
- package/dist/commands/sys/panels/models-panel.d.ts +77 -0
- package/dist/commands/sys/panels/models-panel.js +647 -0
- package/dist/commands/sys/panels/panel.d.ts +45 -0
- package/dist/commands/sys/panels/panel.js +137 -0
- package/dist/commands/sys/panels/plugins-panel.d.ts +16 -0
- package/dist/commands/sys/panels/plugins-panel.js +80 -0
- package/dist/commands/sys/panels/prefs-panel.d.ts +21 -0
- package/dist/commands/sys/panels/prefs-panel.js +85 -0
- package/dist/commands/sys/panels/provider-panel.d.ts +62 -0
- package/dist/commands/sys/panels/provider-panel.js +538 -0
- package/dist/commands/sys/settings-shell.d.ts +55 -0
- package/dist/commands/sys/settings-shell.js +400 -0
- package/dist/commands/sys/settings.d.ts +2 -0
- package/dist/commands/sys/settings.js +31 -0
- package/dist/commands/sys/setup-core.d.ts +3 -33
- package/dist/commands/sys/setup-core.js +27 -78
- package/dist/commands/sys/setup-settings-runner.d.ts +24 -0
- package/dist/commands/sys/setup-settings-runner.js +63 -0
- package/dist/commands/sys/setup-wizard.d.ts +26 -125
- package/dist/commands/sys/setup-wizard.js +209 -2473
- package/dist/commands/sys/setup.js +5 -7
- package/dist/commands/sys.js +3 -2
- package/dist/core/__tests__/broker-extension-canvas-db-boundary.test.js +50 -0
- package/dist/core/__tests__/broker-launch-candidates.test.js +20 -9
- package/dist/core/__tests__/broker-preflight.test.js +1 -1
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +108 -27
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +14 -0
- package/dist/core/__tests__/canvas.test.js +2 -36
- package/dist/core/__tests__/close.test.js +14 -3
- package/dist/core/__tests__/context-intro.test.js +32 -143
- package/dist/core/__tests__/cron-broker-capacity.test.js +22 -3
- package/dist/core/__tests__/daemon-boot.test.js +2 -20
- package/dist/core/__tests__/daemon-wedge.test.js +1 -1
- package/dist/core/__tests__/dead-node-policy-table.test.js +1 -18
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +2 -2
- package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
- package/dist/core/__tests__/focuses.test.js +4 -98
- package/dist/core/__tests__/helpers/broker-clients.d.ts +0 -3
- package/dist/core/__tests__/helpers/broker-clients.js +0 -14
- package/dist/core/__tests__/helpers/harness.d.ts +0 -8
- package/dist/core/__tests__/helpers/harness.js +7 -87
- package/dist/core/__tests__/helpers/viewer-panes.js +1 -1
- package/dist/core/__tests__/inline-memory-refs.test.js +5 -134
- package/dist/core/__tests__/kickoff.test.js +1 -1
- package/dist/core/__tests__/memory-resolver-precedence.test.js +29 -1
- package/dist/core/__tests__/migration.test.js +35 -48
- package/dist/core/__tests__/on-read-dedup-resume.test.js +22 -12
- package/dist/core/__tests__/passive-subscription.test.js +26 -49
- package/dist/core/__tests__/preview-registry-sync.test.js +1 -2
- package/dist/core/__tests__/prune.test.js +94 -1
- package/dist/core/__tests__/review-model-floor.test.js +0 -32
- package/dist/core/__tests__/seam/broker-attach-multiclient.test.js +255 -0
- package/dist/core/__tests__/{full → seam}/broker-attach-stream.test.js +39 -7
- package/dist/core/__tests__/{full → seam}/broker-crash-teardown.test.js +30 -29
- package/dist/core/__tests__/{full → seam}/broker-pane-resolution.test.js +54 -5
- package/dist/core/__tests__/{full → seam}/broker-provider-retry.test.js +134 -82
- package/dist/core/__tests__/{full → seam}/broker-whip-interrupt.test.js +15 -4
- package/dist/core/__tests__/{full/idle-release-settlement.test.js → seam/dormancy-release.test.js} +69 -17
- package/dist/core/__tests__/{full/yield-message-transaction.test.js → seam/yield-refresh-transaction.test.js} +31 -9
- package/dist/core/__tests__/secrets.test.js +1 -7
- package/dist/core/__tests__/{broker-fork-seam.test.js → serial/broker-fork-seam.test.js} +58 -7
- package/dist/core/__tests__/{broker-sdk-wiring.test.js → serial/broker-sdk-wiring.test.js} +18 -162
- package/dist/core/__tests__/{broker-snapshot-history.test.js → serial/broker-snapshot-history.test.js} +9 -9
- package/dist/core/__tests__/{command-plugins.test.js → serial/command-plugins.test.js} +105 -15
- package/dist/core/__tests__/{deferred-no-wake.test.js → serial/deferred-no-wake.test.js} +23 -20
- package/dist/core/__tests__/{flagship-lifecycle.test.js → serial/flagship-lifecycle.test.js} +7 -7
- package/dist/core/__tests__/{host-teardown-process-group.test.js → serial/host-teardown-process-group.test.js} +6 -6
- package/dist/core/__tests__/{human-deliver-e2e.test.js → serial/human-deliver-e2e.test.js} +12 -12
- package/dist/core/__tests__/{live-mutation.test.js → serial/live-mutation.test.js} +5 -5
- package/dist/core/__tests__/{refresh-stall-recycle.test.js → serial/refresh-stall-recycle.test.js} +5 -14
- package/dist/core/__tests__/{revive.test.js → serial/revive.test.js} +15 -100
- package/dist/core/__tests__/{spawn-root.test.js → serial/spawn-root.test.js} +32 -15
- package/dist/core/__tests__/serial/subscription-delivery.test.js +126 -0
- package/dist/core/__tests__/serial/tmux-surface.test.js +256 -0
- package/dist/core/__tests__/{worktree.test.js → serial/worktree.test.js} +15 -15
- package/dist/core/__tests__/session-model.test.js +0 -18
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +9 -0
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +50 -0
- package/dist/core/broker-client/__tests__/transport-relay.test.js +15 -16
- package/dist/core/broker-client/__tests__/transport-socket.test.js +28 -0
- package/dist/core/broker-client/client.d.ts +2 -2
- package/dist/core/broker-client/client.js +4 -7
- package/dist/core/broker-client/transport-relay.d.ts +13 -19
- package/dist/core/broker-client/transport-relay.js +19 -34
- package/dist/core/broker-client/transport-socket.d.ts +2 -2
- package/dist/core/broker-client/transport-socket.js +9 -2
- package/dist/core/broker-client/transport.d.ts +7 -3
- package/dist/core/broker-client/transport.js +4 -4
- package/dist/core/canvas/__tests__/attention.test.js +1 -18
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +7 -64
- package/dist/core/canvas/__tests__/remote-transport.test.js +2 -3
- package/dist/core/canvas/__tests__/render-remote.test.js +7 -52
- package/dist/core/canvas/browse/app.d.ts +2 -17
- package/dist/core/canvas/browse/app.js +16 -88
- package/dist/core/canvas/browse/model.d.ts +0 -4
- package/dist/core/canvas/browse/model.js +1 -12
- package/dist/core/canvas/browse/render.js +6 -3
- package/dist/core/canvas/crons.d.ts +8 -5
- package/dist/core/canvas/crons.js +7 -6
- package/dist/core/canvas/extensions.d.ts +7 -3
- package/dist/core/canvas/extensions.js +16 -4
- package/dist/core/canvas/nav-model.d.ts +0 -4
- package/dist/core/canvas/nav-model.js +2 -14
- package/dist/core/canvas/node-order.d.ts +3 -0
- package/dist/core/canvas/node-order.js +12 -0
- package/dist/core/canvas/remote-canvas-source.d.ts +3 -6
- package/dist/core/canvas/remote-canvas-source.js +8 -15
- package/dist/core/command.js +11 -4
- package/dist/core/config.js +3 -1
- package/dist/core/events/envelope.js +1 -1
- package/dist/core/events/read.js +7 -21
- package/dist/core/events/source.d.ts +0 -2
- package/dist/core/events/source.js +0 -16
- package/dist/core/events/types.d.ts +1 -1
- package/dist/core/fault-classifier.d.ts +1 -1
- package/dist/core/feed/passive.d.ts +0 -11
- package/dist/core/feed/passive.js +1 -62
- package/dist/core/human/__tests__/{inbox-core.test.js → serial/inbox-core.test.js} +6 -6
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -96
- package/dist/core/keybindings/background-bash.js +3 -4
- package/dist/core/keybindings/catalog.js +1 -1
- package/dist/core/memory/inline-ref-grammar.js +3 -3
- package/dist/core/memory-resolver.d.ts +11 -25
- package/dist/core/memory-resolver.js +51 -33
- package/dist/core/model-routes.d.ts +15 -0
- package/dist/core/model-routes.js +12 -0
- package/dist/core/pool-credential-store.js +1 -1
- package/dist/core/preview-registry.js +8 -9
- package/dist/core/profiles/__tests__/root-profile.test.js +4 -14
- package/dist/core/review/realize.d.ts +0 -13
- package/dist/core/review/realize.js +0 -40
- package/dist/core/review/types.d.ts +1 -1
- package/dist/core/runtime/__tests__/engine-route.test.js +2 -2
- package/dist/core/runtime/bearings.js +1 -11
- package/dist/core/runtime/broker/client-registry.d.ts +33 -0
- package/dist/core/runtime/broker/client-registry.js +164 -0
- package/dist/core/runtime/broker/daemon-ops.d.ts +19 -0
- package/dist/core/runtime/broker/daemon-ops.js +49 -0
- package/dist/core/runtime/broker/event-projection.d.ts +42 -0
- package/dist/core/runtime/broker/event-projection.js +178 -0
- package/dist/core/runtime/broker/fault-retry.d.ts +39 -0
- package/dist/core/runtime/broker/fault-retry.js +277 -0
- package/dist/core/runtime/broker/frame-dispatch.d.ts +238 -0
- package/dist/core/runtime/broker/frame-dispatch.js +1537 -0
- package/dist/core/runtime/broker/inbox.d.ts +22 -0
- package/dist/core/runtime/broker/inbox.js +167 -0
- package/dist/core/runtime/broker/passive.d.ts +16 -0
- package/dist/core/runtime/broker/passive.js +44 -0
- package/dist/core/runtime/broker/read-ops.d.ts +23 -0
- package/dist/core/runtime/broker/read-ops.js +150 -0
- package/dist/core/runtime/broker/rebind.d.ts +74 -0
- package/dist/core/runtime/broker/rebind.js +211 -0
- package/dist/core/runtime/broker/tool-groups.d.ts +39 -0
- package/dist/core/runtime/broker/tool-groups.js +244 -0
- package/dist/core/runtime/broker-extension-render.d.ts +11 -0
- package/dist/core/runtime/broker-extension-render.js +181 -0
- package/dist/core/runtime/broker-persona-guidance.d.ts +13 -0
- package/dist/core/runtime/broker-persona-guidance.js +58 -0
- package/dist/core/runtime/broker-protocol.d.ts +1 -1
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker.d.ts +5 -196
- package/dist/core/runtime/broker.js +276 -3059
- package/dist/core/runtime/canvas-extensions.js +11 -2
- package/dist/core/runtime/fault.js +1 -1
- package/dist/core/runtime/model-selection.d.ts +9 -0
- package/dist/core/runtime/model-selection.js +12 -0
- package/dist/core/runtime/model-swap.d.ts +3 -3
- package/dist/core/runtime/model-swap.js +33 -25
- package/dist/core/runtime/placement-tmux.d.ts +3 -14
- package/dist/core/runtime/placement-tmux.js +11 -80
- package/dist/core/runtime/placement.d.ts +4 -2
- package/dist/core/runtime/placement.js +7 -12
- package/dist/core/runtime/promote.d.ts +14 -1
- package/dist/core/runtime/promote.js +1 -1
- package/dist/core/runtime/reset.d.ts +1 -1
- package/dist/core/runtime/roadmap.js +1 -1
- package/dist/core/runtime/spawn.d.ts +8 -0
- package/dist/core/runtime/spawn.js +36 -6
- package/dist/core/runtime/stop-guard.d.ts +4 -3
- package/dist/core/runtime/stop-guard.js +36 -18
- package/dist/core/runtime/stop-signals.d.ts +4 -1
- package/dist/core/runtime/stream-watchdog.js +4 -5
- package/dist/core/runtime/structured-output.d.ts +15 -2
- package/dist/core/runtime/structured-output.js +35 -12
- package/dist/core/runtime/tmux-bindings.d.ts +25 -0
- package/dist/core/runtime/tmux-bindings.js +682 -0
- package/dist/core/runtime/tmux-chrome.d.ts +2 -1
- package/dist/core/runtime/tmux-chrome.js +3 -2
- package/dist/core/runtime/{tmux.d.ts → tmux-driver.d.ts} +18 -26
- package/dist/core/runtime/tmux-driver.js +598 -0
- package/dist/core/runtime/view-socket.d.ts +14 -0
- package/dist/core/runtime/view-socket.js +83 -0
- package/dist/core/session-model/session-state.d.ts +1 -2
- package/dist/core/session-model/session-state.js +5 -8
- package/dist/core/spawn.d.ts +1 -1
- package/dist/core/spawn.js +2 -2
- package/dist/core/substrate/gate.d.ts +1 -1
- package/dist/core/substrate/on-read-pure.d.ts +7 -0
- package/dist/core/substrate/on-read-pure.js +272 -0
- package/dist/core/termrender/termrender.js +32 -2
- package/dist/core/user-settings.d.ts +23 -1
- package/dist/core/user-settings.js +18 -1
- package/dist/daemon/api/__tests__/node-model-validation.test.js +134 -0
- package/dist/daemon/api/__tests__/review-terminal-conflict.test.js +98 -0
- package/dist/daemon/api/__tests__/{full → seam}/api-server.test.js +6 -48
- package/dist/daemon/api/__tests__/{full → seam}/b10-attach-modelauth.test.js +17 -44
- package/dist/daemon/api/__tests__/{full/push-parity.test.js → seam/leaf-api-parity.test.js} +61 -42
- package/dist/daemon/api/bridge.js +40 -81
- package/dist/daemon/api/handlers/broker-ops.d.ts +2 -0
- package/dist/daemon/api/handlers/broker-ops.js +383 -0
- package/dist/daemon/api/handlers/crons.js +9 -4
- package/dist/daemon/api/handlers/files.js +6 -7
- package/dist/daemon/api/handlers/inbox.js +2 -340
- package/dist/daemon/api/handlers/nodes.d.ts +10 -1
- package/dist/daemon/api/handlers/nodes.js +74 -39
- package/dist/daemon/api/handlers/review-comments.js +5 -1
- package/dist/daemon/api/handlers/reviews.js +0 -19
- package/dist/daemon/api/map.js +25 -4
- package/dist/daemon/api/router.js +2 -3
- package/dist/daemon/api/server.js +5 -7
- package/dist/daemon/cron-run.d.ts +4 -0
- package/dist/daemon/cron-run.js +30 -13
- package/dist/daemon/crtrd.d.ts +3 -58
- package/dist/daemon/crtrd.js +28 -1244
- package/dist/daemon/manage.js +0 -1
- package/dist/daemon/reconcilers/broker-supervision.d.ts +50 -0
- package/dist/daemon/reconcilers/broker-supervision.js +549 -0
- package/dist/daemon/reconcilers/cron-lane.d.ts +10 -0
- package/dist/daemon/reconcilers/cron-lane.js +32 -0
- package/dist/daemon/reconcilers/dormant-inbox.d.ts +18 -0
- package/dist/daemon/reconcilers/dormant-inbox.js +143 -0
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +22 -0
- package/dist/daemon/reconcilers/storage-maintenance.js +213 -0
- package/dist/daemon/review/finish.js +2 -5
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +34 -53
- package/dist/pi-extensions/__tests__/canvas-recap.test.js +16 -113
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +72 -99
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +29 -12
- package/dist/pi-extensions/broker-local.d.ts +20 -0
- package/dist/pi-extensions/broker-local.js +89 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +3 -3
- package/dist/pi-extensions/canvas-context-intro.js +22 -11
- package/dist/pi-extensions/canvas-doc-substrate.js +17 -34
- package/dist/pi-extensions/canvas-goal-capture.js +7 -12
- package/dist/pi-extensions/canvas-inbox-watcher.js +124 -139
- package/dist/pi-extensions/canvas-passive-context.d.ts +3 -3
- package/dist/pi-extensions/canvas-passive-context.js +22 -18
- package/dist/pi-extensions/canvas-recap.d.ts +2 -2
- package/dist/pi-extensions/canvas-recap.js +48 -21
- package/dist/pi-extensions/canvas-review-boundary.js +10 -4
- package/dist/pi-extensions/canvas-stophook.d.ts +0 -5
- package/dist/pi-extensions/canvas-stophook.js +149 -431
- package/dist/pi-extensions/canvas-structured-output.js +52 -30
- package/dist/pi-extensions/envelope-merge.d.ts +13 -0
- package/dist/pi-extensions/envelope-merge.js +29 -0
- package/dist/shared/working-activity.d.ts +3 -2
- package/dist/shared/working-activity.js +3 -2
- package/dist/types.d.ts +6 -5
- package/dist/types.js +2 -2
- package/docs/compat/hearth-crtr-v1.md +4 -12
- package/docs/compat/hearth-crtr-v2.md +3 -11
- package/docs/compat/hearth-crtr-v3.md +4 -12
- package/docs/compat/hearth-crtr-v4.md +3 -11
- package/docs/compat/hearth-crtr-v5.md +3 -11
- package/docs/public-api.md +3 -7
- package/package.json +7 -49
- package/runtime.lock.json +2766 -14533
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/claude-plugin-commands.test.ts +0 -49
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/memory-slash-commands.test.ts +0 -194
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/statusline.test.ts +0 -68
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/test.ts +0 -119
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +0 -439
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +0 -170
- package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +0 -57
- package/dist/clients/attach/__tests__/editor-frame-title.test.js +0 -19
- package/dist/clients/attach/__tests__/input-controller-extension-command.test.js +0 -32
- package/dist/clients/attach/__tests__/markdown-headings.test.js +0 -35
- package/dist/clients/attach/__tests__/session-identity-refresh.test.js +0 -32
- package/dist/clients/conversation/conversation.d.ts +0 -65
- package/dist/clients/conversation/conversation.js +0 -318
- package/dist/clients/conversation/frames.d.ts +0 -30
- package/dist/clients/conversation/frames.js +0 -52
- package/dist/clients/conversation/region.d.ts +0 -51
- package/dist/clients/conversation/region.js +0 -265
- package/dist/clients/inbox/__tests__/inbox-controller.test.js +0 -247
- package/dist/clients/inbox/__tests__/mount-panel.test.js +0 -776
- package/dist/clients/inbox/review/__tests__/remap.test.js +0 -196
- package/dist/clients/inbox/review/columns.d.ts +0 -22
- package/dist/clients/inbox/review/columns.js +0 -40
- package/dist/clients/inbox/review/composition.d.ts +0 -60
- package/dist/clients/inbox/review/composition.js +0 -278
- package/dist/clients/inbox/review/document-region.d.ts +0 -120
- package/dist/clients/surfaces/region.d.ts +0 -16
- package/dist/clients/web/__tests__/push-engine.test.js +0 -158
- package/dist/clients/web/__tests__/push-registry.test.d.ts +0 -1
- package/dist/clients/web/__tests__/push-registry.test.js +0 -177
- package/dist/clients/web/dev-server.d.ts +0 -7
- package/dist/clients/web/dev-server.js +0 -46
- package/dist/clients/web/events.d.ts +0 -19
- package/dist/clients/web/events.js +0 -163
- package/dist/clients/web/push-engine.d.ts +0 -44
- package/dist/clients/web/push-engine.js +0 -125
- package/dist/clients/web/push-registry.d.ts +0 -85
- package/dist/clients/web/push-registry.js +0 -373
- package/dist/clients/web/server.d.ts +0 -19
- package/dist/clients/web/server.js +0 -661
- package/dist/clients/web/web-client/shared/protocol.d.ts +0 -214
- package/dist/clients/web/web-client/shared/protocol.js +0 -8
- package/dist/clients/web/web-cmd.d.ts +0 -2
- package/dist/clients/web/web-cmd.js +0 -141
- package/dist/commands/__tests__/api-canvas-source.test.d.ts +0 -1
- package/dist/commands/__tests__/api-canvas-source.test.js +0 -37
- package/dist/commands/__tests__/canvas-config.test.d.ts +0 -1
- package/dist/commands/__tests__/canvas-config.test.js +0 -107
- package/dist/commands/__tests__/node-no-kickoff-flag.test.d.ts +0 -1
- package/dist/commands/__tests__/node-no-kickoff-flag.test.js +0 -73
- package/dist/commands/__tests__/node-worktree-flag.test.d.ts +0 -1
- package/dist/commands/__tests__/node-worktree-flag.test.js +0 -49
- package/dist/commands/__tests__/search-contents.test.d.ts +0 -1
- package/dist/commands/__tests__/search-contents.test.js +0 -52
- package/dist/commands/memory/__tests__/lint.test.d.ts +0 -1
- package/dist/commands/memory/__tests__/lint.test.js +0 -15
- package/dist/commands/sys/__tests__/config-broker-thresholds.test.d.ts +0 -1
- package/dist/commands/sys/__tests__/config-broker-thresholds.test.js +0 -95
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +0 -1
- package/dist/commands/sys/__tests__/config-keybindings.test.js +0 -55
- package/dist/commands/sys/__tests__/helpers/setup-runtime.d.ts +0 -5
- package/dist/commands/sys/__tests__/helpers/setup-runtime.js +0 -19
- package/dist/commands/sys/__tests__/setup-front-door.test.d.ts +0 -1
- package/dist/commands/sys/__tests__/setup-front-door.test.js +0 -102
- package/dist/commands/sys/__tests__/setup-models.test.d.ts +0 -1
- package/dist/commands/sys/__tests__/setup-models.test.js +0 -205
- package/dist/commands/sys/__tests__/setup-providers-viewport.test.d.ts +0 -1
- package/dist/commands/sys/__tests__/setup-providers-viewport.test.js +0 -155
- package/dist/commands/sys/__tests__/sync-deps.test.d.ts +0 -1
- package/dist/commands/sys/__tests__/sync-deps.test.js +0 -179
- package/dist/commands/sys/__tests__/sync-import.test.d.ts +0 -1
- package/dist/commands/sys/__tests__/sync-import.test.js +0 -315
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +0 -1
- package/dist/core/__tests__/base-worker-prompt.test.js +0 -57
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +0 -1
- package/dist/core/__tests__/command-plugins-surfaces.test.js +0 -359
- package/dist/core/__tests__/connection-reconnect-resume.test.d.ts +0 -1
- package/dist/core/__tests__/connection-reconnect-resume.test.js +0 -36
- package/dist/core/__tests__/finalization-migration.test.d.ts +0 -1
- package/dist/core/__tests__/finalization-migration.test.js +0 -38
- package/dist/core/__tests__/fixtures/extra-extension.d.ts +0 -1
- package/dist/core/__tests__/fixtures/extra-extension.js +0 -3
- package/dist/core/__tests__/fixtures/spawn-proof-target.test.d.ts +0 -1
- package/dist/core/__tests__/fixtures/spawn-proof-target.test.js +0 -15
- package/dist/core/__tests__/fork.test.d.ts +0 -1
- package/dist/core/__tests__/fork.test.js +0 -108
- package/dist/core/__tests__/full/broker-attach-limits.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-attach-limits.test.js +0 -147
- package/dist/core/__tests__/full/broker-deliver-ack.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-deliver-ack.test.js +0 -73
- package/dist/core/__tests__/full/broker-dialogs.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-dialogs.test.js +0 -180
- package/dist/core/__tests__/full/broker-display-snapshot.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-display-snapshot.test.js +0 -75
- package/dist/core/__tests__/full/broker-dormant-wake.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-dormant-wake.test.js +0 -130
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +0 -57
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +0 -80
- package/dist/core/__tests__/full/cascade-close.test.d.ts +0 -1
- package/dist/core/__tests__/full/cascade-close.test.js +0 -240
- package/dist/core/__tests__/full/detach-focus.test.d.ts +0 -1
- package/dist/core/__tests__/full/detach-focus.test.js +0 -126
- package/dist/core/__tests__/full/idle-release-settlement.test.d.ts +0 -1
- package/dist/core/__tests__/full/issue-146-yield-spawn-focus.test.d.ts +0 -1
- package/dist/core/__tests__/full/issue-146-yield-spawn-focus.test.js +0 -107
- package/dist/core/__tests__/full/park-clock-release.test.d.ts +0 -1
- package/dist/core/__tests__/full/park-clock-release.test.js +0 -56
- package/dist/core/__tests__/full/yield-message-transaction.test.d.ts +0 -1
- package/dist/core/__tests__/memory-index-render.test.d.ts +0 -1
- package/dist/core/__tests__/memory-index-render.test.js +0 -60
- package/dist/core/__tests__/model-routes.test.d.ts +0 -1
- package/dist/core/__tests__/model-routes.test.js +0 -41
- package/dist/core/__tests__/on-read-identity.test.d.ts +0 -1
- package/dist/core/__tests__/on-read-identity.test.js +0 -61
- package/dist/core/__tests__/phase4-review-migration.test.d.ts +0 -1
- package/dist/core/__tests__/phase4-review-migration.test.js +0 -100
- package/dist/core/__tests__/prune-to-limit.test.d.ts +0 -1
- package/dist/core/__tests__/prune-to-limit.test.js +0 -126
- package/dist/core/__tests__/reset.test.d.ts +0 -1
- package/dist/core/__tests__/reset.test.js +0 -69
- package/dist/core/__tests__/spawn-no-kickoff.test.d.ts +0 -1
- package/dist/core/__tests__/spawn-no-kickoff.test.js +0 -115
- package/dist/core/__tests__/steer-note.test.d.ts +0 -1
- package/dist/core/__tests__/steer-note.test.js +0 -41
- package/dist/core/__tests__/subcommand-tier.test.d.ts +0 -1
- package/dist/core/__tests__/subcommand-tier.test.js +0 -99
- package/dist/core/__tests__/subscription-delivery.test.js +0 -211
- package/dist/core/__tests__/test-runner-cheap-sharding.test.d.ts +0 -1
- package/dist/core/__tests__/test-runner-cheap-sharding.test.js +0 -97
- package/dist/core/__tests__/tmux-surface.test.js +0 -484
- package/dist/core/__tests__/wake-bearings.test.d.ts +0 -1
- package/dist/core/__tests__/wake-bearings.test.js +0 -102
- package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +0 -1
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +0 -146
- package/dist/core/canvas/__tests__/source-resolve.test.d.ts +0 -1
- package/dist/core/canvas/__tests__/source-resolve.test.js +0 -77
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +0 -1
- package/dist/core/canvas/browse/__tests__/model.test.js +0 -290
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +0 -1
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +0 -134
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +0 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +0 -111
- package/dist/core/canvas/browse/rebuild-coalescer.d.ts +0 -7
- package/dist/core/canvas/browse/rebuild-coalescer.js +0 -61
- package/dist/core/canvas/remote-event-stream.d.ts +0 -24
- package/dist/core/canvas/remote-event-stream.js +0 -94
- package/dist/core/feed/__tests__/coalesce-human.test.d.ts +0 -1
- package/dist/core/feed/__tests__/coalesce-human.test.js +0 -36
- package/dist/core/feed/__tests__/empty-node-guard.test.d.ts +0 -1
- package/dist/core/feed/__tests__/empty-node-guard.test.js +0 -45
- package/dist/core/host-exports/__tests__/export-prunes-boot-skill.test.d.ts +0 -1
- package/dist/core/host-exports/__tests__/export-prunes-boot-skill.test.js +0 -84
- package/dist/core/human/__tests__/visible-paths.test.d.ts +0 -1
- package/dist/core/human/__tests__/visible-paths.test.js +0 -28
- package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +0 -1
- package/dist/core/keybindings/__tests__/inbox-affordance.test.js +0 -38
- package/dist/core/review/__tests__/project.test.d.ts +0 -1
- package/dist/core/review/__tests__/project.test.js +0 -71
- package/dist/core/runtime/__tests__/command-expansion.test.d.ts +0 -1
- package/dist/core/runtime/__tests__/command-expansion.test.js +0 -25
- package/dist/core/runtime/__tests__/session-visibility.test.d.ts +0 -1
- package/dist/core/runtime/__tests__/session-visibility.test.js +0 -119
- package/dist/core/runtime/__tests__/spawn-at-given-id.test.d.ts +0 -1
- package/dist/core/runtime/__tests__/spawn-at-given-id.test.js +0 -34
- package/dist/core/runtime/tmux.js +0 -1314
- package/dist/daemon/api/__tests__/full/push-parity.test.d.ts +0 -1
- package/dist/daemon/api/__tests__/full/worktree-parity.test.d.ts +0 -1
- package/dist/daemon/api/__tests__/full/worktree-parity.test.js +0 -160
- package/dist/daemon/api/__tests__/inbox.test.d.ts +0 -1
- package/dist/daemon/api/__tests__/inbox.test.js +0 -489
- package/dist/web-client/apple-touch-icon.png +0 -0
- package/dist/web-client/assets/fragment-mono-latin-400-normal-BYwT3kSJ.woff +0 -0
- package/dist/web-client/assets/fragment-mono-latin-400-normal-yxdJ5AmL.woff2 +0 -0
- package/dist/web-client/assets/fragment-mono-latin-ext-400-normal-BbKYyvR9.woff2 +0 -0
- package/dist/web-client/assets/fragment-mono-latin-ext-400-normal-CT4YFKeK.woff +0 -0
- package/dist/web-client/assets/index-CCPaLeW7.css +0 -2
- package/dist/web-client/assets/index-kyOWsnZK.js +0 -86
- package/dist/web-client/assets/martian-mono-cyrillic-wght-normal-B84CD5C_.woff2 +0 -0
- package/dist/web-client/assets/martian-mono-latin-ext-wght-normal-DlL6xMw5.woff2 +0 -0
- package/dist/web-client/assets/martian-mono-latin-wght-normal-5W32yIyr.woff2 +0 -0
- package/dist/web-client/assets/workbox-window.prod.es5-Bd17z0YL.js +0 -2
- package/dist/web-client/icon-192.png +0 -0
- package/dist/web-client/icon-512.png +0 -0
- package/dist/web-client/icon-maskable-512.png +0 -0
- package/dist/web-client/index.html +0 -17
- package/dist/web-client/manifest.webmanifest +0 -1
- package/dist/web-client/sw.js +0 -1
- /package/dist/api/__tests__/{client.test.d.ts → serial/client.test.d.ts} +0 -0
- /package/dist/clients/inbox/__tests__/{inbox-controller.test.d.ts → serial/inbox-controller.test.d.ts} +0 -0
- /package/dist/clients/inbox/__tests__/{mount-panel.test.d.ts → serial/mount-panel.test.d.ts} +0 -0
- /package/dist/clients/{attach/__tests__/attach-chrome-remote.test.d.ts → inbox/review/__tests__/teardown-notice.test.d.ts} +0 -0
- /package/dist/{clients/attach/__tests__/attach-keybindings.test.d.ts → core/__tests__/broker-extension-canvas-db-boundary.test.d.ts} +0 -0
- /package/dist/{clients/attach/__tests__/crtr-output-coverage.test.d.ts → core/__tests__/seam/broker-attach-multiclient.test.d.ts} +0 -0
- /package/dist/core/__tests__/{full → seam}/broker-attach-stream.test.d.ts +0 -0
- /package/dist/core/__tests__/{full → seam}/broker-crash-teardown.test.d.ts +0 -0
- /package/dist/core/__tests__/{full → seam}/broker-pane-resolution.test.d.ts +0 -0
- /package/dist/core/__tests__/{full → seam}/broker-provider-retry.test.d.ts +0 -0
- /package/dist/core/__tests__/{full → seam}/broker-whip-interrupt.test.d.ts +0 -0
- /package/dist/{clients/attach/__tests__/editor-frame-title.test.d.ts → core/__tests__/seam/dormancy-release.test.d.ts} +0 -0
- /package/dist/{clients/attach/__tests__/input-controller-extension-command.test.d.ts → core/__tests__/seam/yield-refresh-transaction.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-fork-seam.test.d.ts → serial/broker-fork-seam.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-sdk-wiring.test.d.ts → serial/broker-sdk-wiring.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-snapshot-history.test.d.ts → serial/broker-snapshot-history.test.d.ts} +0 -0
- /package/dist/core/__tests__/{command-plugins.test.d.ts → serial/command-plugins.test.d.ts} +0 -0
- /package/dist/core/__tests__/{deferred-no-wake.test.d.ts → serial/deferred-no-wake.test.d.ts} +0 -0
- /package/dist/core/__tests__/{flagship-lifecycle.test.d.ts → serial/flagship-lifecycle.test.d.ts} +0 -0
- /package/dist/core/__tests__/{host-teardown-process-group.test.d.ts → serial/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-deliver-e2e.test.d.ts → serial/human-deliver-e2e.test.d.ts} +0 -0
- /package/dist/core/__tests__/{live-mutation.test.d.ts → serial/live-mutation.test.d.ts} +0 -0
- /package/dist/core/__tests__/{refresh-stall-recycle.test.d.ts → serial/refresh-stall-recycle.test.d.ts} +0 -0
- /package/dist/core/__tests__/{revive.test.d.ts → serial/revive.test.d.ts} +0 -0
- /package/dist/core/__tests__/{spawn-root.test.d.ts → serial/spawn-root.test.d.ts} +0 -0
- /package/dist/core/__tests__/{subscription-delivery.test.d.ts → serial/subscription-delivery.test.d.ts} +0 -0
- /package/dist/core/__tests__/{tmux-surface.test.d.ts → serial/tmux-surface.test.d.ts} +0 -0
- /package/dist/core/__tests__/{worktree.test.d.ts → serial/worktree.test.d.ts} +0 -0
- /package/dist/{clients/attach/__tests__/markdown-headings.test.d.ts → core/__tests__/watchdog-abort-arms-retry.test.d.ts} +0 -0
- /package/dist/{clients/attach/__tests__/session-identity-refresh.test.d.ts → core/broker-client/__tests__/transport-socket.test.d.ts} +0 -0
- /package/dist/core/human/__tests__/{inbox-core.test.d.ts → serial/inbox-core.test.d.ts} +0 -0
- /package/dist/{clients/inbox/review/__tests__/remap.test.d.ts → daemon/api/__tests__/node-model-validation.test.d.ts} +0 -0
- /package/dist/{clients/surfaces/region.js → daemon/api/__tests__/review-terminal-conflict.test.d.ts} +0 -0
- /package/dist/daemon/api/__tests__/{full → seam}/api-server.test.d.ts +0 -0
- /package/dist/daemon/api/__tests__/{full → seam}/b10-attach-modelauth.test.d.ts +0 -0
- /package/dist/{clients/web/__tests__/push-engine.test.d.ts → daemon/api/__tests__/seam/leaf-api-parity.test.d.ts} +0 -0
|
@@ -17,63 +17,41 @@
|
|
|
17
17
|
// engine seam — the fake engine receives the real, extension-loaded loader so
|
|
18
18
|
// the real canvas-stophook fires session_start).
|
|
19
19
|
import { createServer } from 'node:net';
|
|
20
|
-
import { existsSync,
|
|
21
|
-
import { execFile } from 'node:child_process';
|
|
22
|
-
import { tmpdir } from 'node:os';
|
|
20
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
23
21
|
import { join } from 'node:path';
|
|
24
22
|
import { randomUUID } from 'node:crypto';
|
|
25
23
|
import { mark } from '../timing.js';
|
|
26
24
|
import { getAgentDir, initTheme, ModelRegistry, ModelRuntime, } from '@earendil-works/pi-coding-agent';
|
|
27
|
-
import {
|
|
28
|
-
import { jobDir, nodeDir, sessionListCachePath, viewSocketPath } from '../canvas/paths.js';
|
|
25
|
+
import { nodeDir, viewSocketPath } from '../canvas/paths.js';
|
|
29
26
|
import { bindBrokerEventSource } from '../events/source.js';
|
|
30
27
|
import { emitEvent } from '../events/emit.js';
|
|
31
28
|
import { operationIdContext } from '../events/operation-id.js';
|
|
32
29
|
import { findMissingPackagePaths } from './package-health.js';
|
|
33
|
-
import { SessionListCache } from './session-list-cache.js';
|
|
34
|
-
import { getNode, updateNode } from '../canvas/index.js';
|
|
35
|
-
import { readConfig } from '../config.js';
|
|
36
|
-
import { atomicWriteJson, readJsonIfExists } from '../fs-utils.js';
|
|
37
|
-
import { buildRefInventory } from '../memory/inline-ref-inventory.js';
|
|
38
|
-
import { buildGuidance } from '../memory/inline-ref-guidance.js';
|
|
39
|
-
import { clearSessionCache } from '../substrate/session-cache.js';
|
|
40
30
|
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
41
|
-
import { CANVAS_EXTENSIONS,
|
|
31
|
+
import { CANVAS_EXTENSIONS, piInvocationToSdkConfig, } from './launch.js';
|
|
42
32
|
import { assertEngineVersion, loadBrokerEngine } from './broker-sdk.js';
|
|
43
33
|
import { resolveBundledPiPackageDir } from './pi-cli.js';
|
|
44
|
-
import { classify } from '../fault-classifier.js';
|
|
45
34
|
import { PoolCredentialStore } from '../pool-credential-store.js';
|
|
46
|
-
import { clearFault, isModelNotFoundError, isProviderUnconfiguredError, readFault, recordFault, recordFaultForCurrentNode } from './fault.js';
|
|
47
35
|
import { clearCleanAbort, markBusy, markCleanAbort } from './busy.js';
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import { headlessToolGroupSummary, serializeToolGroup } from './tool-group-summary.js';
|
|
52
|
-
import { markFatalOnExhaust, nextFaultRetry, policyFor } from './fault-recovery.js';
|
|
36
|
+
import { recordFaultForCurrentNode } from './fault.js';
|
|
37
|
+
import { modelRequestFromConfig, selectInitialModelRoute, unregisteredConcreteModel } from '../model-routes.js';
|
|
38
|
+
import { KNOWN_STREAM_WAIT_UI } from './stream-watchdog.js';
|
|
53
39
|
import { probeOnline as probeOnlineReal } from './connectivity.js';
|
|
54
|
-
import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
import {
|
|
40
|
+
import { CRTR_CYCLE_CUSTOM_TYPE, cycleAwareMessages } from './session-cycles.js';
|
|
41
|
+
import { visibleMessages } from './session-visibility.js';
|
|
42
|
+
import { isUnknownModel } from './model-selection.js';
|
|
43
|
+
import { BrokerClientRegistry } from './broker/client-registry.js';
|
|
44
|
+
import { ToolGroupTracker } from './broker/tool-groups.js';
|
|
45
|
+
import { FaultRetry } from './broker/fault-retry.js';
|
|
46
|
+
import { EventProjection } from './broker/event-projection.js';
|
|
47
|
+
import { createFrameDispatchContext, handleFrame } from './broker/frame-dispatch.js';
|
|
48
|
+
import { RebindDriver } from './broker/rebind.js';
|
|
49
|
+
import { brokerExtensionState, commitBrokerModel } from './broker/daemon-ops.js';
|
|
50
|
+
import { FrameOverflowError, } from './broker-protocol.js';
|
|
63
51
|
/** A review companion may only navigate or fork within a branch whose ancestry
|
|
64
52
|
* has crossed its durable transcript boundary. The broker sends this typed local
|
|
65
53
|
* failure as a dedicated error frame without invoking pi's tree mutation. */
|
|
66
|
-
export
|
|
67
|
-
export class ReviewBoundaryNavigationError extends Error {
|
|
68
|
-
targetId;
|
|
69
|
-
code = REVIEW_BOUNDARY_NAVIGATION_CODE;
|
|
70
|
-
constructor(targetId) {
|
|
71
|
-
super(`review companion cannot navigate before the visible transcript boundary (target: ${targetId})`);
|
|
72
|
-
this.targetId = targetId;
|
|
73
|
-
this.name = 'ReviewBoundaryNavigationError';
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
54
|
+
export { ReviewBoundaryNavigationError, REVIEW_BOUNDARY_NAVIGATION_CODE } from './broker/frame-dispatch.js';
|
|
77
55
|
/**
|
|
78
56
|
* pi 0.82 made `ModelRuntime` the canonical model/auth surface and demoted
|
|
79
57
|
* `ModelRegistry` to the synchronous facade over it (`find`/`getAll`/
|
|
@@ -89,282 +67,6 @@ function registryOf(services) {
|
|
|
89
67
|
function modelRuntimePaths(agentDir) {
|
|
90
68
|
return { authPath: join(agentDir, 'auth.json'), modelsPath: join(agentDir, 'models.json') };
|
|
91
69
|
}
|
|
92
|
-
function parseModelSpec(spec) {
|
|
93
|
-
const i = spec.lastIndexOf(':');
|
|
94
|
-
if (i <= 0)
|
|
95
|
-
return { modelSpec: spec };
|
|
96
|
-
const suffix = spec.slice(i + 1);
|
|
97
|
-
if (!THINKING_LEVELS.has(suffix))
|
|
98
|
-
return { modelSpec: spec };
|
|
99
|
-
return { modelSpec: spec.slice(0, i), thinkingLevel: suffix };
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* The pinned pi SDK never yields `session.model === undefined` for a model-less
|
|
103
|
-
* boot: Agent.createMutableAgentState() falls back to a hardcoded
|
|
104
|
-
* `{ provider:'unknown', id:'unknown', api:'unknown', … }` stub (DEFAULT_MODEL)
|
|
105
|
-
* whenever no model is supplied at construction, and `session.model` returns it
|
|
106
|
-
* verbatim. This mirrors the SDK's own (non-exported) interactive-mode.js
|
|
107
|
-
* `isUnknownModel()` so the broker recognizes that stub as "no model" wherever it
|
|
108
|
-
* guards the model-less launch path (kickoff deferral, reload_auth re-drive) and
|
|
109
|
-
* refuses to record/broadcast it as a real `unknown/unknown` model.
|
|
110
|
-
*/
|
|
111
|
-
export function isUnknownModel(model) {
|
|
112
|
-
return (model !== null &&
|
|
113
|
-
model !== undefined &&
|
|
114
|
-
model.provider === 'unknown' &&
|
|
115
|
-
model.id === 'unknown' &&
|
|
116
|
-
model.api === 'unknown');
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Route a writable client's `prompt`/`follow_up` frame against the LIVE session
|
|
120
|
-
* state. The client picks its frame type off a possibly-STALE `isStreaming`
|
|
121
|
-
* snapshot, so the broker is authoritative and the client's choice is a HINT:
|
|
122
|
-
*
|
|
123
|
-
* - m-B (streaming-safe prompt): a `prompt` arriving mid-stream needs
|
|
124
|
-
* PromptOptions.streamingBehavior ("Required if streaming"; prompt() throws
|
|
125
|
-
* without it). "steer" mirrors pi interactive-mode's own
|
|
126
|
-
* Enter-while-streaming submit (`prompt(text, { streamingBehavior: "steer" })`).
|
|
127
|
-
* - m-C (idle-safe follow_up): pi's followUp() only enqueues into a RUNNING
|
|
128
|
-
* agent loop — on an idle session nothing ever drains the queue and the text
|
|
129
|
-
* is silently lost. An idle `follow_up` therefore runs as a prompt now.
|
|
130
|
-
*
|
|
131
|
-
* driveEngine calls this synchronously with `session.isStreaming`, so the
|
|
132
|
-
* routed state cannot go stale between the check and the engine call.
|
|
133
|
-
* C1: images ride as a field on PromptOptions (pi: `prompt(text, { images })`).
|
|
134
|
-
*/
|
|
135
|
-
export function resolveEngineRoute(frame, isStreaming) {
|
|
136
|
-
if (frame.type === 'follow_up' && isStreaming)
|
|
137
|
-
return { call: 'followUp' };
|
|
138
|
-
const options = {};
|
|
139
|
-
if (frame.images !== undefined)
|
|
140
|
-
options.images = frame.images;
|
|
141
|
-
if (isStreaming)
|
|
142
|
-
options.streamingBehavior = 'steer';
|
|
143
|
-
return { call: 'prompt', options };
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* True iff `text` would be consumed by pi's OWN leading-name preflight before
|
|
147
|
-
* `session.prompt/steer/followUp` ever sees it as prose — an extension command
|
|
148
|
-
* (`session.extensionRunner.getCommand`), a `/skill:name` skill, or a `/name`
|
|
149
|
-
* prompt template (mirrors `_tryExecuteExtensionCommand` / `_expandSkillCommand`
|
|
150
|
-
* / `expandPromptTemplate` in the pinned SDK verbatim, including the exact
|
|
151
|
-
* name-parsing substrings). Inline memory-reference guidance must NEVER fire
|
|
152
|
-
* for these — the leading token routes to the engine's own command surface,
|
|
153
|
-
* not prose referencing a memory doc. Each lookup is independently wrapped so
|
|
154
|
-
* an engine missing one accessor (the fake, or a degraded session) degrades
|
|
155
|
-
* that check to "not a match" rather than aborting the whole classification —
|
|
156
|
-
* matching `buildCommandList`'s existing "no live extensionRunner" tolerance.
|
|
157
|
-
*/
|
|
158
|
-
export function isLeadingEngineCommand(text, session) {
|
|
159
|
-
if (text.startsWith('/')) {
|
|
160
|
-
const spaceIndex = text.indexOf(' ');
|
|
161
|
-
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
162
|
-
try {
|
|
163
|
-
if (session.extensionRunner?.getCommand(commandName))
|
|
164
|
-
return true;
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
/* engine without a live extensionRunner (e.g. the fake) — not a match */
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (text.startsWith('/skill:')) {
|
|
171
|
-
const spaceIndex = text.indexOf(' ');
|
|
172
|
-
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
|
|
173
|
-
try {
|
|
174
|
-
if (session.resourceLoader?.getSkills().skills.some((s) => s.name === skillName))
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
/* engine without a live resourceLoader — not a match */
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
if (text.startsWith('/')) {
|
|
182
|
-
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
|
|
183
|
-
const templateName = match?.[1];
|
|
184
|
-
if (templateName !== undefined) {
|
|
185
|
-
try {
|
|
186
|
-
if (session.promptTemplates?.some((t) => t.name === templateName))
|
|
187
|
-
return true;
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
/* engine without a live promptTemplates getter — not a match */
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
/** A `steer` frame normally maps directly to pi's steer queue. Engine commands,
|
|
197
|
-
* skills, and templates must instead pass through prompt()'s leading-command
|
|
198
|
-
* preflight; with streamingBehavior:'steer', ordinary expanded text still lands
|
|
199
|
-
* in the same queue. */
|
|
200
|
-
export function resolveSteerCall(text, session) {
|
|
201
|
-
return isLeadingEngineCommand(text, session) ? 'prompt' : 'steer';
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Enumerate every leading-token STRING this session's engine would
|
|
205
|
-
* independently consume as a leading command/skill/template dispatch BEFORE
|
|
206
|
-
* inline memory-reference guidance ever applies — the read-op sibling of
|
|
207
|
-
* `isLeadingEngineCommand`'s point-lookup, gathered as a full enumeration for
|
|
208
|
-
* viewer-side suppression instead of a per-submission membership check. Same
|
|
209
|
-
* three sources, same shape of the leading match `isLeadingEngineCommand`
|
|
210
|
-
* checks (bare extension-command invocation names and prompt-template names,
|
|
211
|
-
* `skill:<name>` for skills), so `/${token}` round-trips through
|
|
212
|
-
* `isLeadingEngineCommand(text, session)` for every emitted token (locked by
|
|
213
|
-
* a test). Kept STRICTLY separate from `buildCommandList` (command
|
|
214
|
-
* completion) and the ref inventory (`RefMeta` rows) — this is neither.
|
|
215
|
-
*/
|
|
216
|
-
export function engineLeadingCommandTokens(session) {
|
|
217
|
-
const out = [];
|
|
218
|
-
try {
|
|
219
|
-
for (const c of session.extensionRunner?.getRegisteredCommands() ?? [])
|
|
220
|
-
out.push(c.invocationName);
|
|
221
|
-
}
|
|
222
|
-
catch {
|
|
223
|
-
/* engine without a live extensionRunner (e.g. the fake) — none */
|
|
224
|
-
}
|
|
225
|
-
try {
|
|
226
|
-
for (const s of session.resourceLoader?.getSkills().skills ?? [])
|
|
227
|
-
out.push(`skill:${s.name}`);
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
/* engine without a live resourceLoader — none */
|
|
231
|
-
}
|
|
232
|
-
try {
|
|
233
|
-
for (const t of session.promptTemplates ?? [])
|
|
234
|
-
out.push(t.name);
|
|
235
|
-
}
|
|
236
|
-
catch {
|
|
237
|
-
/* engine without a live promptTemplates getter — none */
|
|
238
|
-
}
|
|
239
|
-
return out;
|
|
240
|
-
}
|
|
241
|
-
/**
|
|
242
|
-
* Choose how the hidden `crtr-memory-refs` guidance custom message rides
|
|
243
|
-
* alongside a human frame's own engine call (design Flow B). Pure over the
|
|
244
|
-
* frame's routing outcome — never reads live session state itself, so a
|
|
245
|
-
* caller can decide the delivery mode from the exact same `route`
|
|
246
|
-
* `resolveEngineRoute` already computed for the human call.
|
|
247
|
-
*
|
|
248
|
-
* - `steer` frame -> 'steer' (always; no route call exists).
|
|
249
|
-
* - a TRUE mid-stream follow_up (`routeCall === 'followUp'`) -> 'followUp'.
|
|
250
|
-
* - every other `prompt`/`follow_up` routed as `routeCall === 'prompt'`:
|
|
251
|
-
* idle -> 'nextTurn' (the ONLY case guidance precedes the human call —
|
|
252
|
-
* pi's `prompt()` reads+clears its pending-nextTurn queue synchronously
|
|
253
|
-
* into the SAME turn as the new user message); streaming (m-B
|
|
254
|
-
* steer-while-streaming) -> 'steer', never 'nextTurn' (that would attach
|
|
255
|
-
* to a later, unrelated turn).
|
|
256
|
-
*/
|
|
257
|
-
export function chooseGuidanceDeliveryMode(frameType, routeCall, isStreaming) {
|
|
258
|
-
if (frameType === 'steer')
|
|
259
|
-
return 'steer';
|
|
260
|
-
if (routeCall === 'followUp')
|
|
261
|
-
return 'followUp';
|
|
262
|
-
return isStreaming ? 'steer' : 'nextTurn';
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* Deliver a `nextTurn` guidance custom message ATOMICALLY around the idle-
|
|
266
|
-
* prompt call it's meant to ride alongside. Pi's `_pendingNextTurnMessages`
|
|
267
|
-
* queue (the only public seam for `deliverAs:'nextTurn'`) is drained ONLY deep
|
|
268
|
-
* inside `session.prompt()`, well after several await points — extension-
|
|
269
|
-
* command interception, the `input` extension transform, a streaming reroute,
|
|
270
|
-
* model/auth validation, pre-turn compaction — that can return or throw BEFORE
|
|
271
|
-
* the drain ever runs. `sendCustomMessage`'s nextTurn branch pushes
|
|
272
|
-
* synchronously and unconditionally, so any of those early exits strands the
|
|
273
|
-
* pushed guidance in the queue to attach itself to whichever LATER, unrelated
|
|
274
|
-
* prompt happens to reach the drain first.
|
|
275
|
-
*
|
|
276
|
-
* There is no public SDK seam to observe or cancel a pending nextTurn message
|
|
277
|
-
* (`_pendingNextTurnMessages` is TS-`private` only — a plain runtime field, not
|
|
278
|
-
* `#private`), so this reaches into that one field and removes EXACTLY the
|
|
279
|
-
* entry this call pushed, identified by object identity via a locally-
|
|
280
|
-
* generated marker riding the message's own `details` field (never surfaced to
|
|
281
|
-
* the model, and irrelevant if the message WAS consumed normally — `details`
|
|
282
|
-
* is just carried through like any other field once folded into a real turn).
|
|
283
|
-
* Runs UNCONDITIONALLY after the human call settles, success or failure/early-
|
|
284
|
-
* return alike — a "successful" `prompt()` promise can just as easily be an
|
|
285
|
-
* early return that skipped the drain (an intercepted extension command, an
|
|
286
|
-
* `input` transform, a streaming reroute) as a genuine consumed turn, so only
|
|
287
|
-
* checking `.catch` is not enough.
|
|
288
|
-
*/
|
|
289
|
-
export async function promptWithNextTurnGuidance(session, guidance, runPrompt) {
|
|
290
|
-
// Must survive Pi's structured-clone step when the queued custom message is
|
|
291
|
-
// folded into the turn. A Symbol reaches that step in `details` and aborts
|
|
292
|
-
// the real prompt with "could not be cloned" before the provider runs.
|
|
293
|
-
const marker = `crtr-memory-refs-pending:${randomUUID()}`;
|
|
294
|
-
let queued = false;
|
|
295
|
-
try {
|
|
296
|
-
await session.sendCustomMessage({ customType: 'crtr-memory-refs', content: guidance, display: false, details: marker }, { deliverAs: 'nextTurn' });
|
|
297
|
-
queued = true;
|
|
298
|
-
}
|
|
299
|
-
catch (err) {
|
|
300
|
-
emitEvent({
|
|
301
|
-
level: 'warn',
|
|
302
|
-
event: 'broker.memory_refs.guidance_send_failed',
|
|
303
|
-
error: err,
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
try {
|
|
307
|
-
await runPrompt();
|
|
308
|
-
}
|
|
309
|
-
finally {
|
|
310
|
-
if (queued)
|
|
311
|
-
clearOwnPendingNextTurnGuidance(session, marker);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
/** Splice out exactly the pending-nextTurn entry `marker` identifies (object-
|
|
315
|
-
* identity match on `details`), if it is still present — a no-op (and safe)
|
|
316
|
-
* if `session.prompt()` already drained it normally, or if the session/field
|
|
317
|
-
* shape doesn't match (defensive against an engine without the field, e.g. a
|
|
318
|
-
* future SDK bump or a structurally-different test double). */
|
|
319
|
-
function clearOwnPendingNextTurnGuidance(session, marker) {
|
|
320
|
-
const pending = session._pendingNextTurnMessages;
|
|
321
|
-
if (!Array.isArray(pending))
|
|
322
|
-
return;
|
|
323
|
-
const idx = pending.findIndex((m) => m?.details === marker);
|
|
324
|
-
if (idx !== -1)
|
|
325
|
-
pending.splice(idx, 1);
|
|
326
|
-
}
|
|
327
|
-
/** True iff a parsed JSONL branch-entry line is a hidden (`display:false`)
|
|
328
|
-
* custom message — the general display boundary crossed if left in an
|
|
329
|
-
* exported branch (not just `crtr-memory-refs` guidance; any hidden custom
|
|
330
|
-
* content). Covers both persisted shapes a branch entry can take: a
|
|
331
|
-
* `type:'custom_message'` entry (display at the entry's own top level) and a
|
|
332
|
-
* `type:'message'` entry wrapping a `role:'custom'` message (display nested
|
|
333
|
-
* under `.message`). */
|
|
334
|
-
export function isHiddenJsonlBranchEntry(entry) {
|
|
335
|
-
if (entry === null || typeof entry !== 'object')
|
|
336
|
-
return false;
|
|
337
|
-
const e = entry;
|
|
338
|
-
if (e.type === 'custom_message' && e.display === false)
|
|
339
|
-
return true;
|
|
340
|
-
if (e.type === 'message' && e.message?.role === 'custom' && e.message.display === false)
|
|
341
|
-
return true;
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
/** Rewrite an already-exported JSONL file in place, dropping every hidden
|
|
345
|
-
* (`display:false`) branch entry while re-chaining `parentId` so the
|
|
346
|
-
* remaining entries stay a single valid linear branch — applying the general
|
|
347
|
-
* display:false boundary to `session.exportToJsonl`'s own output, which
|
|
348
|
-
* serializes every entry verbatim regardless of display. The header line
|
|
349
|
-
* (line 1) is copied through untouched. */
|
|
350
|
-
export function stripHiddenEntriesFromJsonlExport(filePath) {
|
|
351
|
-
const raw = readFileSync(filePath, 'utf8');
|
|
352
|
-
const lines = raw.split('\n').filter((l) => l !== '');
|
|
353
|
-
if (lines.length === 0)
|
|
354
|
-
return;
|
|
355
|
-
const [headerLine, ...entryLines] = lines;
|
|
356
|
-
let prevId = null;
|
|
357
|
-
const kept = [];
|
|
358
|
-
for (const line of entryLines) {
|
|
359
|
-
const entry = JSON.parse(line);
|
|
360
|
-
if (isHiddenJsonlBranchEntry(entry))
|
|
361
|
-
continue;
|
|
362
|
-
kept.push(JSON.stringify({ ...entry, parentId: prevId }));
|
|
363
|
-
if (typeof entry.id === 'string')
|
|
364
|
-
prevId = entry.id;
|
|
365
|
-
}
|
|
366
|
-
writeFileSync(filePath, `${[headerLine, ...kept].join('\n')}\n`);
|
|
367
|
-
}
|
|
368
70
|
function formatModelSpec(model, thinkingLevel) {
|
|
369
71
|
if (model === null || model === undefined || isUnknownModel(model))
|
|
370
72
|
return null;
|
|
@@ -378,33 +80,9 @@ function formatExactModelSpec(model, thinkingLevel) {
|
|
|
378
80
|
const base = formatModelSpec(model, undefined);
|
|
379
81
|
return base === null ? null : `${base}:${thinkingLevel === undefined || thinkingLevel === '' ? 'off' : thinkingLevel}`;
|
|
380
82
|
}
|
|
381
|
-
function faultRetryProbe() {
|
|
382
|
-
const mode = process.env['CRTR_FAULT_RETRY_PROBE'];
|
|
383
|
-
if (mode === 'always')
|
|
384
|
-
return async () => true;
|
|
385
|
-
if (mode === 'never')
|
|
386
|
-
return async () => false;
|
|
387
|
-
return probeOnlineReal;
|
|
388
|
-
}
|
|
389
83
|
// ---------------------------------------------------------------------------
|
|
390
84
|
// Tunables (T3 backpressure / T4 dialog anti-deadlock)
|
|
391
85
|
// ---------------------------------------------------------------------------
|
|
392
|
-
/** Per-viewer outbound high-water mark (M1). A viewer whose unflushed backlog
|
|
393
|
-
* exceeds EITHER bound is DROPPED (socket destroyed) rather than allowed to
|
|
394
|
-
* apply indefinite backpressure to the shared engine. Modeled on pi's RPC
|
|
395
|
-
* output-guard: bound the queue, shed the slow viewer. */
|
|
396
|
-
const MAX_QUEUED_FRAMES = 1000;
|
|
397
|
-
const MAX_PENDING_BYTES = 32 * 1024 * 1024; // 32 MiB
|
|
398
|
-
/** F2 (attach typing-lag fix, 2026-06-09): coalesce window for relayed
|
|
399
|
-
* `message_update` frames. pi emits one per content delta and each carries the
|
|
400
|
-
* FULL accumulated assistant message, so a verbatim relay re-serializes (and
|
|
401
|
-
* every viewer re-parses + re-renders) the whole message per token — O(len²)
|
|
402
|
-
* bytes over a long reply, and measured ~3× the viewer's render CPU vs 75 ms
|
|
403
|
-
* coalescing. Only the LATEST pending update matters (each supersedes the
|
|
404
|
-
* last), so the relay keeps one and flushes it on this timer — or immediately,
|
|
405
|
-
* BEFORE any other event type, preserving ordering (an update never arrives
|
|
406
|
-
* after its message_end). */
|
|
407
|
-
const MESSAGE_UPDATE_COALESCE_MS = 75;
|
|
408
86
|
/** Broker-side default dialog timeout (C2 anti-deadlock, T4). When an extension
|
|
409
87
|
* dialog is forwarded to every writable client, the broker ALWAYS arms a timeout
|
|
410
88
|
* (this default, or a shorter per-dialog `opts.timeout` if the extension passed
|
|
@@ -412,36 +90,6 @@ const MESSAGE_UPDATE_COALESCE_MS = 75;
|
|
|
412
90
|
* — can never hang the agent turn forever. On fire it resolves to the SAFE default
|
|
413
91
|
* (deny for confirm; cancel/undefined for select/input/editor). */
|
|
414
92
|
const DEFAULT_DIALOG_TIMEOUT_MS = 120_000; // 120 s
|
|
415
|
-
/** Admission bound for `runReplacement` (one active head + at most one queued
|
|
416
|
-
* follow-up) and the mutation-through-rebind deadline each admitted head gets
|
|
417
|
-
* once it becomes the tail's head. The SDK has no cancellation primitive for
|
|
418
|
-
* newSession/switchSession/fork/bindExtensions, so an admitted replacement that
|
|
419
|
-
* neither settles nor completes within its deadline leaves runtime/shared state
|
|
420
|
-
* indeterminate — the only safe response is broker-terminal exit, never a retry
|
|
421
|
-
* and never letting a queued replacement start against that uncertain state. */
|
|
422
|
-
const MAX_ADMITTED_REPLACEMENTS = 2; // active + at most one queued
|
|
423
|
-
const REPLACEMENT_DEADLINE_MS = 30_000;
|
|
424
|
-
/** Locally branded sentinel `runReplacement`'s deadline race resolves to when
|
|
425
|
-
* the budget elapses before the runtime promise settles — never compared via
|
|
426
|
-
* string matching. */
|
|
427
|
-
const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
|
|
428
|
-
const PENDING_TOOL_SUMMARY_KEY_MAX_CHARS = 1_000;
|
|
429
|
-
const PENDING_TOOL_SUMMARY_SEGMENT_MAX_CHARS = 10_000;
|
|
430
|
-
function normalizePendingToolGroupSummary(value) {
|
|
431
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
432
|
-
return null;
|
|
433
|
-
const record = value;
|
|
434
|
-
if (Object.keys(record).sort().join(',') !== 'key,segment,version')
|
|
435
|
-
return null;
|
|
436
|
-
if (record.version !== 1 || typeof record.key !== 'string' || typeof record.segment !== 'string')
|
|
437
|
-
return null;
|
|
438
|
-
if (record.key.length === 0 ||
|
|
439
|
-
record.key.length > PENDING_TOOL_SUMMARY_KEY_MAX_CHARS ||
|
|
440
|
-
record.segment.length === 0 ||
|
|
441
|
-
record.segment.length > PENDING_TOOL_SUMMARY_SEGMENT_MAX_CHARS)
|
|
442
|
-
return null;
|
|
443
|
-
return { version: 1, key: record.key, segment: record.segment };
|
|
444
|
-
}
|
|
445
93
|
// ---------------------------------------------------------------------------
|
|
446
94
|
// M3 (scout mq5thyli): the active engine session, exposed so broker-cli's FATAL
|
|
447
95
|
// handlers (uncaughtException / unhandledRejection / the runBroker reject) can
|
|
@@ -567,14 +215,14 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
567
215
|
// Review identity is immutable for the broker's lifetime. Snapshot producers
|
|
568
216
|
// close over this one construction-time classification rather than re-reading
|
|
569
217
|
// canvas metadata for every welcome or session replacement.
|
|
570
|
-
const reviewCompanion =
|
|
218
|
+
const reviewCompanion = (await brokerExtensionState(nodeId)).node.review_binding != null;
|
|
571
219
|
// M3: name the boot session as the process-fatal disposal target immediately —
|
|
572
220
|
// enqueueRebind reassigns this on every later replacement, but without this
|
|
573
221
|
// initial assignment `activeSession` stays null (and disposeActiveSession() a
|
|
574
222
|
// no-op) for every exit that happens before the FIRST session replacement,
|
|
575
223
|
// which is the common case.
|
|
576
224
|
activeSession = session;
|
|
577
|
-
const retryProbe =
|
|
225
|
+
const retryProbe = probeOnlineReal;
|
|
578
226
|
// Initialize pi's process-global theme. The pi binary does this at boot
|
|
579
227
|
// (main.js: initTheme(settingsManager.getTheme(), appMode === 'interactive')),
|
|
580
228
|
// but the SDK convenience path the broker uses does NOT. Without it, ANY
|
|
@@ -621,2714 +269,278 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
621
269
|
// survives a session replacement (new_session/switch_session/fork), not only at
|
|
622
270
|
// boot.
|
|
623
271
|
// -------------------------------------------------------------------------
|
|
624
|
-
// Socket fan-out
|
|
272
|
+
// Socket fan-out, dialogs, and transcript tool groups
|
|
625
273
|
// -------------------------------------------------------------------------
|
|
626
274
|
const sockPath = viewSocketPath(nodeId);
|
|
627
|
-
const
|
|
628
|
-
const clients = new Set();
|
|
275
|
+
const registry = new BrokerClientRegistry(nodeId);
|
|
629
276
|
const pendingDialogs = new Map();
|
|
630
|
-
|
|
631
|
-
// extension may paint a widget (notably the inactivity brief) while nobody is
|
|
632
|
-
// attached; retain the latest keyed state so the next hello can replay it
|
|
633
|
-
// immediately after welcome instead of making "persistent" mean "only if you
|
|
634
|
-
// happened to be watching when the frame fired".
|
|
635
|
-
const displayStatuses = new Map();
|
|
636
|
-
const displayWidgets = new Map();
|
|
637
|
-
let displayTitle;
|
|
638
|
-
let workingActivity = DEFAULT_WORKING_ACTIVITY;
|
|
639
|
-
const toolSummariesPath = join(jobDir(nodeId), 'tool-summaries.json');
|
|
640
|
-
const pendingToolSummaryPath = join(jobDir(nodeId), 'pending-tool-summary.json');
|
|
641
|
-
const toolGroupSummaries = new Map();
|
|
642
|
-
try {
|
|
643
|
-
const persisted = readJsonIfExists(toolSummariesPath);
|
|
644
|
-
for (const [key, summary] of Object.entries(normalizeToolGroupSummaryRecord(persisted))) {
|
|
645
|
-
toolGroupSummaries.set(key, summary);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
catch {
|
|
649
|
-
// A malformed sidecar merely withholds summaries; transcript tools remain visible.
|
|
650
|
-
}
|
|
651
|
-
const summaryAttempted = new Set(toolGroupSummaries.keys());
|
|
652
|
-
const generatingToolGroups = new Map();
|
|
653
|
-
const toolGroupsByCallId = new Map();
|
|
654
|
-
const summaryTasks = new Set();
|
|
655
|
-
let openToolGroup;
|
|
656
|
-
let lastToolGroupBoundaryProse = '';
|
|
657
|
-
let sawAssistantProse = false;
|
|
277
|
+
const toolGroups = new ToolGroupTracker(nodeId, (frame) => registry.broadcast(frame));
|
|
658
278
|
let disposed = false;
|
|
659
279
|
let disposePending = false;
|
|
660
|
-
let agentRunActive = false;
|
|
661
|
-
let shutdownRequested = false;
|
|
662
280
|
let server;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
281
|
+
let uiContext;
|
|
282
|
+
let rebind;
|
|
283
|
+
let faultRetry;
|
|
284
|
+
let projection;
|
|
285
|
+
let notifyTurnAccepted = () => { };
|
|
286
|
+
let resetFrameState = () => { };
|
|
287
|
+
rebind = new RebindDriver({
|
|
288
|
+
nodeId, cfg, session, services, runtime, registry, toolGroups,
|
|
289
|
+
uiContext: () => uiContext,
|
|
290
|
+
faultRetry: () => faultRetry,
|
|
291
|
+
projection: () => projection,
|
|
292
|
+
setActiveSession: (candidate) => { activeSession = candidate; },
|
|
293
|
+
disposeAndExit: (reason, status) => disposeAndExit(reason, status),
|
|
294
|
+
onSessionRebind: () => resetFrameState(),
|
|
295
|
+
reWelcomeAll: () => reWelcomeAll(),
|
|
296
|
+
});
|
|
297
|
+
// Pi's model controls mutate only the live engine. Commit the concrete model
|
|
298
|
+
// and thinking choice through crtrd before acknowledging or broadcasting it,
|
|
299
|
+
// so the daemon remains the only durable canvas owner.
|
|
300
|
+
const persistModelChoice = async (pinnedOverride, userSelected = false) => {
|
|
301
|
+
const liveSession = rebind.session();
|
|
302
|
+
const spec = formatModelSpec(liveSession.model, liveSession.thinkingLevel);
|
|
303
|
+
if (spec === null)
|
|
304
|
+
return null;
|
|
305
|
+
const result = await commitBrokerModel(nodeId, { spec, pinnedOverride, userSelected });
|
|
306
|
+
if (userSelected) {
|
|
307
|
+
delete process.env['CRTR_MODEL_INTENT'];
|
|
308
|
+
cfg.model = result.modelOverride;
|
|
677
309
|
}
|
|
678
|
-
return
|
|
310
|
+
return result.modelOverride;
|
|
679
311
|
};
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
// a crash-orphaned file).
|
|
689
|
-
const persistAttachState = () => {
|
|
690
|
-
try {
|
|
691
|
-
let viewers = 0;
|
|
692
|
-
for (const c of clients)
|
|
693
|
-
if (c.helloed)
|
|
694
|
-
viewers += 1;
|
|
695
|
-
const dirPath = jobDir(nodeId);
|
|
696
|
-
if (!existsSync(dirPath))
|
|
697
|
-
mkdirSync(dirPath, { recursive: true });
|
|
698
|
-
writeFileSync(attachPath, JSON.stringify({ viewers, updated: new Date().toISOString() }, null, 2), 'utf8');
|
|
699
|
-
}
|
|
700
|
-
catch {
|
|
701
|
-
/* presence is best-effort; never crash the broker */
|
|
702
|
-
}
|
|
312
|
+
// Pi emits no AgentSessionEvent for a model switch, so after a successful
|
|
313
|
+
// set_model/cycle_model the broker announces it itself. A durable change is
|
|
314
|
+
// broadcast only after crtrd returns the committed override.
|
|
315
|
+
const broadcastModelChanged = async (persist = true, pinnedOverride, userSelected = false) => {
|
|
316
|
+
const liveSession = rebind.session();
|
|
317
|
+
const liveSpec = formatModelSpec(liveSession.model, liveSession.thinkingLevel);
|
|
318
|
+
const spec = persist ? await persistModelChoice(pinnedOverride, userSelected) : liveSpec;
|
|
319
|
+
registry.broadcast({ type: 'model_changed', model: isUnknownModel(liveSession.model) ? undefined : liveSession.model, spec });
|
|
703
320
|
};
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
321
|
+
faultRetry = new FaultRetry({
|
|
322
|
+
nodeId,
|
|
323
|
+
cfg,
|
|
324
|
+
retryProbe,
|
|
325
|
+
currentSession: () => rebind.session(),
|
|
326
|
+
currentServices: () => rebind.services(),
|
|
327
|
+
installedGeneration: () => rebind.installedGeneration(),
|
|
328
|
+
registryOf,
|
|
329
|
+
formatModelSpec,
|
|
330
|
+
broadcastModelChanged,
|
|
331
|
+
});
|
|
332
|
+
projection = new EventProjection({
|
|
333
|
+
registry,
|
|
334
|
+
faultRetry,
|
|
335
|
+
installedGeneration: () => rebind.installedGeneration(),
|
|
336
|
+
currentSession: () => rebind.session(),
|
|
337
|
+
notifyTurnAccepted: () => notifyTurnAccepted(),
|
|
338
|
+
emitStartupMilestone,
|
|
339
|
+
formatModelSpec,
|
|
340
|
+
refreshIntent: async () => (await brokerExtensionState(nodeId)).node.intent === 'refresh',
|
|
341
|
+
onRefreshIntentError: (error) => {
|
|
342
|
+
emitEvent({ level: 'error', event: 'broker.daemon_state.read_failed', error });
|
|
343
|
+
disposeAndExit('daemon-state-read-failed', 1);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
const buildSnapshot = () => {
|
|
347
|
+
const liveSession = rebind.session();
|
|
348
|
+
return ({
|
|
349
|
+
messages: snapshotMessages(liveSession, reviewCompanion),
|
|
350
|
+
stats: liveSession.getSessionStats(),
|
|
351
|
+
// pi's `get_state` (rpc-mode.js), field for field, from the same getters.
|
|
352
|
+
// The ONE divergence: a model-less session (undefined OR the SDK
|
|
353
|
+
// unknown-stub) reports no model, matching broadcastModelChanged — never a
|
|
354
|
+
// fictitious `unknown` selection.
|
|
355
|
+
state: {
|
|
356
|
+
model: isUnknownModel(liveSession.model) ? undefined : liveSession.model,
|
|
357
|
+
thinkingLevel: liveSession.thinkingLevel,
|
|
358
|
+
isStreaming: liveSession.isStreaming,
|
|
359
|
+
isCompacting: liveSession.isCompacting,
|
|
360
|
+
steeringMode: liveSession.steeringMode,
|
|
361
|
+
followUpMode: liveSession.followUpMode,
|
|
362
|
+
sessionFile: liveSession.sessionFile,
|
|
363
|
+
sessionId: liveSession.sessionId,
|
|
364
|
+
sessionName: liveSession.sessionName,
|
|
365
|
+
autoCompactionEnabled: liveSession.autoCompactionEnabled,
|
|
366
|
+
messageCount: liveSession.messages.length,
|
|
367
|
+
pendingMessageCount: liveSession.pendingMessageCount,
|
|
368
|
+
},
|
|
369
|
+
// Broker-owned levels (see BrokerSnapshot.display). Copied out so a later
|
|
370
|
+
// mutation of the retained maps cannot alias a frame already serialized.
|
|
371
|
+
display: {
|
|
372
|
+
...registry.displaySnapshot(),
|
|
373
|
+
},
|
|
374
|
+
toolGroupSummaries: toolGroups.summaries(),
|
|
375
|
+
workingActivity: projection.currentWorkingActivity(),
|
|
718
376
|
});
|
|
719
|
-
clients.delete(client);
|
|
720
|
-
persistAttachState(); // a viewer was shed
|
|
721
|
-
try {
|
|
722
|
-
client.socket.destroy();
|
|
723
|
-
}
|
|
724
|
-
catch {
|
|
725
|
-
/* ignore */
|
|
726
|
-
}
|
|
727
377
|
};
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
378
|
+
// Send a client its catch-up snapshot. welcome.pending_dialog carries a single
|
|
379
|
+
// still-in-flight dialog to a WRITABLE client attaching mid-dialog; an observer
|
|
380
|
+
// can never answer one, so observers get null. The pendingDialogs map is
|
|
381
|
+
// insertion-ordered — the first entry is the canonical one carried here; any
|
|
382
|
+
// extras are replayed explicitly by the caller (rare: concurrent dialogs).
|
|
383
|
+
const sendWelcome = (client) => {
|
|
384
|
+
const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
|
|
385
|
+
registry.sendFrame(client, {
|
|
386
|
+
type: 'welcome',
|
|
387
|
+
snapshot: buildSnapshot(),
|
|
388
|
+
role: client.role,
|
|
389
|
+
pending_dialog: first !== undefined ? first.request : null,
|
|
390
|
+
agentDir: getAgentDir(),
|
|
391
|
+
}, true);
|
|
392
|
+
// No display_* replay: the retained extension state rides IN the snapshot
|
|
393
|
+
// (`snapshot.display`), so catch-up is one atomic frame. A viewer attaching
|
|
394
|
+
// after an idle brief was painted still sees it on first paint, and there is
|
|
395
|
+
// no window between `welcome` and the replay where chrome renders blank.
|
|
396
|
+
};
|
|
397
|
+
// Attach-mid-dialog replay: a dialog raised before this client attached is
|
|
398
|
+
// still pending (a peer detaching never cancels one — see makeBrokerUiContext),
|
|
399
|
+
// so a writable client joining afterwards is handed every in-flight dialog and
|
|
400
|
+
// becomes a full peer in the race to answer it. `welcome.pending_dialog` already
|
|
401
|
+
// carried the FIRST entry, so this replays only the extras.
|
|
402
|
+
const replayExtraPendingDialogsTo = (client) => {
|
|
403
|
+
const pend = [...pendingDialogs.values()];
|
|
404
|
+
for (let i = 1; i < pend.length; i++)
|
|
405
|
+
registry.sendFrame(client, pend[i].request);
|
|
406
|
+
};
|
|
407
|
+
// After a session-replacing op (new_session/switch_session/fork) the engine's
|
|
408
|
+
// entire message history changed, so every attached viewer must rebuild from a
|
|
409
|
+
// fresh snapshot of the NEW session.
|
|
410
|
+
const reWelcomeAll = () => {
|
|
411
|
+
registry.forEachHelloed(sendWelcome);
|
|
412
|
+
};
|
|
413
|
+
// -------------------------------------------------------------------------
|
|
414
|
+
// The single exit helper (plan §0): dispose the engine, close + unlink the
|
|
415
|
+
// socket, exit 0. Idempotent — every trigger converges here.
|
|
416
|
+
// -------------------------------------------------------------------------
|
|
417
|
+
const completeExit = (status) => {
|
|
418
|
+
if (disposed)
|
|
748
419
|
return;
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
420
|
+
disposed = true;
|
|
421
|
+
// Daemon teardown cannot wait for a recap's 45-second model timeout. Preserve
|
|
422
|
+
// exactly the newest already-capped input so the successor broker can finish
|
|
423
|
+
// it, persist the normal summary, and remove the handoff record.
|
|
424
|
+
toolGroups.persistPendingSummary();
|
|
425
|
+
projection.dispose();
|
|
426
|
+
// disposeActiveSession atomically clears the process-global pointer before
|
|
427
|
+
// disposing — whichever engine session (committed or an in-flight rebind
|
|
428
|
+
// candidate) it currently names, matching broker-cli's fatal-handler primitive.
|
|
429
|
+
disposeActiveSession();
|
|
752
430
|
try {
|
|
753
|
-
|
|
754
|
-
// OS — a finer-grained drain signal than a socket-wide 'drain' event, and it
|
|
755
|
-
// keeps the accounting exact. write()'s boolean return is the coarse
|
|
756
|
-
// "buffer full" hint; the per-chunk callback is what we account on.
|
|
757
|
-
client.socket.write(data, () => {
|
|
758
|
-
client.queuedFrames -= 1;
|
|
759
|
-
client.pendingBytes -= bytes;
|
|
760
|
-
});
|
|
431
|
+
server?.close();
|
|
761
432
|
}
|
|
762
433
|
catch {
|
|
763
|
-
|
|
764
|
-
client.queuedFrames -= 1;
|
|
765
|
-
client.pendingBytes -= bytes;
|
|
766
|
-
}
|
|
767
|
-
};
|
|
768
|
-
const broadcast = (frame) => {
|
|
769
|
-
for (const c of clients)
|
|
770
|
-
if (c.helloed)
|
|
771
|
-
sendFrame(c, frame);
|
|
772
|
-
};
|
|
773
|
-
/** Retain and broadcast extension UI frames. The three display METHODS of pi's
|
|
774
|
-
* `extension_ui_request` envelope are levels, so their last value is kept for
|
|
775
|
-
* the welcome snapshot; updating a keyed entry moves it to the end, matching
|
|
776
|
-
* pi's insertion-ordered widget behavior. Every other method (notify, the
|
|
777
|
-
* blocking dialogs) simply passes through. */
|
|
778
|
-
const broadcastUi = (frame) => {
|
|
779
|
-
if (frame.type === 'extension_ui_request') {
|
|
780
|
-
if (frame.method === 'setStatus') {
|
|
781
|
-
displayStatuses.delete(frame.statusKey);
|
|
782
|
-
if (frame.statusText !== undefined)
|
|
783
|
-
displayStatuses.set(frame.statusKey, frame.statusText);
|
|
784
|
-
}
|
|
785
|
-
else if (frame.method === 'setWidget') {
|
|
786
|
-
displayWidgets.delete(frame.widgetKey);
|
|
787
|
-
if (frame.widgetLines !== undefined) {
|
|
788
|
-
displayWidgets.set(frame.widgetKey, {
|
|
789
|
-
lines: [...frame.widgetLines],
|
|
790
|
-
placement: frame.widgetPlacement ?? 'aboveEditor',
|
|
791
|
-
});
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
else if (frame.method === 'setTitle') {
|
|
795
|
-
displayTitle = frame.title;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
broadcast(frame);
|
|
799
|
-
};
|
|
800
|
-
/** Record and asynchronously summarize the maximal non-boundary region between
|
|
801
|
-
* visible prose messages. A boundary can land while a tool is still running,
|
|
802
|
-
* so closed groups retain their call ids until every result has joined the
|
|
803
|
-
* same serialized segment; only then may they become collapsible. */
|
|
804
|
-
const ensureOpenToolGroup = () => {
|
|
805
|
-
if (openToolGroup === undefined) {
|
|
806
|
-
openToolGroup = {
|
|
807
|
-
toolCount: 0,
|
|
808
|
-
pieces: [],
|
|
809
|
-
boundaryProse: lastToolGroupBoundaryProse,
|
|
810
|
-
pendingToolIds: new Set(),
|
|
811
|
-
closed: false,
|
|
812
|
-
summarizeWhenSettled: false,
|
|
813
|
-
};
|
|
434
|
+
/* ignore */
|
|
814
435
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const clearPendingToolGroupSummary = (input) => {
|
|
436
|
+
// Destroy every viewer socket without waiting for close events.
|
|
437
|
+
registry.dispose();
|
|
818
438
|
try {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
unlinkSync(pendingToolSummaryPath);
|
|
439
|
+
if (existsSync(sockPath))
|
|
440
|
+
unlinkSync(sockPath);
|
|
822
441
|
}
|
|
823
442
|
catch {
|
|
824
|
-
|
|
825
|
-
// no-op read on the next boot, but must not revoke the recap just written.
|
|
443
|
+
/* ignore */
|
|
826
444
|
}
|
|
445
|
+
// A clean exit never leaves a stale presence claim behind.
|
|
446
|
+
registry.clearAttachState();
|
|
447
|
+
process.exit(status);
|
|
827
448
|
};
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
if (summaryAttempted.has(key) || generatingToolGroups.has(key))
|
|
449
|
+
const disposeAndExit = (reason, status = 0) => {
|
|
450
|
+
if (disposed)
|
|
831
451
|
return;
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
452
|
+
const graceful = reason === 'shutdown-hook' || reason === 'shutdown-after-settle';
|
|
453
|
+
// A graceful shutdown (yield, dormancy, daemon restart) is the final
|
|
454
|
+
// boundary of the conversation's last tool group. Group state lives only in
|
|
455
|
+
// this process, so a group left open here is unsummarizable forever and its
|
|
456
|
+
// calls render raw in every future view of the transcript. Close it now —
|
|
457
|
+
// generateSettledToolGroup registers its task synchronously, so the wait
|
|
458
|
+
// below covers the summary it launches. Any pending tool arguments are
|
|
459
|
+
// included without inventing a result; the visible abort remains outside
|
|
460
|
+
// the recap and Ctrl+O retains every underlying call row.
|
|
461
|
+
if (graceful)
|
|
462
|
+
toolGroups.finishOpenToolGroup(true);
|
|
463
|
+
// A normal terminal shutdown follows a visible boundary that may have just
|
|
464
|
+
// launched a capped (45s) headless summary. Keep the broker and its child
|
|
465
|
+
// alive until those already-started tasks persist/broadcast, rather than
|
|
466
|
+
// process.exit() orphaning pi and losing the sidecar. Abrupt failures and
|
|
467
|
+
// signals retain their existing immediate teardown behavior.
|
|
468
|
+
if (graceful && toolGroups.hasSummaryTasks()) {
|
|
469
|
+
if (disposePending)
|
|
847
470
|
return;
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
broadcast({ type: 'tool_group_summary', key, summary });
|
|
851
|
-
})
|
|
852
|
-
.catch(() => undefined)
|
|
853
|
-
.finally(() => {
|
|
854
|
-
generatingToolGroups.delete(key);
|
|
855
|
-
summaryTasks.delete(task);
|
|
856
|
-
});
|
|
857
|
-
summaryTasks.add(task);
|
|
858
|
-
};
|
|
859
|
-
const generateSettledToolGroup = (group) => {
|
|
860
|
-
if (!group.closed || group.pendingToolIds.size > 0 || group.key === undefined || !group.summarizeWhenSettled)
|
|
471
|
+
disposePending = true;
|
|
472
|
+
void toolGroups.waitForSummaryTasks().finally(() => completeExit(status));
|
|
861
473
|
return;
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
key: group.key,
|
|
865
|
-
segment: serializeToolGroup(group.boundaryProse, group.pieces),
|
|
866
|
-
});
|
|
474
|
+
}
|
|
475
|
+
completeExit(status);
|
|
867
476
|
};
|
|
477
|
+
// -------------------------------------------------------------------------
|
|
478
|
+
// Extension-dialog routing (C2). makeBrokerUiContext owns the dialogPromise;
|
|
479
|
+
// here we just give it the three broker-side hooks it needs: the live set of
|
|
480
|
+
// writable clients (empty = nobody can answer → noOp fallback), how to forward a
|
|
481
|
+
// dialog to one of them, and the pending-dialog registry whichever one answers
|
|
482
|
+
// first settles through. The zero-writable path NEVER hangs and NEVER waits on a
|
|
483
|
+
// per-dialog timeout (design §5.4's timeout premise is false — see
|
|
484
|
+
// makeBrokerUiContext).
|
|
485
|
+
// -------------------------------------------------------------------------
|
|
486
|
+
uiContext = makeBrokerUiContext({
|
|
487
|
+
writable: () => registry.writableClients(),
|
|
488
|
+
forward: (client, request) => registry.sendFrame(client, request),
|
|
489
|
+
pending: pendingDialogs,
|
|
490
|
+
broadcast: (frame) => registry.broadcastUi(frame),
|
|
491
|
+
// A deliberate managed-provider cooldown wait is known liveness, not a wedged
|
|
492
|
+
// stream: pause the dead-stream watchdog for its duration. Only pause for a
|
|
493
|
+
// finite future deadline, and resume ONLY while the same turn is still
|
|
494
|
+
// awaiting the provider (streaming, no tool running) so a wait that outlived
|
|
495
|
+
// its turn can't rearm a stale session; otherwise fully reset.
|
|
496
|
+
onKnownStreamWait: (deadlineMs) => projection.knownStreamWait(deadlineMs),
|
|
497
|
+
});
|
|
498
|
+
// Bind the rebind driver before accepting socket frames. It owns subscription
|
|
499
|
+
// replacement and its bounded runtime-mutation admission lane.
|
|
500
|
+
rebind.setRebindSession();
|
|
501
|
+
await rebind.enqueueRebind(session, services);
|
|
502
|
+
const frameDispatch = createFrameDispatchContext({
|
|
503
|
+
nodeId, cfg, registry, toolGroups, projection, rebind, reviewCompanion,
|
|
504
|
+
pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit,
|
|
505
|
+
persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec,
|
|
506
|
+
formatExactModelSpec, resolveLaunchModel,
|
|
507
|
+
});
|
|
508
|
+
notifyTurnAccepted = frameDispatch.notifyTurnAccepted;
|
|
509
|
+
resetFrameState = frameDispatch.resetForSession;
|
|
510
|
+
// -------------------------------------------------------------------------
|
|
511
|
+
// 4 (plan): the socket listener — unlink any stale socket first.
|
|
512
|
+
// -------------------------------------------------------------------------
|
|
868
513
|
try {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if (existsSync(pendingToolSummaryPath)) {
|
|
872
|
-
unlinkSync(pendingToolSummaryPath);
|
|
873
|
-
emitEvent({
|
|
874
|
-
level: 'warn',
|
|
875
|
-
event: 'broker.startup.warning',
|
|
876
|
-
fields: { stage: 'pending_tool_summary', message: 'removed an invalid pending tool summary' },
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
else if (toolGroupSummaries.has(pending.key)) {
|
|
881
|
-
unlinkSync(pendingToolSummaryPath);
|
|
882
|
-
}
|
|
883
|
-
else {
|
|
884
|
-
generateToolGroupSummary(pending);
|
|
885
|
-
}
|
|
514
|
+
if (existsSync(sockPath))
|
|
515
|
+
unlinkSync(sockPath);
|
|
886
516
|
}
|
|
887
|
-
catch
|
|
888
|
-
|
|
889
|
-
level: 'warn',
|
|
890
|
-
event: 'broker.startup.warning',
|
|
891
|
-
fields: {
|
|
892
|
-
stage: 'pending_tool_summary',
|
|
893
|
-
message: error instanceof Error ? error.message : String(error),
|
|
894
|
-
},
|
|
895
|
-
});
|
|
517
|
+
catch {
|
|
518
|
+
/* a fresh listen below will surface a real problem */
|
|
896
519
|
}
|
|
897
|
-
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
// An abort is itself the visible evidence that any pending calls did not
|
|
904
|
-
// finish. Their recorded arguments still belong to the recap, and Ctrl+O
|
|
905
|
-
// retains the full rows, so let the completed pre-abort span summarize.
|
|
906
|
-
if (settleInterruptedTools) {
|
|
907
|
-
for (const id of group.pendingToolIds)
|
|
908
|
-
toolGroupsByCallId.delete(id);
|
|
909
|
-
group.pendingToolIds.clear();
|
|
910
|
-
}
|
|
911
|
-
// Configuration is read at the boundary, not when a slow tool later settles.
|
|
912
|
-
group.summarizeWhenSettled = readConfig('user').summarize_tool_calls;
|
|
913
|
-
generateSettledToolGroup(group);
|
|
914
|
-
};
|
|
915
|
-
const visibleBoundary = (prose) => {
|
|
916
|
-
finishOpenToolGroup();
|
|
917
|
-
lastToolGroupBoundaryProse = prose;
|
|
918
|
-
};
|
|
919
|
-
const observeToolGroupEvent = (event) => {
|
|
920
|
-
// The viewer turns every compaction_end into a summary, cancellation, or
|
|
921
|
-
// error line, each of which is a hard transcript seam.
|
|
922
|
-
if (event.type === 'compaction_end') {
|
|
923
|
-
finishOpenToolGroup();
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
// A failed automatic retry is rendered as ChatView's visible error line.
|
|
927
|
-
// Successful retries only clear activity and remain within the same group.
|
|
928
|
-
if (event.type === 'auto_retry_end' && event.success !== true) {
|
|
929
|
-
finishOpenToolGroup();
|
|
930
|
-
return;
|
|
931
|
-
}
|
|
932
|
-
// The attach viewer renders aborted and errored assistant endings as visible
|
|
933
|
-
// transcript seams. Close and settle the pre-abort group here so pending tool
|
|
934
|
-
// arguments can be summarized without pulling the seam into the recap.
|
|
935
|
-
if (event.type === 'message_end') {
|
|
936
|
-
const message = event.message;
|
|
937
|
-
if (message !== null && typeof message === 'object') {
|
|
938
|
-
const item = message;
|
|
939
|
-
if (item.role === 'assistant' && (item.stopReason === 'aborted' || item.stopReason === 'error')) {
|
|
940
|
-
finishOpenToolGroup(true);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
if (event.type === 'tool_execution_start') {
|
|
946
|
-
const group = ensureOpenToolGroup();
|
|
947
|
-
const id = typeof event.toolCallId === 'string' ? event.toolCallId : '';
|
|
948
|
-
if (group.key === undefined && id !== '')
|
|
949
|
-
group.key = id;
|
|
950
|
-
if (id !== '') {
|
|
951
|
-
group.pendingToolIds.add(id);
|
|
952
|
-
toolGroupsByCallId.set(id, group);
|
|
520
|
+
server = createServer((socket) => {
|
|
521
|
+
const client = registry.create(socket);
|
|
522
|
+
socket.on('data', (chunk) => {
|
|
523
|
+
let frames;
|
|
524
|
+
try {
|
|
525
|
+
frames = client.decoder.push(chunk);
|
|
953
526
|
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
result: event.result,
|
|
966
|
-
isError: event.isError === true,
|
|
967
|
-
});
|
|
968
|
-
if (id !== '') {
|
|
969
|
-
group.pendingToolIds.delete(id);
|
|
970
|
-
toolGroupsByCallId.delete(id);
|
|
527
|
+
catch (err) {
|
|
528
|
+
// G7: a client frame over the bounded decoder caps (FrameOverflowError) is
|
|
529
|
+
// cap-and-dropped — best-effort error frame, then destroy the peer. The
|
|
530
|
+
// broker survives. The try/catch is around push() itself (not just the
|
|
531
|
+
// per-frame loop) because push() throws BEFORE returning any frames.
|
|
532
|
+
if (err instanceof FrameOverflowError) {
|
|
533
|
+
registry.sendFrame(client, { type: 'error', code: 'frame_overflow', message: err.message });
|
|
534
|
+
registry.dropClient(client, `frame overflow: ${err.message}`);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
registry.dropClient(client, `decoder error: ${String(err)}`);
|
|
971
538
|
}
|
|
972
|
-
|
|
973
|
-
}
|
|
974
|
-
return;
|
|
975
|
-
}
|
|
976
|
-
if (event.type !== 'message_start' && event.type !== 'message_update')
|
|
977
|
-
return;
|
|
978
|
-
const message = event.message;
|
|
979
|
-
if (message === null || typeof message !== 'object')
|
|
980
|
-
return;
|
|
981
|
-
const item = message;
|
|
982
|
-
const role = item.role;
|
|
983
|
-
if (role === 'assistant') {
|
|
984
|
-
if (event.type === 'message_start')
|
|
985
|
-
sawAssistantProse = false;
|
|
986
|
-
const prose = assistantVisibleText(item);
|
|
987
|
-
if (!sawAssistantProse && prose.trim() !== '') {
|
|
988
|
-
sawAssistantProse = true;
|
|
989
|
-
visibleBoundary(prose);
|
|
990
|
-
}
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
if (event.type !== 'message_start')
|
|
994
|
-
return;
|
|
995
|
-
if (role === 'compactionSummary' || role === 'branchSummary' || role === 'bashExecution' || role === 'status' || role === 'error' || item.customType === 'crtr-cycle-divider') {
|
|
996
|
-
finishOpenToolGroup();
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
|
-
if (isTrueUserMessage(item)) {
|
|
1000
|
-
visibleBoundary(generatedContextText(item));
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
if (item.display === false)
|
|
1004
|
-
return;
|
|
1005
|
-
const text = generatedContextText(item);
|
|
1006
|
-
const isBearingsOrRevive = item.customType === 'crtr-context' || (role === 'user' && text.startsWith('You have been revived fresh after a context refresh'));
|
|
1007
|
-
if (isBearingsOrRevive) {
|
|
1008
|
-
if (openToolGroup !== undefined && text !== '')
|
|
1009
|
-
openToolGroup.pieces.push({ type: 'text', text });
|
|
1010
|
-
return;
|
|
1011
|
-
}
|
|
1012
|
-
// Generated inbox/situational/nudge and visible custom output are part of
|
|
1013
|
-
// the between-message activity and may begin a group before its first tool.
|
|
1014
|
-
const group = ensureOpenToolGroup();
|
|
1015
|
-
if (text !== '')
|
|
1016
|
-
group.pieces.push({ type: 'text', text });
|
|
1017
|
-
};
|
|
1018
|
-
// ---------------------------------------------------------------------------
|
|
1019
|
-
// F2: message_update coalescing. `relayEvent` is the single entry point for
|
|
1020
|
-
// engine events headed to viewers: a message_update is held (latest wins) and
|
|
1021
|
-
// flushed on a short timer; ANY other event flushes the held update first,
|
|
1022
|
-
// synchronously, so cross-type ordering is exactly the engine's. The timer is
|
|
1023
|
-
// unref'd — it must never hold the broker process open past dispose.
|
|
1024
|
-
// ---------------------------------------------------------------------------
|
|
1025
|
-
let pendingUpdate = null;
|
|
1026
|
-
let pendingUpdateTimer = null;
|
|
1027
|
-
let faultRetryTimer = null;
|
|
1028
|
-
let nextSubscriptionGeneration = 0;
|
|
1029
|
-
let installedGeneration;
|
|
1030
|
-
// Dead-provider-stream detector (see stream-watchdog.ts): poked while a turn
|
|
1031
|
-
// is awaiting the provider (NOT while a tool executes — a silent long tool
|
|
1032
|
-
// must not trip it), cleared at agent_end. Fires at most once per stall,
|
|
1033
|
-
// aborting the session so the fault-retry machinery (armed at agent_settled)
|
|
1034
|
-
// re-drives the turn instead of the broker wedging until the daemon's
|
|
1035
|
-
// 20-minute hard kick.
|
|
1036
|
-
const streamWatchdogMs = resolveStreamWatchdogMs();
|
|
1037
|
-
const streamWatchdog = new StreamWatchdog((armedGeneration) => {
|
|
1038
|
-
const generation = installedGeneration;
|
|
1039
|
-
if (generation === undefined || armedGeneration !== generation.id)
|
|
1040
|
-
return;
|
|
1041
|
-
emitEvent({
|
|
1042
|
-
level: 'debug',
|
|
1043
|
-
event: 'broker.runtime.diagnostic',
|
|
1044
|
-
fields: { message: `stream watchdog: no engine event for ${streamWatchdogMs}ms while streaming — aborting stalled provider stream` },
|
|
1045
|
-
});
|
|
1046
|
-
void generation.session.abort().catch((err) => {
|
|
1047
|
-
emitEvent({
|
|
1048
|
-
level: 'debug',
|
|
1049
|
-
event: 'broker.runtime.diagnostic',
|
|
1050
|
-
fields: { message: `stream watchdog abort failed: ${String(err)}` },
|
|
1051
|
-
});
|
|
1052
|
-
});
|
|
1053
|
-
}, streamWatchdogMs);
|
|
1054
|
-
const flushPendingUpdate = () => {
|
|
1055
|
-
if (pendingUpdateTimer !== null) {
|
|
1056
|
-
clearTimeout(pendingUpdateTimer);
|
|
1057
|
-
pendingUpdateTimer = null;
|
|
1058
|
-
}
|
|
1059
|
-
if (pendingUpdate !== null) {
|
|
1060
|
-
const frame = pendingUpdate;
|
|
1061
|
-
pendingUpdate = null;
|
|
1062
|
-
broadcast(frame);
|
|
1063
|
-
}
|
|
1064
|
-
};
|
|
1065
|
-
const clearFaultRetryTimer = () => {
|
|
1066
|
-
if (faultRetryTimer === null)
|
|
1067
|
-
return;
|
|
1068
|
-
clearTimeout(faultRetryTimer);
|
|
1069
|
-
faultRetryTimer = null;
|
|
1070
|
-
};
|
|
1071
|
-
const failedRetryRoutes = new Set();
|
|
1072
|
-
let providerFallbackInFlight = null;
|
|
1073
|
-
// Models that have already produced a 404 not_found this session. Guards the
|
|
1074
|
-
// not-found fallback below against an infinite swap→re-drive→404 loop when the
|
|
1075
|
-
// fallback TARGET is itself unavailable (or equals the model that just failed).
|
|
1076
|
-
const notFoundModels = new Set();
|
|
1077
|
-
let notFoundFallbackInFlight = null;
|
|
1078
|
-
// Tier 3 model-less boot: the first-prompt kickoff can't fire at listen time
|
|
1079
|
-
// when the session booted with no model at all. Hold the prompt text here and
|
|
1080
|
-
// fire it once a model actually becomes available (reload_auth re-drive,
|
|
1081
|
-
// below) instead of the old unconditional call. For the common model-present
|
|
1082
|
-
// path this fires immediately at listen time — byte-identical to the prior
|
|
1083
|
-
// behavior.
|
|
1084
|
-
let kickoffPending;
|
|
1085
|
-
let kickoffInFlight = false;
|
|
1086
|
-
// One /login can fan the same reload into this broker through several clients.
|
|
1087
|
-
// Coalesce those frames onto one local auth refresh + route reconciliation so
|
|
1088
|
-
// an idle prompt can await the exact work promised by the reload ack.
|
|
1089
|
-
let reloadAuthInFlight = null;
|
|
1090
|
-
// A fresh auth reload is also the explicit recovery trigger for a turn parked
|
|
1091
|
-
// on a fatal provider-auth fault. Keep at most one rewind + prompt in flight
|
|
1092
|
-
// when the daemon and an attached viewer both fan the same login update.
|
|
1093
|
-
let authFaultRedriveInFlight = false;
|
|
1094
|
-
// A model-less boot is EITHER a literal `undefined` model (the fake engine) OR
|
|
1095
|
-
// the real SDK's unknown-stub (see isUnknownModel) — the real pinned engine
|
|
1096
|
-
// NEVER yields `undefined`, so every model-less guard must accept both.
|
|
1097
|
-
const sessionHasNoModel = () => session.model === undefined || isUnknownModel(session.model);
|
|
1098
|
-
const drivePendingKickoff = () => {
|
|
1099
|
-
if (kickoffPending === undefined || kickoffInFlight || sessionHasNoModel())
|
|
1100
|
-
return;
|
|
1101
|
-
const prompt = kickoffPending;
|
|
1102
|
-
kickoffPending = undefined;
|
|
1103
|
-
kickoffInFlight = true;
|
|
1104
|
-
trackPromptDispatch(operationIdContext.fresh(() => session.prompt(prompt)
|
|
1105
|
-
.catch((err) => {
|
|
1106
|
-
emitEvent({
|
|
1107
|
-
level: 'error',
|
|
1108
|
-
event: 'broker.prompt.first_failed',
|
|
1109
|
-
error: err,
|
|
1110
|
-
});
|
|
1111
|
-
})
|
|
1112
|
-
.finally(() => {
|
|
1113
|
-
kickoffInFlight = false;
|
|
1114
|
-
})));
|
|
1115
|
-
};
|
|
1116
|
-
const maybeSwitchProviderForRetry = (event) => {
|
|
1117
|
-
if (event.type !== 'auto_retry_start')
|
|
1118
|
-
return;
|
|
1119
|
-
const retry = event;
|
|
1120
|
-
const kind = classify('pi→provider', retry.errorMessage ?? '').kind;
|
|
1121
|
-
if (kind !== 'rate-limit' && kind !== 'connection')
|
|
1122
|
-
return;
|
|
1123
|
-
if (providerFallbackInFlight !== null)
|
|
1124
|
-
return;
|
|
1125
|
-
const generation = installedGeneration;
|
|
1126
|
-
if (generation === undefined)
|
|
1127
|
-
return;
|
|
1128
|
-
const candidateSession = session;
|
|
1129
|
-
const candidateServices = services;
|
|
1130
|
-
// An exact-consult launch locks the session to its one captured model.
|
|
1131
|
-
// Other requests advance only through their declared route expansion.
|
|
1132
|
-
if (cfg.modelExact === true)
|
|
1133
|
-
return;
|
|
1134
|
-
const currentSpec = formatModelSpec(session.model, session.thinkingLevel);
|
|
1135
|
-
if (currentSpec === null)
|
|
1136
|
-
return;
|
|
1137
|
-
const routeRequest = modelRequestFromConfig(cfg.model, process.env['CRTR_MODEL_INTENT'], false);
|
|
1138
|
-
if (routeRequest !== undefined) {
|
|
1139
|
-
const routes = expandModelCandidates(routeRequest, process.env['CRTR_NODE_CWD'] ?? cfg.cwd, process.env['CRTR_PROFILE_ID']);
|
|
1140
|
-
const current = parseModelSpec(currentSpec);
|
|
1141
|
-
const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
|
|
1142
|
-
const nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => !failedRetryRoutes.has(candidate.routeId) && resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0);
|
|
1143
|
-
if (nextRoute === undefined)
|
|
1144
|
-
return;
|
|
1145
|
-
const fallbackPromise = (async () => {
|
|
1146
|
-
const target = registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
|
|
1147
|
-
if (!target)
|
|
1148
|
-
return;
|
|
1149
|
-
await candidateSession.setModel(target);
|
|
1150
|
-
if (installedGeneration !== generation || session !== candidateSession)
|
|
1151
|
-
return;
|
|
1152
|
-
candidateSession.setThinkingLevel((nextRoute.thinkingLevel ?? 'off'));
|
|
1153
|
-
failedRetryRoutes.add(nextRoute.routeId);
|
|
1154
|
-
broadcastModelChanged(false);
|
|
1155
|
-
})().catch((err) => { emitEvent({ level: 'debug', event: 'broker.runtime.diagnostic', fields: { message: `provider route fallback failed: ${err instanceof Error ? err.message : String(err)}` } }); }).finally(() => {
|
|
1156
|
-
if (installedGeneration === generation)
|
|
1157
|
-
providerFallbackInFlight = null;
|
|
1158
|
-
});
|
|
1159
|
-
providerFallbackInFlight = fallbackPromise;
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
};
|
|
1163
|
-
// A 404 or unconfigured provider is terminal to pi's ordinary retry loop.
|
|
1164
|
-
// Advance once through the request's declared route expansion, then re-drive
|
|
1165
|
-
// the turn on the selected viable route. The raw registry never supplies this
|
|
1166
|
-
// recovery path: it is an explicit routing decision, not a last-resort guess.
|
|
1167
|
-
const maybeFallbackOnUnusableModel = (event) => {
|
|
1168
|
-
if (event.type !== 'message_end')
|
|
1169
|
-
return;
|
|
1170
|
-
const msg = event.message;
|
|
1171
|
-
if (msg === undefined || msg.role !== 'assistant' || msg.stopReason !== 'error')
|
|
1172
|
-
return;
|
|
1173
|
-
const unconfigured = isProviderUnconfiguredError(msg.errorMessage ?? '');
|
|
1174
|
-
if (!unconfigured && !isModelNotFoundError(msg.errorMessage ?? ''))
|
|
1175
|
-
return;
|
|
1176
|
-
// Exact-consult launches fail closed. A concrete request's expansion already
|
|
1177
|
-
// contains only the identical model through its eligible credential sources.
|
|
1178
|
-
if (cfg.modelExact === true)
|
|
1179
|
-
return;
|
|
1180
|
-
if (notFoundFallbackInFlight !== null)
|
|
1181
|
-
return;
|
|
1182
|
-
const generation = installedGeneration;
|
|
1183
|
-
if (generation === undefined)
|
|
1184
|
-
return;
|
|
1185
|
-
const candidateSession = session;
|
|
1186
|
-
const candidateServices = services;
|
|
1187
|
-
const currentSpec = formatModelSpec(session.model, session.thinkingLevel);
|
|
1188
|
-
if (currentSpec === null)
|
|
1189
|
-
return;
|
|
1190
|
-
const current = parseModelSpec(currentSpec);
|
|
1191
|
-
// A 404 rejects the provider/model itself, not only one thinking setting.
|
|
1192
|
-
notFoundModels.add(current.modelSpec);
|
|
1193
|
-
const routeRequest = modelRequestFromConfig(cfg.model, process.env['CRTR_MODEL_INTENT'], false);
|
|
1194
|
-
const routes = routeRequest ? expandModelCandidates(routeRequest, process.env['CRTR_NODE_CWD'] ?? cfg.cwd, process.env['CRTR_PROFILE_ID']) : [];
|
|
1195
|
-
const liveRank = routes.find((candidate) => `${candidate.providerId}/${candidate.modelId}` === current.modelSpec && candidate.thinkingLevel === current.thinkingLevel)?.rank;
|
|
1196
|
-
const nextRoute = liveRank === undefined ? undefined : routes.slice(liveRank + 1).find((candidate) => {
|
|
1197
|
-
if (notFoundModels.has(`${candidate.providerId}/${candidate.modelId}`))
|
|
1198
|
-
return false;
|
|
1199
|
-
return resolveProviderCandidates([{ ...candidate, ...probeRouteAvailability(candidate, registryOf(candidateServices)), automaticFallback: true }]).candidates.length > 0;
|
|
1200
|
-
});
|
|
1201
|
-
if (!nextRoute)
|
|
1202
|
-
return;
|
|
1203
|
-
const target = `${nextRoute.providerId}/${nextRoute.modelId}${nextRoute.thinkingLevel ? `:${nextRoute.thinkingLevel}` : ''}`;
|
|
1204
|
-
const fallbackPromise = (async () => {
|
|
1205
|
-
const model = registryOf(candidateServices).find(nextRoute.providerId, nextRoute.modelId);
|
|
1206
|
-
if (model === undefined) {
|
|
1207
|
-
emitEvent({
|
|
1208
|
-
level: 'debug',
|
|
1209
|
-
event: 'broker.runtime.diagnostic',
|
|
1210
|
-
fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback skipped: ${target} is not registered` },
|
|
1211
|
-
});
|
|
1212
|
-
return;
|
|
1213
|
-
}
|
|
1214
|
-
await candidateSession.setModel(model);
|
|
1215
|
-
if (installedGeneration !== generation || session !== candidateSession)
|
|
1216
|
-
return;
|
|
1217
|
-
candidateSession.setThinkingLevel((nextRoute.thinkingLevel ?? 'off'));
|
|
1218
|
-
broadcastModelChanged();
|
|
1219
|
-
const resolved = formatModelSpec(candidateSession.model, candidateSession.thinkingLevel) ?? target;
|
|
1220
|
-
emitEvent({
|
|
1221
|
-
level: 'debug',
|
|
1222
|
-
event: 'broker.runtime.diagnostic',
|
|
1223
|
-
fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback: ${currentSpec} unavailable → ${resolved}; re-driving turn` },
|
|
1224
|
-
});
|
|
1225
|
-
// Re-drive: a 404 left the turn errored, so the model swap alone changes
|
|
1226
|
-
// nothing until the next prompt — re-issue so the now-healthy model picks
|
|
1227
|
-
// the task back up. We fire on message_end, BEFORE the errored turn has
|
|
1228
|
-
// fully settled, so wait for the engine to go idle first: prompt() THROWS
|
|
1229
|
-
// if called while streaming without a streamingBehavior, which would swap
|
|
1230
|
-
// the model but silently skip the continuation.
|
|
1231
|
-
await candidateSession.agent.waitForIdle();
|
|
1232
|
-
if (installedGeneration !== generation || session !== candidateSession)
|
|
1233
|
-
return;
|
|
1234
|
-
// The parked fatal describes a model this session no longer uses. Clearing
|
|
1235
|
-
// it after the errored turn has settled (waitForIdle is past agent_end, so
|
|
1236
|
-
// publishSettledProviderFault has already written it) keeps a recovered
|
|
1237
|
-
// node from reviving straight back into fault-recovery chrome.
|
|
1238
|
-
if (unconfigured)
|
|
1239
|
-
clearFault(nodeId, { link: 'pi→provider' });
|
|
1240
|
-
await operationIdContext.fresh(() => session.prompt(formatModelFallbackRecovery(currentSpec, resolved, unconfigured ? 'credential' : 'not-found')));
|
|
1241
|
-
})()
|
|
1242
|
-
.catch((err) => {
|
|
1243
|
-
emitEvent({
|
|
1244
|
-
level: 'debug',
|
|
1245
|
-
event: 'broker.runtime.diagnostic',
|
|
1246
|
-
fields: { message: `${unconfigured ? 'unconfigured-provider' : 'not_found'} fallback failed: ${err instanceof Error ? err.message : String(err)}` },
|
|
1247
|
-
});
|
|
1248
|
-
})
|
|
1249
|
-
.finally(() => {
|
|
1250
|
-
if (installedGeneration === generation)
|
|
1251
|
-
notFoundFallbackInFlight = null;
|
|
1252
|
-
});
|
|
1253
|
-
notFoundFallbackInFlight = fallbackPromise;
|
|
1254
|
-
};
|
|
1255
|
-
const isActiveAutoFault = (fault) => {
|
|
1256
|
-
if (fault === null)
|
|
1257
|
-
return false;
|
|
1258
|
-
if (fault.retry.disposition !== 'auto')
|
|
1259
|
-
return false;
|
|
1260
|
-
if (fault.retry.by !== 'daemon')
|
|
1261
|
-
return false;
|
|
1262
|
-
return fault.kind === 'connection' || fault.kind === 'rate-limit' || fault.kind === 'overloaded';
|
|
1263
|
-
};
|
|
1264
|
-
const faultRetryDelayMs = (fault, now) => {
|
|
1265
|
-
if (fault.retry.nextAt !== undefined) {
|
|
1266
|
-
const ms = Date.parse(fault.retry.nextAt) - now;
|
|
1267
|
-
return Number.isFinite(ms) ? Math.max(0, ms) : null;
|
|
1268
|
-
}
|
|
1269
|
-
const plan = nextFaultRetry(fault, now);
|
|
1270
|
-
return plan?.delayMs ?? null;
|
|
1271
|
-
};
|
|
1272
|
-
// Node timers accept only a signed int32 delay. Long fault deadlines (for
|
|
1273
|
-
// example an invalid credential's remediation backoff) are chunked so they
|
|
1274
|
-
// cannot overflow into an immediate retry.
|
|
1275
|
-
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
1276
|
-
const clearAndArmFaultRetry = (delayMs, generation) => {
|
|
1277
|
-
if (installedGeneration !== generation)
|
|
1278
|
-
return;
|
|
1279
|
-
clearFaultRetryTimer();
|
|
1280
|
-
const chunkMs = Math.min(Math.max(0, delayMs), MAX_TIMEOUT_MS);
|
|
1281
|
-
const remainingMs = Math.max(0, delayMs - chunkMs);
|
|
1282
|
-
faultRetryTimer = setTimeout(() => {
|
|
1283
|
-
faultRetryTimer = null;
|
|
1284
|
-
if (installedGeneration !== generation)
|
|
1285
|
-
return;
|
|
1286
|
-
if (remainingMs > 0) {
|
|
1287
|
-
clearAndArmFaultRetry(remainingMs, generation);
|
|
1288
|
-
return;
|
|
1289
|
-
}
|
|
1290
|
-
void runFaultRetry().catch((err) => {
|
|
1291
|
-
emitEvent({
|
|
1292
|
-
level: 'debug',
|
|
1293
|
-
event: 'broker.runtime.diagnostic',
|
|
1294
|
-
fields: { message: `fault retry fire failed: ${err instanceof Error ? err.message : String(err)}` },
|
|
1295
|
-
});
|
|
1296
|
-
});
|
|
1297
|
-
}, chunkMs);
|
|
1298
|
-
if (typeof faultRetryTimer.unref === 'function')
|
|
1299
|
-
faultRetryTimer.unref();
|
|
1300
|
-
};
|
|
1301
|
-
const scheduleFaultRetry = () => {
|
|
1302
|
-
const generation = installedGeneration;
|
|
1303
|
-
if (generation === undefined || session.isStreaming)
|
|
1304
|
-
return;
|
|
1305
|
-
if (faultRetryTimer !== null)
|
|
1306
|
-
return;
|
|
1307
|
-
const fault = readFault(nodeId);
|
|
1308
|
-
if (!isActiveAutoFault(fault)) {
|
|
1309
|
-
clearFaultRetryTimer();
|
|
1310
|
-
return;
|
|
1311
|
-
}
|
|
1312
|
-
const delayMs = faultRetryDelayMs(fault, Date.now());
|
|
1313
|
-
if (delayMs === null)
|
|
1314
|
-
return;
|
|
1315
|
-
clearAndArmFaultRetry(delayMs, generation);
|
|
1316
|
-
};
|
|
1317
|
-
const publishSettledProviderFault = (generation) => {
|
|
1318
|
-
const agentEnd = generation.stagedProviderAgentEnd;
|
|
1319
|
-
const overflowFailure = generation.stagedOverflowFailure;
|
|
1320
|
-
generation.stagedProviderAgentEnd = null;
|
|
1321
|
-
generation.stagedOverflowFailure = null;
|
|
1322
|
-
const settledSession = generation.session;
|
|
1323
|
-
const messages = Array.isArray(agentEnd?.messages) ? agentEnd.messages : [];
|
|
1324
|
-
const last = [...messages].reverse().find((message) => typeof message === 'object' && message !== null && message.role === 'assistant');
|
|
1325
|
-
if (overflowFailure !== null) {
|
|
1326
|
-
// The settled terminal outcome supersedes any auto marker retained while
|
|
1327
|
-
// the broker re-drove this same turn; fault priority must not preserve a
|
|
1328
|
-
// now-obsolete retry plan over Pi's definitive recovery result.
|
|
1329
|
-
clearFault(nodeId, { link: 'pi→provider' });
|
|
1330
|
-
recordFault(nodeId, {
|
|
1331
|
-
link: 'pi→provider',
|
|
1332
|
-
op: 'context overflow recovery',
|
|
1333
|
-
kind: 'context-overflow',
|
|
1334
|
-
retry: { disposition: 'fatal' },
|
|
1335
|
-
message: overflowFailure.errorMessage,
|
|
1336
|
-
anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
|
|
1337
|
-
});
|
|
1338
|
-
return;
|
|
1339
|
-
}
|
|
1340
|
-
if (last?.stopReason !== 'error')
|
|
1341
|
-
return;
|
|
1342
|
-
const raw = typeof last.errorMessage === 'string'
|
|
1343
|
-
? last.errorMessage
|
|
1344
|
-
: 'engine error (no errorMessage recorded)';
|
|
1345
|
-
const uncompactedOverflow = isContextOverflow(last, settledSession.model?.contextWindow);
|
|
1346
|
-
const classified = classify('pi→provider', last);
|
|
1347
|
-
const coolingDeadline = extractCoolingDeadline(last);
|
|
1348
|
-
if (uncompactedOverflow)
|
|
1349
|
-
clearFault(nodeId, { link: 'pi→provider' });
|
|
1350
|
-
recordFault(nodeId, {
|
|
1351
|
-
link: 'pi→provider',
|
|
1352
|
-
op: 'generation turn',
|
|
1353
|
-
kind: uncompactedOverflow ? 'context-overflow' : classified.kind,
|
|
1354
|
-
retry: uncompactedOverflow
|
|
1355
|
-
? { disposition: 'fatal' }
|
|
1356
|
-
: classified.disposition === 'auto'
|
|
1357
|
-
? { disposition: 'auto', by: 'daemon', ...(coolingDeadline !== undefined ? { nextAt: new Date(coolingDeadline).toISOString() } : {}) }
|
|
1358
|
-
: { disposition: classified.disposition },
|
|
1359
|
-
message: raw,
|
|
1360
|
-
anchorEntryId: settledSession.sessionManager.getLeafId?.() ?? undefined,
|
|
1361
|
-
});
|
|
1362
|
-
};
|
|
1363
|
-
const runFaultRetry = async () => {
|
|
1364
|
-
const generation = installedGeneration;
|
|
1365
|
-
if (generation === undefined)
|
|
1366
|
-
return;
|
|
1367
|
-
const candidateSession = generation.session;
|
|
1368
|
-
if (candidateSession.isStreaming)
|
|
1369
|
-
return;
|
|
1370
|
-
const initial = readFault(nodeId);
|
|
1371
|
-
if (!isActiveAutoFault(initial))
|
|
1372
|
-
return;
|
|
1373
|
-
let fault = initial;
|
|
1374
|
-
if (initial.kind === 'connection') {
|
|
1375
|
-
const online = await retryProbe();
|
|
1376
|
-
// The probe can take seconds. A turn may have started, or the marker may
|
|
1377
|
-
// have been cleared/replaced/recovered, in that window. Re-validate
|
|
1378
|
-
// against fresh state before re-arming or consuming an attempt —
|
|
1379
|
-
// otherwise a stale retry resurrects a recovered episode or double-drives
|
|
1380
|
-
// a now-streaming session.
|
|
1381
|
-
if (installedGeneration !== generation || session !== candidateSession || candidateSession.isStreaming)
|
|
1382
|
-
return;
|
|
1383
|
-
const fresh = readFault(nodeId);
|
|
1384
|
-
if (!isActiveAutoFault(fresh) || fresh.since !== initial.since)
|
|
1385
|
-
return;
|
|
1386
|
-
if (!online) {
|
|
1387
|
-
clearAndArmFaultRetry(policyFor(fresh.kind).baseDelayMs, generation);
|
|
1388
|
-
return;
|
|
1389
|
-
}
|
|
1390
|
-
fault = fresh;
|
|
1391
|
-
}
|
|
1392
|
-
const now = Date.now();
|
|
1393
|
-
const attempt = fault.retry.attempt ?? 0;
|
|
1394
|
-
const policy = policyFor(fault.kind);
|
|
1395
|
-
if (attempt >= policy.max) {
|
|
1396
|
-
recordFault(nodeId, markFatalOnExhaust(fault));
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
|
-
const plan = nextFaultRetry(fault, now);
|
|
1400
|
-
if (plan === null) {
|
|
1401
|
-
recordFault(nodeId, markFatalOnExhaust(fault));
|
|
1402
|
-
return;
|
|
1403
|
-
}
|
|
1404
|
-
const anchorEntryId = fault.anchorEntryId;
|
|
1405
|
-
if (anchorEntryId !== undefined) {
|
|
1406
|
-
try {
|
|
1407
|
-
const leafId = candidateSession.sessionManager.getLeafId?.() ?? null;
|
|
1408
|
-
if (leafId !== anchorEntryId) {
|
|
1409
|
-
await candidateSession.navigateTree(anchorEntryId);
|
|
1410
|
-
if (installedGeneration !== generation || session !== candidateSession)
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
catch (err) {
|
|
1415
|
-
emitEvent({
|
|
1416
|
-
level: 'debug',
|
|
1417
|
-
event: 'broker.runtime.diagnostic',
|
|
1418
|
-
fields: { message: `fault retry rewind skipped: ${err instanceof Error ? err.message : String(err)}` },
|
|
1419
|
-
});
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
if (installedGeneration !== generation || session !== candidateSession)
|
|
1423
|
-
return;
|
|
1424
|
-
recordFault(nodeId, plan.fault);
|
|
1425
|
-
await operationIdContext.fresh(() => session.prompt(fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY));
|
|
1426
|
-
};
|
|
1427
|
-
// Waiters for "the engine ACCEPTED a prompt and a turn is now running". A
|
|
1428
|
-
// routed prompt's own promise settles only when the whole TURN ends, which is
|
|
1429
|
-
// minutes too late to ack a deliver; agent_start is the engine's own signal
|
|
1430
|
-
// that the call got past its model/auth gate. The deliver ack races these two.
|
|
1431
|
-
const turnAcceptedWaiters = new Set();
|
|
1432
|
-
const notifyTurnAccepted = () => {
|
|
1433
|
-
if (turnAcceptedWaiters.size === 0)
|
|
1434
|
-
return;
|
|
1435
|
-
const waiters = [...turnAcceptedWaiters];
|
|
1436
|
-
turnAcceptedWaiters.clear();
|
|
1437
|
-
for (const waiter of waiters)
|
|
1438
|
-
waiter();
|
|
1439
|
-
};
|
|
1440
|
-
const relayEvent = (event, generation) => {
|
|
1441
|
-
maybeSwitchProviderForRetry(event);
|
|
1442
|
-
maybeFallbackOnUnusableModel(event);
|
|
1443
|
-
const type = event.type;
|
|
1444
|
-
if (type === 'agent_start') {
|
|
1445
|
-
try {
|
|
1446
|
-
workingActivity = randomWorkingActivity(readConfig('user').working_gerunds);
|
|
1447
|
-
}
|
|
1448
|
-
catch {
|
|
1449
|
-
workingActivity = DEFAULT_WORKING_ACTIVITY;
|
|
1450
|
-
}
|
|
1451
|
-
// Announce the selected label before relaying pi's verbatim agent_start so
|
|
1452
|
-
// every viewer paints this turn with the same random choice.
|
|
1453
|
-
broadcast({ type: 'working_activity', activity: workingActivity });
|
|
1454
|
-
notifyTurnAccepted();
|
|
1455
|
-
emitStartupMilestone('broker.startup.agent_start', formatModelSpec(session.model, session.thinkingLevel));
|
|
1456
|
-
}
|
|
1457
|
-
if (type === 'message_update') {
|
|
1458
|
-
const message = event.message;
|
|
1459
|
-
if (message?.role === 'assistant')
|
|
1460
|
-
emitStartupMilestone('broker.startup.provider_first_delta', formatModelSpec(session.model, session.thinkingLevel));
|
|
1461
|
-
}
|
|
1462
|
-
if (type === 'message_end') {
|
|
1463
|
-
const message = event.message;
|
|
1464
|
-
if (message?.role === 'assistant')
|
|
1465
|
-
emitStartupMilestone('broker.startup.assistant_message_end', formatModelSpec(session.model, session.thinkingLevel));
|
|
1466
|
-
}
|
|
1467
|
-
if (type === 'agent_start' || type === 'turn_start')
|
|
1468
|
-
clearFaultRetryTimer();
|
|
1469
|
-
// The watchdog covers ONLY time awaiting the provider. `session.isStreaming`
|
|
1470
|
-
// stays true through tool execution, so a legitimately silent tool lasting
|
|
1471
|
-
// longer than the timeout would otherwise be aborted mid-run: disarm while
|
|
1472
|
-
// any tool executes and rearm once the last one ends. Multiple tools can run
|
|
1473
|
-
// concurrently, so track in-flight ids and only rearm when the set drains.
|
|
1474
|
-
if (type === 'agent_end') {
|
|
1475
|
-
// Clear the in-flight set too: an abnormal run can emit agent_end after a
|
|
1476
|
-
// tool_execution_start with no matching tool_execution_end. A leftover id
|
|
1477
|
-
// would keep executingTools non-empty forever, gating every future poke
|
|
1478
|
-
// (size === 0) and permanently disarming the watchdog until a rebind.
|
|
1479
|
-
generation.executingTools.clear();
|
|
1480
|
-
streamWatchdog.reset();
|
|
1481
|
-
}
|
|
1482
|
-
else if (type === 'tool_execution_start') {
|
|
1483
|
-
generation.executingTools.add(event.toolCallId ?? '');
|
|
1484
|
-
streamWatchdog.clear();
|
|
1485
|
-
}
|
|
1486
|
-
else if (type === 'tool_execution_end') {
|
|
1487
|
-
generation.executingTools.delete(event.toolCallId ?? '');
|
|
1488
|
-
if (generation.executingTools.size === 0 && generation.session.isStreaming)
|
|
1489
|
-
streamWatchdog.poke(generation.id);
|
|
1490
|
-
}
|
|
1491
|
-
else if (generation.session.isStreaming && generation.executingTools.size === 0) {
|
|
1492
|
-
streamWatchdog.poke(generation.id);
|
|
1493
|
-
}
|
|
1494
|
-
if (type === 'message_update') {
|
|
1495
|
-
pendingUpdate = event; // latest full-message update supersedes the held one
|
|
1496
|
-
if (pendingUpdateTimer === null) {
|
|
1497
|
-
pendingUpdateTimer = setTimeout(flushPendingUpdate, MESSAGE_UPDATE_COALESCE_MS);
|
|
1498
|
-
if (typeof pendingUpdateTimer.unref === 'function')
|
|
1499
|
-
pendingUpdateTimer.unref();
|
|
1500
|
-
}
|
|
1501
|
-
return;
|
|
1502
|
-
}
|
|
1503
|
-
// Any non-update event: flush the held update FIRST so a viewer never sees
|
|
1504
|
-
// (e.g.) message_end before the update it supersedes. A refresh-yield aborts
|
|
1505
|
-
// its own turn; the visible cycle divider names that boundary, so do not also
|
|
1506
|
-
// render it as an operation failure.
|
|
1507
|
-
flushPendingUpdate();
|
|
1508
|
-
if (type === 'message_end' && getNode(nodeId)?.intent === 'refresh') {
|
|
1509
|
-
const messageEnd = event;
|
|
1510
|
-
broadcast({ ...messageEnd, message: withoutYieldAbort(messageEnd.message) });
|
|
1511
|
-
return;
|
|
1512
|
-
}
|
|
1513
|
-
broadcast(event);
|
|
1514
|
-
};
|
|
1515
|
-
// Persist the live model + thinking level into the node's durable launch
|
|
1516
|
-
// recipe so a daemon restart/revive recreates the settings the user sees.
|
|
1517
|
-
// pi's model/thinking controls only mutate the in-memory engine; without this
|
|
1518
|
-
// the node reverts to its persona/spawn settings on the next broker boot. We
|
|
1519
|
-
// write the full `provider/id:thinking` spec to BOTH model_override (so
|
|
1520
|
-
// polymorphs preserve it via buildLaunchSpec) and launch.model (the recipe
|
|
1521
|
-
// buildPiArgv replays on revive). Best-effort: a degenerate/ephemeral node
|
|
1522
|
-
// with no canvas row just skips persistence. CRTR_NODE_ID is set in the
|
|
1523
|
-
// broker's own env (merged by piInvocationToSdkConfig at boot).
|
|
1524
|
-
// `pinnedOverride`, when passed, is the durable launch-recipe decision for
|
|
1525
|
-
// an explicit model change, derived from its raw pre-normalize token. A
|
|
1526
|
-
// `userSelected` change also clears the older logical route intent: otherwise
|
|
1527
|
-
// resolveLaunchModel ignores the newly persisted concrete spec at reboot and
|
|
1528
|
-
// resolves the persona's original family/strength again. Algorithmic provider
|
|
1529
|
-
// fallback leaves that intent intact.
|
|
1530
|
-
const persistModelChoice = (pinnedOverride, userSelected = false) => {
|
|
1531
|
-
const nodeId = process.env['CRTR_NODE_ID'];
|
|
1532
|
-
const spec = formatModelSpec(session.model, session.thinkingLevel);
|
|
1533
|
-
if (nodeId === undefined || nodeId === '' || spec === null)
|
|
1534
|
-
return;
|
|
1535
|
-
try {
|
|
1536
|
-
const meta = getNode(nodeId);
|
|
1537
|
-
if (meta === null)
|
|
1538
|
-
return;
|
|
1539
|
-
const launch = meta.launch === undefined
|
|
1540
|
-
? undefined
|
|
1541
|
-
: userSelected
|
|
1542
|
-
? (() => {
|
|
1543
|
-
const { modelIntent: _modelIntent, env, ...rest } = meta.launch;
|
|
1544
|
-
const { CRTR_MODEL_INTENT: _intentEnv, ...remainingEnv } = env;
|
|
1545
|
-
return {
|
|
1546
|
-
...rest,
|
|
1547
|
-
model: spec,
|
|
1548
|
-
...(pinnedOverride !== undefined ? { modelProviderPinned: pinnedOverride } : {}),
|
|
1549
|
-
env: remainingEnv,
|
|
1550
|
-
};
|
|
1551
|
-
})()
|
|
1552
|
-
: { ...meta.launch, model: spec, ...(pinnedOverride !== undefined ? { modelProviderPinned: pinnedOverride } : {}) };
|
|
1553
|
-
if (userSelected) {
|
|
1554
|
-
delete process.env['CRTR_MODEL_INTENT'];
|
|
1555
|
-
cfg.model = spec;
|
|
1556
|
-
}
|
|
1557
|
-
updateNode(nodeId, { model_override: spec, ...(launch !== undefined ? { launch } : {}) });
|
|
1558
|
-
}
|
|
1559
|
-
catch {
|
|
1560
|
-
// Persistence is best-effort; the live switch already succeeded.
|
|
1561
|
-
}
|
|
1562
|
-
};
|
|
1563
|
-
// pi emits no AgentSessionEvent for a model switch, so after a successful
|
|
1564
|
-
// set_model/cycle_model the broker announces it itself — otherwise the new
|
|
1565
|
-
// model reaches no viewer (the requester gets only a bare ack) and every
|
|
1566
|
-
// footer shows the stale model until the next unrelated event.
|
|
1567
|
-
const broadcastModelChanged = (persist = true, pinnedOverride, userSelected = false) => {
|
|
1568
|
-
if (persist)
|
|
1569
|
-
persistModelChoice(pinnedOverride, userSelected);
|
|
1570
|
-
broadcast({ type: 'model_changed', model: isUnknownModel(session.model) ? undefined : session.model, spec: formatModelSpec(session.model, session.thinkingLevel) });
|
|
1571
|
-
};
|
|
1572
|
-
const buildSnapshot = () => ({
|
|
1573
|
-
messages: snapshotMessages(session, reviewCompanion),
|
|
1574
|
-
stats: session.getSessionStats(),
|
|
1575
|
-
// pi's `get_state` (rpc-mode.js), field for field, from the same getters.
|
|
1576
|
-
// The ONE divergence: a model-less session (undefined OR the SDK
|
|
1577
|
-
// unknown-stub) reports no model, matching broadcastModelChanged — never a
|
|
1578
|
-
// fictitious `unknown` selection.
|
|
1579
|
-
state: {
|
|
1580
|
-
model: isUnknownModel(session.model) ? undefined : session.model,
|
|
1581
|
-
thinkingLevel: session.thinkingLevel,
|
|
1582
|
-
isStreaming: session.isStreaming,
|
|
1583
|
-
isCompacting: session.isCompacting,
|
|
1584
|
-
steeringMode: session.steeringMode,
|
|
1585
|
-
followUpMode: session.followUpMode,
|
|
1586
|
-
sessionFile: session.sessionFile,
|
|
1587
|
-
sessionId: session.sessionId,
|
|
1588
|
-
sessionName: session.sessionName,
|
|
1589
|
-
autoCompactionEnabled: session.autoCompactionEnabled,
|
|
1590
|
-
messageCount: session.messages.length,
|
|
1591
|
-
pendingMessageCount: session.pendingMessageCount,
|
|
1592
|
-
},
|
|
1593
|
-
// Broker-owned levels (see BrokerSnapshot.display). Copied out so a later
|
|
1594
|
-
// mutation of the retained maps cannot alias a frame already serialized.
|
|
1595
|
-
display: {
|
|
1596
|
-
statuses: Object.fromEntries(displayStatuses),
|
|
1597
|
-
widgets: Object.fromEntries([...displayWidgets].map(([k, w]) => [k, { lines: [...w.lines], placement: w.placement }])),
|
|
1598
|
-
title: displayTitle,
|
|
1599
|
-
},
|
|
1600
|
-
toolGroupSummaries: Object.fromEntries(toolGroupSummaries),
|
|
1601
|
-
workingActivity,
|
|
1602
|
-
});
|
|
1603
|
-
// Send a client its catch-up snapshot. welcome.pending_dialog carries a single
|
|
1604
|
-
// still-in-flight dialog to a WRITABLE client attaching mid-dialog; an observer
|
|
1605
|
-
// can never answer one, so observers get null. The pendingDialogs map is
|
|
1606
|
-
// insertion-ordered — the first entry is the canonical one carried here; any
|
|
1607
|
-
// extras are replayed explicitly by the caller (rare: concurrent dialogs).
|
|
1608
|
-
const sendWelcome = (client) => {
|
|
1609
|
-
const first = client.role === 'controller' ? pendingDialogs.values().next().value : undefined;
|
|
1610
|
-
sendFrame(client, {
|
|
1611
|
-
type: 'welcome',
|
|
1612
|
-
snapshot: buildSnapshot(),
|
|
1613
|
-
role: client.role,
|
|
1614
|
-
pending_dialog: first !== undefined ? first.request : null,
|
|
1615
|
-
agentDir: getAgentDir(),
|
|
1616
|
-
}, true);
|
|
1617
|
-
// No display_* replay: the retained extension state rides IN the snapshot
|
|
1618
|
-
// (`snapshot.display`), so catch-up is one atomic frame. A viewer attaching
|
|
1619
|
-
// after an idle brief was painted still sees it on first paint, and there is
|
|
1620
|
-
// no window between `welcome` and the replay where chrome renders blank.
|
|
1621
|
-
};
|
|
1622
|
-
// Attach-mid-dialog replay: a dialog raised before this client attached is
|
|
1623
|
-
// still pending (a peer detaching never cancels one — see makeBrokerUiContext),
|
|
1624
|
-
// so a writable client joining afterwards is handed every in-flight dialog and
|
|
1625
|
-
// becomes a full peer in the race to answer it. `welcome.pending_dialog` already
|
|
1626
|
-
// carried the FIRST entry, so this replays only the extras.
|
|
1627
|
-
const replayExtraPendingDialogsTo = (client) => {
|
|
1628
|
-
const pend = [...pendingDialogs.values()];
|
|
1629
|
-
for (let i = 1; i < pend.length; i++)
|
|
1630
|
-
sendFrame(client, pend[i].request);
|
|
1631
|
-
};
|
|
1632
|
-
// After a session-replacing op (new_session/switch_session/fork) the engine's
|
|
1633
|
-
// entire message history changed, so every attached viewer must rebuild from a
|
|
1634
|
-
// fresh snapshot of the NEW session.
|
|
1635
|
-
const reWelcomeAll = () => {
|
|
1636
|
-
for (const c of clients)
|
|
1637
|
-
if (c.helloed)
|
|
1638
|
-
sendWelcome(c);
|
|
1639
|
-
};
|
|
1640
|
-
// -------------------------------------------------------------------------
|
|
1641
|
-
// The single exit helper (plan §0): dispose the engine, close + unlink the
|
|
1642
|
-
// socket, exit 0. Idempotent — every trigger converges here.
|
|
1643
|
-
// -------------------------------------------------------------------------
|
|
1644
|
-
const completeExit = (status) => {
|
|
1645
|
-
if (disposed)
|
|
1646
|
-
return;
|
|
1647
|
-
disposed = true;
|
|
1648
|
-
// Daemon teardown cannot wait for a recap's 45-second model timeout. Preserve
|
|
1649
|
-
// exactly the newest already-capped input so the successor broker can finish
|
|
1650
|
-
// it, persist the normal summary, and remove the handoff record.
|
|
1651
|
-
const pendingSummary = [...generatingToolGroups.values()].at(-1);
|
|
1652
|
-
if (pendingSummary !== undefined) {
|
|
1653
|
-
try {
|
|
1654
|
-
atomicWriteJson(pendingToolSummaryPath, pendingSummary);
|
|
1655
|
-
}
|
|
1656
|
-
catch (error) {
|
|
1657
|
-
emitEvent({
|
|
1658
|
-
level: 'warn',
|
|
1659
|
-
event: 'broker.runtime.diagnostic',
|
|
1660
|
-
fields: {
|
|
1661
|
-
message: `failed to persist pending tool summary: ${error instanceof Error ? error.message : String(error)}`,
|
|
1662
|
-
},
|
|
1663
|
-
});
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
streamWatchdog.reset();
|
|
1667
|
-
// disposeActiveSession atomically clears the process-global pointer before
|
|
1668
|
-
// disposing — whichever engine session (committed or an in-flight rebind
|
|
1669
|
-
// candidate) it currently names, matching broker-cli's fatal-handler primitive.
|
|
1670
|
-
disposeActiveSession();
|
|
1671
|
-
try {
|
|
1672
|
-
server?.close();
|
|
1673
|
-
}
|
|
1674
|
-
catch {
|
|
1675
|
-
/* ignore */
|
|
1676
|
-
}
|
|
1677
|
-
// Finite and best-effort: destroy every currently tracked client socket so
|
|
1678
|
-
// teardown below can't race a viewer write, then drop the set. Do not wait
|
|
1679
|
-
// for 'close' events.
|
|
1680
|
-
for (const client of clients) {
|
|
1681
|
-
try {
|
|
1682
|
-
client.socket.destroy();
|
|
1683
|
-
}
|
|
1684
|
-
catch {
|
|
1685
|
-
/* ignore */
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
clients.clear();
|
|
1689
|
-
try {
|
|
1690
|
-
if (existsSync(sockPath))
|
|
1691
|
-
unlinkSync(sockPath);
|
|
1692
|
-
}
|
|
1693
|
-
catch {
|
|
1694
|
-
/* ignore */
|
|
1695
|
-
}
|
|
1696
|
-
try {
|
|
1697
|
-
// A clean exit never leaves a stale presence claim behind.
|
|
1698
|
-
if (existsSync(attachPath))
|
|
1699
|
-
unlinkSync(attachPath);
|
|
1700
|
-
}
|
|
1701
|
-
catch {
|
|
1702
|
-
/* ignore */
|
|
1703
|
-
}
|
|
1704
|
-
process.exit(status);
|
|
1705
|
-
};
|
|
1706
|
-
const disposeAndExit = (reason, status = 0) => {
|
|
1707
|
-
if (disposed)
|
|
1708
|
-
return;
|
|
1709
|
-
const graceful = reason === 'shutdown-hook' || reason === 'shutdown-after-settle';
|
|
1710
|
-
// A graceful shutdown (yield, dormancy, daemon restart) is the final
|
|
1711
|
-
// boundary of the conversation's last tool group. Group state lives only in
|
|
1712
|
-
// this process, so a group left open here is unsummarizable forever and its
|
|
1713
|
-
// calls render raw in every future view of the transcript. Close it now —
|
|
1714
|
-
// generateSettledToolGroup registers its task synchronously, so the wait
|
|
1715
|
-
// below covers the summary it launches. Any pending tool arguments are
|
|
1716
|
-
// included without inventing a result; the visible abort remains outside
|
|
1717
|
-
// the recap and Ctrl+O retains every underlying call row.
|
|
1718
|
-
if (graceful)
|
|
1719
|
-
finishOpenToolGroup(true);
|
|
1720
|
-
// A normal terminal shutdown follows a visible boundary that may have just
|
|
1721
|
-
// launched a capped (45s) headless summary. Keep the broker and its child
|
|
1722
|
-
// alive until those already-started tasks persist/broadcast, rather than
|
|
1723
|
-
// process.exit() orphaning pi and losing the sidecar. Abrupt failures and
|
|
1724
|
-
// signals retain their existing immediate teardown behavior.
|
|
1725
|
-
if (graceful && summaryTasks.size > 0) {
|
|
1726
|
-
if (disposePending)
|
|
1727
|
-
return;
|
|
1728
|
-
disposePending = true;
|
|
1729
|
-
void Promise.allSettled([...summaryTasks]).finally(() => completeExit(status));
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
completeExit(status);
|
|
1733
|
-
};
|
|
1734
|
-
// A stophook can request shutdown while pi is still unwinding a completed
|
|
1735
|
-
// turn. Let that turn reach agent_settled first: inbox delivery commits its
|
|
1736
|
-
// durable cursor there, and only there. Explicit operator shutdown and OS
|
|
1737
|
-
// signals remain abrupt through disposeAndExit.
|
|
1738
|
-
const requestShutdown = () => {
|
|
1739
|
-
if (disposed || shutdownRequested)
|
|
1740
|
-
return;
|
|
1741
|
-
if (!agentRunActive) {
|
|
1742
|
-
disposeAndExit('shutdown-hook');
|
|
1743
|
-
return;
|
|
1744
|
-
}
|
|
1745
|
-
shutdownRequested = true;
|
|
1746
|
-
};
|
|
1747
|
-
// -------------------------------------------------------------------------
|
|
1748
|
-
// Extension-dialog routing (C2). makeBrokerUiContext owns the dialogPromise;
|
|
1749
|
-
// here we just give it the three broker-side hooks it needs: the live set of
|
|
1750
|
-
// writable clients (empty = nobody can answer → noOp fallback), how to forward a
|
|
1751
|
-
// dialog to one of them, and the pending-dialog registry whichever one answers
|
|
1752
|
-
// first settles through. The zero-writable path NEVER hangs and NEVER waits on a
|
|
1753
|
-
// per-dialog timeout (design §5.4's timeout premise is false — see
|
|
1754
|
-
// makeBrokerUiContext).
|
|
1755
|
-
// -------------------------------------------------------------------------
|
|
1756
|
-
const uiContext = makeBrokerUiContext({
|
|
1757
|
-
writable: writableClients,
|
|
1758
|
-
forward: (client, request) => sendFrame(client, request),
|
|
1759
|
-
pending: pendingDialogs,
|
|
1760
|
-
broadcast: broadcastUi,
|
|
1761
|
-
// A deliberate managed-provider cooldown wait is known liveness, not a wedged
|
|
1762
|
-
// stream: pause the dead-stream watchdog for its duration. Only pause for a
|
|
1763
|
-
// finite future deadline, and resume ONLY while the same turn is still
|
|
1764
|
-
// awaiting the provider (streaming, no tool running) so a wait that outlived
|
|
1765
|
-
// its turn can't rearm a stale session; otherwise fully reset.
|
|
1766
|
-
onKnownStreamWait: (deadlineMs) => {
|
|
1767
|
-
if (!Number.isFinite(deadlineMs) || deadlineMs <= Date.now())
|
|
1768
|
-
return () => { };
|
|
1769
|
-
const generation = installedGeneration;
|
|
1770
|
-
if (generation === undefined)
|
|
1771
|
-
return () => { };
|
|
1772
|
-
const waitingSession = generation.session;
|
|
1773
|
-
streamWatchdog.pause();
|
|
1774
|
-
return () => {
|
|
1775
|
-
if (installedGeneration !== generation || session !== waitingSession)
|
|
1776
|
-
return;
|
|
1777
|
-
releaseKnownStreamWait(streamWatchdog, generation.id, waitingSession.isStreaming, generation.executingTools.size > 0);
|
|
1778
|
-
};
|
|
1779
|
-
},
|
|
1780
|
-
});
|
|
1781
|
-
// -------------------------------------------------------------------------
|
|
1782
|
-
// 3+4 (plan): bind the FULL canvas extensions (mode 'rpc') AND fan the single
|
|
1783
|
-
// engine event stream to all clients, VERBATIM — both wrapped in one
|
|
1784
|
-
// ticketed rebind lane so a session-replacing op (new_session/switch_session/fork)
|
|
1785
|
-
// can re-run them against the NEW session. This mirrors pi's rpc-mode callback:
|
|
1786
|
-
// the AgentSessionRuntime tears down the old
|
|
1787
|
-
// session, builds the next, then calls this back; we re-bind extensions and
|
|
1788
|
-
// re-subscribe so the canvas hooks + the fan-out follow the live session.
|
|
1789
|
-
//
|
|
1790
|
-
// bindExtensions fires session_start → the stophook records pi_session_id/file
|
|
1791
|
-
// + recordPid(pid); the shutdownHandler is the precise broker-exit trigger (the
|
|
1792
|
-
// stophook calls ctx.shutdown() only in its done / idle-release / refresh
|
|
1793
|
-
// branches, NOT on reprompt). A requested in-turn shutdown exits after the
|
|
1794
|
-
// subscriber observes agent_settled, while every other branch stays alive. The
|
|
1795
|
-
// relay must not infer shutdown from raw agent_end: that would override the
|
|
1796
|
-
// stophook's routing decision. shutdownHandler + the inbound `shutdown` frame
|
|
1797
|
-
// are the only exit triggers.
|
|
1798
|
-
//
|
|
1799
|
-
// m7: the subscriber body is wrapped in try/catch — a synchronous throw out of
|
|
1800
|
-
// a subscriber otherwise propagates as a run failure that would crash the broker.
|
|
1801
|
-
// -------------------------------------------------------------------------
|
|
1802
|
-
let unsubscribe;
|
|
1803
|
-
const removeInstalledSubscription = () => {
|
|
1804
|
-
const installed = unsubscribe;
|
|
1805
|
-
installed?.();
|
|
1806
|
-
unsubscribe = undefined;
|
|
1807
|
-
installedGeneration = undefined;
|
|
1808
|
-
};
|
|
1809
|
-
let rebindTail = Promise.resolve();
|
|
1810
|
-
let latestRebindRequest = 0;
|
|
1811
|
-
const captureSystemPrompt = (candidateSession) => {
|
|
1812
|
-
try {
|
|
1813
|
-
const dir = jobDir(nodeId);
|
|
1814
|
-
mkdirSync(dir, { recursive: true });
|
|
1815
|
-
writeFileSync(join(dir, 'system-prompt.md'), candidateSession.systemPrompt, 'utf8');
|
|
1816
|
-
}
|
|
1817
|
-
catch {
|
|
1818
|
-
/* non-fatal: diagnostic only */
|
|
1819
|
-
}
|
|
1820
|
-
};
|
|
1821
|
-
const executeSubscriptionCallback = (generation, event) => {
|
|
1822
|
-
try {
|
|
1823
|
-
if (event.type === 'agent_start')
|
|
1824
|
-
agentRunActive = true;
|
|
1825
|
-
if (event.type === 'agent_end') {
|
|
1826
|
-
generation.stagedProviderAgentEnd = event;
|
|
1827
|
-
// A continuation after failed compaction owns the final settled outcome.
|
|
1828
|
-
generation.stagedOverflowFailure = null;
|
|
1829
|
-
}
|
|
1830
|
-
if (event.type === 'compaction_end' &&
|
|
1831
|
-
event.reason === 'overflow' &&
|
|
1832
|
-
event.willRetry === false &&
|
|
1833
|
-
typeof event.errorMessage === 'string') {
|
|
1834
|
-
generation.stagedOverflowFailure = { errorMessage: event.errorMessage };
|
|
1835
|
-
}
|
|
1836
|
-
if (event.type === 'agent_settled') {
|
|
1837
|
-
agentRunActive = false;
|
|
1838
|
-
publishSettledProviderFault(generation);
|
|
1839
|
-
if (shutdownRequested) {
|
|
1840
|
-
disposeAndExit('shutdown-after-settle');
|
|
1841
|
-
return;
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
observeToolGroupEvent(event);
|
|
1845
|
-
relayEvent(event, generation);
|
|
1846
|
-
if (event.type === 'agent_settled')
|
|
1847
|
-
scheduleFaultRetry();
|
|
1848
|
-
}
|
|
1849
|
-
catch (err) {
|
|
1850
|
-
emitEvent({
|
|
1851
|
-
level: 'debug',
|
|
1852
|
-
event: 'broker.runtime.diagnostic',
|
|
1853
|
-
fields: { message: `event relay threw: ${String(err)}` },
|
|
1854
|
-
});
|
|
1855
|
-
}
|
|
1856
|
-
};
|
|
1857
|
-
const enqueueRebind = (candidateSession, candidateServices) => {
|
|
1858
|
-
const ticket = ++latestRebindRequest;
|
|
1859
|
-
const task = rebindTail.then(async () => {
|
|
1860
|
-
if (ticket !== latestRebindRequest)
|
|
1861
|
-
return;
|
|
1862
|
-
installedGeneration?.executingTools.clear();
|
|
1863
|
-
removeInstalledSubscription();
|
|
1864
|
-
// This candidate is now the engine session a mid-rebind fatal cleanup must
|
|
1865
|
-
// dispose — set before the awaited bindExtensions so a timeout/fatal during
|
|
1866
|
-
// that await disposes the candidate rather than the stale committed session.
|
|
1867
|
-
// Shared session/services ownership still only commits below, post-await.
|
|
1868
|
-
activeSession = candidateSession;
|
|
1869
|
-
streamWatchdog.reset();
|
|
1870
|
-
agentRunActive = false;
|
|
1871
|
-
if (pendingUpdateTimer !== null) {
|
|
1872
|
-
clearTimeout(pendingUpdateTimer);
|
|
1873
|
-
pendingUpdateTimer = null;
|
|
1874
|
-
}
|
|
1875
|
-
pendingUpdate = null;
|
|
1876
|
-
clearFaultRetryTimer();
|
|
1877
|
-
providerFallbackInFlight = null;
|
|
1878
|
-
notFoundFallbackInFlight = null;
|
|
1879
|
-
reloadAuthInFlight = null;
|
|
1880
|
-
openToolGroup = undefined;
|
|
1881
|
-
toolGroupsByCallId.clear();
|
|
1882
|
-
sawAssistantProse = false;
|
|
1883
|
-
await candidateSession.bindExtensions({
|
|
1884
|
-
uiContext,
|
|
1885
|
-
// 'rpc' — NOT 'print'. Per pi docs/rpc.md:1078 this is exactly our shape:
|
|
1886
|
-
// a headless engine whose UI methods are functional through a remote client
|
|
1887
|
-
// (our uiContext relays them as `display_*` / `extension_ui_request` frames).
|
|
1888
|
-
// 'print' would tell every extension `ctx.mode === 'print'` — pi's own label
|
|
1889
|
-
// for "extensions run but can't prompt" — while we hand them a uiContext that
|
|
1890
|
-
// can. (`ctx.hasUI` is derived from uiContext identity, not from mode, so it
|
|
1891
|
-
// was already true; this fixes the mode label the extensions actually read.)
|
|
1892
|
-
// The `ctx.mode === 'tui'` guards in canvas-view/-resume/-commands stay dead.
|
|
1893
|
-
mode: 'rpc',
|
|
1894
|
-
shutdownHandler: requestShutdown,
|
|
1895
|
-
});
|
|
1896
|
-
if (ticket !== latestRebindRequest)
|
|
1897
|
-
return;
|
|
1898
|
-
removeInstalledSubscription();
|
|
1899
|
-
session = candidateSession;
|
|
1900
|
-
services = candidateServices;
|
|
1901
|
-
if (cfg.editorName !== undefined && cfg.editorName !== '') {
|
|
1902
|
-
try {
|
|
1903
|
-
candidateSession.setSessionName(cfg.editorName);
|
|
1904
|
-
}
|
|
1905
|
-
catch {
|
|
1906
|
-
/* non-fatal: naming is cosmetic */
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
const generation = {
|
|
1910
|
-
id: ++nextSubscriptionGeneration,
|
|
1911
|
-
session: candidateSession,
|
|
1912
|
-
fallback: { blocked: false },
|
|
1913
|
-
stagedProviderAgentEnd: null,
|
|
1914
|
-
stagedOverflowFailure: null,
|
|
1915
|
-
executingTools: new Set(),
|
|
1916
|
-
};
|
|
1917
|
-
unsubscribe = session.subscribe((event) => {
|
|
1918
|
-
if (installedGeneration !== generation)
|
|
1919
|
-
return;
|
|
1920
|
-
const ambientId = operationIdContext.current();
|
|
1921
|
-
if (ambientId !== undefined) {
|
|
1922
|
-
executeSubscriptionCallback(generation, event);
|
|
1923
|
-
return;
|
|
1924
|
-
}
|
|
1925
|
-
const fallback = generation.fallback;
|
|
1926
|
-
if (event.type === 'turn_start' && fallback.storedId !== undefined) {
|
|
1927
|
-
fallback.storedId = undefined;
|
|
1928
|
-
fallback.blocked = true;
|
|
1929
|
-
operationIdContext.fresh(() => {
|
|
1930
|
-
emitEvent({
|
|
1931
|
-
level: 'warn',
|
|
1932
|
-
event: 'broker.turn.fallback_overlap',
|
|
1933
|
-
fields: { generation: generation.id, engine_event: event.type },
|
|
1934
|
-
});
|
|
1935
|
-
executeSubscriptionCallback(generation, event);
|
|
1936
|
-
});
|
|
1937
|
-
return;
|
|
1938
|
-
}
|
|
1939
|
-
if (event.type === 'turn_start' && !fallback.blocked) {
|
|
1940
|
-
operationIdContext.fresh((operationId) => {
|
|
1941
|
-
fallback.storedId = operationId;
|
|
1942
|
-
executeSubscriptionCallback(generation, event);
|
|
1943
|
-
});
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
const storedId = fallback.storedId;
|
|
1947
|
-
if (storedId !== undefined && !fallback.blocked) {
|
|
1948
|
-
operationIdContext.run(storedId, () => {
|
|
1949
|
-
executeSubscriptionCallback(generation, event);
|
|
1950
|
-
if (event.type === 'agent_settled' && fallback.storedId === storedId) {
|
|
1951
|
-
fallback.storedId = undefined;
|
|
1952
|
-
}
|
|
1953
|
-
});
|
|
1954
|
-
return;
|
|
1955
|
-
}
|
|
1956
|
-
executeSubscriptionCallback(generation, event);
|
|
1957
|
-
});
|
|
1958
|
-
installedGeneration = generation;
|
|
1959
|
-
captureSystemPrompt(candidateSession);
|
|
1960
|
-
});
|
|
1961
|
-
rebindTail = task.catch(() => undefined);
|
|
1962
|
-
return task;
|
|
1963
|
-
};
|
|
1964
|
-
// Register the rebind BEFORE the first bind so a replacement triggered during
|
|
1965
|
-
// startup is honored; then use the same ticketed lane for the initial install.
|
|
1966
|
-
runtime?.setRebindSession((candidateSession) => enqueueRebind(candidateSession, runtime.services));
|
|
1967
|
-
await enqueueRebind(session, services);
|
|
1968
|
-
// -------------------------------------------------------------------------
|
|
1969
|
-
// Drive the engine on behalf of a writable client.
|
|
1970
|
-
// -------------------------------------------------------------------------
|
|
1971
|
-
// ---------------------------------------------------------------------------
|
|
1972
|
-
// Inline memory-reference inventory (design: inline memory references) — a
|
|
1973
|
-
// single broker-level memo shared by BOTH the `list_memory_refs` read-op
|
|
1974
|
-
// reply and per-submission guidance resolution below, so a burst of reads +
|
|
1975
|
-
// submissions between reloads never re-walks the corpus per call. Cleared
|
|
1976
|
-
// (work item 3) only on a SUCCESSFUL `session.reload()` — a failed reload
|
|
1977
|
-
// leaves the prior memo intact, since nothing about the corpus is known to
|
|
1978
|
-
// have changed.
|
|
1979
|
-
// ---------------------------------------------------------------------------
|
|
1980
|
-
let memoizedRefInventory = null;
|
|
1981
|
-
const getRefInventory = () => {
|
|
1982
|
-
if (memoizedRefInventory === null)
|
|
1983
|
-
memoizedRefInventory = buildRefInventory();
|
|
1984
|
-
return memoizedRefInventory;
|
|
1985
|
-
};
|
|
1986
|
-
const buildListMemoryRefsData = (id) => ({
|
|
1987
|
-
type: 'data',
|
|
1988
|
-
id,
|
|
1989
|
-
kind: 'list_memory_refs',
|
|
1990
|
-
refs: getRefInventory().refs,
|
|
1991
|
-
engineLeadingCommandNames: engineLeadingCommandTokens(session),
|
|
1992
|
-
});
|
|
1993
|
-
/** Resolve the hidden guidance text for a human frame's verbatim text, or
|
|
1994
|
-
* `null` when no guidance should be sent (a leading engine command/skill/
|
|
1995
|
-
* template, no resolvable refs, or a degraded inventory/guidance build —
|
|
1996
|
-
* work item 6: any failure here degrades to "no guidance", logged once as a
|
|
1997
|
-
* structured warning, and NEVER blocks/alters the human delivery below). */
|
|
1998
|
-
const resolveFrameGuidance = (text) => {
|
|
1999
|
-
try {
|
|
2000
|
-
if (isLeadingEngineCommand(text, session))
|
|
2001
|
-
return null;
|
|
2002
|
-
return buildGuidance(text, getRefInventory().names);
|
|
2003
|
-
}
|
|
2004
|
-
catch (err) {
|
|
2005
|
-
emitEvent({
|
|
2006
|
-
level: 'warn',
|
|
2007
|
-
event: 'broker.memory_refs.guidance_resolve_failed',
|
|
2008
|
-
error: err,
|
|
2009
|
-
});
|
|
2010
|
-
return null;
|
|
2011
|
-
}
|
|
2012
|
-
};
|
|
2013
|
-
// Prompt dispatches whose promise has not yet settled. `session.prompt()`
|
|
2014
|
-
// does async setup (input transforms, model/auth validation, compaction)
|
|
2015
|
-
// BEFORE `_isAgentRunActive` flips, so for a short window after a routed
|
|
2016
|
-
// deliver the engine reads idle and `session.abort()` is a silent no-op — an
|
|
2017
|
-
// id-bearing abort racing that window would miss the turn entirely (verified
|
|
2018
|
-
// live). The counter lets it distinguish "genuinely idle" from "turn still
|
|
2019
|
-
// igniting" and wait for the run to become abortable.
|
|
2020
|
-
let pendingPromptDispatches = 0;
|
|
2021
|
-
const trackPromptDispatch = (p) => {
|
|
2022
|
-
pendingPromptDispatches++;
|
|
2023
|
-
const tracked = Promise.resolve(p);
|
|
2024
|
-
void tracked.finally(() => {
|
|
2025
|
-
pendingPromptDispatches--;
|
|
2026
|
-
});
|
|
2027
|
-
return tracked;
|
|
2028
|
-
};
|
|
2029
|
-
/**
|
|
2030
|
-
* Drive the engine for one client frame. For a prompt/follow_up frame this
|
|
2031
|
-
* returns a promise that settles with the engine call's OUTCOME — `undefined`
|
|
2032
|
-
* when the call succeeded, the rejection value when it did not — so a caller
|
|
2033
|
-
* that must report success (the `deliver` ack) can read it instead of assuming
|
|
2034
|
-
* the dispatch worked. Every other frame type keeps the fire-and-forget shape
|
|
2035
|
-
* and returns `undefined`.
|
|
2036
|
-
*/
|
|
2037
|
-
const driveEngine = (client, frame) => {
|
|
2038
|
-
// The engine call's failure, captured for the returned outcome. relayError
|
|
2039
|
-
// still relays the error frame; recording it is what lets the caller ack
|
|
2040
|
-
// from the outcome rather than from the fact that a call was made at all.
|
|
2041
|
-
let failure;
|
|
2042
|
-
const relayError = (err) => {
|
|
2043
|
-
failure = err;
|
|
2044
|
-
sendFrame(client, { type: 'error', code: 'engine_error', message: String(err) });
|
|
2045
|
-
};
|
|
2046
|
-
// `prompt { interrupt: true }` is one atomic viewer intention: end whatever
|
|
2047
|
-
// is producing now, then start this as a new turn. A normal mid-stream
|
|
2048
|
-
// prompt deliberately steers into pi's queue; the whip must not.
|
|
2049
|
-
if (frame.type === 'prompt' && frame.interrupt === true) {
|
|
2050
|
-
return (async () => {
|
|
2051
|
-
if (session.isBashRunning) {
|
|
2052
|
-
session.abortBash();
|
|
2053
|
-
}
|
|
2054
|
-
else {
|
|
2055
|
-
// A prompt can be in pi's async setup before isStreaming flips. Match
|
|
2056
|
-
// the correlated interrupt's guard so a whip cannot silently miss it.
|
|
2057
|
-
if (!session.isStreaming && pendingPromptDispatches > 0) {
|
|
2058
|
-
const deadline = Date.now() + 5_000;
|
|
2059
|
-
while (Date.now() < deadline && !session.isStreaming && pendingPromptDispatches > 0) {
|
|
2060
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
if (session.isStreaming)
|
|
2064
|
-
await session.abort();
|
|
2065
|
-
}
|
|
2066
|
-
return driveEngine(client, {
|
|
2067
|
-
type: 'prompt',
|
|
2068
|
-
text: frame.text,
|
|
2069
|
-
...(frame.images !== undefined ? { images: frame.images } : {}),
|
|
2070
|
-
});
|
|
2071
|
-
})().catch((err) => {
|
|
2072
|
-
relayError(err);
|
|
2073
|
-
return failure;
|
|
2074
|
-
});
|
|
2075
|
-
}
|
|
2076
|
-
// Hidden `crtr-memory-refs` guidance rides ALONGSIDE the human frame's own
|
|
2077
|
-
// engine call, never rewriting `frame.text` — see resolveFrameGuidance /
|
|
2078
|
-
// chooseGuidanceDeliveryMode above. A failed SEND (as opposed to a failed
|
|
2079
|
-
// resolve, handled above) is likewise logged-and-swallowed: it must never
|
|
2080
|
-
// reach `relayError`, which is reserved for the human call's own outcome.
|
|
2081
|
-
const sendGuidance = (guidance, deliverAs) => session
|
|
2082
|
-
.sendCustomMessage({ customType: 'crtr-memory-refs', content: guidance, display: false }, { deliverAs })
|
|
2083
|
-
.catch((err) => {
|
|
2084
|
-
emitEvent({
|
|
2085
|
-
level: 'warn',
|
|
2086
|
-
event: 'broker.memory_refs.guidance_send_failed',
|
|
2087
|
-
error: err,
|
|
2088
|
-
});
|
|
2089
|
-
});
|
|
2090
|
-
switch (frame.type) {
|
|
2091
|
-
case 'prompt':
|
|
2092
|
-
case 'follow_up': {
|
|
2093
|
-
// Settle on the engine call's outcome: `failure` is set by relayError
|
|
2094
|
-
// before the caught promise resolves, so reading it after the chain is
|
|
2095
|
-
// the call's verdict.
|
|
2096
|
-
const settle = (p) => p.then(() => failure);
|
|
2097
|
-
const dispatch = () => {
|
|
2098
|
-
const route = resolveEngineRoute(frame, session.isStreaming);
|
|
2099
|
-
const guidance = resolveFrameGuidance(frame.text);
|
|
2100
|
-
if (guidance === null) {
|
|
2101
|
-
if (route.call === 'prompt') {
|
|
2102
|
-
// A routed prompt STARTS a turn — including an idle follow_up
|
|
2103
|
-
// escalated by m-C — so it gets a fresh operation id. A true
|
|
2104
|
-
// (mid-stream) followUp rides the current turn's operation, like
|
|
2105
|
-
// steer.
|
|
2106
|
-
return settle(trackPromptDispatch(operationIdContext.fresh(() => session.prompt(frame.text, route.options).catch(relayError))));
|
|
2107
|
-
}
|
|
2108
|
-
// C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
|
|
2109
|
-
return settle(session.followUp(frame.text, frame.images).catch(relayError));
|
|
2110
|
-
}
|
|
2111
|
-
const deliverAs = chooseGuidanceDeliveryMode(frame.type, route.call, session.isStreaming);
|
|
2112
|
-
if (route.call === 'prompt') {
|
|
2113
|
-
if (deliverAs === 'nextTurn') {
|
|
2114
|
-
// The ONLY ordering where guidance precedes the human call — pi's
|
|
2115
|
-
// prompt() reads+clears its pending-nextTurn queue synchronously
|
|
2116
|
-
// at call time, injecting it into the SAME turn as the new user
|
|
2117
|
-
// message. promptWithNextTurnGuidance queues the guidance and then
|
|
2118
|
-
// ATOMICALLY clears its own pending entry if prompt() settles
|
|
2119
|
-
// without ever reaching that drain (an early throw/return leaves
|
|
2120
|
-
// nothing stranded to leak into a later, unrelated turn).
|
|
2121
|
-
return settle(trackPromptDispatch(promptWithNextTurnGuidance(session, guidance, () => operationIdContext.fresh(() => session.prompt(frame.text, route.options))).catch(relayError)));
|
|
2122
|
-
}
|
|
2123
|
-
return settle(trackPromptDispatch(operationIdContext.fresh(() => session.prompt(frame.text, route.options).catch(relayError).then(() => sendGuidance(guidance, deliverAs)))));
|
|
2124
|
-
}
|
|
2125
|
-
// deliverAs is always 'followUp' here per chooseGuidanceDeliveryMode's
|
|
2126
|
-
// contract (a followUp routeCall never yields 'nextTurn').
|
|
2127
|
-
return settle(session.followUp(frame.text, frame.images).catch(relayError).then(() => sendGuidance(guidance, deliverAs)));
|
|
2128
|
-
};
|
|
2129
|
-
// /login acknowledges before its asynchronous local refresh completes.
|
|
2130
|
-
// An idle turn must not overtake that refresh and launch on the stale
|
|
2131
|
-
// route; true mid-stream follow-ups keep their immediate queue behavior.
|
|
2132
|
-
const authReload = session.isStreaming ? undefined : reloadAuthInFlight?.promise;
|
|
2133
|
-
if (authReload === undefined)
|
|
2134
|
-
return dispatch();
|
|
2135
|
-
const waitingGeneration = installedGeneration;
|
|
2136
|
-
const waitingSession = session;
|
|
2137
|
-
return authReload.then(() => {
|
|
2138
|
-
if (installedGeneration !== waitingGeneration || session !== waitingSession) {
|
|
2139
|
-
relayError(new Error('session changed while prompt waited for authentication reload'));
|
|
2140
|
-
return failure;
|
|
2141
|
-
}
|
|
2142
|
-
return dispatch();
|
|
2143
|
-
});
|
|
2144
|
-
}
|
|
2145
|
-
case 'steer': {
|
|
2146
|
-
// A viewer submits ordinary text as `steer` while the agent is running,
|
|
2147
|
-
// including a slash command injected by the tmux prefix menu. pi's
|
|
2148
|
-
// steer() deliberately rejects registered extension commands because
|
|
2149
|
-
// they cannot be queued; prompt() performs the command preflight first
|
|
2150
|
-
// and executes it immediately even while streaming. It also expands a
|
|
2151
|
-
// matching skill/template before applying the same steer behavior.
|
|
2152
|
-
if (resolveSteerCall(frame.text, session) === 'prompt') {
|
|
2153
|
-
void session.prompt(frame.text, { images: frame.images, streamingBehavior: 'steer' }).catch(relayError);
|
|
2154
|
-
break;
|
|
2155
|
-
}
|
|
2156
|
-
const guidance = resolveFrameGuidance(frame.text);
|
|
2157
|
-
// C1: pi's steer() takes images as a POSITIONAL 2nd arg (`steer(text, images?)`).
|
|
2158
|
-
if (guidance === null) {
|
|
2159
|
-
void session.steer(frame.text, frame.images).catch(relayError);
|
|
2160
|
-
break;
|
|
2161
|
-
}
|
|
2162
|
-
void session.steer(frame.text, frame.images).catch(relayError).then(() => sendGuidance(guidance, 'steer'));
|
|
2163
|
-
break;
|
|
2164
|
-
}
|
|
2165
|
-
case 'abort': {
|
|
2166
|
-
// esc/abort cancels whatever is live: a running `!` command (abortBash)
|
|
2167
|
-
// takes precedence over the agent turn, mirroring pi interactive mode.
|
|
2168
|
-
if (session.isBashRunning) {
|
|
2169
|
-
session.abortBash();
|
|
2170
|
-
if (frame.id !== undefined)
|
|
2171
|
-
ackTo(client, 'abort', true, 'bash', frame.id);
|
|
2172
|
-
break;
|
|
2173
|
-
}
|
|
2174
|
-
if (frame.id === undefined) {
|
|
2175
|
-
// A viewer's Esc — unchanged instant semantics, no ack.
|
|
2176
|
-
void session.abort().catch(relayError);
|
|
2177
|
-
break;
|
|
2178
|
-
}
|
|
2179
|
-
// An id-bearing abort (a one-shot daemon interrupt) must close the
|
|
2180
|
-
// pre-turn window: a routed deliver whose prompt() is still in async
|
|
2181
|
-
// setup reads as idle and abort() would silently miss it. If a prompt
|
|
2182
|
-
// dispatch is pending, wait (bounded) for the run to become abortable
|
|
2183
|
-
// — or for every dispatch to settle without one (an early return) —
|
|
2184
|
-
// then abort and ack with what was actually interrupted.
|
|
2185
|
-
void (async () => {
|
|
2186
|
-
try {
|
|
2187
|
-
if (!session.isStreaming && pendingPromptDispatches > 0) {
|
|
2188
|
-
const deadline = Date.now() + 5_000;
|
|
2189
|
-
while (Date.now() < deadline && !session.isStreaming && pendingPromptDispatches > 0) {
|
|
2190
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
const detail = session.isStreaming ? 'turn' : 'idle';
|
|
2194
|
-
await session.abort();
|
|
2195
|
-
ackTo(client, 'abort', true, detail, frame.id);
|
|
2196
|
-
}
|
|
2197
|
-
catch (err) {
|
|
2198
|
-
engineErrorTo(client, frame.id)(err);
|
|
2199
|
-
}
|
|
2200
|
-
})();
|
|
2201
|
-
break;
|
|
2202
|
-
}
|
|
2203
|
-
case 'bash': {
|
|
2204
|
-
// `!`/`!!` bash: run via session.executeBash (records a bashExecution
|
|
2205
|
-
// context message, starts NO agent turn) and relay its lifecycle to ALL
|
|
2206
|
-
// viewers as bash_start/bash_output/bash_end so each renders the live
|
|
2207
|
-
// component. Serialized like pi's interactive mode — reject a second `!`
|
|
2208
|
-
// while one is already running rather than racing _bashAbortController.
|
|
2209
|
-
const command = frame.command;
|
|
2210
|
-
if (command.trim() === '')
|
|
2211
|
-
break;
|
|
2212
|
-
if (session.isBashRunning) {
|
|
2213
|
-
sendFrame(client, {
|
|
2214
|
-
type: 'error',
|
|
2215
|
-
code: 'bash_busy',
|
|
2216
|
-
message: 'A bash command is already running',
|
|
2217
|
-
});
|
|
2218
|
-
break;
|
|
2219
|
-
}
|
|
2220
|
-
const exclude = frame.excludeFromContext === true;
|
|
2221
|
-
// Order before any held assistant message_update, so a `!` run mid-stream
|
|
2222
|
-
// never renders ahead of the update it follows.
|
|
2223
|
-
flushPendingUpdate();
|
|
2224
|
-
// The live bash component is a visible transcript boundary, even though
|
|
2225
|
-
// its persisted bashExecution message arrives later via the session.
|
|
2226
|
-
finishOpenToolGroup();
|
|
2227
|
-
broadcast({ type: 'bash_start', command, excludeFromContext: exclude });
|
|
2228
|
-
void session
|
|
2229
|
-
.executeBash(command, (chunk) => broadcast({ type: 'bash_output', chunk }), {
|
|
2230
|
-
excludeFromContext: exclude,
|
|
2231
|
-
})
|
|
2232
|
-
.then((result) => {
|
|
2233
|
-
broadcast({
|
|
2234
|
-
type: 'bash_end',
|
|
2235
|
-
exitCode: result.exitCode,
|
|
2236
|
-
cancelled: result.cancelled,
|
|
2237
|
-
truncated: result.truncated,
|
|
2238
|
-
fullOutputPath: result.fullOutputPath,
|
|
2239
|
-
});
|
|
2240
|
-
})
|
|
2241
|
-
.catch(relayError);
|
|
2242
|
-
break;
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
};
|
|
2246
|
-
// -------------------------------------------------------------------------
|
|
2247
|
-
// Command-op helpers (T3, §2.3). The write guard is hoisted here (it was
|
|
2248
|
-
// inlined twice) and reused by all mutating ops; the ack/error replies
|
|
2249
|
-
// and a few resolvers keep the per-op cases one-liners.
|
|
2250
|
-
// -------------------------------------------------------------------------
|
|
2251
|
-
/** Reject a read-only client for a mutating op. The decision reads this
|
|
2252
|
-
* client's OWN fixed role — there is no shared writer slot, so any number of
|
|
2253
|
-
* writable clients pass concurrently. Returns true when rejected (the caller
|
|
2254
|
-
* should `break`). */
|
|
2255
|
-
const notWritable = (client, what, id) => {
|
|
2256
|
-
if (client.role === 'controller')
|
|
2257
|
-
return false;
|
|
2258
|
-
sendFrame(client, {
|
|
2259
|
-
type: 'error',
|
|
2260
|
-
code: 'read_only',
|
|
2261
|
-
message: `a read-only client may not ${what}`,
|
|
2262
|
-
// M1: echo a correlated request's id so its pending-by-id promise rejects
|
|
2263
|
-
// rather than hanging (e.g. a read-only `dequeue`).
|
|
2264
|
-
...(id !== undefined ? { id } : {}),
|
|
2265
|
-
});
|
|
2266
|
-
return true;
|
|
2267
|
-
};
|
|
2268
|
-
const ackTo = (client, op, ok = true, detail, id) => sendFrame(client, {
|
|
2269
|
-
type: 'ack',
|
|
2270
|
-
for: op,
|
|
2271
|
-
ok,
|
|
2272
|
-
...(detail !== undefined ? { detail } : {}),
|
|
2273
|
-
...(id !== undefined ? { id } : {}),
|
|
2274
|
-
});
|
|
2275
|
-
const engineErrorTo = (client, id) => (err) => sendFrame(client, {
|
|
2276
|
-
type: 'error',
|
|
2277
|
-
code: 'engine_error',
|
|
2278
|
-
message: String(err),
|
|
2279
|
-
// M1: a correlated read-op / dequeue failure echoes its id so the viewer's
|
|
2280
|
-
// pending-by-id promise rejects instead of hanging; uncorrelated callers
|
|
2281
|
-
// pass no id (unchanged behavior).
|
|
2282
|
-
...(id !== undefined ? { id } : {}),
|
|
2283
|
-
});
|
|
2284
|
-
const isPreBoundaryReviewEntry = (entryId) => reviewCompanion &&
|
|
2285
|
-
!session.sessionManager.getBranch(entryId).some((entry) => entry.type === 'custom_message' && entry.customType === REVIEW_BOUNDARY_CUSTOM_TYPE);
|
|
2286
|
-
const reviewBoundaryRefusalTo = (client, targetId) => {
|
|
2287
|
-
const refusal = new ReviewBoundaryNavigationError(targetId);
|
|
2288
|
-
sendFrame(client, {
|
|
2289
|
-
type: 'error',
|
|
2290
|
-
code: refusal.code,
|
|
2291
|
-
message: refusal.message,
|
|
2292
|
-
});
|
|
2293
|
-
};
|
|
2294
|
-
/** Resolve a `provider/id` model spec against the LIVE services registry (which
|
|
2295
|
-
* carries any extension-registered providers). Pure: returns undefined on a
|
|
2296
|
-
* malformed spec or an unknown model. */
|
|
2297
|
-
const findModelSpec = (spec) => {
|
|
2298
|
-
const { modelSpec } = parseModelSpec(spec);
|
|
2299
|
-
const slash = modelSpec.indexOf('/');
|
|
2300
|
-
if (slash <= 0)
|
|
2301
|
-
return undefined;
|
|
2302
|
-
return registryOf(services).find(modelSpec.slice(0, slash), modelSpec.slice(slash + 1));
|
|
2303
|
-
};
|
|
2304
|
-
/** Resolve a model from a free-text query — `/model opus`, `/model fable`. Tries
|
|
2305
|
-
* the exact `provider/id` spec first; failing that, searches the registry the
|
|
2306
|
-
* way the picker's search box does (case-insensitive substring on the model id
|
|
2307
|
-
* and the `provider/id` key) and returns the BEST match, preferring authed/
|
|
2308
|
-
* available models. Returns undefined only when nothing matches. */
|
|
2309
|
-
const resolveModelQuery = (query) => {
|
|
2310
|
-
const exact = findModelSpec(query);
|
|
2311
|
-
if (exact)
|
|
2312
|
-
return exact;
|
|
2313
|
-
const q = query.trim().toLowerCase();
|
|
2314
|
-
if (q === '')
|
|
2315
|
-
return undefined;
|
|
2316
|
-
let all;
|
|
2317
|
-
try {
|
|
2318
|
-
all = registryOf(services).getAll();
|
|
2319
|
-
}
|
|
2320
|
-
catch {
|
|
2321
|
-
return undefined;
|
|
2322
|
-
}
|
|
2323
|
-
let availableKeys;
|
|
2324
|
-
try {
|
|
2325
|
-
availableKeys = new Set(registryOf(services).getAvailable().map(modelKey));
|
|
2326
|
-
}
|
|
2327
|
-
catch {
|
|
2328
|
-
availableKeys = new Set();
|
|
2329
|
-
}
|
|
2330
|
-
// Rank: exact id (100) > exact provider/id (90) > id prefix (80) > id
|
|
2331
|
-
// substring (60) > provider/id substring (40); +5 if the model is available.
|
|
2332
|
-
const rank = (m) => {
|
|
2333
|
-
const id = m.id.toLowerCase();
|
|
2334
|
-
const key = modelKey(m).toLowerCase();
|
|
2335
|
-
let base = -1;
|
|
2336
|
-
if (id === q)
|
|
2337
|
-
base = 100;
|
|
2338
|
-
else if (key === q)
|
|
2339
|
-
base = 90;
|
|
2340
|
-
else if (id.startsWith(q))
|
|
2341
|
-
base = 80;
|
|
2342
|
-
else if (id.includes(q))
|
|
2343
|
-
base = 60;
|
|
2344
|
-
else if (key.includes(q))
|
|
2345
|
-
base = 40;
|
|
2346
|
-
else
|
|
2347
|
-
return -1;
|
|
2348
|
-
return base + (availableKeys.has(modelKey(m)) ? 5 : 0);
|
|
2349
|
-
};
|
|
2350
|
-
let best;
|
|
2351
|
-
let bestRank = 0;
|
|
2352
|
-
for (const m of all) {
|
|
2353
|
-
const r = rank(m);
|
|
2354
|
-
if (r > bestRank) {
|
|
2355
|
-
bestRank = r;
|
|
2356
|
-
best = m;
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
return best;
|
|
2360
|
-
};
|
|
2361
|
-
const buildCommandList = () => {
|
|
2362
|
-
const out = [];
|
|
2363
|
-
for (const b of BUILTIN_SLASH_COMMANDS) {
|
|
2364
|
-
out.push({ name: b.name, description: b.description, source: 'builtin' });
|
|
2365
|
-
}
|
|
2366
|
-
try {
|
|
2367
|
-
for (const c of session.extensionRunner.getRegisteredCommands()) {
|
|
2368
|
-
const expansion = c.expansion;
|
|
2369
|
-
out.push({
|
|
2370
|
-
name: c.invocationName,
|
|
2371
|
-
description: c.description ?? '',
|
|
2372
|
-
source: 'command',
|
|
2373
|
-
...(isDeterministicCommandExpansion(expansion) ? { expansion } : {}),
|
|
2374
|
-
});
|
|
2375
|
-
}
|
|
2376
|
-
}
|
|
2377
|
-
catch {
|
|
2378
|
-
/* an engine without a live extensionRunner (e.g. the fake) — builtins only */
|
|
2379
|
-
}
|
|
2380
|
-
try {
|
|
2381
|
-
for (const t of session.promptTemplates) {
|
|
2382
|
-
out.push({ name: t.name, description: t.description, source: 'template', template: t.content });
|
|
2383
|
-
}
|
|
2384
|
-
}
|
|
2385
|
-
catch {
|
|
2386
|
-
/* ignore */
|
|
2387
|
-
}
|
|
2388
|
-
return out;
|
|
2389
|
-
};
|
|
2390
|
-
// -------------------------------------------------------------------------
|
|
2391
|
-
// Read-op data builders (operator-view picker payloads, §5 Unit A). Each is a
|
|
2392
|
-
// PURE getter read against the live engine session — the data a native pi
|
|
2393
|
-
// picker's constructor needs, serialized for the viewer. Not role-gated
|
|
2394
|
-
// (read-only, like get_commands), so the web bridge's observer connection can
|
|
2395
|
-
// populate pickers too.
|
|
2396
|
-
// -------------------------------------------------------------------------
|
|
2397
|
-
// A model-less session's model is EITHER undefined OR the SDK unknown-stub;
|
|
2398
|
-
// both map to the WireModelRef no-model value (null), so list_models.current
|
|
2399
|
-
// and get_settings.settings.model never disclose a fictitious `unknown/unknown`.
|
|
2400
|
-
const toModelRef = (m) => m === undefined || isUnknownModel(m) ? null : { provider: m.provider, id: m.id };
|
|
2401
|
-
const modelKey = (m) => `${m.provider}/${m.id}`;
|
|
2402
|
-
const buildListModelsData = (id) => {
|
|
2403
|
-
const reg = registryOf(session);
|
|
2404
|
-
let availableIds = [];
|
|
2405
|
-
try {
|
|
2406
|
-
availableIds = reg.getAvailable().map(modelKey);
|
|
2407
|
-
}
|
|
2408
|
-
catch {
|
|
2409
|
-
/* getAvailable touches auth config; degrade to "none known available" */
|
|
2410
|
-
}
|
|
2411
|
-
return {
|
|
2412
|
-
type: 'data',
|
|
2413
|
-
id,
|
|
2414
|
-
kind: 'list_models',
|
|
2415
|
-
models: reg.getAll(),
|
|
2416
|
-
current: toModelRef(session.model),
|
|
2417
|
-
availableIds,
|
|
2418
|
-
scopedModels: session.scopedModels.map((s) => ({
|
|
2419
|
-
model: s.model,
|
|
2420
|
-
thinkingLevel: s.thinkingLevel,
|
|
2421
|
-
})),
|
|
2422
|
-
enabledModelIds: session.settingsManager.getEnabledModels() ?? null,
|
|
2423
|
-
};
|
|
2424
|
-
};
|
|
2425
|
-
const buildScopedModelsData = (id) => ({
|
|
2426
|
-
type: 'data',
|
|
2427
|
-
id,
|
|
2428
|
-
kind: 'list_scoped_models',
|
|
2429
|
-
allModels: registryOf(session).getAll(),
|
|
2430
|
-
enabledModelIds: session.settingsManager.getEnabledModels() ?? null,
|
|
2431
|
-
});
|
|
2432
|
-
const buildGetTreeData = (id) => {
|
|
2433
|
-
const sm = session.sessionManager;
|
|
2434
|
-
const tree = reviewCompanion ? boundaryRootedTree(sm.getTree()) : sm.getTree();
|
|
2435
|
-
const visibleEntryIds = new Set();
|
|
2436
|
-
const collectEntryIds = (nodes) => {
|
|
2437
|
-
for (const node of nodes) {
|
|
2438
|
-
visibleEntryIds.add(node.entry.id);
|
|
2439
|
-
collectEntryIds(node.children);
|
|
2440
|
-
}
|
|
2441
|
-
};
|
|
2442
|
-
collectEntryIds(tree);
|
|
2443
|
-
const forkPoints = session.getUserMessagesForForking()
|
|
2444
|
-
.filter((u) => visibleEntryIds.has(u.entryId))
|
|
2445
|
-
.map((u) => ({
|
|
2446
|
-
id: u.entryId,
|
|
2447
|
-
text: u.text,
|
|
2448
|
-
timestamp: sm.getEntry(u.entryId)?.timestamp,
|
|
2449
|
-
}));
|
|
2450
|
-
const currentLeafId = sm.getLeafId();
|
|
2451
|
-
return {
|
|
2452
|
-
type: 'data',
|
|
2453
|
-
id,
|
|
2454
|
-
kind: 'get_tree',
|
|
2455
|
-
tree,
|
|
2456
|
-
currentLeafId: currentLeafId !== null && visibleEntryIds.has(currentLeafId) ? currentLeafId : null,
|
|
2457
|
-
forkPoints,
|
|
2458
|
-
};
|
|
2459
|
-
};
|
|
2460
|
-
const buildSettingsData = (id) => {
|
|
2461
|
-
const sm = session.settingsManager;
|
|
2462
|
-
// Theme names are enumerable broker-side via the resource loader. The theme
|
|
2463
|
-
// SUBMENU itself is viewer-local (theme is a viewer-only concern), but the
|
|
2464
|
-
// settings menu still shows currentTheme/availableThemes, so include them.
|
|
2465
|
-
let availableThemes = [];
|
|
2466
|
-
try {
|
|
2467
|
-
availableThemes = session.resourceLoader
|
|
2468
|
-
.getThemes()
|
|
2469
|
-
.themes.map((t) => t.name ?? '')
|
|
2470
|
-
.filter((n) => n !== '');
|
|
2471
|
-
}
|
|
2472
|
-
catch {
|
|
2473
|
-
/* loader without themes — viewer falls back to its local theme registry */
|
|
2474
|
-
}
|
|
2475
|
-
const settings = {
|
|
2476
|
-
autoCompact: session.autoCompactionEnabled,
|
|
2477
|
-
showImages: sm.getShowImages(),
|
|
2478
|
-
imageWidthCells: sm.getImageWidthCells(),
|
|
2479
|
-
autoResizeImages: sm.getImageAutoResize(),
|
|
2480
|
-
blockImages: sm.getBlockImages(),
|
|
2481
|
-
enableSkillCommands: sm.getEnableSkillCommands(),
|
|
2482
|
-
steeringMode: session.steeringMode,
|
|
2483
|
-
followUpMode: session.followUpMode,
|
|
2484
|
-
transport: sm.getTransport(),
|
|
2485
|
-
httpIdleTimeoutMs: sm.getHttpIdleTimeoutMs(),
|
|
2486
|
-
thinkingLevel: session.thinkingLevel,
|
|
2487
|
-
availableThinkingLevels: session.getAvailableThinkingLevels(),
|
|
2488
|
-
currentTheme: sm.getTheme() ?? '',
|
|
2489
|
-
// Light/dark appearance is a property of the terminal the VIEWER is attached
|
|
2490
|
-
// to; the headless broker has none, so emit a placeholder. The viewer owns
|
|
2491
|
-
// the theme submenu and resolves real terminal appearance locally.
|
|
2492
|
-
terminalTheme: 'dark',
|
|
2493
|
-
availableThemes,
|
|
2494
|
-
hideThinkingBlock: sm.getHideThinkingBlock(),
|
|
2495
|
-
collapseChangelog: sm.getCollapseChangelog(),
|
|
2496
|
-
enableInstallTelemetry: sm.getEnableInstallTelemetry(),
|
|
2497
|
-
doubleEscapeAction: sm.getDoubleEscapeAction(),
|
|
2498
|
-
treeFilterMode: sm.getTreeFilterMode(),
|
|
2499
|
-
showHardwareCursor: sm.getShowHardwareCursor(),
|
|
2500
|
-
editorPaddingX: sm.getEditorPaddingX(),
|
|
2501
|
-
autocompleteMaxVisible: sm.getAutocompleteMaxVisible(),
|
|
2502
|
-
quietStartup: sm.getQuietStartup(),
|
|
2503
|
-
clearOnShrink: sm.getClearOnShrink(),
|
|
2504
|
-
showTerminalProgress: sm.getShowTerminalProgress(),
|
|
2505
|
-
warnings: sm.getWarnings(),
|
|
2506
|
-
defaultProjectTrust: sm.getDefaultProjectTrust(),
|
|
2507
|
-
showCacheMissNotices: sm.getShowCacheMissNotices(),
|
|
2508
|
-
outputPad: sm.getOutputPad(),
|
|
2509
|
-
autoRetry: session.autoRetryEnabled,
|
|
2510
|
-
model: toModelRef(session.model),
|
|
2511
|
-
};
|
|
2512
|
-
return { type: 'data', id, kind: 'get_settings', settings };
|
|
2513
|
-
};
|
|
2514
|
-
// Cache per-session wire info keyed by (path, mtime, size) so reopening the
|
|
2515
|
-
// picker doesn't re-read the whole corpus (see session-list-cache.ts).
|
|
2516
|
-
const sessionListCache = new SessionListCache(sessionListCachePath());
|
|
2517
|
-
// Pre-warm the cwd scope (the picker's default tab) in the background so the
|
|
2518
|
-
// first /resume open on this fresh broker is already warm.
|
|
2519
|
-
sessionListCache.prewarm(session.sessionManager.getSessionDir());
|
|
2520
|
-
const buildSessionsData = async (id, scope) => {
|
|
2521
|
-
const sm = session.sessionManager;
|
|
2522
|
-
const sessionDir = sm.getSessionDir();
|
|
2523
|
-
const sessions = scope === 'all'
|
|
2524
|
-
? await sessionListCache.listAll(piSessionsRoot())
|
|
2525
|
-
: await sessionListCache.listDir(sessionDir);
|
|
2526
|
-
return {
|
|
2527
|
-
type: 'data',
|
|
2528
|
-
id,
|
|
2529
|
-
kind: 'list_sessions',
|
|
2530
|
-
scope,
|
|
2531
|
-
sessions,
|
|
2532
|
-
currentSessionFile: session.sessionFile,
|
|
2533
|
-
};
|
|
2534
|
-
};
|
|
2535
|
-
/** Runtime-mutating viewer replacements are serialized through their complete
|
|
2536
|
-
* rebind, re-welcome, and client settlement. Admission is bounded to at most
|
|
2537
|
-
* one active head plus one queued follow-up (`MAX_ADMITTED_REPLACEMENTS`); a
|
|
2538
|
-
* request over that cap is a definite, correlated rejection that never touches
|
|
2539
|
-
* the tail. Each admitted head gets one `REPLACEMENT_DEADLINE_MS` budget
|
|
2540
|
-
* covering the full runtime mutation through its awaited rebind. The SDK has
|
|
2541
|
-
* no cancellation primitive for these mutations, so a rejection or an expired
|
|
2542
|
-
* deadline after admission leaves runtime/shared state indeterminate — the
|
|
2543
|
-
* only truthful response is broker-terminal exit, never a retry, and the tail
|
|
2544
|
-
* never advances to the queued closure against that uncertain state. */
|
|
2545
|
-
let replacementTail = Promise.resolve();
|
|
2546
|
-
let admittedReplacements = 0;
|
|
2547
|
-
const runReplacement = (client, op, run) => {
|
|
2548
|
-
if (runtime === undefined) {
|
|
2549
|
-
sendFrame(client, {
|
|
2550
|
-
type: 'error',
|
|
2551
|
-
code: 'engine_error',
|
|
2552
|
-
message: 'session replacement unsupported at this engine pin',
|
|
2553
|
-
});
|
|
2554
|
-
return;
|
|
2555
|
-
}
|
|
2556
|
-
if (admittedReplacements >= MAX_ADMITTED_REPLACEMENTS) {
|
|
2557
|
-
operationIdContext.fresh(() => {
|
|
2558
|
-
emitEvent({
|
|
2559
|
-
level: 'warn',
|
|
2560
|
-
event: 'broker.session_replacement.rejected',
|
|
2561
|
-
outcome: 'skipped',
|
|
2562
|
-
fields: {
|
|
2563
|
-
op,
|
|
2564
|
-
reason: 'capacity',
|
|
2565
|
-
admitted: MAX_ADMITTED_REPLACEMENTS,
|
|
2566
|
-
cap: MAX_ADMITTED_REPLACEMENTS,
|
|
2567
|
-
},
|
|
2568
|
-
});
|
|
2569
|
-
});
|
|
2570
|
-
ackTo(client, op, false, `session replacement busy (${MAX_ADMITTED_REPLACEMENTS} active or queued)`);
|
|
2571
|
-
return;
|
|
2572
|
-
}
|
|
2573
|
-
admittedReplacements++;
|
|
2574
|
-
const task = replacementTail.then(async () => {
|
|
2575
|
-
try {
|
|
2576
|
-
// The whole attempt — success, cancellation, rejection, and timeout —
|
|
2577
|
-
// executes inside one fresh operation scope so its outcome event (of
|
|
2578
|
-
// whichever kind fires) carries one coherent operation id.
|
|
2579
|
-
await operationIdContext.fresh(async () => {
|
|
2580
|
-
const startedAt = process.hrtime.bigint();
|
|
2581
|
-
let timer;
|
|
2582
|
-
try {
|
|
2583
|
-
const deadline = new Promise((resolve) => {
|
|
2584
|
-
timer = setTimeout(() => resolve(REPLACEMENT_DEADLINE_EXCEEDED), REPLACEMENT_DEADLINE_MS);
|
|
2585
|
-
});
|
|
2586
|
-
const outcome = await Promise.race([run(runtime), deadline]);
|
|
2587
|
-
if (outcome === REPLACEMENT_DEADLINE_EXCEEDED) {
|
|
2588
|
-
emitEvent({
|
|
2589
|
-
level: 'error',
|
|
2590
|
-
event: 'broker.session_replacement.timed_out',
|
|
2591
|
-
outcome: 'failed',
|
|
2592
|
-
duration_ms: Number(process.hrtime.bigint() - startedAt) / 1_000_000,
|
|
2593
|
-
fields: { op, deadline_ms: REPLACEMENT_DEADLINE_MS, mutation_outcome: 'indeterminate' },
|
|
2594
|
-
});
|
|
2595
|
-
ackTo(client, op, false, 'session replacement timed out; broker restarting because session state is indeterminate');
|
|
2596
|
-
disposeAndExit('session-replacement-timeout', 1);
|
|
2597
|
-
return;
|
|
2598
|
-
}
|
|
2599
|
-
if (!outcome.cancelled)
|
|
2600
|
-
reWelcomeAll();
|
|
2601
|
-
ackTo(client, op, !outcome.cancelled, outcome.cancelled ? outcome.detail ?? 'cancelled by extension' : undefined);
|
|
2602
|
-
}
|
|
2603
|
-
catch (err) {
|
|
2604
|
-
emitEvent({
|
|
2605
|
-
level: 'error',
|
|
2606
|
-
event: 'broker.session_replacement.failed',
|
|
2607
|
-
outcome: 'failed',
|
|
2608
|
-
error: err,
|
|
2609
|
-
fields: { op, mutation_outcome: 'indeterminate' },
|
|
2610
|
-
});
|
|
2611
|
-
ackTo(client, op, false, 'session replacement failed; broker restarting because session state is indeterminate');
|
|
2612
|
-
disposeAndExit('session-replacement-failed', 1);
|
|
2613
|
-
}
|
|
2614
|
-
finally {
|
|
2615
|
-
clearTimeout(timer);
|
|
2616
|
-
}
|
|
2617
|
-
});
|
|
2618
|
-
}
|
|
2619
|
-
finally {
|
|
2620
|
-
admittedReplacements--;
|
|
2621
|
-
}
|
|
2622
|
-
});
|
|
2623
|
-
replacementTail = task.catch(() => undefined);
|
|
2624
|
-
};
|
|
2625
|
-
const handleFrame = (client, frame) => {
|
|
2626
|
-
switch (frame.type) {
|
|
2627
|
-
case 'hello': {
|
|
2628
|
-
// Admit at the REQUESTED role — unconditionally. `controller` is a
|
|
2629
|
-
// per-client write capability, not a singleton slot, so an arriving
|
|
2630
|
-
// writable client never demotes (or is demoted by) an already-attached
|
|
2631
|
-
// one: N terminal panes and browser tabs are all writable at once.
|
|
2632
|
-
//
|
|
2633
|
-
// A repeated hello on an already-helloed socket must not RE-role it: the
|
|
2634
|
-
// role is fixed for the socket's lifetime, so every downstream gate and
|
|
2635
|
-
// every already-fanned-out dialog keep the authority the first welcome
|
|
2636
|
-
// stated. A duplicate hello still re-sends the catch-up snapshot.
|
|
2637
|
-
const firstHello = !client.helloed;
|
|
2638
|
-
client.id = frame.client_id;
|
|
2639
|
-
if (firstHello) {
|
|
2640
|
-
client.helloed = true;
|
|
2641
|
-
client.role = frame.role === 'controller' ? 'controller' : 'observer';
|
|
2642
|
-
}
|
|
2643
|
-
sendWelcome(client);
|
|
2644
|
-
if (firstHello)
|
|
2645
|
-
persistAttachState(); // a helloed viewer arrived
|
|
2646
|
-
// welcome carried the FIRST pending dialog; replay any extras so a writable
|
|
2647
|
-
// client attaching mid-dialog can answer every in-flight dialog.
|
|
2648
|
-
if (client.role === 'controller')
|
|
2649
|
-
replayExtraPendingDialogsTo(client);
|
|
2650
|
-
break;
|
|
2651
|
-
}
|
|
2652
|
-
case 'prompt':
|
|
2653
|
-
case 'steer':
|
|
2654
|
-
case 'follow_up':
|
|
2655
|
-
case 'abort':
|
|
2656
|
-
case 'bash': {
|
|
2657
|
-
if (notWritable(client, 'drive the engine'))
|
|
2658
|
-
break;
|
|
2659
|
-
driveEngine(client, frame);
|
|
2660
|
-
break;
|
|
2661
|
-
}
|
|
2662
|
-
case 'deliver': {
|
|
2663
|
-
// Interactive delivery from a one-shot server client (crtrd's
|
|
2664
|
-
// `POST /messages` `delivery:'interactive'` path). Routed HERE,
|
|
2665
|
-
// broker-side, because only the broker authoritatively knows streaming
|
|
2666
|
-
// state: the synthesized `prompt` frame reuses resolveEngineRoute's m-B
|
|
2667
|
-
// routing (idle → prompt(); mid-stream → prompt({streamingBehavior:
|
|
2668
|
-
// 'steer'}), pi's own Enter-while-streaming submit) plus the memory-refs
|
|
2669
|
-
// guidance path — byte-identical behavior to a viewer's Enter. Acked
|
|
2670
|
-
// once ACCEPTED (correlated by the frame's id) so the daemon returns
|
|
2671
|
-
// deterministically; `detail` reports which route was taken.
|
|
2672
|
-
//
|
|
2673
|
-
// The ack reports the engine call's OUTCOME, never merely the fact that
|
|
2674
|
-
// a call was made: `session.prompt()` rejects outright when the model's
|
|
2675
|
-
// provider has no credential, and acking unconditionally reported
|
|
2676
|
-
// `delivered:true` for a message the engine had refused — the caller's
|
|
2677
|
-
// one-shot client then closed before the error frame arrived and the
|
|
2678
|
-
// message vanished with no trace. A prompt route therefore acks on the
|
|
2679
|
-
// race between acceptance (agent_start) and rejection; a steer route
|
|
2680
|
-
// joins a turn already running, so no new agent_start is coming and its
|
|
2681
|
-
// ack stays at routing time.
|
|
2682
|
-
if (notWritable(client, 'drive the engine', frame.id))
|
|
2683
|
-
break;
|
|
2684
|
-
const via = session.isStreaming ? 'steer' : 'prompt';
|
|
2685
|
-
let delivered = false;
|
|
2686
|
-
const ackAccepted = () => {
|
|
2687
|
-
if (delivered)
|
|
2688
|
-
return;
|
|
2689
|
-
delivered = true;
|
|
2690
|
-
ackTo(client, 'deliver', true, via, frame.id);
|
|
2691
|
-
};
|
|
2692
|
-
if (via === 'prompt')
|
|
2693
|
-
turnAcceptedWaiters.add(ackAccepted);
|
|
2694
|
-
const outcome = driveEngine(client, {
|
|
2695
|
-
type: 'prompt',
|
|
2696
|
-
text: frame.text,
|
|
2697
|
-
...(frame.images !== undefined ? { images: frame.images } : {}),
|
|
2698
|
-
});
|
|
2699
|
-
if (via === 'steer' || outcome === undefined) {
|
|
2700
|
-
turnAcceptedWaiters.delete(ackAccepted);
|
|
2701
|
-
ackAccepted();
|
|
2702
|
-
break;
|
|
2703
|
-
}
|
|
2704
|
-
void outcome.then((failure) => {
|
|
2705
|
-
turnAcceptedWaiters.delete(ackAccepted);
|
|
2706
|
-
if (failure === undefined) {
|
|
2707
|
-
ackAccepted();
|
|
2708
|
-
return;
|
|
2709
|
-
}
|
|
2710
|
-
// The error frame already went to the client, but a one-shot deliver
|
|
2711
|
-
// client is gone by then — record it where it survives.
|
|
2712
|
-
emitEvent({ level: 'error', event: 'broker.prompt.deliver_failed', error: failure });
|
|
2713
|
-
if (delivered)
|
|
2714
|
-
return;
|
|
2715
|
-
delivered = true;
|
|
2716
|
-
ackTo(client, 'deliver', false, String(failure), frame.id);
|
|
2717
|
-
});
|
|
2718
|
-
break;
|
|
2719
|
-
}
|
|
2720
|
-
case 'extension_ui_response': {
|
|
2721
|
-
if (notWritable(client, 'answer dialogs'))
|
|
2722
|
-
break;
|
|
2723
|
-
// First response for this id wins: `resolve` settles the entry and deletes
|
|
2724
|
-
// it, so every later response from a peer that was also fanned this dialog
|
|
2725
|
-
// finds nothing here and is a silent no-op.
|
|
2726
|
-
pendingDialogs.get(frame.id)?.resolve(frame);
|
|
2727
|
-
break;
|
|
2728
|
-
}
|
|
2729
|
-
// --- extended engine-command ops (T3, §1.2 floor set) ------------------
|
|
2730
|
-
case 'set_model': {
|
|
2731
|
-
if (notWritable(client, 'set the model'))
|
|
2732
|
-
break;
|
|
2733
|
-
const requested = parseModelSpec(frame.model);
|
|
2734
|
-
let model;
|
|
2735
|
-
let normalized;
|
|
2736
|
-
try {
|
|
2737
|
-
// Tier/alias specs (`opus`, `strong`, `anthropic/strong`, …) MUST resolve
|
|
2738
|
-
// through the config ladder before touching the registry: the fuzzy
|
|
2739
|
-
// registry search rank-ties `opus` between claude-3-opus-20240229 and
|
|
2740
|
-
// claude-opus-4-8, and the deprecated 2024 model wins on catalog order.
|
|
2741
|
-
// Concrete `provider/id` specs and genuine substrings (`fable`) pass
|
|
2742
|
-
// through normalizeModel unchanged and take the exact/search path below.
|
|
2743
|
-
normalized = parseModelSpec(normalizeModel(requested.modelSpec));
|
|
2744
|
-
model = resolveModelQuery(normalized.modelSpec);
|
|
2745
|
-
}
|
|
2746
|
-
catch (err) {
|
|
2747
|
-
// N2: registry.find should never throw on the real SDK, but a degenerate
|
|
2748
|
-
// engine must still get a reply rather than a silently-dropped frame.
|
|
2749
|
-
engineErrorTo(client)(err);
|
|
2750
|
-
break;
|
|
2751
|
-
}
|
|
2752
|
-
if (model === undefined) {
|
|
2753
|
-
sendFrame(client, {
|
|
2754
|
-
type: 'error',
|
|
2755
|
-
code: 'engine_error',
|
|
2756
|
-
message: `no model matching '${frame.model}' in the registry`,
|
|
2757
|
-
});
|
|
2758
|
-
break;
|
|
2759
|
-
}
|
|
2760
|
-
void session
|
|
2761
|
-
.setModel(model)
|
|
2762
|
-
.then(() => {
|
|
2763
|
-
// The caller's explicit `:thinking` wins; else the ladder cell's.
|
|
2764
|
-
const thinking = requested.thinkingLevel ?? normalized.thinkingLevel;
|
|
2765
|
-
if (thinking !== undefined)
|
|
2766
|
-
session.setThinkingLevel(thinking);
|
|
2767
|
-
ackTo(client, 'set_model');
|
|
2768
|
-
// frame.pinned, when the sender pre-normalized `frame.model` (e.g.
|
|
2769
|
-
// setModelLive), carries the RAW-token pin decision explicitly.
|
|
2770
|
-
// Otherwise frame.model itself IS the raw/explicit token (a
|
|
2771
|
-
// slash-command arg, or a concrete picker pick) and derives it.
|
|
2772
|
-
broadcastModelChanged(true, frame.pinned !== undefined ? frame.pinned : isProviderPinnedModelToken(frame.model), true);
|
|
2773
|
-
})
|
|
2774
|
-
.catch(engineErrorTo(client));
|
|
2775
|
-
break;
|
|
2776
|
-
}
|
|
2777
|
-
case 'deliver_custom_message': {
|
|
2778
|
-
if (notWritable(client, 'deliver a custom message'))
|
|
2779
|
-
break;
|
|
2780
|
-
// Never triggers a turn either way. `nextTurn` folds the message into
|
|
2781
|
-
// whatever turn comes next (situational context); omitting it pushes
|
|
2782
|
-
// straight onto the message list so it lands FIRST in a still-empty
|
|
2783
|
-
// session (a freshly-claimed spare's bearings). The inbox watcher's own
|
|
2784
|
-
// situational send uses `followUp` + triggerTurn because there its job
|
|
2785
|
-
// IS to wake the node.
|
|
2786
|
-
void session
|
|
2787
|
-
.sendCustomMessage({
|
|
2788
|
-
customType: frame.customType,
|
|
2789
|
-
content: frame.content,
|
|
2790
|
-
display: true,
|
|
2791
|
-
...(frame.details !== undefined ? { details: frame.details } : {}),
|
|
2792
|
-
}, ...(frame.deliverAs !== undefined ? [{ deliverAs: frame.deliverAs }] : []))
|
|
2793
|
-
.then(() => ackTo(client, 'deliver_custom_message'))
|
|
2794
|
-
.catch(engineErrorTo(client));
|
|
2795
|
-
break;
|
|
2796
|
-
}
|
|
2797
|
-
case 'cycle_model': {
|
|
2798
|
-
if (notWritable(client, 'cycle the model'))
|
|
2799
|
-
break;
|
|
2800
|
-
void session
|
|
2801
|
-
.cycleModel(frame.direction)
|
|
2802
|
-
.then(() => {
|
|
2803
|
-
ackTo(client, 'cycle_model');
|
|
2804
|
-
broadcastModelChanged(true, undefined, true);
|
|
2805
|
-
})
|
|
2806
|
-
.catch(engineErrorTo(client));
|
|
2807
|
-
break;
|
|
2808
|
-
}
|
|
2809
|
-
case 'cycle_ladder': {
|
|
2810
|
-
if (notWritable(client, 'cycle the model ladder'))
|
|
2811
|
-
break;
|
|
2812
|
-
// Resolve the next interleaved-ladder rung from the current model+thinking
|
|
2813
|
-
// spec, then reuse the set_model path (registry resolve + thinking apply).
|
|
2814
|
-
// The cycle only offers signed-in provider columns — same rule as the
|
|
2815
|
-
// setup wizard's Models panel: pool occupancy for a managed provider
|
|
2816
|
-
// (read live, so a login that landed mid-session counts immediately),
|
|
2817
|
-
// else the registry's auth status. When nobody is signed in the helper
|
|
2818
|
-
// returns every provider, so the cycle stays walkable pre-login.
|
|
2819
|
-
const offeredProviders = (() => {
|
|
2820
|
-
try {
|
|
2821
|
-
const registry = registryOf(services);
|
|
2822
|
-
return configuredLadderProviders((providerId) => (isManagedProvider(providerId) && readSubscriptionPool(providerId).length > 0)
|
|
2823
|
-
|| registry.getProviderAuthStatus(providerId).configured);
|
|
2824
|
-
}
|
|
2825
|
-
catch {
|
|
2826
|
-
return undefined; // auth state unreadable — cycle the full ladder
|
|
2827
|
-
}
|
|
2828
|
-
})();
|
|
2829
|
-
const currentSpec = formatModelSpec(session.model, session.thinkingLevel) ?? '';
|
|
2830
|
-
const nextSpec = parseModelSpec(nextLadderModel(currentSpec, frame.direction === 'backward' ? -1 : 1, offeredProviders));
|
|
2831
|
-
let ladderModel;
|
|
2832
|
-
try {
|
|
2833
|
-
ladderModel = resolveModelQuery(nextSpec.modelSpec);
|
|
2834
|
-
}
|
|
2835
|
-
catch (err) {
|
|
2836
|
-
engineErrorTo(client)(err);
|
|
2837
|
-
break;
|
|
2838
|
-
}
|
|
2839
|
-
if (ladderModel === undefined) {
|
|
2840
|
-
sendFrame(client, {
|
|
2841
|
-
type: 'error',
|
|
2842
|
-
code: 'engine_error',
|
|
2843
|
-
message: `ladder model '${nextSpec.modelSpec}' not in the registry`,
|
|
2844
|
-
});
|
|
2845
|
-
break;
|
|
2846
|
-
}
|
|
2847
|
-
void session
|
|
2848
|
-
.setModel(ladderModel)
|
|
2849
|
-
.then(() => {
|
|
2850
|
-
if (nextSpec.thinkingLevel !== undefined)
|
|
2851
|
-
session.setThinkingLevel(nextSpec.thinkingLevel);
|
|
2852
|
-
ackTo(client, 'cycle_ladder');
|
|
2853
|
-
broadcastModelChanged(true, undefined, true);
|
|
2854
|
-
})
|
|
2855
|
-
.catch(engineErrorTo(client));
|
|
2856
|
-
break;
|
|
2857
|
-
}
|
|
2858
|
-
case 'cycle_thinking': {
|
|
2859
|
-
if (notWritable(client, 'cycle the thinking level'))
|
|
2860
|
-
break;
|
|
2861
|
-
try {
|
|
2862
|
-
session.cycleThinkingLevel();
|
|
2863
|
-
persistModelChoice(undefined, true);
|
|
2864
|
-
ackTo(client, 'cycle_thinking');
|
|
2865
|
-
}
|
|
2866
|
-
catch (err) {
|
|
2867
|
-
engineErrorTo(client)(err);
|
|
2868
|
-
}
|
|
2869
|
-
break;
|
|
2870
|
-
}
|
|
2871
|
-
case 'dequeue': {
|
|
2872
|
-
if (notWritable(client, 'dequeue messages', frame.id))
|
|
2873
|
-
break;
|
|
2874
|
-
try {
|
|
2875
|
-
const { steering, followUp } = session.clearQueue();
|
|
2876
|
-
sendFrame(client, { type: 'data', id: frame.id, kind: 'dequeue', steering, followUp });
|
|
2877
|
-
}
|
|
2878
|
-
catch (err) {
|
|
2879
|
-
engineErrorTo(client, frame.id)(err);
|
|
2880
|
-
}
|
|
2881
|
-
break;
|
|
2882
|
-
}
|
|
2883
|
-
case 'set_thinking_level': {
|
|
2884
|
-
if (notWritable(client, 'set the thinking level'))
|
|
2885
|
-
break;
|
|
2886
|
-
try {
|
|
2887
|
-
session.setThinkingLevel(frame.level);
|
|
2888
|
-
persistModelChoice(undefined, true);
|
|
2889
|
-
ackTo(client, 'set_thinking_level');
|
|
2890
|
-
}
|
|
2891
|
-
catch (err) {
|
|
2892
|
-
engineErrorTo(client)(err);
|
|
2893
|
-
}
|
|
2894
|
-
break;
|
|
2895
|
-
}
|
|
2896
|
-
case 'set_auto_retry': {
|
|
2897
|
-
if (notWritable(client, 'set auto-retry'))
|
|
2898
|
-
break;
|
|
2899
|
-
try {
|
|
2900
|
-
session.setAutoRetryEnabled(frame.enabled);
|
|
2901
|
-
ackTo(client, 'set_auto_retry');
|
|
2902
|
-
}
|
|
2903
|
-
catch (err) {
|
|
2904
|
-
engineErrorTo(client)(err);
|
|
2905
|
-
}
|
|
2906
|
-
break;
|
|
2907
|
-
}
|
|
2908
|
-
case 'set_auto_compaction': {
|
|
2909
|
-
if (notWritable(client, 'set auto-compaction'))
|
|
2910
|
-
break;
|
|
2911
|
-
try {
|
|
2912
|
-
session.setAutoCompactionEnabled(frame.enabled);
|
|
2913
|
-
ackTo(client, 'set_auto_compaction');
|
|
2914
|
-
}
|
|
2915
|
-
catch (err) {
|
|
2916
|
-
engineErrorTo(client)(err);
|
|
2917
|
-
}
|
|
2918
|
-
break;
|
|
2919
|
-
}
|
|
2920
|
-
case 'compact': {
|
|
2921
|
-
if (notWritable(client, 'compact the session'))
|
|
2922
|
-
break;
|
|
2923
|
-
void session
|
|
2924
|
-
.compact(frame.instructions)
|
|
2925
|
-
.then(() => ackTo(client, 'compact'))
|
|
2926
|
-
.catch(engineErrorTo(client));
|
|
2927
|
-
break;
|
|
2928
|
-
}
|
|
2929
|
-
case 'set_session_name': {
|
|
2930
|
-
if (notWritable(client, 'rename the session'))
|
|
2931
|
-
break;
|
|
2932
|
-
try {
|
|
2933
|
-
session.setSessionName(frame.name);
|
|
2934
|
-
ackTo(client, 'set_session_name');
|
|
2935
|
-
}
|
|
2936
|
-
catch (err) {
|
|
2937
|
-
engineErrorTo(client)(err);
|
|
2938
|
-
}
|
|
2939
|
-
break;
|
|
2940
|
-
}
|
|
2941
|
-
case 'get_commands': {
|
|
2942
|
-
// OBSERVERS may call this: the merged command inventory is static
|
|
2943
|
-
// (extensions + templates + skills + builtins) and drives nothing in the
|
|
2944
|
-
// engine, so it is not a controller-gated op. The web bridge fetches it on
|
|
2945
|
-
// its observer-by-default upstream connection to populate the palette.
|
|
2946
|
-
// The merged command list rides in ack.detail as JSON (the foundation's
|
|
2947
|
-
// AckFrame.detail field) — the viewer (T6) JSON.parses it when for ===
|
|
2948
|
-
// 'get_commands'. Keeps every command op a uniform ack reply.
|
|
2949
|
-
try {
|
|
2950
|
-
ackTo(client, 'get_commands', true, JSON.stringify(buildCommandList()));
|
|
2951
|
-
}
|
|
2952
|
-
catch (err) {
|
|
2953
|
-
engineErrorTo(client)(err);
|
|
2954
|
-
}
|
|
2955
|
-
break;
|
|
2956
|
-
}
|
|
2957
|
-
// --- read-op picker data (§5 Unit A) ----------------------------------
|
|
2958
|
-
// NOT controller-gated: read-only, like get_commands. The web bridge's
|
|
2959
|
-
// observer connection populates pickers through these too.
|
|
2960
|
-
case 'list_models': {
|
|
2961
|
-
try {
|
|
2962
|
-
sendFrame(client, buildListModelsData(frame.id));
|
|
2963
|
-
}
|
|
2964
|
-
catch (err) {
|
|
2965
|
-
engineErrorTo(client, frame.id)(err);
|
|
2966
|
-
}
|
|
2967
|
-
break;
|
|
2968
|
-
}
|
|
2969
|
-
case 'list_scoped_models': {
|
|
2970
|
-
try {
|
|
2971
|
-
sendFrame(client, buildScopedModelsData(frame.id));
|
|
2972
|
-
}
|
|
2973
|
-
catch (err) {
|
|
2974
|
-
engineErrorTo(client, frame.id)(err);
|
|
2975
|
-
}
|
|
2976
|
-
break;
|
|
2977
|
-
}
|
|
2978
|
-
case 'list_memory_refs': {
|
|
2979
|
-
try {
|
|
2980
|
-
sendFrame(client, buildListMemoryRefsData(frame.id));
|
|
2981
|
-
}
|
|
2982
|
-
catch (err) {
|
|
2983
|
-
engineErrorTo(client, frame.id)(err);
|
|
2984
|
-
}
|
|
2985
|
-
break;
|
|
2986
|
-
}
|
|
2987
|
-
case 'get_tree': {
|
|
2988
|
-
try {
|
|
2989
|
-
sendFrame(client, buildGetTreeData(frame.id));
|
|
2990
|
-
}
|
|
2991
|
-
catch (err) {
|
|
2992
|
-
engineErrorTo(client, frame.id)(err);
|
|
2993
|
-
}
|
|
2994
|
-
break;
|
|
2995
|
-
}
|
|
2996
|
-
case 'get_settings': {
|
|
2997
|
-
try {
|
|
2998
|
-
sendFrame(client, buildSettingsData(frame.id));
|
|
2999
|
-
}
|
|
3000
|
-
catch (err) {
|
|
3001
|
-
engineErrorTo(client, frame.id)(err);
|
|
3002
|
-
}
|
|
3003
|
-
break;
|
|
3004
|
-
}
|
|
3005
|
-
case 'list_sessions': {
|
|
3006
|
-
// Session listing reads the session dir off disk — async; reply when it
|
|
3007
|
-
// resolves (or relay the error, correlated by id so the viewer doesn't hang).
|
|
3008
|
-
void buildSessionsData(frame.id, frame.scope ?? 'cwd')
|
|
3009
|
-
.then((d) => sendFrame(client, d))
|
|
3010
|
-
.catch(engineErrorTo(client, frame.id));
|
|
3011
|
-
break;
|
|
3012
|
-
}
|
|
3013
|
-
case 'get_human_fork_coordinates': {
|
|
3014
|
-
// NOT controller-gated: this is a pure observer read. Branch creation is
|
|
3015
|
-
// intentionally delegated to a detached SDK manager in the ask publisher;
|
|
3016
|
-
// calling createBranchedSession here corrupts this live manager's identity.
|
|
3017
|
-
try {
|
|
3018
|
-
const sm = session.sessionManager;
|
|
3019
|
-
const sourceFile = sm.getSessionFile();
|
|
3020
|
-
const leafId = sm.getLeafId();
|
|
3021
|
-
if (sourceFile === undefined || sourceFile === '' || leafId === null) {
|
|
3022
|
-
engineErrorTo(client, frame.id)(new Error('no persisted source session leaf'));
|
|
3023
|
-
break;
|
|
3024
|
-
}
|
|
3025
|
-
sendFrame(client, {
|
|
3026
|
-
type: 'data',
|
|
3027
|
-
id: frame.id,
|
|
3028
|
-
kind: 'human_fork_coordinates',
|
|
3029
|
-
sourceFile,
|
|
3030
|
-
sourceSessionId: sm.getSessionId(),
|
|
3031
|
-
leafId,
|
|
3032
|
-
modelSpec: formatExactModelSpec(session.model, session.thinkingLevel),
|
|
3033
|
-
});
|
|
3034
|
-
}
|
|
3035
|
-
catch (err) {
|
|
3036
|
-
engineErrorTo(client, frame.id)(err);
|
|
3037
|
-
}
|
|
3038
|
-
break;
|
|
3039
|
-
}
|
|
3040
|
-
case 'navigate_tree': {
|
|
3041
|
-
if (notWritable(client, 'navigate the session tree'))
|
|
3042
|
-
break;
|
|
3043
|
-
if (isPreBoundaryReviewEntry(frame.targetId)) {
|
|
3044
|
-
reviewBoundaryRefusalTo(client, frame.targetId);
|
|
3045
|
-
break;
|
|
3046
|
-
}
|
|
3047
|
-
// navigateTree rewinds IN-PLACE (same session file, new leaf) and emits no
|
|
3048
|
-
// relayed event, so every viewer must be re-snapshotted onto the rewound
|
|
3049
|
-
// transcript — same reWelcomeAll the session-replacing ops use. The ack's
|
|
3050
|
-
// detail carries the navigated-to user message's text (pi parity: the
|
|
3051
|
-
// interactive tree navigator restores it to the editor for re-editing).
|
|
3052
|
-
void session
|
|
3053
|
-
.navigateTree(frame.targetId, frame.options)
|
|
3054
|
-
.then((r) => {
|
|
3055
|
-
if (!r.cancelled)
|
|
3056
|
-
reWelcomeAll();
|
|
3057
|
-
ackTo(client, 'navigate_tree', !r.cancelled, r.editorText);
|
|
3058
|
-
})
|
|
3059
|
-
.catch(engineErrorTo(client));
|
|
3060
|
-
break;
|
|
3061
|
-
}
|
|
3062
|
-
case 'reload': {
|
|
3063
|
-
if (notWritable(client, 'reload'))
|
|
3064
|
-
break;
|
|
3065
|
-
// A SUCCESSFUL reload invalidates the memoized ref inventory (work item
|
|
3066
|
-
// 3) so the next read-op/submission re-walks the corpus, AND clears the
|
|
3067
|
-
// shared substrate parse cache buildRefInventory reads through —
|
|
3068
|
-
// otherwise a fresh buildRefInventory() call would still see the stale
|
|
3069
|
-
// cached docs. A FAILED reload (`.catch`) leaves both untouched: nothing
|
|
3070
|
-
// about the corpus is known to have changed.
|
|
3071
|
-
void session
|
|
3072
|
-
.reload()
|
|
3073
|
-
.then(() => {
|
|
3074
|
-
memoizedRefInventory = null;
|
|
3075
|
-
clearSessionCache();
|
|
3076
|
-
ackTo(client, 'reload');
|
|
3077
|
-
})
|
|
3078
|
-
.catch(engineErrorTo(client));
|
|
3079
|
-
break;
|
|
3080
|
-
}
|
|
3081
|
-
case 'export': {
|
|
3082
|
-
if (notWritable(client, 'export the session'))
|
|
3083
|
-
break;
|
|
3084
|
-
if (frame.format === 'jsonl') {
|
|
3085
|
-
try {
|
|
3086
|
-
const outPath = session.exportToJsonl(frame.path);
|
|
3087
|
-
stripHiddenEntriesFromJsonlExport(outPath);
|
|
3088
|
-
ackTo(client, 'export');
|
|
3089
|
-
}
|
|
3090
|
-
catch (err) {
|
|
3091
|
-
engineErrorTo(client)(err);
|
|
3092
|
-
}
|
|
3093
|
-
}
|
|
3094
|
-
else {
|
|
3095
|
-
void session
|
|
3096
|
-
.exportToHtml(frame.path)
|
|
3097
|
-
.then(() => ackTo(client, 'export'))
|
|
3098
|
-
.catch(engineErrorTo(client));
|
|
3099
|
-
}
|
|
3100
|
-
break;
|
|
3101
|
-
}
|
|
3102
|
-
case 'new_session': {
|
|
3103
|
-
if (notWritable(client, 'start a new session'))
|
|
3104
|
-
break;
|
|
3105
|
-
runReplacement(client, 'new_session', (rt) => rt.newSession());
|
|
3106
|
-
break;
|
|
3107
|
-
}
|
|
3108
|
-
case 'switch_session': {
|
|
3109
|
-
if (notWritable(client, 'switch sessions'))
|
|
3110
|
-
break;
|
|
3111
|
-
runReplacement(client, 'switch_session', (rt) => rt.switchSession(frame.path));
|
|
3112
|
-
break;
|
|
3113
|
-
}
|
|
3114
|
-
case 'fork': {
|
|
3115
|
-
if (notWritable(client, 'fork the session'))
|
|
3116
|
-
break;
|
|
3117
|
-
if (isPreBoundaryReviewEntry(frame.entryId)) {
|
|
3118
|
-
reviewBoundaryRefusalTo(client, frame.entryId);
|
|
3119
|
-
break;
|
|
3120
|
-
}
|
|
3121
|
-
runReplacement(client, 'fork', (rt) => rt.fork(frame.entryId));
|
|
3122
|
-
break;
|
|
3123
|
-
}
|
|
3124
|
-
case 'clone': {
|
|
3125
|
-
if (notWritable(client, 'clone the session'))
|
|
3126
|
-
break;
|
|
3127
|
-
runReplacement(client, 'clone', async (rt) => {
|
|
3128
|
-
const sm = session.sessionManager;
|
|
3129
|
-
const leafId = sm.getLeafId();
|
|
3130
|
-
if (leafId === null) {
|
|
3131
|
-
return { cancelled: true, detail: 'no current leaf to clone from' };
|
|
3132
|
-
}
|
|
3133
|
-
const newPath = sm.createBranchedSession(leafId);
|
|
3134
|
-
if (newPath === undefined) {
|
|
3135
|
-
return { cancelled: true, detail: 'createBranchedSession returned no path' };
|
|
3136
|
-
}
|
|
3137
|
-
return rt.switchSession(newPath);
|
|
3138
|
-
});
|
|
3139
|
-
break;
|
|
3140
|
-
}
|
|
3141
|
-
case 'share': {
|
|
3142
|
-
if (notWritable(client, 'share the session'))
|
|
3143
|
-
break;
|
|
3144
|
-
const tmpPath = join(tmpdir(), `pi-share-${Date.now()}.html`);
|
|
3145
|
-
void session.exportToHtml(tmpPath)
|
|
3146
|
-
.then(() => new Promise((resolve, reject) => {
|
|
3147
|
-
execFile('gh', ['gist', 'create', '--secret', tmpPath], { timeout: 30_000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
|
3148
|
-
try {
|
|
3149
|
-
if (existsSync(tmpPath))
|
|
3150
|
-
unlinkSync(tmpPath);
|
|
3151
|
-
}
|
|
3152
|
-
catch { /* cleanup */ }
|
|
3153
|
-
if (err) {
|
|
3154
|
-
reject(err);
|
|
3155
|
-
return;
|
|
3156
|
-
}
|
|
3157
|
-
resolve(stdout.trim());
|
|
3158
|
-
});
|
|
3159
|
-
}))
|
|
3160
|
-
.then((url) => {
|
|
3161
|
-
ackTo(client, 'share', true, url);
|
|
3162
|
-
})
|
|
3163
|
-
.catch((err) => {
|
|
3164
|
-
const msg = String(err?.message ?? err);
|
|
3165
|
-
ackTo(client, 'share', false, msg.includes('not found') || msg.includes('gh: command not found') || msg.includes('ENOENT')
|
|
3166
|
-
? '`gh` CLI not found — install GitHub CLI and authenticate with `gh auth login`'
|
|
3167
|
-
: `share failed: ${msg}`);
|
|
3168
|
-
});
|
|
3169
|
-
break;
|
|
3170
|
-
}
|
|
3171
|
-
case 'reload_auth': {
|
|
3172
|
-
// Open to any client, observers included: reload_auth is an idempotent
|
|
3173
|
-
// local re-read of shared credentials + the runtime's availability
|
|
3174
|
-
// snapshot. It does not steer the conversation, so the daemon's
|
|
3175
|
-
// canvas-wide /login fan can ride an observer connection.
|
|
3176
|
-
try {
|
|
3177
|
-
ackTo(client, 'reload_auth');
|
|
3178
|
-
const authGeneration = installedGeneration;
|
|
3179
|
-
const existingAuthReload = reloadAuthInFlight;
|
|
3180
|
-
let authReloadPromise;
|
|
3181
|
-
if (existingAuthReload !== null && existingAuthReload.generation === authGeneration) {
|
|
3182
|
-
authReloadPromise = existingAuthReload.promise;
|
|
3183
|
-
}
|
|
3184
|
-
if (authGeneration !== undefined && authReloadPromise === undefined) {
|
|
3185
|
-
const authSession = session;
|
|
3186
|
-
const authServices = services;
|
|
3187
|
-
authReloadPromise = (async () => {
|
|
3188
|
-
// Credentials are read fresh by PoolCredentialStore; only the
|
|
3189
|
-
// ModelRuntime snapshot needs rebuilding. Authentication reloads
|
|
3190
|
-
// never fetch provider catalogs over the network.
|
|
3191
|
-
await authServices.modelRuntime.refresh({ allowNetwork: false });
|
|
3192
|
-
if (installedGeneration !== authGeneration || session !== authSession)
|
|
3193
|
-
return;
|
|
3194
|
-
const modelLess = authSession.model === undefined || isUnknownModel(authSession.model);
|
|
3195
|
-
const reconcilesLogicalRoute = cfg.modelExact !== true
|
|
3196
|
-
&& cfg.modelProviderPinned !== true
|
|
3197
|
-
&& (process.env['CRTR_MODEL_INTENT'] ?? '').trim() !== '';
|
|
3198
|
-
if (modelLess || reconcilesLogicalRoute) {
|
|
3199
|
-
const resolved = await resolveLaunchModel(authServices, cfg);
|
|
3200
|
-
if (installedGeneration !== authGeneration || session !== authSession)
|
|
3201
|
-
return;
|
|
3202
|
-
if (resolved !== undefined) {
|
|
3203
|
-
const currentSpec = formatModelSpec(authSession.model, authSession.thinkingLevel);
|
|
3204
|
-
const resolvedSpec = formatModelSpec(resolved.model, resolved.thinkingLevel);
|
|
3205
|
-
if (resolvedSpec !== null && resolvedSpec !== currentSpec) {
|
|
3206
|
-
await authSession.setModel(resolved.model);
|
|
3207
|
-
if (installedGeneration !== authGeneration || session !== authSession)
|
|
3208
|
-
return;
|
|
3209
|
-
authSession.setThinkingLevel((resolved.thinkingLevel ?? 'off'));
|
|
3210
|
-
}
|
|
3211
|
-
}
|
|
3212
|
-
}
|
|
3213
|
-
if (installedGeneration !== authGeneration || session !== authSession)
|
|
3214
|
-
return;
|
|
3215
|
-
// Persist an algorithmic route choice without clearing the logical
|
|
3216
|
-
// intent that makes future reconciliation possible.
|
|
3217
|
-
broadcastModelChanged();
|
|
3218
|
-
drivePendingKickoff();
|
|
3219
|
-
})().catch((err) => {
|
|
3220
|
-
emitEvent({ level: 'warn', event: 'broker.auth_reload.reconcile_failed', error: err });
|
|
3221
|
-
});
|
|
3222
|
-
const work = { generation: authGeneration, promise: authReloadPromise };
|
|
3223
|
-
reloadAuthInFlight = work;
|
|
3224
|
-
void authReloadPromise.finally(() => {
|
|
3225
|
-
if (reloadAuthInFlight === work)
|
|
3226
|
-
reloadAuthInFlight = null;
|
|
3227
|
-
});
|
|
3228
|
-
}
|
|
3229
|
-
const authFault = readFault(nodeId);
|
|
3230
|
-
const authFaultGeneration = installedGeneration;
|
|
3231
|
-
const authFaultSession = session;
|
|
3232
|
-
if (authFault?.link === 'pi→provider'
|
|
3233
|
-
&& authFault.kind === 'auth'
|
|
3234
|
-
&& !authFaultSession.isStreaming
|
|
3235
|
-
&& !authFaultRedriveInFlight) {
|
|
3236
|
-
authFaultRedriveInFlight = true;
|
|
3237
|
-
void (async () => {
|
|
3238
|
-
try {
|
|
3239
|
-
if (authReloadPromise !== undefined)
|
|
3240
|
-
await authReloadPromise;
|
|
3241
|
-
if (installedGeneration !== authFaultGeneration || session !== authFaultSession)
|
|
3242
|
-
return;
|
|
3243
|
-
const anchorEntryId = authFault.anchorEntryId;
|
|
3244
|
-
if (anchorEntryId !== undefined) {
|
|
3245
|
-
try {
|
|
3246
|
-
const leafId = authFaultSession.sessionManager.getLeafId?.() ?? null;
|
|
3247
|
-
if (leafId !== anchorEntryId)
|
|
3248
|
-
await authFaultSession.navigateTree(anchorEntryId);
|
|
3249
|
-
if (installedGeneration !== authFaultGeneration || session !== authFaultSession)
|
|
3250
|
-
return;
|
|
3251
|
-
}
|
|
3252
|
-
catch (err) {
|
|
3253
|
-
emitEvent({ level: 'warn', event: 'broker.auth_fault.rewind_skipped', error: err });
|
|
3254
|
-
}
|
|
3255
|
-
}
|
|
3256
|
-
const fresh = readFault(nodeId);
|
|
3257
|
-
if (installedGeneration !== authFaultGeneration
|
|
3258
|
-
|| session !== authFaultSession
|
|
3259
|
-
|| authFaultSession.isStreaming
|
|
3260
|
-
|| fresh?.link !== 'pi→provider'
|
|
3261
|
-
|| fresh.kind !== 'auth'
|
|
3262
|
-
|| fresh.since !== authFault.since)
|
|
3263
|
-
return;
|
|
3264
|
-
clearFault(nodeId, { link: 'pi→provider' });
|
|
3265
|
-
await authFaultSession.prompt(AUTH_FAULT_RECOVERY_BODY);
|
|
3266
|
-
}
|
|
3267
|
-
catch (err) {
|
|
3268
|
-
emitEvent({ level: 'error', event: 'broker.auth_fault.redrive_failed', error: err });
|
|
3269
|
-
}
|
|
3270
|
-
finally {
|
|
3271
|
-
authFaultRedriveInFlight = false;
|
|
3272
|
-
}
|
|
3273
|
-
})();
|
|
3274
|
-
}
|
|
3275
|
-
}
|
|
3276
|
-
catch (err) {
|
|
3277
|
-
engineErrorTo(client)(err);
|
|
3278
|
-
}
|
|
3279
|
-
break;
|
|
3280
|
-
}
|
|
3281
|
-
case 'bye':
|
|
3282
|
-
client.socket.end();
|
|
3283
|
-
break;
|
|
3284
|
-
case 'shutdown':
|
|
3285
|
-
disposeAndExit('shutdown-frame');
|
|
3286
|
-
break;
|
|
3287
|
-
}
|
|
3288
|
-
};
|
|
3289
|
-
// -------------------------------------------------------------------------
|
|
3290
|
-
// 4 (plan): the socket listener — unlink any stale socket first.
|
|
3291
|
-
// -------------------------------------------------------------------------
|
|
3292
|
-
try {
|
|
3293
|
-
if (existsSync(sockPath))
|
|
3294
|
-
unlinkSync(sockPath);
|
|
3295
|
-
}
|
|
3296
|
-
catch {
|
|
3297
|
-
/* a fresh listen below will surface a real problem */
|
|
3298
|
-
}
|
|
3299
|
-
server = createServer((socket) => {
|
|
3300
|
-
const client = {
|
|
3301
|
-
id: '',
|
|
3302
|
-
role: 'observer',
|
|
3303
|
-
socket,
|
|
3304
|
-
decoder: new FrameDecoder(BROKER_READ_CAPS),
|
|
3305
|
-
helloed: false,
|
|
3306
|
-
pendingBytes: 0,
|
|
3307
|
-
queuedFrames: 0,
|
|
3308
|
-
};
|
|
3309
|
-
clients.add(client);
|
|
3310
|
-
socket.on('data', (chunk) => {
|
|
3311
|
-
let frames;
|
|
3312
|
-
try {
|
|
3313
|
-
frames = client.decoder.push(chunk);
|
|
3314
|
-
}
|
|
3315
|
-
catch (err) {
|
|
3316
|
-
// G7: a client frame over the bounded decoder caps (FrameOverflowError) is
|
|
3317
|
-
// cap-and-dropped — best-effort error frame, then destroy the peer. The
|
|
3318
|
-
// broker survives. The try/catch is around push() itself (not just the
|
|
3319
|
-
// per-frame loop) because push() throws BEFORE returning any frames.
|
|
3320
|
-
if (err instanceof FrameOverflowError) {
|
|
3321
|
-
sendFrame(client, { type: 'error', code: 'frame_overflow', message: err.message });
|
|
3322
|
-
dropClient(client, `frame overflow: ${err.message}`);
|
|
3323
|
-
}
|
|
3324
|
-
else {
|
|
3325
|
-
dropClient(client, `decoder error: ${String(err)}`);
|
|
3326
|
-
}
|
|
3327
|
-
return;
|
|
539
|
+
return;
|
|
3328
540
|
}
|
|
3329
541
|
for (const raw of frames) {
|
|
3330
542
|
try {
|
|
3331
|
-
handleFrame(client, raw);
|
|
543
|
+
handleFrame(frameDispatch, client, raw);
|
|
3332
544
|
}
|
|
3333
545
|
catch {
|
|
3334
546
|
/* one bad frame never crashes the broker */
|
|
@@ -3336,8 +548,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
3336
548
|
}
|
|
3337
549
|
});
|
|
3338
550
|
const drop = () => {
|
|
3339
|
-
|
|
3340
|
-
persistAttachState(); // a viewer disconnected
|
|
551
|
+
registry.removeClient(client); // a viewer disconnected
|
|
3341
552
|
// A writable client detaching does NOT cancel in-flight dialogs — they stay
|
|
3342
553
|
// pending under the broker-side default timeout, so a peer that is still
|
|
3343
554
|
// attached (or one that attaches next and gets the replay) can still answer.
|
|
@@ -3366,13 +577,12 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
3366
577
|
// reload_auth re-drive resolves a model, at which point it fires. For the
|
|
3367
578
|
// common model-present path this fires immediately — unchanged behavior.
|
|
3368
579
|
if (cfg.firstPrompt !== undefined && cfg.firstPrompt !== '') {
|
|
3369
|
-
|
|
3370
|
-
drivePendingKickoff();
|
|
580
|
+
frameDispatch.kickoff(cfg.firstPrompt);
|
|
3371
581
|
}
|
|
3372
582
|
});
|
|
3373
583
|
// Initialize presence to the truthful zero state at boot — this also
|
|
3374
584
|
// overwrites a stale attach.json a crashed prior incarnation left behind.
|
|
3375
|
-
persistAttachState();
|
|
585
|
+
registry.persistAttachState();
|
|
3376
586
|
// A daemon fleet bounce gives a busy Pi a short, bounded chance to persist
|
|
3377
587
|
// its aborted turn before teardown. `session.abort()` resolves only after Pi
|
|
3378
588
|
// reaches idle (and thus after its message_end persistence); only then may we
|
|
@@ -3385,23 +595,23 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
3385
595
|
if (disposed || signalExitInFlight)
|
|
3386
596
|
return;
|
|
3387
597
|
signalExitInFlight = true;
|
|
3388
|
-
const busy =
|
|
598
|
+
const busy = rebind.isAgentRunActive() || rebind.session().isStreaming || frameDispatch.pendingPromptDispatches() > 0;
|
|
3389
599
|
if (busy) {
|
|
3390
600
|
const deadline = Date.now() + ABORT_BEFORE_EXIT_MS;
|
|
3391
601
|
// prompt() has a real async pre-turn window where abort() silently no-ops.
|
|
3392
602
|
// Wait only within the daemon's fixed teardown budget for it to become
|
|
3393
603
|
// abortable. A still-pending setup is deliberately marked dirty below so
|
|
3394
604
|
// recovery starts a cycle instead of silently losing the prompt on resume.
|
|
3395
|
-
while (pendingPromptDispatches > 0 && !session.isStreaming && Date.now() < deadline) {
|
|
605
|
+
while (frameDispatch.pendingPromptDispatches() > 0 && !rebind.session().isStreaming && Date.now() < deadline) {
|
|
3396
606
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
3397
607
|
}
|
|
3398
|
-
if (session.isStreaming) {
|
|
608
|
+
if (rebind.session().isStreaming) {
|
|
3399
609
|
const remaining = deadline - Date.now();
|
|
3400
610
|
if (remaining > 0) {
|
|
3401
611
|
let timer;
|
|
3402
612
|
try {
|
|
3403
613
|
const settled = await Promise.race([
|
|
3404
|
-
session.abort().then(() => true, () => false),
|
|
614
|
+
rebind.session().abort().then(() => true, () => false),
|
|
3405
615
|
new Promise((resolve) => { timer = setTimeout(() => resolve(false), remaining); }),
|
|
3406
616
|
]);
|
|
3407
617
|
if (settled)
|
|
@@ -3413,7 +623,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
3413
623
|
}
|
|
3414
624
|
}
|
|
3415
625
|
}
|
|
3416
|
-
else if (pendingPromptDispatches > 0 ||
|
|
626
|
+
else if (frameDispatch.pendingPromptDispatches() > 0 || rebind.isAgentRunActive()) {
|
|
3417
627
|
// The engine never reached an abortable persisted turn in the budget.
|
|
3418
628
|
// Preserve the existing dirty-death recovery cycle rather than certifying
|
|
3419
629
|
// a session that may not contain the prompt it was preparing.
|
|
@@ -3475,10 +685,17 @@ async function resolveLaunchModel(services, cfg) {
|
|
|
3475
685
|
const routeRequest = modelRequestFromConfig(cfg.model, process.env['CRTR_MODEL_INTENT'], cfg.modelExact === true);
|
|
3476
686
|
if (routeRequest === undefined)
|
|
3477
687
|
return undefined;
|
|
3478
|
-
const
|
|
688
|
+
const registry = registryOf(services);
|
|
689
|
+
const routeOptions = {
|
|
3479
690
|
cwd: process.env['CRTR_NODE_CWD'] ?? cfg.cwd,
|
|
3480
691
|
profileId: process.env['CRTR_PROFILE_ID'],
|
|
3481
|
-
}
|
|
692
|
+
};
|
|
693
|
+
const unknown = unregisteredConcreteModel(routeRequest, registry, routeOptions);
|
|
694
|
+
if (unknown !== undefined) {
|
|
695
|
+
recordFaultForCurrentNode({ link: 'crtr→pi', op: 'model resolution at launch', kind: 'model-not-found', retry: { disposition: 'fatal' }, message: `model '${unknown.request.spec}' is not registered in the serving model registry` });
|
|
696
|
+
throw new Error(`[broker] FATAL: model '${unknown.request.spec}' is not registered in the serving model registry`);
|
|
697
|
+
}
|
|
698
|
+
const selection = selectInitialModelRoute(routeRequest, registry, routeOptions);
|
|
3482
699
|
const selected = selection.selected;
|
|
3483
700
|
if (selected !== undefined) {
|
|
3484
701
|
const candidate = selected.candidate;
|