@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
package/dist/commands/node.js
CHANGED
|
@@ -1,2068 +1,14 @@
|
|
|
1
|
-
// `crtr node` — create, inspect, message,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { parseOutputSchemaValue } from '../core/runtime/structured-output.js';
|
|
12
|
-
import { nodeSnapshotLeaf } from './node-snapshot.js';
|
|
13
|
-
import { nodeTranscriptLeaf } from './node-transcript.js';
|
|
14
|
-
import { nodeContextLeaf } from './node-context.js';
|
|
15
|
-
import { nodeArtifactsLeaf } from './node-inspect-artifacts.js';
|
|
1
|
+
// `crtr node` — create, inspect, message, and manage canvas nodes.
|
|
2
|
+
import { defineBranch } from '../core/command.js';
|
|
3
|
+
import { nodeNew } from './node/create.js';
|
|
4
|
+
import { nodeInspect, nodeFork } from './node/inspect.js';
|
|
5
|
+
import { nodeLifecycle, nodeConfig, nodePromote, nodeYield } from './node/lifecycle.js';
|
|
6
|
+
import { nodeMessage } from './node/message.js';
|
|
7
|
+
import { nodeWaitBranch } from './node/wait.js';
|
|
8
|
+
import { nodeSubscription } from './node/subscription.js';
|
|
9
|
+
import { nodeBash } from './node/bash.js';
|
|
10
|
+
import { pyramidSlash } from './node/pyramid.js';
|
|
16
11
|
import { nodeWorktreeBranch } from './node-worktree.js';
|
|
17
|
-
import { nodeReviveLeaf } from './node-lifecycle-revive.js';
|
|
18
|
-
// Pure tmux primitives (tmux.js is openDb-free) — routed through the sanctioned
|
|
19
|
-
// placement-tmux seam (§5.1 lint: commands never import the tmux driver directly).
|
|
20
|
-
import { shellQuote, currentTmux, renameWindow, displayPopup, windowOfPane, getPaneOption, setPaneOption, respawnPaneSync, closePane, listLivePanes, openNodeWindow, selectWindow, selectPane, switchClient, paneLocation, paneExists, splitWindow, selectLayout, ensureSession, } from '../core/runtime/placement-tmux.js';
|
|
21
|
-
import { isPidAlive } from '../core/canvas/pid.js';
|
|
22
|
-
import { fullName } from '../core/canvas/labels.js';
|
|
23
|
-
import { contextDir } from '../core/canvas/paths.js';
|
|
24
|
-
import { readMergedLaunchConfig } from '../core/config.js';
|
|
25
|
-
import { loadProfileManifest } from '../core/profiles/manifest.js';
|
|
26
|
-
import { profilesStateBlock } from '../core/profiles/state-block.js';
|
|
27
|
-
import { stateBlock } from '../core/help.js';
|
|
28
|
-
import { fuzzyMatch } from '../core/canvas/browse/model.js';
|
|
29
|
-
import { loadPins, savePins } from '../core/canvas/browse/pins.js';
|
|
30
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
31
|
-
import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobPaths, formatBashElapsed, jobStartedAtMs, readJobPgid, tailJobLog } from '../core/bash-jobs.js';
|
|
32
|
-
// The API seam — every canvas mutation/read routes through the daemon (B-1).
|
|
33
|
-
import { cliClient, cliCanvasSource, ApiCanvasSource, rethrowAsCliError, getNodeOrNull } from './api-client.js';
|
|
34
|
-
// Past this much context, an ORCHESTRATOR that spawns a managed child is better
|
|
35
|
-
// off yielding than holding its fat window open for the child's result: the
|
|
36
|
-
// child's finish revives it fresh against its roadmap, so a clean window absorbs
|
|
37
|
-
// the result instead of this bloated one. Below the steering bands (130k+) on
|
|
38
|
-
// purpose — catch it at spawn, before the window is critically full.
|
|
39
|
-
const YIELD_NUDGE_THRESHOLD = 100_000;
|
|
40
|
-
const STD_CHILD_FOLLOW_UP = "Do not wait, poll, or duplicate this child's assignment in your own context — there is no result to await, stopping will not strand you, and duplicated work wastes the delegated context. You're auto-subscribed, so its finish wakes you on its own. Two moves only: do other independent work right now, or stop and end your turn — the wake brings you back.";
|
|
41
|
-
/** Decision road sign for a managed (non-root) child. Normally STD_CHILD_FOLLOW_UP,
|
|
42
|
-
* but when the SPAWNER is an orchestrator whose context has already grown past
|
|
43
|
-
* YIELD_NUDGE_THRESHOLD, steer it to yield now and let its fresh revive handle
|
|
44
|
-
* the child's result. */
|
|
45
|
-
export async function childFollowUp(spawnerId) {
|
|
46
|
-
if (spawnerId === undefined || spawnerId === '')
|
|
47
|
-
return STD_CHILD_FOLLOW_UP;
|
|
48
|
-
const spawner = await getNodeOrNull(spawnerId);
|
|
49
|
-
if (spawner === null || spawner.mode !== 'orchestrator')
|
|
50
|
-
return STD_CHILD_FOLLOW_UP;
|
|
51
|
-
const ctxTokens = spawner.context_tokens ?? null;
|
|
52
|
-
if (ctxTokens === null || ctxTokens < YIELD_NUDGE_THRESHOLD)
|
|
53
|
-
return STD_CHILD_FOLLOW_UP;
|
|
54
|
-
const k = Math.round(ctxTokens / 1000);
|
|
55
|
-
return `Child spawned — you're auto-subscribed, so its finish wakes you on its own; never wait, poll, or duplicate the child's assignment in your own context. But you're an orchestrator already carrying ~${k}k of context: rather than hold this window open for the result, checkpoint context/roadmap.md and \`crtr node yield\` now. Yielding ends this turn, and the child's completion then revives you fresh against your roadmap — let that clean revive absorb the result instead of this bloated context.`;
|
|
56
|
-
}
|
|
57
|
-
/** Validate a `--kind` against the installed personas; throws a listing InputError. */
|
|
58
|
-
function assertKind(kind) {
|
|
59
|
-
const kinds = Object.keys(readMergedLaunchConfig().kinds).sort();
|
|
60
|
-
if (!kinds.includes(kind)) {
|
|
61
|
-
throw new InputError({ error: 'unknown_kind', message: `unknown kind: ${kind}`, field: 'kind', next: `Valid kinds: ${kinds.join(', ')}.` });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/** A live `<kinds count=N>` state element — one `<kind> — <whenToUse>` line per
|
|
65
|
-
* installed top-level persona kind (project > user > builtin), lazily built so
|
|
66
|
-
* it reflects the caller's cwd/project scope. Appended to `node new -h` and
|
|
67
|
-
* `node promote -h` so custom kinds appear in help. Soft-fails via the renderer.
|
|
68
|
-
*
|
|
69
|
-
* Keep this block cheap and caller-agnostic: help should not depend on the
|
|
70
|
-
* running node's current persona env, which can lag after polymorph. Sub-kinds
|
|
71
|
-
* (e.g. `plan/reviewers/security`) are OMITTED here — `KindConfig.availableTo`
|
|
72
|
-
* defaults to the sub-kind's own top-level ancestor, and this generic block has
|
|
73
|
-
* no single ancestor to check against, so only an explicit `availableTo: ['*']`
|
|
74
|
-
* sub-kind (universally available) earns a place in this list. A direct launch
|
|
75
|
-
* by full sub-kind string is always valid regardless (visibility-only
|
|
76
|
-
* filtering) — discover the roster for a specific kind via
|
|
77
|
-
* `crtr sys prompt-review --list --kind <kind>`. */
|
|
78
|
-
function kindsStateBlock() {
|
|
79
|
-
const registry = readMergedLaunchConfig().kinds;
|
|
80
|
-
const kinds = Object.keys(registry)
|
|
81
|
-
.filter((k) => {
|
|
82
|
-
const slash = k.indexOf('/');
|
|
83
|
-
if (slash === -1)
|
|
84
|
-
return true;
|
|
85
|
-
const availableTo = registry[k]?.availableTo ?? [k.slice(0, slash)];
|
|
86
|
-
return availableTo.includes('*');
|
|
87
|
-
})
|
|
88
|
-
.sort();
|
|
89
|
-
const lines = kinds
|
|
90
|
-
.map((k) => {
|
|
91
|
-
const w = registry[k]?.whenToUse ?? '';
|
|
92
|
-
return w ? `${k} — ${w}` : k;
|
|
93
|
-
})
|
|
94
|
-
.join('\n');
|
|
95
|
-
return stateBlock('kinds', { count: kinds.length }, lines);
|
|
96
|
-
}
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// Local viewer placement — pure tmux tails.
|
|
99
|
-
//
|
|
100
|
-
// The daemon owns the broker launch + the view.sock readiness wait (via
|
|
101
|
-
// `ensureAttach`); these helpers own ONLY the on-screen viewer pane. A node's
|
|
102
|
-
// ONE viewer is identified by scanning live panes for the `@crtr_node` tag a
|
|
103
|
-
// `crtr surface attach` viewer self-sets on connect — the pure-tmux equivalent
|
|
104
|
-
// of placement.ts's focus-rows DB registry (which the CLI can no longer reach).
|
|
105
|
-
// ---------------------------------------------------------------------------
|
|
106
|
-
/** Env handed to a viewer/root pane — passthrough CRTR_HOME so it targets the
|
|
107
|
-
* same canvas home as the caller. */
|
|
108
|
-
function viewerEnv() {
|
|
109
|
-
const crtrHome = process.env['CRTR_HOME'];
|
|
110
|
-
return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
|
|
111
|
-
}
|
|
112
|
-
/** The live viewer pane for `nodeId` (the pane a surface-attach viewer tagged
|
|
113
|
-
* `@crtr_node`), or undefined. Pure tag scan over live panes. */
|
|
114
|
-
function viewerPaneOf(nodeId) {
|
|
115
|
-
const panes = listLivePanes();
|
|
116
|
-
if (panes === null)
|
|
117
|
-
return undefined;
|
|
118
|
-
for (const pane of panes) {
|
|
119
|
-
if (getPaneOption(pane, '@crtr_node') === nodeId)
|
|
120
|
-
return pane;
|
|
121
|
-
}
|
|
122
|
-
return undefined;
|
|
123
|
-
}
|
|
124
|
-
/** Open a fresh surface-attach viewer SPLIT beside `callerPane` and rebalance
|
|
125
|
-
* its window to even side-by-side widths. Pure tmux. */
|
|
126
|
-
function openBesideCaller(callerPane, nodeId, cwd, callerSession, revived) {
|
|
127
|
-
const opened = splitWindow(callerPane, { cwd, env: viewerEnv(), command: `crtr surface attach to ${nodeId}` });
|
|
128
|
-
if (opened === null)
|
|
129
|
-
return { focused: false, session: null, inPlace: false, revived };
|
|
130
|
-
const loc = paneLocation(opened);
|
|
131
|
-
if (loc !== null)
|
|
132
|
-
selectLayout(loc.window, 'even-horizontal');
|
|
133
|
-
selectPane(opened);
|
|
134
|
-
return { focused: true, session: loc?.session ?? callerSession, inPlace: false, revived };
|
|
135
|
-
}
|
|
136
|
-
/** Bring a node's ONE viewer forefront beside (or navigated to) the caller —
|
|
137
|
-
* the pure-tmux port of placement.ts `focus()`. The server (`ensureAttach`)
|
|
138
|
-
* owns the broker revive + the view.sock accept-wait; the `@crtr_node` pane tag
|
|
139
|
-
* is the viewer registry. crtr is tmux-only: no caller pane ⇒ not focused. */
|
|
140
|
-
export async function focusViewer(nodeId, opts) {
|
|
141
|
-
const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
142
|
-
if (callerPane === undefined || callerPane === '') {
|
|
143
|
-
return { focused: false, session: null, inPlace: false, revived: false };
|
|
144
|
-
}
|
|
145
|
-
// (a) Server ensures the broker engine is alive + its view.sock accepts.
|
|
146
|
-
const attach = await cliClient().ensureAttach(nodeId).catch(rethrowAsCliError);
|
|
147
|
-
const revived = attach.revived;
|
|
148
|
-
const callerSession = paneLocation(callerPane)?.session ?? null;
|
|
149
|
-
// (b0) --in-place → the caller pane BECOMES this node's viewer, replacing what
|
|
150
|
-
// it showed. Must be driven from OUTSIDE that pane's process tree.
|
|
151
|
-
if (opts.inPlace === true) {
|
|
152
|
-
const priorNodeId = getPaneOption(callerPane, '@crtr_node');
|
|
153
|
-
const existing = viewerPaneOf(nodeId);
|
|
154
|
-
if (existing !== undefined && existing !== callerPane && paneExists(existing))
|
|
155
|
-
closePane(existing);
|
|
156
|
-
try {
|
|
157
|
-
setPaneOption(callerPane, '@crtr_node', '');
|
|
158
|
-
}
|
|
159
|
-
catch { /* best-effort */ }
|
|
160
|
-
const window = windowOfPane(callerPane);
|
|
161
|
-
const env = priorNodeId !== undefined && priorNodeId !== ''
|
|
162
|
-
? { ...viewerEnv(), CRTR_ATTACH_ROSTER: priorNodeId }
|
|
163
|
-
: viewerEnv();
|
|
164
|
-
const ok = respawnPaneSync({ pane: callerPane, cwd: opts.cwd, env, command: `crtr surface attach to ${nodeId}` });
|
|
165
|
-
if (ok && window !== null)
|
|
166
|
-
renameWindow(window, opts.name);
|
|
167
|
-
return { focused: ok, session: callerSession, inPlace: true, revived };
|
|
168
|
-
}
|
|
169
|
-
// (b) --new-pane → always a fresh viewer beside the caller; drop any prior
|
|
170
|
-
// viewer this node holds elsewhere (one viewer per node).
|
|
171
|
-
if (opts.newPane === true) {
|
|
172
|
-
const prior = viewerPaneOf(nodeId);
|
|
173
|
-
if (prior !== undefined && paneExists(prior))
|
|
174
|
-
closePane(prior);
|
|
175
|
-
return openBesideCaller(callerPane, nodeId, opts.cwd, callerSession, revived);
|
|
176
|
-
}
|
|
177
|
-
// (c) Reuse the node's existing tagged viewer wherever it already lives.
|
|
178
|
-
const existing = viewerPaneOf(nodeId);
|
|
179
|
-
if (existing !== undefined) {
|
|
180
|
-
const loc = paneLocation(existing);
|
|
181
|
-
if (loc !== null) {
|
|
182
|
-
switchClient(loc.session);
|
|
183
|
-
selectWindow(loc.session, loc.window);
|
|
184
|
-
selectPane(existing);
|
|
185
|
-
return { focused: true, session: loc.session, inPlace: true, revived };
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
// (d) No live viewer → open one beside the caller.
|
|
189
|
-
return openBesideCaller(callerPane, nodeId, opts.cwd, callerSession, revived);
|
|
190
|
-
}
|
|
191
|
-
/** Open a viewer for a freshly-spawned --root node in a FRESH WINDOW in the
|
|
192
|
-
* caller's current tmux session (the --root spawn-viewer path). Pure tmux; the
|
|
193
|
-
* broker is already launching server-side, so the attach pane self-polls the
|
|
194
|
-
* cold socket. Returns the opened window/session, empty when not in tmux. */
|
|
195
|
-
function openSpawnViewer(nodeId, cwd, name) {
|
|
196
|
-
const loc = currentTmux();
|
|
197
|
-
const session = loc?.session;
|
|
198
|
-
if (session === undefined || session === '')
|
|
199
|
-
return {};
|
|
200
|
-
ensureSession(session, cwd);
|
|
201
|
-
const opened = openNodeWindow({ session, name, cwd, env: viewerEnv(), command: `crtr surface attach to ${nodeId}` });
|
|
202
|
-
if (opened === null)
|
|
203
|
-
return {};
|
|
204
|
-
selectWindow(session, opened.window);
|
|
205
|
-
selectPane(opened.pane);
|
|
206
|
-
return { window: opened.window, session };
|
|
207
|
-
}
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
// node new — spawn a terminal worker as a detached headless broker under the root
|
|
210
|
-
// ---------------------------------------------------------------------------
|
|
211
|
-
const nodeCreationEngine = defineLeaf({
|
|
212
|
-
name: 'new',
|
|
213
|
-
description: 'create and start a node immediately',
|
|
214
|
-
whenToUse: 'you need a managed child or independent root to start immediately',
|
|
215
|
-
tier: 'important',
|
|
216
|
-
help: {
|
|
217
|
-
name: 'node new',
|
|
218
|
-
summary: 'create a node immediately and start its detached broker',
|
|
219
|
-
params: [
|
|
220
|
-
{ kind: 'stdin', name: 'prompt', required: false, constraint: 'First user message. Required unless --no-kickoff is given.' },
|
|
221
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind. The <kinds> list below names every top-level installable kind.' },
|
|
222
|
-
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for hands-on work; orchestrator when the unit itself needs decomposition across children.' },
|
|
223
|
-
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Directory the node is pinned to. Defaults to the caller cwd.' },
|
|
224
|
-
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name. Defaults to the kind.' },
|
|
225
|
-
{ kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
|
|
226
|
-
{ kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
|
|
227
|
-
{ kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent resident root with no parent or subscription. Mutually exclusive with --worktree.' },
|
|
228
|
-
{ kind: 'flag', name: 'worktree', type: 'string', required: false, constraint: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.' },
|
|
229
|
-
{ kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the new node from an existing node id, absolute session path, or partial pi session uuid.' },
|
|
230
|
-
{ kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Durable model override: provider/id, provider/tier, tier, or family alias.' },
|
|
231
|
-
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Profile id or unique profile name. Omit to inherit the caller profile.' },
|
|
232
|
-
{ kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Hidden ambient context appended to injected bearings, never visible chat.' },
|
|
233
|
-
{ kind: 'flag', name: 'no-kickoff', type: 'bool', required: false, constraint: 'With --root, start the broker and session idle without a first message or model turn. Mutually exclusive with a prompt and --mode orchestrator.' },
|
|
234
|
-
{ kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. The node must submit a conforming result to finish.' },
|
|
235
|
-
],
|
|
236
|
-
output: [
|
|
237
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'New node id.' },
|
|
238
|
-
{ name: 'name', type: 'string', required: true, constraint: 'Display name.' },
|
|
239
|
-
{ name: 'status', type: 'string', required: true, constraint: 'Always "active".' },
|
|
240
|
-
{ name: 'window', type: 'string', required: false, constraint: 'tmux window id when a viewer opens.' },
|
|
241
|
-
{ name: 'session', type: 'string', required: false, constraint: 'tmux session when a viewer opens.' },
|
|
242
|
-
{ name: 'follow_up', type: 'string', required: true, constraint: 'Managed child: continue unrelated work or stop; its final report wakes you. Root: hand it off; no report wakes you.' },
|
|
243
|
-
],
|
|
244
|
-
dynamicState: () => [kindsStateBlock(), profilesStateBlock()].join('\n'),
|
|
245
|
-
outputKind: 'object',
|
|
246
|
-
effects: [
|
|
247
|
-
'Creates nodes/<id>/ in the canvas home, indexes the node in canvas.db, and starts its detached broker engine.',
|
|
248
|
-
'A managed child creates an active parent subscription; --root creates an independent node with spawned_by provenance and no parent subscription.',
|
|
249
|
-
'--worktree creates a managed worktree. --output-schema writes nodes/<id>/output-schema.json. --no-kickoff starts an idle root without a first model turn.',
|
|
250
|
-
'An in-tmux root opens a viewer. When auto_open_child_viewers is true, a managed child opens beside a live spawner viewer; false leaves it running in the background.',
|
|
251
|
-
],
|
|
252
|
-
},
|
|
253
|
-
run: async (input) => {
|
|
254
|
-
const prompt = input['prompt'] ?? '';
|
|
255
|
-
const noKickoff = input['noKickoff'] === true;
|
|
256
|
-
if (!noKickoff && prompt.trim() === '') {
|
|
257
|
-
throw new InputError({ error: 'empty_prompt', message: 'a prompt is required (stdin or positional)', next: 'Pipe a task on stdin or pass it as an argument, or pass --no-kickoff to spawn an idle node with none.' });
|
|
258
|
-
}
|
|
259
|
-
if (noKickoff && prompt.trim() !== '') {
|
|
260
|
-
throw new InputError({ error: 'no_kickoff_with_prompt', message: '--no-kickoff cannot be combined with a prompt', field: 'no-kickoff', next: 'Drop --no-kickoff, or drop the prompt.' });
|
|
261
|
-
}
|
|
262
|
-
const kind = input['kind'] ?? 'general';
|
|
263
|
-
const mode = (input['mode'] ?? 'base');
|
|
264
|
-
const cwd = input['cwd'] ?? process.cwd();
|
|
265
|
-
const name = input['name'];
|
|
266
|
-
const parent = input['parent'];
|
|
267
|
-
const root = input['root'] === true;
|
|
268
|
-
const worktreeBaseRaw = input['worktree'];
|
|
269
|
-
if (worktreeBaseRaw !== undefined && worktreeBaseRaw.trim() === '') {
|
|
270
|
-
throw new InputError({ error: 'worktree_base_required', message: '--worktree requires a base branch', field: 'worktree', next: 'Pass an existing local branch that landing can fast-forward.' });
|
|
271
|
-
}
|
|
272
|
-
const worktreeBase = worktreeBaseRaw?.trim();
|
|
273
|
-
const forkFrom = input['forkFrom'];
|
|
274
|
-
const modelSpec = input['model']?.trim();
|
|
275
|
-
if (modelSpec === '') {
|
|
276
|
-
throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Pass a provider/id, provider/tier, tier, or family alias.' });
|
|
277
|
-
}
|
|
278
|
-
const profile = input['profile'];
|
|
279
|
-
const situationalContextRaw = input['situationalContext']?.trim();
|
|
280
|
-
const situationalContext = situationalContextRaw !== undefined && situationalContextRaw !== '' ? situationalContextRaw : undefined;
|
|
281
|
-
const outputSchemaParsed = parseOutputSchemaValue(input['outputSchema']);
|
|
282
|
-
const outputSchema = outputSchemaParsed !== null ? JSON.stringify(outputSchemaParsed) : null;
|
|
283
|
-
const nodeId = input['nodeId']?.trim();
|
|
284
|
-
if (root && worktreeBase !== undefined) {
|
|
285
|
-
throw new InputError({ error: 'worktree_root_conflict', message: '--worktree is mutually exclusive with --root', field: 'worktree', next: 'Drop either --root or --worktree.' });
|
|
286
|
-
}
|
|
287
|
-
if (noKickoff && !root) {
|
|
288
|
-
throw new InputError({ error: 'no_kickoff_requires_root', message: '--no-kickoff is only valid with --root', field: 'no-kickoff', next: 'Add --root, or drop --no-kickoff and provide a prompt.' });
|
|
289
|
-
}
|
|
290
|
-
if (noKickoff && mode === 'orchestrator') {
|
|
291
|
-
throw new InputError({ error: 'no_kickoff_orchestrator_unsupported', message: '--no-kickoff cannot be combined with --mode orchestrator', field: 'no-kickoff', next: 'Drop --no-kickoff and provide a prompt, or spawn in base mode.' });
|
|
292
|
-
}
|
|
293
|
-
const parentId = parent ?? process.env['CRTR_NODE_ID'];
|
|
294
|
-
mark('node_new.create_start', { root });
|
|
295
|
-
const detail = await cliClient().createNode({
|
|
296
|
-
kind,
|
|
297
|
-
mode,
|
|
298
|
-
cwd,
|
|
299
|
-
...(name !== undefined ? { name } : {}),
|
|
300
|
-
prompt,
|
|
301
|
-
...(root ? { root: true } : { parent: parentId ?? null }),
|
|
302
|
-
...(worktreeBase !== undefined ? { worktree: worktreeBase } : {}),
|
|
303
|
-
...(forkFrom !== undefined ? { fork_from: forkFrom } : {}),
|
|
304
|
-
...(modelSpec !== undefined ? { model: modelSpec } : {}),
|
|
305
|
-
...(profile !== undefined ? { profile } : {}),
|
|
306
|
-
...(situationalContext !== undefined ? { situational_context: situationalContext } : {}),
|
|
307
|
-
...(noKickoff ? { no_kickoff: true } : {}),
|
|
308
|
-
...(outputSchema !== null ? { output_schema: outputSchema } : {}),
|
|
309
|
-
...(nodeId !== undefined && nodeId !== '' ? { node_id: nodeId } : {}),
|
|
310
|
-
// A bare `--root --no-kickoff` create is exactly what the warm pool holds
|
|
311
|
-
// ready; every other shape is rejected server-side and cold-spawns.
|
|
312
|
-
...(root ? { prefer_warm: true } : {}),
|
|
313
|
-
}).catch(rethrowAsCliError);
|
|
314
|
-
mark('node_new.node_created', { node: detail.node_id });
|
|
315
|
-
let placement = {};
|
|
316
|
-
if (root)
|
|
317
|
-
placement = openSpawnViewer(detail.node_id, detail.cwd, detail.name);
|
|
318
|
-
if (root)
|
|
319
|
-
mark('node_new.viewer_window_opened');
|
|
320
|
-
return {
|
|
321
|
-
node_id: detail.node_id,
|
|
322
|
-
name: detail.name,
|
|
323
|
-
window: placement.window,
|
|
324
|
-
session: placement.session,
|
|
325
|
-
status: detail.status,
|
|
326
|
-
follow_up: root
|
|
327
|
-
? (noKickoff
|
|
328
|
-
? 'Independent root spawned idle. No kickoff turn ran; hand it off or attach and prompt it. No report wakes you.'
|
|
329
|
-
: 'Independent root spawned. You are not subscribed, so its finish does not wake you; hand it off and move on.')
|
|
330
|
-
: await childFollowUp(parentId),
|
|
331
|
-
};
|
|
332
|
-
},
|
|
333
|
-
render: (r) => {
|
|
334
|
-
const win = r['window'] ? ` in window ${r['window']}` : '';
|
|
335
|
-
const lines = [`Spawned "${r['name']}" (${r['node_id']}) — ${r['status']}${win}.`];
|
|
336
|
-
if (r['session'])
|
|
337
|
-
lines.push(`- session: ${r['session']}`);
|
|
338
|
-
lines.push('', String(r['follow_up']));
|
|
339
|
-
return lines.join('\n');
|
|
340
|
-
},
|
|
341
|
-
});
|
|
342
|
-
function paramsFor(source, names) {
|
|
343
|
-
const selected = new Set(names);
|
|
344
|
-
return (source ?? []).filter((param) => selected.has(param.name));
|
|
345
|
-
}
|
|
346
|
-
function outputsFor(source, names) {
|
|
347
|
-
const selected = new Set(names);
|
|
348
|
-
return source.filter((field) => selected.has(field.name));
|
|
349
|
-
}
|
|
350
|
-
function constrainParams(params, constraints) {
|
|
351
|
-
return params.map((param) => constraints[param.name] === undefined ? param : { ...param, constraint: constraints[param.name] });
|
|
352
|
-
}
|
|
353
|
-
function constrainOutputs(outputs, constraints) {
|
|
354
|
-
return outputs.map((field) => constraints[field.name] === undefined ? field : { ...field, constraint: constraints[field.name] });
|
|
355
|
-
}
|
|
356
|
-
const nodeNew = defineLeaf({
|
|
357
|
-
name: 'new',
|
|
358
|
-
description: 'create and start a node immediately',
|
|
359
|
-
whenToUse: 'you need a managed child or independent root to start immediately',
|
|
360
|
-
tier: 'important',
|
|
361
|
-
help: {
|
|
362
|
-
...nodeCreationEngine.help,
|
|
363
|
-
name: 'node new',
|
|
364
|
-
summary: 'create a node immediately and start its detached broker',
|
|
365
|
-
guide: undefined,
|
|
366
|
-
params: constrainParams(paramsFor(nodeCreationEngine.help.params, ['prompt', 'kind', 'mode', 'cwd', 'name', 'parent', 'node-id', 'root', 'worktree', 'fork-from', 'model', 'profile', 'situational-context', 'no-kickoff', 'output-schema']), {
|
|
367
|
-
prompt: 'First user message. Required unless --no-kickoff is given.',
|
|
368
|
-
cwd: 'Directory the node is pinned to. Defaults to the caller cwd.',
|
|
369
|
-
'node-id': 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.',
|
|
370
|
-
'situational-context': 'Hidden ambient context appended to injected bearings, never visible chat.',
|
|
371
|
-
'no-kickoff': 'With --root, start the broker and session idle without a first message or model turn. Mutually exclusive with a prompt and --mode orchestrator.',
|
|
372
|
-
'output-schema': 'Path to a JSON Schema file, or inline JSON beginning with `{`. The node must submit a conforming result to finish.',
|
|
373
|
-
worktree: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.',
|
|
374
|
-
}),
|
|
375
|
-
output: [
|
|
376
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'New node id.' },
|
|
377
|
-
{ name: 'name', type: 'string', required: true, constraint: 'Display name.' },
|
|
378
|
-
{ name: 'status', type: 'string', required: true, constraint: 'Always "active".' },
|
|
379
|
-
{ name: 'window', type: 'string', required: false, constraint: 'tmux window id when a viewer opens.' },
|
|
380
|
-
{ name: 'session', type: 'string', required: false, constraint: 'tmux session when a viewer opens.' },
|
|
381
|
-
{ name: 'follow_up', type: 'string', required: true, constraint: 'Managed child: continue unrelated work or stop; its final report wakes you. Root: hand it off; no report wakes you.' },
|
|
382
|
-
],
|
|
383
|
-
effects: [
|
|
384
|
-
'Creates nodes/<id>/ in the canvas home, indexes the node in canvas.db, and starts its detached broker engine.',
|
|
385
|
-
'A managed child creates an active parent subscription; its pushes wake the parent. --root creates an independent node with spawned_by provenance and no parent subscription.',
|
|
386
|
-
'--worktree creates a managed worktree at ~/.crouter/canvas/worktrees/<node-id>/ on a new crtr/<node-id> branch. --output-schema writes nodes/<id>/output-schema.json. --no-kickoff starts an idle root without a first model turn.',
|
|
387
|
-
'An in-tmux root opens a viewer in the current tmux session. When auto_open_child_viewers is true, a managed child opens beside a live spawner viewer; false leaves it running in the background.'
|
|
388
|
-
],
|
|
389
|
-
},
|
|
390
|
-
run: async (input, context) => {
|
|
391
|
-
const result = await nodeCreationEngine.run(input, context);
|
|
392
|
-
if (result === undefined)
|
|
393
|
-
return;
|
|
394
|
-
const { node_id, name, window, session, status, follow_up } = result;
|
|
395
|
-
return { node_id, name, window, session, status, follow_up };
|
|
396
|
-
},
|
|
397
|
-
render: nodeCreationEngine.render,
|
|
398
|
-
});
|
|
399
|
-
function requireNonBlankDiscriminant(input, name) {
|
|
400
|
-
const value = input[name];
|
|
401
|
-
if (value !== undefined && (typeof value !== 'string' || value.trim() === '')) {
|
|
402
|
-
const flag = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
403
|
-
throw new InputError({ error: 'empty_discriminant', message: `--${flag} must contain non-whitespace text`, received: typeof value === 'string' ? value : String(value), field: flag, next: `Pass a non-empty --${flag} value, or omit the flag.` });
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
// ---------------------------------------------------------------------------
|
|
407
|
-
// node inspect list — the active canvas (or a status slice)
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
const nodeList = defineLeaf({
|
|
410
|
-
name: 'list',
|
|
411
|
-
description: 'list nodes on the canvas',
|
|
412
|
-
whenToUse: 'you want a flat roster of the nodes on the canvas, sliced by displayed name, status, kind, mode, lifecycle, profile, cwd, and graph scope (--under a node, for that node plus its subscription descendants). The filters AND together. Use `node inspect show` instead to drill into one node and its spine neighbors, `canvas dashboard` for the tree shape, and `canvas attention` to find nodes blocked on a human',
|
|
413
|
-
help: {
|
|
414
|
-
name: 'node inspect list',
|
|
415
|
-
summary: 'list nodes on the canvas, filtered by any combination of attribute slices and a graph-scope (--under) that AND together',
|
|
416
|
-
params: [
|
|
417
|
-
{ kind: 'flag', name: 'status', type: 'string', required: false, constraint: 'Filter status (comma-separated for several; accepted values: active, idle, done, dead, canceled). NOTE: `active` means the engine process is live (never closed), NOT that the node is generating right now — an active node is usually dormant between turns. To tell working-vs-idle, check the pi session-file mtime or CPU, not status.' },
|
|
418
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Filter by persona kind (comma-separated for several). The <kinds> list below names every top-level installable kind; a sub-kind (e.g. plan/reviewers/security) filters too even though it is not listed.' },
|
|
419
|
-
{ kind: 'flag', name: 'mode', type: 'string', required: false, constraint: 'Filter by mode (comma-separated for several; accepted values: base, orchestrator).' },
|
|
420
|
-
{ kind: 'flag', name: 'lifecycle', type: 'string', required: false, constraint: 'Filter by lifecycle (comma-separated for several; accepted values: terminal, resident).' },
|
|
421
|
-
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Filter by profile. On the local canvas, accepts an exact profile id or a unique manifest name from the <profiles> list below. Against a remote canvas, pass the exact profile id carried by its node rows.' },
|
|
422
|
-
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Filter by the displayed node label (handle plus generated description) with the canvas browser’s case-insensitive fuzzy subsequence match.' },
|
|
423
|
-
{ kind: 'flag', name: 'cwd', type: 'string', required: false, constraint: 'Filter to nodes pinned to this exact cwd (absolute path). Roster is canvas-wide (all cwds) by default.' },
|
|
424
|
-
{ kind: 'flag', name: 'under', type: 'string', required: false, constraint: 'Graph scope: restrict to this node and its subscription descendants (the sub-DAG reachable downward — its reports, theirs, …). This is "one initiative / my subtree". ANDs with every attribute filter above, narrowing any of them to this subtree.' },
|
|
425
|
-
{ kind: 'flag', name: 'canvas', type: 'string', required: false, constraint: 'Read from the named remote canvas target (see `crtr canvas config list`) instead of the local canvas. Overrides `crtr canvas use`.' },
|
|
426
|
-
],
|
|
427
|
-
output: [
|
|
428
|
-
{ name: 'nodes', type: 'object[]', required: true, constraint: 'Rows include {node_id, name, kind, mode, lifecycle, status, cwd, profile_id, parent, created}. `name` is the displayed node label searched by --name and shown in canvas views. `profile_id` is the stable profile-directory id (null only for historical no-profile nodes).' },
|
|
429
|
-
],
|
|
430
|
-
outputKind: 'object',
|
|
431
|
-
dynamicState: () => [kindsStateBlock(), profilesStateBlock()].join('\n'),
|
|
432
|
-
effects: ['Read-only: lists nodes through the daemon API.'],
|
|
433
|
-
},
|
|
434
|
-
run: async (input) => {
|
|
435
|
-
const csv = (k) => {
|
|
436
|
-
const raw = input[k];
|
|
437
|
-
return raw !== undefined && raw !== '' ? raw.split(',').map((s) => s.trim()).filter((s) => s !== '') : undefined;
|
|
438
|
-
};
|
|
439
|
-
const status = csv('status');
|
|
440
|
-
const kinds = csv('kind');
|
|
441
|
-
const modes = csv('mode');
|
|
442
|
-
const lifecycles = csv('lifecycle');
|
|
443
|
-
const profile = input['profile'];
|
|
444
|
-
const name = input['name'];
|
|
445
|
-
const cwd = input['cwd'];
|
|
446
|
-
const under = input['under'];
|
|
447
|
-
// Profile names are local manifest state. A remote roster instead carries
|
|
448
|
-
// stable profile ids, which remain meaningful without mirroring manifests.
|
|
449
|
-
const canvasSource = cliCanvasSource({ canvasName: input['canvas'] });
|
|
450
|
-
const remote = !(canvasSource instanceof ApiCanvasSource);
|
|
451
|
-
const profileId = profile === undefined
|
|
452
|
-
? undefined
|
|
453
|
-
: remote
|
|
454
|
-
? profile
|
|
455
|
-
: loadProfileManifest(profile).profileId;
|
|
456
|
-
// Graph scope (--under): self + subscription descendants. Validate the anchor
|
|
457
|
-
// so a typo'd id is a structured error, not a silently-empty roster.
|
|
458
|
-
let scope;
|
|
459
|
-
if (under !== undefined && under !== '') {
|
|
460
|
-
if (await canvasSource.getNode(under).catch(rethrowAsCliError) === null) {
|
|
461
|
-
throw new InputError({ error: 'not_found', message: `no node: ${under}`, next: 'List nodes with `crtr node inspect list`, then pass a real id to --under.' });
|
|
462
|
-
}
|
|
463
|
-
scope = new Set([under, ...(await canvasSource.view(under))]);
|
|
464
|
-
}
|
|
465
|
-
const rows = (await canvasSource.listNodes(status !== undefined ? { status } : undefined).catch(rethrowAsCliError)).filter((row) => {
|
|
466
|
-
if (scope !== undefined && !scope.has(row.node_id))
|
|
467
|
-
return false;
|
|
468
|
-
if (kinds !== undefined && !kinds.includes(row.kind))
|
|
469
|
-
return false;
|
|
470
|
-
if (modes !== undefined && !modes.includes(row.mode))
|
|
471
|
-
return false;
|
|
472
|
-
if (lifecycles !== undefined && !lifecycles.includes(row.lifecycle))
|
|
473
|
-
return false;
|
|
474
|
-
if (profileId !== undefined && row.profile_id !== profileId)
|
|
475
|
-
return false;
|
|
476
|
-
if (cwd !== undefined && cwd !== '' && row.cwd !== cwd)
|
|
477
|
-
return false;
|
|
478
|
-
return true;
|
|
479
|
-
});
|
|
480
|
-
const namedRows = (await Promise.all(rows.map(async (row) => {
|
|
481
|
-
const meta = await canvasSource.getNode(row.node_id);
|
|
482
|
-
return { ...row, name: fullName(meta ?? row) };
|
|
483
|
-
}))).filter((row) => name === undefined || name === '' || fuzzyMatch(name, row.name));
|
|
484
|
-
// Fault diagnosis lives on the local canvas views, not this roster.
|
|
485
|
-
const nodes = namedRows;
|
|
486
|
-
return { nodes };
|
|
487
|
-
},
|
|
488
|
-
});
|
|
489
|
-
// ---------------------------------------------------------------------------
|
|
490
|
-
// node inspect show — a node + its place in the spine
|
|
491
|
-
// ---------------------------------------------------------------------------
|
|
492
|
-
// Enrich a bare subscription edge with the neighbor's name/kind/status so a
|
|
493
|
-
// debugger reads who a report/manager IS without a second `show` per id.
|
|
494
|
-
async function enrichNeighborFromSource(source, ref) {
|
|
495
|
-
const n = await source.getNode(ref.node_id);
|
|
496
|
-
return {
|
|
497
|
-
node_id: ref.node_id,
|
|
498
|
-
name: n ? fullName(n) : '(missing)',
|
|
499
|
-
kind: n?.kind ?? '?',
|
|
500
|
-
status: n ? (n.intent ? `${n.status}/${n.intent}` : n.status) : '?',
|
|
501
|
-
active: ref.active,
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
/** Symbol-keyed marker set on a remote-passthrough `node inspect show` result so
|
|
505
|
-
* `render()` below can tell it came from a remote canvas without adding an
|
|
506
|
-
* enumerable field — `JSON.stringify` (the `--json` path) skips symbol keys,
|
|
507
|
-
* so this is invisible to machine output. */
|
|
508
|
-
const REMOTE_SHOW_MARKER = Symbol('remote-show');
|
|
509
|
-
const nodeShow = defineLeaf({
|
|
510
|
-
name: 'show',
|
|
511
|
-
description: 'show a node + its spine neighbors, artifacts, and drill-in pointers',
|
|
512
|
-
whenToUse: 'you want one node in depth — the debugging entrypoint: a glanceable meta slice, named spine neighbors, artifact and pending-cron counts, and exact drill-in paths. Use `node inspect list` for the flat roster or `canvas dashboard` for the whole-tree shape',
|
|
513
|
-
help: {
|
|
514
|
-
name: 'node inspect show',
|
|
515
|
-
summary: 'show a node in depth — meta slice, named neighbors, artifact/pending-cron counts, and drill-in pointers',
|
|
516
|
-
params: [
|
|
517
|
-
{ kind: 'positional', name: 'node', required: true, constraint: 'Node id.' },
|
|
518
|
-
{ kind: 'flag', name: 'canvas', type: 'string', required: false, constraint: 'Read from the named remote canvas target (see `crtr canvas config list`) instead of the local canvas. Overrides `crtr canvas use`.' },
|
|
519
|
-
],
|
|
520
|
-
output: [
|
|
521
|
-
{ name: 'node', type: 'object', required: true, constraint: 'The full node meta (system prompt + every telemetry field). The render surfaces a glanceable slice; `--json` carries the complete dump.' },
|
|
522
|
-
{ name: 'reports', type: 'object[]', required: true, constraint: 'Who this node subscribes to (its reports/down), each enriched with {node_id, name, kind, status, active} — these are typically its spawned children.' },
|
|
523
|
-
{ name: 'managers', type: 'object[]', required: true, constraint: 'Who subscribes to this node (its managers/up), same enriched shape.' },
|
|
524
|
-
{ name: 'artifacts', type: 'object', required: true, constraint: 'Counts of what the node left on disk by corpus: {report, doc, roadmap}. Enumerate them with `node inspect artifacts <id>`.' },
|
|
525
|
-
{ name: 'pending_crons', type: 'number', required: true, constraint: 'How many pending crons were created by this node. Inspect with `crtr cron list`.' },
|
|
526
|
-
{ name: 'paths', type: 'object', required: true, constraint: 'Resolved on-disk locations: {context_dir, reports_dir, session_file} — read these directly to debug.' },
|
|
527
|
-
{ name: 'follow_up', type: 'string', required: true, constraint: 'The concrete next commands to drill deeper.' },
|
|
528
|
-
],
|
|
529
|
-
outputKind: 'object',
|
|
530
|
-
effects: ['Read-only: reads the node, its edges and artifacts, and pending crons through crtrd.'],
|
|
531
|
-
// The node meta object inlines the full system prompt + every telemetry
|
|
532
|
-
// field — a machine-readable dump we suppress from the viewer preview; the
|
|
533
|
-
// render below surfaces only a glanceable slice. Same preview rationale as
|
|
534
|
-
// canvas snapshot / node inspect snapshot.
|
|
535
|
-
preview: { suppressOutput: true },
|
|
536
|
-
},
|
|
537
|
-
run: async (input) => {
|
|
538
|
-
const id = input['node'];
|
|
539
|
-
const canvasName = input['canvas'];
|
|
540
|
-
const canvasSource = cliCanvasSource({ canvasName });
|
|
541
|
-
// Remote branch: `node inspect show` already computes this exact
|
|
542
|
-
// {node, reports, managers, artifacts, pending_crons, paths, follow_up}
|
|
543
|
-
// shape in-guest — passthrough it verbatim rather than re-deriving
|
|
544
|
-
// artifacts/crons/paths from LOCAL disk (which would be meaningless for
|
|
545
|
-
// a remote node id). The `render` below works unchanged on either branch
|
|
546
|
-
// since it only reads generic fields off the returned object.
|
|
547
|
-
if (!(canvasSource instanceof ApiCanvasSource)) {
|
|
548
|
-
const passthrough = await canvasSource.rawShow(id);
|
|
549
|
-
if (passthrough === null) {
|
|
550
|
-
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list --canvas <name>`.' });
|
|
551
|
-
}
|
|
552
|
-
const result = passthrough;
|
|
553
|
-
result[REMOTE_SHOW_MARKER] = true;
|
|
554
|
-
return result;
|
|
555
|
-
}
|
|
556
|
-
// Raw DTO carries paths + session file + edges the lossy NodeMeta projection
|
|
557
|
-
// drops; fetch it directly (a 404 maps to the clean not_found contract).
|
|
558
|
-
const node = await cliClient()
|
|
559
|
-
.getNode(id)
|
|
560
|
-
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
561
|
-
const reports = await Promise.all((await canvasSource.subscriptionsOf(id)).map((ref) => enrichNeighborFromSource(canvasSource, ref)));
|
|
562
|
-
const managers = await Promise.all((await canvasSource.subscribersOf(id)).map((ref) => enrichNeighborFromSource(canvasSource, ref)));
|
|
563
|
-
const arts = await cliClient().getArtifacts(id);
|
|
564
|
-
const artifacts = {
|
|
565
|
-
report: arts.artifacts.filter((a) => a.source.startsWith('report')).length,
|
|
566
|
-
doc: arts.artifacts.filter((a) => a.source === 'doc').length,
|
|
567
|
-
roadmap: arts.artifacts.filter((a) => a.source === 'roadmap').length,
|
|
568
|
-
};
|
|
569
|
-
const pending_crons = (await cliClient().listCrons().catch(rethrowAsCliError)).filter((cron) => cron.created_by === id).length;
|
|
570
|
-
const paths = {
|
|
571
|
-
context_dir: node.paths.context_dir,
|
|
572
|
-
reports_dir: node.paths.reports_dir,
|
|
573
|
-
session_file: node.pi_session_file ?? null,
|
|
574
|
-
};
|
|
575
|
-
// Auto-gathered drill-in pointers: the exact commands a debugger reaches for
|
|
576
|
-
// next, populated only when there is actually something behind them.
|
|
577
|
-
const hints = [];
|
|
578
|
-
const artTotal = artifacts.report + artifacts.doc + artifacts.roadmap;
|
|
579
|
-
if (artTotal > 0)
|
|
580
|
-
hints.push(`Read its ${artTotal} artifact(s): \`crtr node inspect artifacts ${id}\` → each ref feeds \`crtr canvas history read <ref>\`.`);
|
|
581
|
-
hints.push(`Browse its context + reports dirs (and its subscriptions') in nvim: \`crtr node inspect context ${id}\`.`);
|
|
582
|
-
if (paths.session_file)
|
|
583
|
-
hints.push(`Read the raw conversation transcript: ${paths.session_file}`);
|
|
584
|
-
hints.push(`On-disk context dir (ls/read directly): ${paths.context_dir}`);
|
|
585
|
-
if (pending_crons > 0)
|
|
586
|
-
hints.push(`It created ${pending_crons} pending cron(s): \`crtr cron list\`.`);
|
|
587
|
-
if (reports.length > 0)
|
|
588
|
-
hints.push(`Drill into a child/report: \`crtr node inspect show <node_id>\` (${reports.map((r) => r['node_id']).join(', ')}).`);
|
|
589
|
-
hints.push(`Full machine dump (system prompt + all telemetry): \`crtr --json node inspect show ${id}\`.`);
|
|
590
|
-
return { node, reports, managers, artifacts, pending_crons, paths, follow_up: hints.join('\n') };
|
|
591
|
-
},
|
|
592
|
-
render: (r) => {
|
|
593
|
-
const node = r['node'];
|
|
594
|
-
const id = String(node['node_id']);
|
|
595
|
-
const pid = node['pi_pid'];
|
|
596
|
-
const isRemoteShow = Boolean(r[REMOTE_SHOW_MARKER]);
|
|
597
|
-
const alive = isRemoteShow ? 'remote (liveness not checked locally)' : (isPidAlive(pid) ? 'alive' : 'dead');
|
|
598
|
-
const intent = node['intent'] ? `/${String(node['intent'])}` : '';
|
|
599
|
-
const parts = [];
|
|
600
|
-
// 1) Glanceable meta slice — the debug-critical fields, not the full dump.
|
|
601
|
-
parts.push(`# ${fullName(node)} (${id})`);
|
|
602
|
-
const meta = [
|
|
603
|
-
`- kind/mode/lifecycle: ${node['kind']} / ${node['mode']} / ${node['lifecycle']}`,
|
|
604
|
-
`- status: ${node['status']}${intent}`,
|
|
605
|
-
`- broker pid: ${pid ?? '(none)'} — ${alive}`,
|
|
606
|
-
`- cwd: ${node['cwd']}`,
|
|
607
|
-
`- host: ${node['host_kind']}` + (node['profile_id'] ? ` · profile ${node['profile_id']}` : ''),
|
|
608
|
-
`- cycles: ${node['cycles']} · created ${node['created']}`,
|
|
609
|
-
];
|
|
610
|
-
if (node['parent'])
|
|
611
|
-
meta.push(`- parent: ${node['parent']}`);
|
|
612
|
-
if (node['waiting_for'])
|
|
613
|
-
meta.push(`- waiting for message: ${node['waiting_for']}`);
|
|
614
|
-
parts.push(meta.join('\n'));
|
|
615
|
-
// 2) Neighbor tables (reports down, managers up), pre-enriched with names.
|
|
616
|
-
const table = (label, rows) => {
|
|
617
|
-
if (rows.length === 0)
|
|
618
|
-
return `${label}: none`;
|
|
619
|
-
const cols = ['node_id', 'name', 'kind', 'status', 'active'];
|
|
620
|
-
const head = `| ${cols.join(' | ')} |`;
|
|
621
|
-
const sep = `| ${cols.map(() => '---').join(' | ')} |`;
|
|
622
|
-
const body = rows.map((row) => `| ${cols.map((c) => String(row[c] ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ')).join(' | ')} |`).join('\n');
|
|
623
|
-
return `${rows.length} ${label}:\n\n${head}\n${sep}\n${body}`;
|
|
624
|
-
};
|
|
625
|
-
parts.push(table('reports (down — subscriptions, usually children)', r['reports']));
|
|
626
|
-
parts.push(table('managers (up — subscribers)', r['managers']));
|
|
627
|
-
// 3) On-disk footprint at a glance.
|
|
628
|
-
const a = r['artifacts'];
|
|
629
|
-
parts.push(`artifacts: ${a['report']} report(s), ${a['doc']} doc(s), ${a['roadmap']} roadmap · ${r['pending_crons']} pending cron(s)`);
|
|
630
|
-
// 4) Auto-gathered drill-in pointers.
|
|
631
|
-
parts.push(`Drill deeper:\n${String(r['follow_up'])}`);
|
|
632
|
-
return parts.join('\n\n');
|
|
633
|
-
},
|
|
634
|
-
});
|
|
635
|
-
// ---------------------------------------------------------------------------
|
|
636
|
-
// node fork — branch a conversation + durable context into an idle root
|
|
637
|
-
// ---------------------------------------------------------------------------
|
|
638
|
-
const nodeFork = defineLeaf({
|
|
639
|
-
name: 'fork',
|
|
640
|
-
description: 'fork a node into an independent idle root with the same conversation and durable context',
|
|
641
|
-
whenToUse: 'a conversation has already accumulated the context you want, and you want a new human-driven branch beside it without sending a prompt or inheriting any graph-management responsibility. This copies the conversation and durable context, then waits for the fork’s first real user message to supply its mandate. Use `node new --fork-from` instead when spawning a managed worker with an immediate task',
|
|
642
|
-
help: {
|
|
643
|
-
name: 'node fork',
|
|
644
|
-
summary: 'create an independent resident root from a node’s conversation and durable context, with no kickoff prompt or model turn',
|
|
645
|
-
params: [
|
|
646
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Source node id. Mutually exclusive with --pane; one of them is required.' },
|
|
647
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the source node. Mutually exclusive with --node; one of them is required. The prefix menu passes this for you and the new viewer splits beside this pane.' },
|
|
648
|
-
],
|
|
649
|
-
output: [
|
|
650
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The new independent root id.' },
|
|
651
|
-
{ name: 'focused', type: 'boolean', required: true, constraint: 'True when a new viewer pane opened beside the caller; false outside tmux.' },
|
|
652
|
-
{ name: 'session', type: 'string', required: false, constraint: 'The tmux session containing the new viewer, when one opened.' },
|
|
653
|
-
],
|
|
654
|
-
outputKind: 'object',
|
|
655
|
-
effects: [
|
|
656
|
-
'Creates a resident base-mode root with the source node’s copied pi conversation, cwd, profile, kind, model override, and durable context files. Top-level initial-prompt.md, yield-message.md, and result.json are omitted.',
|
|
657
|
-
'Copies no parent, subscriptions, subscribers, children, crons, worktree, situational context, or lifecycle responsibility. Sends no prompt and consumes no model turn; the first real user message becomes the fork’s mandate.',
|
|
658
|
-
'Inside tmux, opens and focuses a surface-attach viewer as an even-horizontal split in the source pane’s window. Outside tmux, the root is still created but no interactive fallback opens.',
|
|
659
|
-
],
|
|
660
|
-
},
|
|
661
|
-
run: async (input) => {
|
|
662
|
-
const pane = input['pane'];
|
|
663
|
-
const explicitNode = input['node'];
|
|
664
|
-
if (explicitNode !== undefined && pane !== undefined) {
|
|
665
|
-
throw new InputError({ error: 'conflicting_target', message: '--node and --pane are mutually exclusive', next: 'Pass the source node id or its viewer pane, not both.' });
|
|
666
|
-
}
|
|
667
|
-
let sourceId = explicitNode;
|
|
668
|
-
if (sourceId === undefined && pane !== undefined)
|
|
669
|
-
sourceId = nodeInPane(pane);
|
|
670
|
-
if (sourceId === undefined || sourceId === '') {
|
|
671
|
-
throw new InputError({ error: 'no_node', message: 'no source node was provided or found in the pane', next: 'Pass --node <id>, or --pane <pane-id> for a pane showing a node.' });
|
|
672
|
-
}
|
|
673
|
-
const detail = await cliClient().forkNode(sourceId).catch(rethrowAsCliError);
|
|
674
|
-
const placement = await focusViewer(detail.node_id, {
|
|
675
|
-
cwd: detail.cwd,
|
|
676
|
-
name: detail.name,
|
|
677
|
-
...(pane !== undefined ? { pane } : {}),
|
|
678
|
-
newPane: true,
|
|
679
|
-
});
|
|
680
|
-
return { node_id: detail.node_id, focused: placement.focused, session: placement.session };
|
|
681
|
-
},
|
|
682
|
-
render: (result) => {
|
|
683
|
-
const placement = result['focused'] === true
|
|
684
|
-
? ` Viewer opened beside the source${result['session'] ? ` in session ${result['session']}` : ''}.`
|
|
685
|
-
: ' No viewer opened because no tmux pane was available.';
|
|
686
|
-
return `Forked independent idle root ${result['node_id']}.${placement} It will make no model call until its first real user message.`;
|
|
687
|
-
},
|
|
688
|
-
});
|
|
689
|
-
// ---------------------------------------------------------------------------
|
|
690
|
-
// node inspect — read the graph (list, show, context)
|
|
691
|
-
// ---------------------------------------------------------------------------
|
|
692
|
-
const nodeInspect = defineBranch({
|
|
693
|
-
name: 'inspect',
|
|
694
|
-
description: 'read one node — its graph position, context, and artifacts',
|
|
695
|
-
whenToUse: 'reading everything about ONE node you can name: enumerate the roster (`list`), inspect a node and its spine neighbors (`show`), browse its on-disk context dir (`context`), or list its reports/docs as refs (`artifacts`). Use `canvas dashboard` instead to render the whole tree SHAPE, or `canvas attention` to find which nodes are blocked on a human',
|
|
696
|
-
help: {
|
|
697
|
-
name: 'node inspect',
|
|
698
|
-
summary: 'read one node (or the roster) — topology, context dir, and artifacts',
|
|
699
|
-
},
|
|
700
|
-
children: [nodeList, nodeShow, nodeSnapshotLeaf, nodeTranscriptLeaf, nodeContextLeaf, nodeArtifactsLeaf],
|
|
701
|
-
});
|
|
702
|
-
// ---------------------------------------------------------------------------
|
|
703
|
-
// surface node focus — bring a node's viewer forefront (across roots if needed)
|
|
704
|
-
// ---------------------------------------------------------------------------
|
|
705
|
-
export const surfaceNodeFocusLeaf = defineLeaf({
|
|
706
|
-
name: 'focus',
|
|
707
|
-
description: 'bring a node into view without accidentally reviving finished work',
|
|
708
|
-
whenToUse: 'you want to bring a specific node into view — live nodes open or move their viewer beside your current pane; completed, canceled, or crashed nodes open their saved transcript instead. Use `node lifecycle revive` only when you deliberately want to relaunch a dormant agent. Use `surface node cycle` to walk neighbors, and `node message send` to steer without leaving where you are',
|
|
709
|
-
help: {
|
|
710
|
-
name: 'surface node focus',
|
|
711
|
-
summary: 'bring a node into view — attach to a live broker, or open a saved transcript for a non-live node without reviving it',
|
|
712
|
-
params: [
|
|
713
|
-
{ kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
|
|
714
|
-
{ kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Always open a NEW viewer SIDE-BY-SIDE with your current pane instead of moving/reusing the node\'s existing viewer. Two agents on screen at once (F4).' },
|
|
715
|
-
{ kind: 'flag', name: 'in-place', type: 'bool', required: false, constraint: 'Re-exec the --pane pane ONTO this node, REPLACING whatever viewer it showed (a swap in place, not a split beside). Backs the attach viewer\'s subagent scroll+swap (↓ into the reports roster, Enter dives into the child right in your pane). MUST be driven from OUTSIDE that pane\'s process tree (e.g. `tmux run-shell`) — respawn-pane -k kills whatever runs in it. Mutually exclusive with --new-pane.' },
|
|
716
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to focus beside (default: caller TMUX_PANE). Used by the canvas browser popup to focus back into the originating pane.' },
|
|
717
|
-
],
|
|
718
|
-
output: [
|
|
719
|
-
{ name: 'focused', type: 'boolean', required: true, constraint: 'True when the node\'s viewer was brought into view.' },
|
|
720
|
-
{ name: 'session', type: 'string', required: false, constraint: 'The tmux session the viewer lives in.' },
|
|
721
|
-
{ name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing a live node had to relaunch its broker; false for a saved-transcript inspection.' },
|
|
722
|
-
{ name: 'in_place', type: 'boolean', required: true, constraint: 'True when an existing viewer was navigated to (no new pane); false when a fresh viewer pane was opened or moved beside you.' },
|
|
723
|
-
],
|
|
724
|
-
outputKind: 'object',
|
|
725
|
-
effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For done/dead/canceled nodes: opens a read-only saved transcript popup. It does not launch a broker or change node status.', 'With --new-pane/--in-place on a live node: controls whether the viewer splits beside or replaces the caller pane.'],
|
|
726
|
-
},
|
|
727
|
-
run: async (input) => {
|
|
728
|
-
const id = input['node'];
|
|
729
|
-
const node = await cliClient()
|
|
730
|
-
.getNode(id)
|
|
731
|
-
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
732
|
-
// A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
|
|
733
|
-
// screen), NOT a pi conversation — it has no session. Reviving one boots a
|
|
734
|
-
// confused blank "you have been revived" pi, so refuse rather than focus it.
|
|
735
|
-
// (The nav/resume UIs already hide human nodes; this guards a hand-typed id.)
|
|
736
|
-
if (node.kind === 'human') {
|
|
737
|
-
throw new InputError({
|
|
738
|
-
error: 'not_focusable',
|
|
739
|
-
message: `node ${id} is a human-ask (kind:human), not a conversation — it has no pi session to focus.`,
|
|
740
|
-
next: `The pending question is queued for the human (${inboxHint()} opens the inbox); see it with \`crtr human list\`, or retract it with \`crtr human cancel ${id}\`.`,
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
// Opening history is not resuming work. A terminal/canceled/crashed node has
|
|
744
|
-
// no live broker to attach to, so show its persisted transcript in a popup;
|
|
745
|
-
// `node lifecycle revive` remains the explicit state-changing operation.
|
|
746
|
-
if (node.status !== 'active' && node.status !== 'idle') {
|
|
747
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
748
|
-
const focused = pane !== undefined && pane !== ''
|
|
749
|
-
? displayPopup(pane, node.cwd, `crtr node inspect transcript ${shellQuote(id)}`)
|
|
750
|
-
: false;
|
|
751
|
-
return { focused, session: null, revived: false, in_place: false };
|
|
752
|
-
}
|
|
753
|
-
// Local viewer tail: open/move the broker-backed viewer beside the caller.
|
|
754
|
-
// The server (ensureAttach) owns the revive + view.sock wait; pure tmux ops
|
|
755
|
-
// own the pane placement.
|
|
756
|
-
const res = await focusViewer(id, {
|
|
757
|
-
cwd: node.cwd,
|
|
758
|
-
name: node.name,
|
|
759
|
-
pane: input['pane'],
|
|
760
|
-
newPane: input['newPane'] === true,
|
|
761
|
-
inPlace: input['inPlace'] === true,
|
|
762
|
-
});
|
|
763
|
-
return { focused: res.focused, session: res.session, revived: res.revived, in_place: res.inPlace };
|
|
764
|
-
},
|
|
765
|
-
});
|
|
766
|
-
// ---------------------------------------------------------------------------
|
|
767
|
-
// node lifecycle recycle — FINALIZE the agent in your pane + recycle it into a fresh root
|
|
768
|
-
// (push its last report as a `final` → mark it done, then boot a fresh resident
|
|
769
|
-
// `crtr` root in the SAME pane; see recycleNode in runtime/recycle.ts). NOT
|
|
770
|
-
// bound to any Alt+C key/menu — d/D there route to `node lifecycle demote` (±--detach),
|
|
771
|
-
// the flip-to-terminal-IN-PLACE action that keeps the agent running.
|
|
772
|
-
// ---------------------------------------------------------------------------
|
|
773
|
-
/** The node "in front of you" in a tmux pane, resolved PURELY from the pane's
|
|
774
|
-
* `@crtr_node` tag. Defaults to $TMUX_PANE / the caller's current pane when
|
|
775
|
-
* `pane` is omitted — shared by `node lifecycle recycle` / `node lifecycle
|
|
776
|
-
* demote` / `node config --lifecycle` / `node lifecycle close` / `surface node cycle`,
|
|
777
|
-
* and the `canvas chord` leaf.
|
|
778
|
-
*
|
|
779
|
-
* A `crtr surface attach` viewer self-tags its pane with the node it views
|
|
780
|
-
* (`@crtr_node`) — the tag IS the handle (a broker engine runs DETACHED with
|
|
781
|
-
* window=null, so there is no window→node fallback). Kept SYNC + tag-only: the
|
|
782
|
-
* re-plumbed CLI no longer reads canvas state to confirm the tagged node is
|
|
783
|
-
* still live, so a stale tag (attach SIGKILLed without clearing) resolves until
|
|
784
|
-
* the pane is respawned. Callers that mutate then re-validate the id via the
|
|
785
|
-
* API (getNode 404 → clean error). */
|
|
786
|
-
export function nodeInPane(pane) {
|
|
787
|
-
const resolvePane = pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
788
|
-
if (resolvePane === undefined || resolvePane === '')
|
|
789
|
-
return undefined;
|
|
790
|
-
const tagged = getPaneOption(resolvePane, '@crtr_node');
|
|
791
|
-
return tagged !== undefined && tagged !== '' ? tagged : undefined;
|
|
792
|
-
}
|
|
793
|
-
const nodeBashBackground = defineLeaf({
|
|
794
|
-
name: 'background',
|
|
795
|
-
description: 'hand the focused node’s running bash command to its background job system',
|
|
796
|
-
whenToUse: 'the tmux prefix menu invokes this when a foreground bash command should stop blocking the agent immediately but keep running as a file-backed background job',
|
|
797
|
-
tier: 'hidden',
|
|
798
|
-
help: {
|
|
799
|
-
name: 'node bash background',
|
|
800
|
-
summary: 'hand every running foreground bash command for one node to its file-backed background job system',
|
|
801
|
-
params: [
|
|
802
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node whose running bash command to background. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
|
|
803
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE. The prefix menu passes this for you.' },
|
|
804
|
-
],
|
|
805
|
-
output: [
|
|
806
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
|
|
807
|
-
{ name: 'backgrounded', type: 'number', required: true, constraint: 'Number of running bash commands handed off.' },
|
|
808
|
-
{ name: 'job_ids', type: 'string[]', required: true, constraint: 'File-backed job ids now running independently of the agent tool call.' },
|
|
809
|
-
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Per handed-off job {job_id, elapsed_ms} — the foreground elapsed time in milliseconds at the moment of handoff.' },
|
|
810
|
-
],
|
|
811
|
-
outputKind: 'object',
|
|
812
|
-
effects: ['Signals every foreground bash command currently running for the node to return from its tool call immediately while its detached supervisor keeps the command running.', 'Each handed-off command retains its log under the node context jobs directory and sends the node an urgent inbox message when it exits, which steers the node mid-turn.'],
|
|
813
|
-
},
|
|
814
|
-
run: async (input) => {
|
|
815
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
816
|
-
let id = input['node'];
|
|
817
|
-
if (id === undefined || id === '')
|
|
818
|
-
id = nodeInPane(pane);
|
|
819
|
-
if (id === undefined || id === '')
|
|
820
|
-
id = process.env['CRTR_NODE_ID'];
|
|
821
|
-
if (id === undefined || id === '') {
|
|
822
|
-
throw new InputError({ error: 'no_node', message: 'no node found for the bash handoff', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
|
|
823
|
-
}
|
|
824
|
-
if ((await getNodeOrNull(id)) === null) {
|
|
825
|
-
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
826
|
-
}
|
|
827
|
-
const jobs = backgroundRunningBashJobs(contextDir(id));
|
|
828
|
-
const now = Date.now();
|
|
829
|
-
const elapsed = jobs.map((job) => ({ job_id: job.jobId, elapsed_ms: now - (jobStartedAtMs(job.jobId) ?? now) }));
|
|
830
|
-
const toast = jobs.length === 0
|
|
831
|
-
? 'crtr: no running bash command'
|
|
832
|
-
: jobs.length === 1
|
|
833
|
-
? `crtr: bash handed off · ${formatBashElapsed(elapsed[0].elapsed_ms)}`
|
|
834
|
-
: `crtr: bash handed off (${jobs.length})`;
|
|
835
|
-
displayMessage(toast, pane);
|
|
836
|
-
return { node_id: id, backgrounded: jobs.length, job_ids: jobs.map((job) => job.jobId), jobs: elapsed };
|
|
837
|
-
},
|
|
838
|
-
render: (result) => {
|
|
839
|
-
if (result['backgrounded'] === 0)
|
|
840
|
-
return `No foreground bash command is running for ${result['node_id']}.`;
|
|
841
|
-
const jobs = result['jobs'];
|
|
842
|
-
const oldestElapsedMs = Math.max(...jobs.map((job) => job.elapsed_ms));
|
|
843
|
-
return `Handed ${result['backgrounded']} bash command(s) to the background job system for ${result['node_id']} · ran ${formatBashElapsed(oldestElapsedMs)} in the foreground.`;
|
|
844
|
-
},
|
|
845
|
-
});
|
|
846
|
-
const nodeBashList = defineLeaf({
|
|
847
|
-
name: 'list',
|
|
848
|
-
description: 'list a node’s live background bash jobs',
|
|
849
|
-
whenToUse: 'the Inspector’s jobs section reads this to show what a node handed off to the background',
|
|
850
|
-
tier: 'hidden',
|
|
851
|
-
help: {
|
|
852
|
-
name: 'node bash list',
|
|
853
|
-
summary: 'read every still-running background bash job owned by one node',
|
|
854
|
-
params: [
|
|
855
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node whose background jobs to list. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
|
|
856
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
|
|
857
|
-
{ kind: 'flag', name: 'tail', type: 'int', required: false, default: 0, constraint: 'Include this many trailing log lines per job (0 = none).' },
|
|
858
|
-
],
|
|
859
|
-
output: [
|
|
860
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
|
|
861
|
-
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job.' },
|
|
862
|
-
],
|
|
863
|
-
outputKind: 'object',
|
|
864
|
-
effects: ['Reads the node context jobs directory. Changes nothing.'],
|
|
865
|
-
},
|
|
866
|
-
run: async (input) => {
|
|
867
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
868
|
-
let id = input['node'];
|
|
869
|
-
if (id === undefined || id === '')
|
|
870
|
-
id = nodeInPane(pane);
|
|
871
|
-
if (id === undefined || id === '')
|
|
872
|
-
id = process.env['CRTR_NODE_ID'];
|
|
873
|
-
if (id === undefined || id === '') {
|
|
874
|
-
throw new InputError({ error: 'no_node', message: 'no node found for the bash job list', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
|
|
875
|
-
}
|
|
876
|
-
const node = await getNodeOrNull(id);
|
|
877
|
-
if (node === null) {
|
|
878
|
-
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
879
|
-
}
|
|
880
|
-
const now = Date.now();
|
|
881
|
-
const tail = Math.max(0, Math.trunc(Number(input['tail'] ?? 0)));
|
|
882
|
-
const dir = contextDir(node.node_id);
|
|
883
|
-
const jobs = activeBackgroundBashJobs(dir).map((job) => ({
|
|
884
|
-
job_id: job.jobId,
|
|
885
|
-
command: job.command,
|
|
886
|
-
started_at: new Date(job.startedAtMs).toISOString(),
|
|
887
|
-
elapsed_ms: now - job.startedAtMs,
|
|
888
|
-
log_path: job.logPath,
|
|
889
|
-
pgid: readJobPgid(dir, job.jobId) ?? null,
|
|
890
|
-
log_tail: tailJobLog(job.logPath, tail),
|
|
891
|
-
}));
|
|
892
|
-
return { node_id: node.node_id, jobs };
|
|
893
|
-
},
|
|
894
|
-
render: (result) => {
|
|
895
|
-
const jobs = result['jobs'];
|
|
896
|
-
if (jobs.length === 0)
|
|
897
|
-
return `No background bash jobs for ${result['node_id']}.`;
|
|
898
|
-
return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}\n log: ${job.log_path}`).join('\n');
|
|
899
|
-
},
|
|
900
|
-
});
|
|
901
|
-
/** SIGTERM the job's process group, give it a moment, then SIGKILL. Returns
|
|
902
|
-
* false only when the group was already gone at the first signal. */
|
|
903
|
-
function stopJobGroup(pgid) {
|
|
904
|
-
try {
|
|
905
|
-
process.kill(-pgid, 'SIGTERM');
|
|
906
|
-
}
|
|
907
|
-
catch {
|
|
908
|
-
return false;
|
|
909
|
-
}
|
|
910
|
-
return true;
|
|
911
|
-
}
|
|
912
|
-
const nodeBashKill = defineLeaf({
|
|
913
|
-
name: 'kill',
|
|
914
|
-
description: 'stop one of a node’s background bash jobs',
|
|
915
|
-
whenToUse: 'a human watching the Inspector or the attach cockpit’s bash row wants a long-running background job to stop now, or an agent is abandoning work it backgrounded',
|
|
916
|
-
tier: 'hidden',
|
|
917
|
-
help: {
|
|
918
|
-
name: 'node bash kill',
|
|
919
|
-
summary: 'terminate a background bash job’s process group and tell the owning node it was canceled',
|
|
920
|
-
params: [
|
|
921
|
-
{ kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list.' },
|
|
922
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
|
|
923
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
|
|
924
|
-
],
|
|
925
|
-
output: [
|
|
926
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
|
|
927
|
-
{ name: 'job_id', type: 'string', required: true, constraint: 'The job that was stopped.' },
|
|
928
|
-
{ name: 'signaled', type: 'boolean', required: true, constraint: 'True when the process group was still alive and received SIGTERM.' },
|
|
929
|
-
],
|
|
930
|
-
outputKind: 'object',
|
|
931
|
-
effects: [
|
|
932
|
-
'Sends SIGTERM (then SIGKILL) to the job’s whole process group, stopping the command and anything it forked.',
|
|
933
|
-
'Records the job’s exit so it stops showing as live, and sends the owning node an urgent inbox message saying the job was stopped — which steers that node mid-turn.',
|
|
934
|
-
],
|
|
935
|
-
},
|
|
936
|
-
run: async (input) => {
|
|
937
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
938
|
-
let id = input['node'];
|
|
939
|
-
if (id === undefined || id === '')
|
|
940
|
-
id = nodeInPane(pane);
|
|
941
|
-
if (id === undefined || id === '')
|
|
942
|
-
id = process.env['CRTR_NODE_ID'];
|
|
943
|
-
if (id === undefined || id === '') {
|
|
944
|
-
throw new InputError({ error: 'no_node', message: 'no node found for the bash job', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
|
|
945
|
-
}
|
|
946
|
-
const node = await getNodeOrNull(id);
|
|
947
|
-
if (node === null) {
|
|
948
|
-
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
949
|
-
}
|
|
950
|
-
const jobId = input['job'];
|
|
951
|
-
const dir = contextDir(node.node_id);
|
|
952
|
-
const paths = bashJobPaths(dir, jobId);
|
|
953
|
-
if (!existsSync(paths.dir)) {
|
|
954
|
-
throw new InputError({ error: 'not_found', message: `no background job ${jobId} for ${node.node_id}`, next: 'List live jobs with `crtr node bash list`.' });
|
|
955
|
-
}
|
|
956
|
-
const pgid = readJobPgid(dir, jobId);
|
|
957
|
-
if (pgid === undefined) {
|
|
958
|
-
throw new InputError({
|
|
959
|
-
error: 'not_stoppable',
|
|
960
|
-
message: `job ${jobId} recorded no process group`,
|
|
961
|
-
next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
|
|
962
|
-
});
|
|
963
|
-
}
|
|
964
|
-
const signaled = stopJobGroup(pgid);
|
|
965
|
-
// The supervisor shares the group it leads, so it usually dies with the
|
|
966
|
-
// command and never writes job.exit. Record the cancellation ourselves —
|
|
967
|
-
// that sentinel is what retires the job from every live-jobs read.
|
|
968
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
969
|
-
try {
|
|
970
|
-
process.kill(-pgid, 'SIGKILL');
|
|
971
|
-
}
|
|
972
|
-
catch { /* already gone */ }
|
|
973
|
-
if (!existsSync(paths.jobExit))
|
|
974
|
-
writeFileSync(paths.jobExit, '143\n');
|
|
975
|
-
await cliClient().sendMessage(node.node_id, {
|
|
976
|
-
body: `Background bash job ${jobId} was stopped by the human before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
|
|
977
|
-
tier: 'urgent',
|
|
978
|
-
});
|
|
979
|
-
return { node_id: node.node_id, job_id: jobId, signaled };
|
|
980
|
-
},
|
|
981
|
-
render: (result) => `Canceled background bash job ${result['job_id']} for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
|
|
982
|
-
});
|
|
983
|
-
const nodeBash = defineBranch({
|
|
984
|
-
name: 'bash',
|
|
985
|
-
description: 'control file-backed bash jobs owned by a node',
|
|
986
|
-
whenToUse: 'a running bash command must be handed off, listed, or stopped — the tmux prefix menu backgrounds, the Inspector’s jobs section lists and cancels',
|
|
987
|
-
tier: 'hidden',
|
|
988
|
-
help: {
|
|
989
|
-
name: 'node bash',
|
|
990
|
-
summary: 'control file-backed bash jobs owned by a node',
|
|
991
|
-
},
|
|
992
|
-
children: [nodeBashBackground, nodeBashList, nodeBashKill],
|
|
993
|
-
});
|
|
994
|
-
export const surfaceNodeIdLeaf = defineLeaf({
|
|
995
|
-
name: 'id',
|
|
996
|
-
description: 'copy the focused node id to the system clipboard',
|
|
997
|
-
whenToUse: 'you need the id of the node in a tmux pane for a follow-up command or external reference',
|
|
998
|
-
help: {
|
|
999
|
-
name: 'surface node id',
|
|
1000
|
-
summary: 'copy the node resolved from a tmux pane to the system clipboard',
|
|
1001
|
-
params: [
|
|
1002
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose focused node to copy. Defaults to $TMUX_PANE / your current pane.' },
|
|
1003
|
-
],
|
|
1004
|
-
output: [
|
|
1005
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The focused node id copied to the system clipboard.' },
|
|
1006
|
-
],
|
|
1007
|
-
outputKind: 'object',
|
|
1008
|
-
effects: ['Writes the focused node id to the system clipboard and shows a transient tmux confirmation.'],
|
|
1009
|
-
},
|
|
1010
|
-
run: async (input) => {
|
|
1011
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
1012
|
-
const nodeId = nodeInPane(pane);
|
|
1013
|
-
if (nodeId === undefined) {
|
|
1014
|
-
displayMessage('crtr: no node in pane', pane);
|
|
1015
|
-
throw new InputError({ error: 'no_node', message: 'no node found in this pane', next: 'Pass --pane <pane-id> for a pane showing an active node.' });
|
|
1016
|
-
}
|
|
1017
|
-
if (!writeClipboardText(nodeId)) {
|
|
1018
|
-
displayMessage('crtr: copy failed', pane);
|
|
1019
|
-
throw new InputError({ error: 'clipboard_write_failed', message: 'could not write the node id to the system clipboard', next: 'Make a system clipboard tool available, then retry.' });
|
|
1020
|
-
}
|
|
1021
|
-
displayMessage('crtr: node id copied', pane);
|
|
1022
|
-
return { node_id: nodeId };
|
|
1023
|
-
},
|
|
1024
|
-
render: (r) => `Copied node id: ${r['node_id']}`,
|
|
1025
|
-
});
|
|
1026
|
-
export const surfaceNodePinLeaf = defineLeaf({
|
|
1027
|
-
name: 'pin',
|
|
1028
|
-
description: 'pin or unpin the node in a tmux pane in the canvas navigator',
|
|
1029
|
-
whenToUse: 'you want the conversation in this pane starred at the top of your canvas navigator (the same pin `canvas browse` toggles), without opening the navigator to do it',
|
|
1030
|
-
help: {
|
|
1031
|
-
name: 'surface node pin',
|
|
1032
|
-
summary: 'toggle the canvas-navigator pin on the node resolved from a tmux pane — a local, per-user view preference that sorts the node to the top of your navigator and fills its Pinned tab, mutating nothing on the canvas',
|
|
1033
|
-
params: [
|
|
1034
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose focused node to pin. Defaults to $TMUX_PANE / your current pane.' },
|
|
1035
|
-
],
|
|
1036
|
-
output: [
|
|
1037
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The node whose pin was toggled.' },
|
|
1038
|
-
{ name: 'pinned', type: 'boolean', required: true, constraint: 'True when the node is now pinned, false when the pin was removed.' },
|
|
1039
|
-
],
|
|
1040
|
-
outputKind: 'object',
|
|
1041
|
-
effects: ['Toggles the node in the per-user navigator pin set and shows a transient tmux confirmation.'],
|
|
1042
|
-
},
|
|
1043
|
-
run: async (input) => {
|
|
1044
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
1045
|
-
const nodeId = nodeInPane(pane);
|
|
1046
|
-
if (nodeId === undefined) {
|
|
1047
|
-
displayMessage('crtr: no node in pane', pane);
|
|
1048
|
-
throw new InputError({ error: 'no_node', message: 'no node found in this pane', next: 'Pass --pane <pane-id> for a pane showing an active node.' });
|
|
1049
|
-
}
|
|
1050
|
-
const pins = loadPins();
|
|
1051
|
-
const pinned = !pins.has(nodeId);
|
|
1052
|
-
if (pinned)
|
|
1053
|
-
pins.add(nodeId);
|
|
1054
|
-
else
|
|
1055
|
-
pins.delete(nodeId);
|
|
1056
|
-
savePins(pins);
|
|
1057
|
-
displayMessage(pinned ? 'crtr: conversation pinned' : 'crtr: conversation unpinned', pane);
|
|
1058
|
-
return { node_id: nodeId, pinned };
|
|
1059
|
-
},
|
|
1060
|
-
render: (r) => (r['pinned'] === true ? `Pinned ${r['node_id']} in the canvas navigator.` : `Unpinned ${r['node_id']}.`),
|
|
1061
|
-
});
|
|
1062
|
-
const nodeRecycle = defineLeaf({
|
|
1063
|
-
name: 'recycle',
|
|
1064
|
-
description: 'finish the agent in your pane + recycle it into a fresh root',
|
|
1065
|
-
whenToUse: 'you are at an agent\'s pane and done with it: finish it cleanly and recycle the pane in one move — push its last message as a final report to everyone waiting on it, mark it done, then boot a fresh crtr root in the same pane to keep working. The human-driver way to end an agent and immediately start over in place. Use `node lifecycle demote` instead to put it on a finishing track IN PLACE (flip terminal, keep it running) without ending it now, `node lifecycle close` to tear a node and its subtree down WITHOUT finishing (no report, revivable), and `push final` when the agent should finish ITSELF from inside its own turn',
|
|
1066
|
-
help: {
|
|
1067
|
-
name: 'node lifecycle recycle',
|
|
1068
|
-
summary: 'finish the agent in your current pane and recycle the pane — push its last message as a final report to everyone waiting on it, mark it done, then boot a fresh crtr root in the same pane',
|
|
1069
|
-
params: [
|
|
1070
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to finish. Defaults to the node occupying --pane (or your current pane).' },
|
|
1071
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to recycle. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
|
|
1072
|
-
],
|
|
1073
|
-
output: [
|
|
1074
|
-
{ name: 'recycled', type: 'boolean', required: true, constraint: 'True when the pane was recycled into a fresh root.' },
|
|
1075
|
-
{ name: 'node_id', type: 'string', required: false, constraint: 'The finished node.' },
|
|
1076
|
-
{ name: 'finalized', type: 'boolean', required: false, constraint: 'True when a final report was pushed to its subscribers.' },
|
|
1077
|
-
{ name: 'delivered', type: 'number', required: false, constraint: 'How many subscribers/managers received the final report.' },
|
|
1078
|
-
{ name: 'new_root', type: 'string', required: false, constraint: 'The fresh root node booted into the pane.' },
|
|
1079
|
-
],
|
|
1080
|
-
outputKind: 'object',
|
|
1081
|
-
effects: ['Pushes a final report from the node (fans out to all subscribers) and marks it done.', 'Kills the agent\'s pi and respawns a fresh resident root in the same tmux pane.'],
|
|
1082
|
-
},
|
|
1083
|
-
run: async (input) => {
|
|
1084
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
1085
|
-
let id = input['node'];
|
|
1086
|
-
if (id === undefined || id === '') {
|
|
1087
|
-
// Derive the node from the pane: which node's window holds it?
|
|
1088
|
-
id = nodeInPane(pane);
|
|
1089
|
-
}
|
|
1090
|
-
if (id === undefined || id === '') {
|
|
1091
|
-
throw new InputError({ error: 'no_node', message: 'no node found in this pane to finish', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
|
|
1092
|
-
}
|
|
1093
|
-
const detail = await cliClient()
|
|
1094
|
-
.getNode(id)
|
|
1095
|
-
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
1096
|
-
// The server finalizes the node + spawns a fresh root (pane-less); the local
|
|
1097
|
-
// tail re-execs the caller's pane onto a fresh `crtr` root so the human keeps
|
|
1098
|
-
// working in place.
|
|
1099
|
-
const recycled = await cliClient().recycleNode(id).catch(rethrowAsCliError);
|
|
1100
|
-
const newRoot = recycled.node_id;
|
|
1101
|
-
let paneRecycled = false;
|
|
1102
|
-
if (pane !== undefined && pane !== '') {
|
|
1103
|
-
paneRecycled = respawnPaneSync({ pane, cwd: detail.cwd, env: viewerEnv(), command: 'crtr' });
|
|
1104
|
-
}
|
|
1105
|
-
return { recycled: paneRecycled, node_id: id, finalized: true, delivered: undefined, new_root: newRoot };
|
|
1106
|
-
},
|
|
1107
|
-
render: (r) => {
|
|
1108
|
-
if (r['recycled'] !== true) {
|
|
1109
|
-
// A broker recycle that finalized + spawned a fresh root but whose new
|
|
1110
|
-
// broker never served its socket: the state DID change (node finished, root
|
|
1111
|
-
// born), only the viewer re-attach failed — say so, don't call it a no-op.
|
|
1112
|
-
if (r['finalized'] === true && r['new_root']) {
|
|
1113
|
-
return `Finished ${r['node_id']} and spawned a fresh broker root (${r['new_root']}), but its viewer did not attach — the new broker did not come up. Re-focus the root to view it.`;
|
|
1114
|
-
}
|
|
1115
|
-
return `Recycle failed for ${r['node_id'] ?? '?'} — not in tmux, or no agent in this pane.`;
|
|
1116
|
-
}
|
|
1117
|
-
const lines = [`Recycled the pane — finished ${r['node_id']}.`, `- finalized: ${r['finalized']}`];
|
|
1118
|
-
if (r['finalized'] === true)
|
|
1119
|
-
lines.push(`- delivered: ${r['delivered']}`);
|
|
1120
|
-
if (r['new_root'])
|
|
1121
|
-
lines.push(`- new root: ${r['new_root']}`);
|
|
1122
|
-
return lines.join('\n');
|
|
1123
|
-
},
|
|
1124
|
-
});
|
|
1125
|
-
// ---------------------------------------------------------------------------
|
|
1126
|
-
// node lifecycle close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
|
|
1127
|
-
// ---------------------------------------------------------------------------
|
|
1128
|
-
const nodeClose = defineLeaf({
|
|
1129
|
-
name: 'close',
|
|
1130
|
-
description: 'close a node + cascade-cancel its exclusive subtree (revivable)',
|
|
1131
|
-
whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. Windows die but nothing is deleted — each closed node keeps its pi session and can be revived later (`node lifecycle revive`). Use `node lifecycle recycle` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
|
|
1132
|
-
help: {
|
|
1133
|
-
name: 'node lifecycle close',
|
|
1134
|
-
summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark them all canceled, and leave each a notice it reads on resume. A descendant still subscribed to by a manager outside the subtree is left running. Nothing is deleted: every closed node keeps its pi session and can be revived later (`crtr node lifecycle revive`)',
|
|
1135
|
-
params: [
|
|
1136
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to close. Defaults to the node occupying --pane (or your current pane).' },
|
|
1137
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to close. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
|
|
1138
|
-
],
|
|
1139
|
-
output: [
|
|
1140
|
-
{ name: 'closed', type: 'boolean', required: true, constraint: 'True when the node (and its exclusive subtree) was closed.' },
|
|
1141
|
-
{ name: 'node_id', type: 'string', required: false, constraint: 'The node that was closed — the cascade root.' },
|
|
1142
|
-
{ name: 'count', type: 'number', required: false, constraint: 'How many nodes were closed (root + cascaded descendants).' },
|
|
1143
|
-
{ name: 'closed_ids', type: 'string[]', required: false, constraint: 'All closed node ids, kill order (leaves first, root last).' },
|
|
1144
|
-
{ name: 'spared', type: 'string[]', required: false, constraint: 'Descendants left alive because a manager outside the subtree still subscribes to them.' },
|
|
1145
|
-
],
|
|
1146
|
-
outputKind: 'object',
|
|
1147
|
-
effects: [
|
|
1148
|
-
'Marks the node and its exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
|
|
1149
|
-
'Kills each closed node\'s tmux window; their pi sessions and canvas edges persist for a later revive.',
|
|
1150
|
-
'Appends a cancellation notice to each closed node\'s inbox, surfaced on its next resume.',
|
|
1151
|
-
],
|
|
1152
|
-
},
|
|
1153
|
-
run: async (input) => {
|
|
1154
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
1155
|
-
let id = input['node'];
|
|
1156
|
-
if (id === undefined || id === '')
|
|
1157
|
-
id = nodeInPane(pane);
|
|
1158
|
-
if (id === undefined || id === '') {
|
|
1159
|
-
throw new InputError({ error: 'no_node', message: 'no node found in this pane to close', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
|
|
1160
|
-
}
|
|
1161
|
-
const res = await cliClient()
|
|
1162
|
-
.closeNode(id)
|
|
1163
|
-
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
1164
|
-
return { closed: true, node_id: res.root, count: res.closed.length, closed_ids: res.closed, spared: res.spared };
|
|
1165
|
-
},
|
|
1166
|
-
render: (r) => {
|
|
1167
|
-
if (r['closed'] !== true)
|
|
1168
|
-
return 'Close failed — no node found in this pane to close.';
|
|
1169
|
-
const spared = r['spared']?.length ?? 0;
|
|
1170
|
-
return `Closed ${r['node_id']} and its exclusive subtree — ${r['count']} node(s) closed${spared > 0 ? `, ${spared} spared (still managed from outside the subtree)` : ''}.`;
|
|
1171
|
-
},
|
|
1172
|
-
});
|
|
1173
|
-
// ---------------------------------------------------------------------------
|
|
1174
|
-
// node config — reconfigure a node's settings headlessly (model/lifecycle/kind/mode/name)
|
|
1175
|
-
// ---------------------------------------------------------------------------
|
|
1176
|
-
const nodeConfig = defineLeaf({
|
|
1177
|
-
name: 'config',
|
|
1178
|
-
description: 'reconfigure a node\'s settings headlessly — model, lifecycle (terminal/resident), kind, mode, name',
|
|
1179
|
-
whenToUse: 'you want to reconfigure a node\'s settings headlessly after spawn — swap its model/provider, flip it terminal↔resident (interactable), respecialize its kind, switch base↔orchestrator mode, or rename it — without opening its viewer. The durable attribute setter; reuses the same recipes a revive reads. Use `node promote` for the opinionated self-promotion path, and `node lifecycle` for teardown actions (recycle/close/demote).',
|
|
1180
|
-
help: {
|
|
1181
|
-
name: 'node config',
|
|
1182
|
-
summary: 'reconfigure a node\'s settings headlessly — model, lifecycle, kind, mode, name',
|
|
1183
|
-
params: [
|
|
1184
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Target node id. Defaults to the node in --pane, else CRTR_NODE_ID.' },
|
|
1185
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to resolve the node from. Defaults to $TMUX_PANE.' },
|
|
1186
|
-
{ kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Model spec: exact provider/id, capability tier (ultra|strong|medium|light), family alias (opus|sonnet|haiku), or a free-text substring when the broker is live. Dormant nodes require a provider/id, tier, or alias.' },
|
|
1187
|
-
{ kind: 'flag', name: 'lifecycle', type: 'enum', choices: ['terminal', 'resident'], required: false, constraint: 'Set lifecycle headlessly without closing the viewer. terminal owes a final and reaps when done; resident stays interactable and wakes on inbox/human.' },
|
|
1188
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Persona kind. The <kinds> list below names every top-level installable kind and when to use each; a sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here.' },
|
|
1189
|
-
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, constraint: 'Set persona mode headlessly. base is hands-on; orchestrator holds a roadmap and delegates. orchestrator seeds a roadmap scaffold if absent.' },
|
|
1190
|
-
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
|
|
1191
|
-
],
|
|
1192
|
-
output: [
|
|
1193
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The reconfigured node.' },
|
|
1194
|
-
{ name: 'model', type: 'string', required: false, constraint: 'The resolved model, present only when --model changed it.' },
|
|
1195
|
-
{ name: 'lifecycle', type: 'string', required: false, constraint: 'The new lifecycle, present only when --lifecycle changed it.' },
|
|
1196
|
-
{ name: 'kind', type: 'string', required: false, constraint: 'The new kind, present only when --kind changed it.' },
|
|
1197
|
-
{ name: 'mode', type: 'string', required: false, constraint: 'The new mode, present only when --mode changed it.' },
|
|
1198
|
-
{ name: 'name', type: 'string', required: false, constraint: 'The new name, present only when --name changed it.' },
|
|
1199
|
-
{ name: 'applied', type: 'string[]', required: false, constraint: 'The fields that changed, in application order.' },
|
|
1200
|
-
],
|
|
1201
|
-
outputKind: 'object',
|
|
1202
|
-
effects: [
|
|
1203
|
-
'Model change first: live broker switches over its socket; dormant node persists the durable recipe (model_override + launch.model).',
|
|
1204
|
-
'Kind, mode, and lifecycle changes rebuild the launch spec from the fresh node meta; setting mode to orchestrator seeds a roadmap scaffold if absent.',
|
|
1205
|
-
'Name changes update the row and, when the node already has a live window, rename that viewer window to the new full name.',
|
|
1206
|
-
],
|
|
1207
|
-
dynamicState: () => kindsStateBlock(),
|
|
1208
|
-
},
|
|
1209
|
-
run: async (input) => {
|
|
1210
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
1211
|
-
const nodeId = await resolveLifecycleNode(input, pane);
|
|
1212
|
-
// Validate the model spec's SHAPE client-side (the server owns the
|
|
1213
|
-
// live/dormant resolution branch); build the patch from the flags set. The
|
|
1214
|
-
// kind is validated against the caller's installed personas here for the
|
|
1215
|
-
// clean listing error, and re-validated server-side against the TARGET
|
|
1216
|
-
// node's scope as a backstop.
|
|
1217
|
-
const modelSpec = input['model']?.trim();
|
|
1218
|
-
if (modelSpec === '') {
|
|
1219
|
-
throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Pass a `provider/id`, a tier (ultra|strong|medium|light), an alias (opus|sonnet|haiku), or a substring.' });
|
|
1220
|
-
}
|
|
1221
|
-
const kindSpec = input['kind']?.trim();
|
|
1222
|
-
const lifecycleSpec = input['lifecycle']?.trim();
|
|
1223
|
-
const modeSpec = input['mode']?.trim();
|
|
1224
|
-
const nameSpec = input['name'];
|
|
1225
|
-
if (kindSpec !== undefined)
|
|
1226
|
-
assertKind(kindSpec);
|
|
1227
|
-
if (lifecycleSpec !== undefined && lifecycleSpec.toLowerCase() !== 'terminal' && lifecycleSpec.toLowerCase() !== 'resident') {
|
|
1228
|
-
throw new InputError({ error: 'bad_lifecycle', message: `invalid lifecycle: ${lifecycleSpec}`, field: 'lifecycle', next: 'Pass `terminal` or `resident`.' });
|
|
1229
|
-
}
|
|
1230
|
-
if (modeSpec !== undefined && modeSpec.toLowerCase() !== 'base' && modeSpec.toLowerCase() !== 'orchestrator') {
|
|
1231
|
-
throw new InputError({ error: 'bad_mode', message: `invalid mode: ${modeSpec}`, field: 'mode', next: 'Pass `base` or `orchestrator`.' });
|
|
1232
|
-
}
|
|
1233
|
-
const applied = [];
|
|
1234
|
-
const patch = {};
|
|
1235
|
-
if (modelSpec !== undefined) {
|
|
1236
|
-
patch.model = modelSpec;
|
|
1237
|
-
applied.push('model');
|
|
1238
|
-
}
|
|
1239
|
-
if (kindSpec !== undefined) {
|
|
1240
|
-
patch.kind = kindSpec;
|
|
1241
|
-
applied.push('kind');
|
|
1242
|
-
}
|
|
1243
|
-
if (lifecycleSpec !== undefined) {
|
|
1244
|
-
patch.lifecycle = lifecycleSpec.toLowerCase();
|
|
1245
|
-
applied.push('lifecycle');
|
|
1246
|
-
}
|
|
1247
|
-
if (modeSpec !== undefined) {
|
|
1248
|
-
patch.mode = modeSpec.toLowerCase();
|
|
1249
|
-
applied.push('mode');
|
|
1250
|
-
}
|
|
1251
|
-
if (nameSpec !== undefined) {
|
|
1252
|
-
patch.name = nameSpec;
|
|
1253
|
-
applied.push('name');
|
|
1254
|
-
}
|
|
1255
|
-
if (applied.length === 0) {
|
|
1256
|
-
throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name.' });
|
|
1257
|
-
}
|
|
1258
|
-
const detail = await cliClient()
|
|
1259
|
-
.patchConfig(nodeId, patch)
|
|
1260
|
-
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
1261
|
-
const result = { node_id: nodeId, applied };
|
|
1262
|
-
if (applied.includes('model'))
|
|
1263
|
-
result.model = detail.model_override ?? modelSpec;
|
|
1264
|
-
if (applied.includes('lifecycle'))
|
|
1265
|
-
result.lifecycle = detail.lifecycle;
|
|
1266
|
-
if (applied.includes('kind'))
|
|
1267
|
-
result.kind = detail.kind;
|
|
1268
|
-
if (applied.includes('mode'))
|
|
1269
|
-
result.mode = detail.mode;
|
|
1270
|
-
if (applied.includes('name'))
|
|
1271
|
-
result.name = detail.name;
|
|
1272
|
-
return result;
|
|
1273
|
-
},
|
|
1274
|
-
render: (r) => {
|
|
1275
|
-
const bits = [];
|
|
1276
|
-
if (r['model'] !== undefined)
|
|
1277
|
-
bits.push(`model=${r['model']}`);
|
|
1278
|
-
if (r['lifecycle'] !== undefined)
|
|
1279
|
-
bits.push(`lifecycle=${r['lifecycle']}`);
|
|
1280
|
-
if (r['kind'] !== undefined)
|
|
1281
|
-
bits.push(`kind=${r['kind']}`);
|
|
1282
|
-
if (r['mode'] !== undefined)
|
|
1283
|
-
bits.push(`mode=${r['mode']}`);
|
|
1284
|
-
if (r['name'] !== undefined)
|
|
1285
|
-
bits.push(`name=${r['name']}`);
|
|
1286
|
-
return `Configured ${r['node_id']} — ${bits.join(', ')}`;
|
|
1287
|
-
},
|
|
1288
|
-
});
|
|
1289
|
-
// ---------------------------------------------------------------------------
|
|
1290
|
-
// surface node cycle — DFS-walk the canvas one viewer at a time (Alt+] / Alt+[)
|
|
1291
|
-
// ---------------------------------------------------------------------------
|
|
1292
|
-
/** Every live node in DFS pre-order across the whole forest. The spawn tree is
|
|
1293
|
-
* the `parent` field; children inherit their parent's row order (created), so
|
|
1294
|
-
* the walk descends into a node's children before moving to its siblings —
|
|
1295
|
-
* exactly "next in pre-order is your first child". Roots are live nodes with no
|
|
1296
|
-
* live parent (a done/dead parent orphans its live children up to the top).
|
|
1297
|
-
* Cycle-safe: a final pass appends any node a cycle kept from being reached. */
|
|
1298
|
-
async function liveDfsOrder() {
|
|
1299
|
-
const rows = await cliCanvasSource().listNodes({ status: ['active', 'idle'] }).catch(rethrowAsCliError); // ORDER BY created
|
|
1300
|
-
const liveIds = new Set(rows.map((r) => r.node_id));
|
|
1301
|
-
const childrenOf = new Map();
|
|
1302
|
-
for (const r of rows) {
|
|
1303
|
-
const p = r.parent;
|
|
1304
|
-
if (p != null && liveIds.has(p)) {
|
|
1305
|
-
const arr = childrenOf.get(p) ?? [];
|
|
1306
|
-
arr.push(r.node_id);
|
|
1307
|
-
childrenOf.set(p, arr);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
const out = [];
|
|
1311
|
-
const seen = new Set();
|
|
1312
|
-
const visit = (id) => {
|
|
1313
|
-
if (seen.has(id))
|
|
1314
|
-
return;
|
|
1315
|
-
seen.add(id);
|
|
1316
|
-
out.push(id);
|
|
1317
|
-
for (const c of childrenOf.get(id) ?? [])
|
|
1318
|
-
visit(c);
|
|
1319
|
-
};
|
|
1320
|
-
for (const r of rows)
|
|
1321
|
-
if (r.parent == null || !liveIds.has(r.parent))
|
|
1322
|
-
visit(r.node_id);
|
|
1323
|
-
for (const r of rows)
|
|
1324
|
-
visit(r.node_id); // stragglers (parent cycles)
|
|
1325
|
-
return out;
|
|
1326
|
-
}
|
|
1327
|
-
export const surfaceNodeCycleLeaf = defineLeaf({
|
|
1328
|
-
name: 'cycle',
|
|
1329
|
-
description: 'DFS-walk to the next/prev live node\'s viewer',
|
|
1330
|
-
whenToUse: 'sweeping the canvas one viewer at a time, descending into children before siblings (bound to Alt+] forward / Alt+[ back). Use `surface node focus` instead to jump straight to a named node',
|
|
1331
|
-
help: {
|
|
1332
|
-
name: 'surface node cycle',
|
|
1333
|
-
summary: 'focus the next/previous live node\'s viewer in DFS pre-order — the canvas walked one viewer at a time, descending into a node\'s children before its siblings (bound to Alt+] forward / Alt+[ back)',
|
|
1334
|
-
params: [
|
|
1335
|
-
{ kind: 'flag', name: 'dir', type: 'enum', choices: ['next', 'prev'], required: false, default: 'next', constraint: 'Direction along the pre-order: next (Alt+], rightward/deeper into children) or prev (Alt+[, back). Wraps at the ends.' },
|
|
1336
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane to cycle FROM. Defaults to $TMUX_PANE / your current pane. The Alt+] / Alt+[ bindings pass this for you.' },
|
|
1337
|
-
],
|
|
1338
|
-
output: [
|
|
1339
|
-
{ name: 'focused', type: 'boolean', required: true, constraint: 'True when the neighbor was brought into view.' },
|
|
1340
|
-
{ name: 'node_id', type: 'string', required: false, constraint: 'The node now in front of you.' },
|
|
1341
|
-
{ name: 'name', type: 'string', required: false, constraint: 'Its display name.' },
|
|
1342
|
-
{ name: 'from', type: 'string', required: false, constraint: 'The node you cycled away from.' },
|
|
1343
|
-
],
|
|
1344
|
-
outputKind: 'object',
|
|
1345
|
-
effects: ['Brings the neighbor\'s viewer forefront beside the caller (like `surface node focus`); the viewer you were watching drops to the background.', 'Relaunches the neighbor\'s broker first if it was dormant.'],
|
|
1346
|
-
},
|
|
1347
|
-
run: async (input) => {
|
|
1348
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane ?? undefined;
|
|
1349
|
-
const dir = (input['dir'] ?? 'next');
|
|
1350
|
-
const fromId = nodeInPane(pane);
|
|
1351
|
-
if (fromId === undefined)
|
|
1352
|
-
return { focused: false };
|
|
1353
|
-
const order = await liveDfsOrder();
|
|
1354
|
-
const i = order.indexOf(fromId);
|
|
1355
|
-
if (i === -1 || order.length < 2)
|
|
1356
|
-
return { focused: false, node_id: fromId, from: fromId };
|
|
1357
|
-
const step = dir === 'next' ? 1 : -1;
|
|
1358
|
-
const targetId = order[(i + step + order.length) % order.length];
|
|
1359
|
-
const target = await getNodeOrNull(targetId);
|
|
1360
|
-
if (target === null)
|
|
1361
|
-
return { focused: false, from: fromId };
|
|
1362
|
-
// Placement brings the neighbor's viewer forefront beside the caller; the
|
|
1363
|
-
// server (ensureAttach, inside focusViewer) relaunches its broker if dormant.
|
|
1364
|
-
const res = await focusViewer(targetId, { cwd: target.cwd, name: target.name, pane });
|
|
1365
|
-
return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
|
|
1366
|
-
},
|
|
1367
|
-
render: (r) => r['focused'] === true
|
|
1368
|
-
? `Cycled to "${r['name'] ?? ''}" (${r['node_id']}) — from ${r['from'] ?? '?'}.`
|
|
1369
|
-
: 'No other live node to focus.',
|
|
1370
|
-
});
|
|
1371
|
-
// ---------------------------------------------------------------------------
|
|
1372
|
-
// Shared implementation engine for the intent-specific message, deadline, and
|
|
1373
|
-
// message leaves below. This descriptor is internal and is never registered.
|
|
1374
|
-
// ---------------------------------------------------------------------------
|
|
1375
|
-
/** Resolve the calling node for a self-anchored deadline cron. */
|
|
1376
|
-
function armerId() {
|
|
1377
|
-
const id = process.env['CRTR_NODE_ID'];
|
|
1378
|
-
if (id === undefined || id === '') {
|
|
1379
|
-
throw new InputError({ error: 'no_node', message: 'no node to anchor a deadline cron (CRTR_NODE_ID unset)', next: 'Run from inside a node.' });
|
|
1380
|
-
}
|
|
1381
|
-
return id;
|
|
1382
|
-
}
|
|
1383
|
-
/** Build ParseOpts, including `tz` only when actually provided. */
|
|
1384
|
-
function parseOpts(now, tz) {
|
|
1385
|
-
return tz !== undefined && tz.trim() !== '' ? { tz, now } : { now };
|
|
1386
|
-
}
|
|
1387
|
-
/** Map a time-grammar error to the rendered error block. */
|
|
1388
|
-
function throwTimeError(e) {
|
|
1389
|
-
const next = {
|
|
1390
|
-
wake_in_past: 'Pick a future instant — a positive duration ("5m","2h") or an ISO time later than now.',
|
|
1391
|
-
bad_when: 'Use a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00").',
|
|
1392
|
-
bad_cadence: 'Use a duration ("6h"), a 5-field cron ("0 9 * * *"), or an @alias ("@daily").',
|
|
1393
|
-
unknown_zone: 'Pass --tz with an IANA zone name (e.g. "America/New_York").',
|
|
1394
|
-
cadence_too_fast: 'Use a cadence of at least 60s (e.g. "1m","5m","1h").',
|
|
1395
|
-
};
|
|
1396
|
-
throw new InputError({ error: e.code, message: e.message, received: e.received, next: next[e.code] ?? 'Fix the time value and retry.' });
|
|
1397
|
-
}
|
|
1398
|
-
/** A human ETA hint like " (~5m)" from a fire-at ISO relative to now. '' if past. */
|
|
1399
|
-
function etaHint(fireAt, now) {
|
|
1400
|
-
const ms = new Date(fireAt).getTime() - now.getTime();
|
|
1401
|
-
if (!Number.isFinite(ms) || ms <= 0)
|
|
1402
|
-
return '';
|
|
1403
|
-
const mins = Math.round(ms / 60_000);
|
|
1404
|
-
if (mins < 60)
|
|
1405
|
-
return ` (~${mins}m)`;
|
|
1406
|
-
const hrs = Math.round(ms / 3_600_000);
|
|
1407
|
-
if (hrs < 48)
|
|
1408
|
-
return ` (~${hrs}h)`;
|
|
1409
|
-
return ` (~${Math.round(ms / 86_400_000)}d)`;
|
|
1410
|
-
}
|
|
1411
|
-
/** Resolve a message target: exactly one of --to/--self. */
|
|
1412
|
-
async function resolveMsgTarget(input) {
|
|
1413
|
-
const to = input['to']?.trim();
|
|
1414
|
-
const self = input['self'] === true;
|
|
1415
|
-
const hasTo = to !== undefined && to !== '';
|
|
1416
|
-
if (hasTo === self) {
|
|
1417
|
-
throw new InputError({
|
|
1418
|
-
error: 'bad_target',
|
|
1419
|
-
message: hasTo && self ? '--to and --self are mutually exclusive' : 'exactly one of --to or --self is required',
|
|
1420
|
-
next: 'Pass --to <node-id> or --self.',
|
|
1421
|
-
});
|
|
1422
|
-
}
|
|
1423
|
-
if (self) {
|
|
1424
|
-
const id = process.env['CRTR_NODE_ID'];
|
|
1425
|
-
if (id === undefined || id === '') {
|
|
1426
|
-
throw new InputError({ error: 'no_node', message: 'no node to target with --self (CRTR_NODE_ID unset)', next: 'Run from inside a node, or pass --to <node-id>.' });
|
|
1427
|
-
}
|
|
1428
|
-
return { targetId: id, isSelf: true };
|
|
1429
|
-
}
|
|
1430
|
-
const exists = await getNodeOrNull(to);
|
|
1431
|
-
if (exists === null) {
|
|
1432
|
-
throw new InputError({ error: 'not_found', message: `no node: ${to}`, field: 'to', next: 'List nodes with `crtr node inspect list`.' });
|
|
1433
|
-
}
|
|
1434
|
-
return { targetId: to, isSelf: false };
|
|
1435
|
-
}
|
|
1436
|
-
const nodeMessageEngine = defineLeaf({
|
|
1437
|
-
name: 'message-engine',
|
|
1438
|
-
description: 'internal engine for immediate node messages',
|
|
1439
|
-
whenToUse: 'internal only',
|
|
1440
|
-
help: {
|
|
1441
|
-
name: 'node message-engine',
|
|
1442
|
-
summary: 'deliver an immediate inbox message or fresh-revive a node',
|
|
1443
|
-
params: [
|
|
1444
|
-
{ kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. Required unless --fresh, --output-schema, or --situational-context is supplied.' },
|
|
1445
|
-
{ kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
|
|
1446
|
-
{ kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
|
|
1447
|
-
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, default: 'normal', constraint: 'Delivery urgency. Deferred never wakes an idle node.' },
|
|
1448
|
-
{ kind: 'flag', name: 'fresh', type: 'bool', required: false, constraint: 'Fresh-revive the target now with no inbox entry. Forbidden with a body, --tier, or --output-schema.' },
|
|
1449
|
-
{ kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. Grants a one-off submit tool.' },
|
|
1450
|
-
{ kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Non-empty hidden ambient context delivered beside the message, or alone.' },
|
|
1451
|
-
{ kind: 'flag', name: 'reopen', type: 'bool', required: false, constraint: 'Clear a finalized target latch before delivery or fresh revive.' },
|
|
1452
|
-
],
|
|
1453
|
-
output: [
|
|
1454
|
-
{ name: 'target', type: 'string', required: true, constraint: 'self or the target node id.' },
|
|
1455
|
-
{ name: 'delivered', type: 'boolean', required: false, constraint: 'True when an inbox message was delivered.' },
|
|
1456
|
-
{ name: 'revived', type: 'boolean', required: false, constraint: 'True when the target was revived.' },
|
|
1457
|
-
{ name: 'guidance', type: 'string', required: true, constraint: 'Immediate action confirmation.' },
|
|
1458
|
-
],
|
|
1459
|
-
outputKind: 'object',
|
|
1460
|
-
effects: [
|
|
1461
|
-
'An inbox message appends one entry. Wake-capable tiers may revive a dormant target; deferred waits for its next natural cycle.',
|
|
1462
|
-
'A fresh revive starts the target now with no inbox entry.',
|
|
1463
|
-
],
|
|
1464
|
-
},
|
|
1465
|
-
run: async (input) => {
|
|
1466
|
-
const situationalContextSupplied = input['situationalContext'] !== undefined;
|
|
1467
|
-
const situationalContextRaw = input['situationalContext']?.trim();
|
|
1468
|
-
if (situationalContextSupplied && situationalContextRaw === '') {
|
|
1469
|
-
throw new InputError({ error: 'empty_situational_context', message: '--situational-context must contain non-whitespace text', field: 'situational-context', next: 'Pass non-whitespace text, or omit --situational-context.' });
|
|
1470
|
-
}
|
|
1471
|
-
const { targetId, isSelf } = await resolveMsgTarget(input);
|
|
1472
|
-
const bodyRaw = input['body'];
|
|
1473
|
-
const body = bodyRaw !== undefined ? bodyRaw.trim() : undefined;
|
|
1474
|
-
const hasBody = body !== undefined && body !== '';
|
|
1475
|
-
const tierRaw = input['tier'];
|
|
1476
|
-
const hasTier = tierRaw !== undefined && tierRaw !== '';
|
|
1477
|
-
const fresh = input['fresh'] === true;
|
|
1478
|
-
const outputSchemaParsed = parseOutputSchemaValue(input['outputSchema']);
|
|
1479
|
-
const outputSchema = outputSchemaParsed !== null ? JSON.stringify(outputSchemaParsed) : null;
|
|
1480
|
-
const hasSituational = situationalContextRaw !== undefined && situationalContextRaw !== '';
|
|
1481
|
-
const reopen = input['reopen'] === true;
|
|
1482
|
-
if (fresh && (hasBody || hasTier || outputSchema !== null)) {
|
|
1483
|
-
throw new InputError({ error: 'bad_fresh_combo', message: '--fresh cannot combine with a body, --tier, or --output-schema', next: 'Drop the conflicting input; --fresh is a revive, not a message.' });
|
|
1484
|
-
}
|
|
1485
|
-
if (outputSchema !== null && tierRaw === 'deferred') {
|
|
1486
|
-
throw new InputError({ error: 'output_schema_deferred_tier', message: '--output-schema cannot combine with --tier deferred because the target must answer', field: 'tier', next: 'Use normal, urgent, or critical tier, or drop --output-schema.' });
|
|
1487
|
-
}
|
|
1488
|
-
const target = isSelf ? 'self' : targetId;
|
|
1489
|
-
const senderFrom = process.env['CRTR_NODE_ID'];
|
|
1490
|
-
const client = cliClient();
|
|
1491
|
-
if (fresh) {
|
|
1492
|
-
const res = await client.sendMessage(targetId, {
|
|
1493
|
-
body: '',
|
|
1494
|
-
fresh: true,
|
|
1495
|
-
...(reopen ? { reopen: true } : {}),
|
|
1496
|
-
...(hasSituational ? { situational_context: situationalContextRaw } : {}),
|
|
1497
|
-
...(senderFrom !== undefined ? { from: senderFrom } : {}),
|
|
1498
|
-
}).catch(rethrowAsCliError);
|
|
1499
|
-
return { target, revived: res.revived, guidance: `Fresh-revived ${isSelf ? 'yourself' : targetId} now.` };
|
|
1500
|
-
}
|
|
1501
|
-
if (!hasBody && !hasSituational && outputSchema === null) {
|
|
1502
|
-
throw new InputError({ error: 'empty_body', message: 'a message body is required unless --output-schema or --situational-context is supplied', field: 'body', next: 'Pass the message on stdin, or supply one of the documented alternatives.' });
|
|
1503
|
-
}
|
|
1504
|
-
const tier = (hasTier ? tierRaw : 'normal');
|
|
1505
|
-
const res = await client.sendMessage(targetId, {
|
|
1506
|
-
body: body ?? '',
|
|
1507
|
-
tier,
|
|
1508
|
-
...(reopen ? { reopen: true } : {}),
|
|
1509
|
-
...(hasSituational ? { situational_context: situationalContextRaw } : {}),
|
|
1510
|
-
...(outputSchema !== null ? { output_schema: outputSchema } : {}),
|
|
1511
|
-
...(senderFrom !== undefined ? { from: senderFrom } : {}),
|
|
1512
|
-
}).catch(rethrowAsCliError);
|
|
1513
|
-
const guidance = res.revived
|
|
1514
|
-
? 'Delivered — the dormant target was revived to receive it.'
|
|
1515
|
-
: tier === 'deferred'
|
|
1516
|
-
? "Delivered — held for the target's next natural cycle; not revived."
|
|
1517
|
-
: 'Delivered.';
|
|
1518
|
-
return { target, delivered: true, revived: res.revived, guidance };
|
|
1519
|
-
},
|
|
1520
|
-
render: (r) => String(r['guidance']),
|
|
1521
|
-
});
|
|
1522
|
-
async function runMessageEngine(input, context, outputNames) {
|
|
1523
|
-
const result = await nodeMessageEngine.run(input, context);
|
|
1524
|
-
if (result === undefined)
|
|
1525
|
-
return;
|
|
1526
|
-
return Object.fromEntries(outputNames.filter((name) => result[name] !== undefined).map((name) => [name, result[name]]));
|
|
1527
|
-
}
|
|
1528
|
-
function messageLeaf(name, path, description, whenToUse, paramNames, outputNames, effects, paramConstraints = {}, outputConstraints = {}) {
|
|
1529
|
-
return defineLeaf({
|
|
1530
|
-
name,
|
|
1531
|
-
description,
|
|
1532
|
-
whenToUse,
|
|
1533
|
-
help: {
|
|
1534
|
-
...nodeMessageEngine.help,
|
|
1535
|
-
name: path,
|
|
1536
|
-
summary: description,
|
|
1537
|
-
guide: undefined,
|
|
1538
|
-
params: constrainParams(paramsFor(nodeMessageEngine.help.params, paramNames), paramConstraints),
|
|
1539
|
-
output: constrainOutputs(outputsFor(nodeMessageEngine.help.output, outputNames), outputConstraints),
|
|
1540
|
-
effects,
|
|
1541
|
-
},
|
|
1542
|
-
run: (input, context) => runMessageEngine(input, context, outputNames),
|
|
1543
|
-
render: nodeMessageEngine.render,
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
const nodeMessageSend = messageLeaf('send', 'node message send', 'deliver an inbox message to an existing node immediately', 'you need to steer or inform a node now without creating an ongoing subscription', ['body', 'to', 'self', 'tier', 'situational-context', 'reopen'], ['target', 'delivered', 'revived', 'guidance'], ['Appends one inbox entry. Critical, urgent, and normal delivery may revive a dormant target; deferred waits for its next natural cycle.']);
|
|
1547
|
-
nodeMessageSend.help.params = (nodeMessageSend.help.params ?? []).map((param) => param.name === 'reopen' && param.kind === 'flag' ? { ...param, hidden: true } : param);
|
|
1548
|
-
const nodeMessageRequestBase = messageLeaf('request', 'node message request', 'deliver an immediate typed-output request to an existing node', 'the target must answer once through a submit tool constrained by a JSON Schema', ['body', 'to', 'self', 'tier', 'output-schema', 'situational-context', 'reopen'], ['target', 'delivered', 'revived', 'guidance'], ['Installs the one-off submit schema before appending the request.']);
|
|
1549
|
-
const nodeMessageRequest = {
|
|
1550
|
-
...nodeMessageRequestBase,
|
|
1551
|
-
help: {
|
|
1552
|
-
...nodeMessageRequestBase.help,
|
|
1553
|
-
params: (nodeMessageRequestBase.help.params ?? []).map((param) => {
|
|
1554
|
-
if (param.name === 'output-schema')
|
|
1555
|
-
return { ...param, required: true };
|
|
1556
|
-
if (param.name === 'tier' && param.kind === 'flag')
|
|
1557
|
-
return { ...param, choices: ['critical', 'urgent', 'normal'] };
|
|
1558
|
-
if (param.name === 'reopen' && param.kind === 'flag')
|
|
1559
|
-
return { ...param, hidden: true };
|
|
1560
|
-
return param;
|
|
1561
|
-
}),
|
|
1562
|
-
},
|
|
1563
|
-
run: (input, context) => {
|
|
1564
|
-
requireNonBlankDiscriminant(input, 'outputSchema');
|
|
1565
|
-
return runMessageEngine(input, context, ['target', 'delivered', 'revived', 'guidance']);
|
|
1566
|
-
},
|
|
1567
|
-
};
|
|
1568
|
-
function deadlineHeredoc(body, nodeId) {
|
|
1569
|
-
let delimiter = 'CRTR_DEADLINE';
|
|
1570
|
-
let suffix = 0;
|
|
1571
|
-
const lines = new Set(body.split('\n'));
|
|
1572
|
-
while (lines.has(delimiter))
|
|
1573
|
-
delimiter = `CRTR_DEADLINE_${++suffix}`;
|
|
1574
|
-
return `crtr node message send --to ${shellQuote(nodeId)} --tier urgent <<'${delimiter}'\n${body}\n${delimiter}`;
|
|
1575
|
-
}
|
|
1576
|
-
const nodeWaitDeadline = defineLeaf({
|
|
1577
|
-
name: 'deadline',
|
|
1578
|
-
description: 'race this node’s next wake against one urgent deadline cron',
|
|
1579
|
-
whenToUse: 'you are waiting on an event nothing can push for, so a self-scheduled deadline must settle the wait',
|
|
1580
|
-
help: {
|
|
1581
|
-
name: 'node wait deadline',
|
|
1582
|
-
summary: 'arm one cancel-on-wake deadline cron for this node',
|
|
1583
|
-
params: [
|
|
1584
|
-
{ kind: 'stdin', name: 'body', required: true, constraint: 'Policy delivered urgently to this node if the deadline fires first.' },
|
|
1585
|
-
{ kind: 'flag', name: 'until', type: 'string', required: true, constraint: 'Future duration, zoned ISO timestamp, or bare ISO timestamp interpreted in --tz or host-local time.' },
|
|
1586
|
-
{ kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA timezone for a bare ISO --until value.' },
|
|
1587
|
-
],
|
|
1588
|
-
output: [
|
|
1589
|
-
{ name: 'id', type: 'string', required: true, constraint: 'The armed cron id.' },
|
|
1590
|
-
{ name: 'name', type: 'string', required: true, constraint: 'The cron label.' },
|
|
1591
|
-
{ name: 'fires_at', type: 'string', required: true, constraint: 'Absolute UTC deadline.' },
|
|
1592
|
-
{ name: 'guidance', type: 'string', required: true, constraint: 'How to inspect the cron and dorm.' },
|
|
1593
|
-
],
|
|
1594
|
-
outputKind: 'object',
|
|
1595
|
-
effects: [
|
|
1596
|
-
'Arms one anchored cancel-on-wake cron. Any wake of this node cancels it; otherwise it urgently sends the body to this node at the deadline.',
|
|
1597
|
-
'Re-arming replaces this node’s pending deadline rather than stacking a second.',
|
|
1598
|
-
],
|
|
1599
|
-
},
|
|
1600
|
-
run: async (input) => {
|
|
1601
|
-
const self = armerId();
|
|
1602
|
-
const body = typeof input['body'] === 'string' ? input['body'].trim() : '';
|
|
1603
|
-
if (body === '')
|
|
1604
|
-
throw new InputError({ error: 'empty_body', message: 'a deadline body is required', field: 'body', next: 'Pass the timeout policy on stdin.' });
|
|
1605
|
-
const until = typeof input['until'] === 'string' ? input['until'].trim() : '';
|
|
1606
|
-
if (until === '')
|
|
1607
|
-
throw new InputError({ error: 'missing_until', message: '--until is required', field: 'until', next: 'Pass a future duration or timestamp.' });
|
|
1608
|
-
const tz = typeof input['tz'] === 'string' && input['tz'].trim() !== '' ? input['tz'] : undefined;
|
|
1609
|
-
const now = new Date();
|
|
1610
|
-
const parsed = parseWhen(until, parseOpts(now, tz));
|
|
1611
|
-
if ('error' in parsed)
|
|
1612
|
-
throwTimeError(parsed.error);
|
|
1613
|
-
const profile = process.env['CRTR_PROFILE_ID'];
|
|
1614
|
-
// One deadline per node: the arm seam REPLACES this node's pending
|
|
1615
|
-
// cancel-on-wake cron rather than stacking a second one (see armCron in
|
|
1616
|
-
// core/canvas/crons.ts, where the invariant is enforced for every caller).
|
|
1617
|
-
const cron = await cliClient().armCron({
|
|
1618
|
-
name: `deadline:${self}`,
|
|
1619
|
-
command: deadlineHeredoc(body, self),
|
|
1620
|
-
fire_at: parsed.fireAt,
|
|
1621
|
-
recur: null,
|
|
1622
|
-
tz: tz ?? null,
|
|
1623
|
-
created_by: self,
|
|
1624
|
-
cwd: process.cwd(),
|
|
1625
|
-
profile: profile !== undefined && profile !== '' ? profile : null,
|
|
1626
|
-
scope: 'profile',
|
|
1627
|
-
on_output: 'on-failure',
|
|
1628
|
-
sink: null,
|
|
1629
|
-
tier: 'normal',
|
|
1630
|
-
expires_at: null,
|
|
1631
|
-
anchor_node: self,
|
|
1632
|
-
cancel_on_wake: true,
|
|
1633
|
-
run_timeout_s: 300,
|
|
1634
|
-
overlap: 'skip',
|
|
1635
|
-
}).catch(rethrowAsCliError);
|
|
1636
|
-
const guidance = `Deadline cron armed for ${cron.fire_at}${etaHint(cron.fire_at, now)}. End your turn to dorm; any earlier wake cancels it. Inspect pending schedules with \`crtr cron list\`.`;
|
|
1637
|
-
return { id: cron.cron_id, name: cron.name, fires_at: cron.fire_at, guidance };
|
|
1638
|
-
},
|
|
1639
|
-
render: (r) => `Deadline cron armed — ${r['name']} (${r['id']}), fires ${r['fires_at']}.\n\n${r['guidance']}`,
|
|
1640
|
-
});
|
|
1641
|
-
const nodeMessage = defineBranch({
|
|
1642
|
-
name: 'message',
|
|
1643
|
-
description: 'deliver immediate inbox work to an existing node',
|
|
1644
|
-
whenToUse: 'you need one immediate delivery: send ordinary information, or request one schema-constrained result',
|
|
1645
|
-
help: { name: 'node message', summary: 'deliver now; choose ordinary delivery or a typed-output obligation' },
|
|
1646
|
-
children: [nodeMessageSend, nodeMessageRequest],
|
|
1647
|
-
});
|
|
1648
|
-
// ---------------------------------------------------------------------------
|
|
1649
|
-
// node subscription — wire the subscribes_to spine between any pair
|
|
1650
|
-
// ---------------------------------------------------------------------------
|
|
1651
|
-
/** Resolve the subscriber: explicit --subscriber wins, else the calling node. */
|
|
1652
|
-
function resolveSubscriber(input) {
|
|
1653
|
-
const sub = input['subscriber'] ?? process.env['CRTR_NODE_ID'];
|
|
1654
|
-
if (sub === undefined || sub === '') {
|
|
1655
|
-
throw new InputError({ error: 'no_subscriber', message: 'no subscriber (set CRTR_NODE_ID or pass --subscriber)', field: 'subscriber', next: 'Run from inside a node, or pass --subscriber <id>.' });
|
|
1656
|
-
}
|
|
1657
|
-
return sub;
|
|
1658
|
-
}
|
|
1659
|
-
const nodeSubscribe = defineLeaf({
|
|
1660
|
-
name: 'add',
|
|
1661
|
-
description: 'wire a subscribes_to edge between any pair (active or --passive)',
|
|
1662
|
-
whenToUse: 'you want to make one node receive another node’s pushes, actively or passively. Managed-child creation already adds its active edge; use this for other subscriber-to-publisher pairs',
|
|
1663
|
-
help: {
|
|
1664
|
-
name: 'node subscription add',
|
|
1665
|
-
summary: 'wire a subscribes_to edge so one node receives another\'s pushes — the subscriber can be you (default) or, with --subscriber, ANY node, to ANY publisher. Re-running flips an existing edge\'s active/passive mode.',
|
|
1666
|
-
params: [
|
|
1667
|
-
{ kind: 'positional', name: 'publisher', required: true, constraint: 'The node to subscribe TO — whose pushes get delivered to the subscriber.' },
|
|
1668
|
-
{ kind: 'flag', name: 'subscriber', type: 'string', required: false, constraint: 'Who receives the pushes. Defaults to the calling node (CRTR_NODE_ID). Pass any node id to wire a third party.' },
|
|
1669
|
-
{ kind: 'flag', name: 'passive', type: 'bool', required: false, constraint: 'Passive subscription: pushes ACCUMULATE without waking the subscriber, then auto-inject as timestamped XML pre-text on its next message. Omit for an active (wake-on-push) subscription.' },
|
|
1670
|
-
],
|
|
1671
|
-
output: [
|
|
1672
|
-
{ name: 'subscribed', type: 'boolean', required: true, constraint: 'True when the edge was created/updated.' },
|
|
1673
|
-
{ name: 'subscriber', type: 'string', required: true, constraint: 'The receiving node.' },
|
|
1674
|
-
{ name: 'publisher', type: 'string', required: true, constraint: 'The node being subscribed to.' },
|
|
1675
|
-
{ name: 'mode', type: 'string', required: true, constraint: '"active" (wakes on push) or "passive" (accumulates, no wake).' },
|
|
1676
|
-
],
|
|
1677
|
-
outputKind: 'object',
|
|
1678
|
-
effects: ['Upserts a subscribes_to edge in canvas.db (active flag set from --passive).', 'Passive edges never wake the subscriber and do not hold it alive (excluded from the stop-guard).'],
|
|
1679
|
-
},
|
|
1680
|
-
run: async (input) => {
|
|
1681
|
-
const publisher = input['publisher'];
|
|
1682
|
-
const subscriber = resolveSubscriber(input);
|
|
1683
|
-
const passive = input['passive'] === true;
|
|
1684
|
-
if (subscriber === publisher) {
|
|
1685
|
-
throw new InputError({ error: 'self_subscribe', message: 'a node cannot subscribe to itself', next: 'Pick a different publisher.' });
|
|
1686
|
-
}
|
|
1687
|
-
await cliClient()
|
|
1688
|
-
.subscribe(subscriber, { target: publisher, active: !passive })
|
|
1689
|
-
.catch((err) => rethrowAsCliError(err));
|
|
1690
|
-
return { subscribed: true, subscriber, publisher, mode: passive ? 'passive' : 'active' };
|
|
1691
|
-
},
|
|
1692
|
-
render: (r) => `Subscribed ${r['subscriber']} → ${r['publisher']} (${r['mode']}).`,
|
|
1693
|
-
});
|
|
1694
|
-
const nodeUnsubscribe = defineLeaf({
|
|
1695
|
-
name: 'remove',
|
|
1696
|
-
description: 'drop a subscribes_to edge',
|
|
1697
|
-
whenToUse: 'you want to stop one node receiving a publisher’s pushes; removal is idempotent',
|
|
1698
|
-
help: {
|
|
1699
|
-
name: 'node subscription remove',
|
|
1700
|
-
summary: 'drop a subscribes_to edge — the subscriber (you by default, or any node via --subscriber) stops receiving the publisher\'s pushes.',
|
|
1701
|
-
params: [
|
|
1702
|
-
{ kind: 'positional', name: 'publisher', required: true, constraint: 'The node to stop subscribing to.' },
|
|
1703
|
-
{ kind: 'flag', name: 'subscriber', type: 'string', required: false, constraint: 'Who to detach. Defaults to the calling node (CRTR_NODE_ID).' },
|
|
1704
|
-
],
|
|
1705
|
-
output: [
|
|
1706
|
-
{ name: 'unsubscribed', type: 'boolean', required: true, constraint: 'True when the edge was removed (idempotent — also true if none existed).' },
|
|
1707
|
-
{ name: 'subscriber', type: 'string', required: true, constraint: 'The detached node.' },
|
|
1708
|
-
{ name: 'publisher', type: 'string', required: true, constraint: 'The node it stopped subscribing to.' },
|
|
1709
|
-
],
|
|
1710
|
-
outputKind: 'object',
|
|
1711
|
-
effects: ['Deletes the subscribes_to edge from canvas.db.'],
|
|
1712
|
-
},
|
|
1713
|
-
run: async (input) => {
|
|
1714
|
-
const publisher = input['publisher'];
|
|
1715
|
-
const subscriber = resolveSubscriber(input);
|
|
1716
|
-
await cliClient()
|
|
1717
|
-
.unsubscribe(subscriber, publisher)
|
|
1718
|
-
.catch((err) => rethrowAsCliError(err));
|
|
1719
|
-
return { unsubscribed: true, subscriber, publisher };
|
|
1720
|
-
},
|
|
1721
|
-
render: (r) => `Unsubscribed ${r['subscriber']} from ${r['publisher']}.`,
|
|
1722
|
-
});
|
|
1723
|
-
const nodeSubscription = defineBranch({
|
|
1724
|
-
name: 'subscription',
|
|
1725
|
-
description: 'manage push-delivery edges between nodes',
|
|
1726
|
-
whenToUse: 'you need an ongoing active or passive subscriber-to-publisher delivery edge that spawn did not create, or need to remove one',
|
|
1727
|
-
help: { name: 'node subscription', summary: 'add or remove subscriber-to-publisher push-delivery edges' },
|
|
1728
|
-
children: [nodeSubscribe, nodeUnsubscribe],
|
|
1729
|
-
});
|
|
1730
|
-
// ---------------------------------------------------------------------------
|
|
1731
|
-
// node promote — become an orchestrator (the worker→orchestrator polymorph)
|
|
1732
|
-
// ---------------------------------------------------------------------------
|
|
1733
|
-
const nodePromote = defineLeaf({
|
|
1734
|
-
name: 'promote',
|
|
1735
|
-
description: 'become an orchestrator of a chosen kind',
|
|
1736
|
-
whenToUse: 'the remaining work contains independent units that can run in parallel, and the task is large enough that coordinating and integrating them will materially improve intelligence, productivity, or elapsed throughput. Become a long-lived, roadmap-holding orchestrator whose primary job is steering those children across refreshes (`node yield`). Keep sequential or tightly coupled work in base mode even when it spans phases or context windows; spawning one helper does not itself require promotion',
|
|
1737
|
-
tier: 'important',
|
|
1738
|
-
help: {
|
|
1739
|
-
name: 'node promote',
|
|
1740
|
-
summary: 'promote yourself to an orchestrator when worthwhile parallel delegation should make coordination your primary job; task duration, sequential phases, or one helper do not qualify. Mode only — lifecycle stays as-is unless you pass --resident',
|
|
1741
|
-
params: [
|
|
1742
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every top-level installable kind and when to use each; a sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here. For an exact composed persona list, use `crtr sys prompt-review --list --kind <kind>` with the target kind. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
|
|
1743
|
-
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it stays dormant, woken by inbox/human, and is never forced to submit a final. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
|
|
1744
|
-
{ kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model you run on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. An orchestrator steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
|
|
1745
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
|
|
1746
|
-
],
|
|
1747
|
-
output: [
|
|
1748
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The promoted node.' },
|
|
1749
|
-
{ name: 'kind', type: 'string', required: true, constraint: 'The kind it now orchestrates as.' },
|
|
1750
|
-
{ name: 'mode', type: 'string', required: true, constraint: 'Now "orchestrator".' },
|
|
1751
|
-
{ name: 'model', type: 'string', required: false, constraint: 'The model tier it now runs on, present only when you changed it with --model.' },
|
|
1752
|
-
{ name: 'lifecycle', type: 'string', required: true, constraint: '"resident" if you passed --resident, else unchanged (typically "terminal").' },
|
|
1753
|
-
{ name: 'roadmap_written', type: 'boolean', required: true, constraint: 'True if a roadmap scaffold was seeded by this call.' },
|
|
1754
|
-
{ name: 'roadmap_path', type: 'string', required: true, constraint: 'Absolute path to your roadmap doc (context/roadmap.md) — edit it to author your plan.' },
|
|
1755
|
-
{ name: 'goal_path', type: 'string', required: true, constraint: 'Absolute path to your goal doc (context/initial-prompt.md) — the mandate you were spawned with.' },
|
|
1756
|
-
],
|
|
1757
|
-
dynamicState: () => kindsStateBlock(),
|
|
1758
|
-
outputKind: 'object',
|
|
1759
|
-
effects: ['Flips mode→orchestrator + kind→chosen (lifecycle unchanged unless --resident, which also flips lifecycle→resident); --model durably pins the model tier; rewrites the launch spec to that kind\'s orchestrator persona; seeds context/roadmap.md scaffold if absent.', 'Your new-role guidance is injected automatically at the turn boundary by the persona injector — the command no longer returns it.'],
|
|
1760
|
-
},
|
|
1761
|
-
run: async (input) => {
|
|
1762
|
-
const id = input['node'] ?? process.env['CRTR_NODE_ID'];
|
|
1763
|
-
if (id === undefined || id === '')
|
|
1764
|
-
throw new InputError({ error: 'no_node', message: 'no node to promote (set CRTR_NODE_ID or pass --node)', next: 'Run from inside a node, or pass --node <id>.' });
|
|
1765
|
-
const kind = input['kind'];
|
|
1766
|
-
if (kind !== undefined)
|
|
1767
|
-
assertKind(kind);
|
|
1768
|
-
const model = input['model'];
|
|
1769
|
-
const resident = input['resident'] === true;
|
|
1770
|
-
const detail = await cliClient()
|
|
1771
|
-
.promoteNode(id, { ...(kind !== undefined ? { kind } : {}), ...(resident ? { resident: true } : {}), ...(model !== undefined ? { model } : {}) })
|
|
1772
|
-
.catch((err) => rethrowAsCliError(err));
|
|
1773
|
-
return {
|
|
1774
|
-
node_id: detail.node_id,
|
|
1775
|
-
kind: detail.kind,
|
|
1776
|
-
mode: detail.mode,
|
|
1777
|
-
lifecycle: detail.lifecycle,
|
|
1778
|
-
...(model !== undefined && detail.model_override != null ? { model: detail.model_override } : {}),
|
|
1779
|
-
roadmap_written: detail.roadmap_written ?? false,
|
|
1780
|
-
roadmap_path: detail.roadmap_path ?? '',
|
|
1781
|
-
goal_path: detail.goal_path ?? '',
|
|
1782
|
-
};
|
|
1783
|
-
},
|
|
1784
|
-
});
|
|
1785
|
-
// ---------------------------------------------------------------------------
|
|
1786
|
-
// Shared lifecycle plumbing — the single implementation behind BOTH `node
|
|
1787
|
-
// lifecycle demote` (the friendly terminal-only verb that pairs with `node
|
|
1788
|
-
// promote`) and `node config --lifecycle` (the low-level orthogonal flip, which
|
|
1789
|
-
// also does resident).
|
|
1790
|
-
// ---------------------------------------------------------------------------
|
|
1791
|
-
/** Resolve the node a demote/config command acts on: explicit --node, else
|
|
1792
|
-
* the node occupying --pane (the Alt+C menu passes #{pane_id}), else the caller
|
|
1793
|
-
* (CRTR_NODE_ID). Throws a rendered error when none resolves or it is unknown. */
|
|
1794
|
-
async function resolveLifecycleNode(input, pane) {
|
|
1795
|
-
let id = input['node'];
|
|
1796
|
-
if (id === undefined || id === '')
|
|
1797
|
-
id = nodeInPane(pane);
|
|
1798
|
-
if (id === undefined || id === '')
|
|
1799
|
-
id = process.env['CRTR_NODE_ID'];
|
|
1800
|
-
if (id === undefined || id === '')
|
|
1801
|
-
throw new InputError({ error: 'no_node', message: 'no node (set CRTR_NODE_ID, pass --node, or run from the agent\'s pane)', next: 'Run from inside a node, pass --node <id>, or --pane <pane>.' });
|
|
1802
|
-
const exists = await getNodeOrNull(id);
|
|
1803
|
-
if (exists === null)
|
|
1804
|
-
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
1805
|
-
return id;
|
|
1806
|
-
}
|
|
1807
|
-
/** Set a node's lifecycle axis and, with `detach`, close its foreground viewer
|
|
1808
|
-
* pane (the broker engine keeps running). Rebuilds the launch spec so a future
|
|
1809
|
-
* revive comes back with the new lifecycle's prompt baked in (the live session
|
|
1810
|
-
* is steered by the persona injector; this fixes the static prompt the daemon
|
|
1811
|
-
* replays). Spine is fixed by parent-ness, so it carries through unchanged.
|
|
1812
|
-
* The ONE implementation `node lifecycle demote` (always terminal) and
|
|
1813
|
-
* `node config --lifecycle` (the passed value) both call — no duplication. */
|
|
1814
|
-
async function setLifecycle(id, value, opts) {
|
|
1815
|
-
// The server owns the lifecycle flip + launch-spec rebuild (handleConfig).
|
|
1816
|
-
const detail = await cliClient()
|
|
1817
|
-
.patchConfig(id, { lifecycle: value })
|
|
1818
|
-
.catch((err) => rethrowAsCliError(err));
|
|
1819
|
-
// --detach: stop foregrounding the agent by closing its viewer pane (the
|
|
1820
|
-
// broker engine keeps generating off-screen); now terminal, it pushes a final
|
|
1821
|
-
// up the spine when it finishes, and a later `focus` reattaches a viewer. The
|
|
1822
|
-
// pure-tmux tail owns the pane; the empty-shell reap is dropped (residual).
|
|
1823
|
-
let detached = false;
|
|
1824
|
-
if (opts.detach === true) {
|
|
1825
|
-
const viewer = viewerPaneOf(id) ?? opts.pane;
|
|
1826
|
-
if (viewer !== undefined && viewer !== '' && paneExists(viewer))
|
|
1827
|
-
detached = closePane(viewer);
|
|
1828
|
-
}
|
|
1829
|
-
return { node_id: detail.node_id, lifecycle: detail.lifecycle, detached };
|
|
1830
|
-
}
|
|
1831
|
-
// ---------------------------------------------------------------------------
|
|
1832
|
-
// node lifecycle demote — flip a node to TERMINAL in place (the friendly half
|
|
1833
|
-
// of the promote/demote pair; bound to Alt+C → d, and → D with --detach). It
|
|
1834
|
-
// stays focused and running but now owes a final up the spine. A terminal-only
|
|
1835
|
-
// skin over the shared setLifecycle plumbing; `node config --lifecycle` is the
|
|
1836
|
-
// orthogonal low-level command (it also does resident). See vision F5.
|
|
1837
|
-
// ---------------------------------------------------------------------------
|
|
1838
|
-
const nodeDemote = defineLeaf({
|
|
1839
|
-
name: 'demote',
|
|
1840
|
-
description: 'demote a node to terminal in place; it stays focused and running but now owes a final; --detach also closes its viewer (broker keeps running off-screen)',
|
|
1841
|
-
whenToUse: 'you are watching a resident/interactive node and want to put it on a finishing track WITHOUT disturbing it: flip it terminal IN PLACE — it keeps its viewer and your focus, keeps running, but now owes a final report up the spine and reaps when done. The friendly counterpart to `node promote`. You can also let go entirely and close its viewer so the agent finishes off-screen on its broker. Use `node lifecycle recycle` instead to FINISH it now and reboot a fresh root in its pane, and `node config --lifecycle` for the orthogonal low-level flip (incl. terminal→resident)',
|
|
1842
|
-
help: {
|
|
1843
|
-
name: 'node lifecycle demote',
|
|
1844
|
-
summary: 'demote a node to terminal IN PLACE — it stays focused and running but now owes a final up the spine and reaps when done. Pairs with `node promote` (mode↑); `--detach` also closes its viewer (the broker engine keeps running off-screen)',
|
|
1845
|
-
params: [
|
|
1846
|
-
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to demote. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
|
|
1847
|
-
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to demote, when --node is omitted. Defaults to $TMUX_PANE. The Alt+C menu passes this for you.' },
|
|
1848
|
-
{ kind: 'flag', name: 'detach', type: 'bool', required: false, constraint: 'After flipping terminal, stop foregrounding the agent by closing its viewer pane. The broker engine keeps generating off-screen and — now terminal — pushes a final up the spine when done; a later `focus` reattaches a viewer. The Alt+C → D move.' },
|
|
1849
|
-
],
|
|
1850
|
-
output: [
|
|
1851
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The demoted node.' },
|
|
1852
|
-
{ name: 'lifecycle', type: 'string', required: true, constraint: 'Always "terminal" after a demote.' },
|
|
1853
|
-
{ name: 'detached', type: 'boolean', required: false, constraint: 'True when --detach closed the agent\'s viewer pane (broker keeps running).' },
|
|
1854
|
-
],
|
|
1855
|
-
outputKind: 'object',
|
|
1856
|
-
effects: ['Flips the node\'s lifecycle→terminal and rebuilds its launch spec so a future revive boots terminal — it stays focused and running, now owing a final up the spine.', 'The persona injector delivers the transition guidance at the next turn boundary (or on the node\'s next revive if it is dormant).', 'With --detach: closes the agent\'s viewer pane WITHOUT touching the broker engine — it keeps generating off-screen, reconnectable by a later `focus`.'],
|
|
1857
|
-
},
|
|
1858
|
-
run: async (input) => {
|
|
1859
|
-
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
1860
|
-
const id = await resolveLifecycleNode(input, pane);
|
|
1861
|
-
const res = await setLifecycle(id, 'terminal', { pane, detach: input['detach'] === true });
|
|
1862
|
-
return { node_id: res.node_id, lifecycle: res.lifecycle, detached: res.detached };
|
|
1863
|
-
},
|
|
1864
|
-
render: (r) => `Demoted ${r['node_id']} — lifecycle now ${r['lifecycle']} (in place)${r['detached'] === true ? ', viewer closed (broker still running off-screen)' : ''}.`,
|
|
1865
|
-
});
|
|
1866
|
-
// ---------------------------------------------------------------------------
|
|
1867
|
-
// node lifecycle — recycle, close, or demote a node
|
|
1868
|
-
// ---------------------------------------------------------------------------
|
|
1869
|
-
const nodeLifecycle = defineBranch({
|
|
1870
|
-
name: 'lifecycle',
|
|
1871
|
-
description: 'lifecycle actions — recycle, close, demote, or revive a node',
|
|
1872
|
-
whenToUse: 'you are ending, transitioning, or bringing back one node you can name: finish the agent in your pane and reboot a fresh root (`recycle`), tear a node + its exclusive subtree down without finishing (`close`), flip a node onto a finishing track in place (`demote`), or bring a dormant node back (`revive`). To merely SET the terminal/resident attribute headlessly use `node config --lifecycle`; to FINISH from inside a node\'s own turn use `crtr push final`; to mass-reconnect EVERY disconnected node at once use `canvas revive --all`.',
|
|
1873
|
-
help: {
|
|
1874
|
-
name: 'node lifecycle',
|
|
1875
|
-
summary: 'end, transition, or revive a node — recycle the pane, close + cascade-cancel a subtree, demote to terminal in place, or revive a dormant node',
|
|
1876
|
-
},
|
|
1877
|
-
children: [nodeRecycle, nodeClose, nodeDemote, nodeReviveLeaf],
|
|
1878
|
-
});
|
|
1879
|
-
// ---------------------------------------------------------------------------
|
|
1880
|
-
// node yield — refresh: discard context, revive fresh in the same mode against roadmap or goal
|
|
1881
|
-
// ---------------------------------------------------------------------------
|
|
1882
|
-
const nodeYield = defineLeaf({
|
|
1883
|
-
name: 'yield',
|
|
1884
|
-
description: 'refresh your context, same mode, against your roadmap or goal',
|
|
1885
|
-
whenToUse: 'your context is filling but the mandate remains; refresh in the same mode, or combine the refresh with promotion when meaningful independent work can run in parallel and coordinating it should become your primary job',
|
|
1886
|
-
help: {
|
|
1887
|
-
name: 'node yield',
|
|
1888
|
-
summary: 'request a fresh context window against your roadmap or original goal; mode stays unchanged unless --promote is passed',
|
|
1889
|
-
guide: 'Persist anything needed after refresh and send any pending human question before invoking; in-flight asks survive the refresh.',
|
|
1890
|
-
params: [
|
|
1891
|
-
{ kind: 'flag', name: 'promote', type: 'bool', required: false, constraint: 'Also become an orchestrator as you refresh. Pass only when (1) the remaining work contains independent units that can run in parallel and (2) the task is large enough that parallel execution will materially improve intelligence, productivity, or elapsed throughput after coordination and synthesis. Task length, context exhaustion, sequential phases, or one helper are not enough; keep hands-on work in base mode and yield again as needed. Promote when coordinating and integrating children becomes your primary job. Already an orchestrator? This is not an error: your mode stays orchestrator and a missing roadmap is seeded.' },
|
|
1892
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Respecialize as you refresh. The <kinds> list below names every top-level available kind and when to choose it; a sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here. Defaults to your current kind. With --promote it sets the orchestrator kind you become; without --promote it changes the kind while preserving your current mode.' },
|
|
1893
|
-
{ kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model your fresh revive runs on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. A node steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
|
|
1894
|
-
// Deliberate fail-first contract (#395): builtin orchestration guidance shows bare
|
|
1895
|
-
// `crtr node yield`; that invocation fails fast and surfaces yield's full help just in
|
|
1896
|
-
// time, at the point of use. Keep the note required rather than pre-documenting stdin
|
|
1897
|
-
// in kernel guidance.
|
|
1898
|
-
{ kind: 'stdin', name: 'message', required: true, constraint: 'A note to your future self — what to do the moment you wake fresh. Surfaced as <yield-message> in the next revive. Pass as a positional or pipe via heredoc.' },
|
|
1899
|
-
],
|
|
1900
|
-
output: [
|
|
1901
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The yielding node.' },
|
|
1902
|
-
{ name: 'promoted', type: 'boolean', required: true, constraint: 'True iff you passed --promote.' },
|
|
1903
|
-
{ name: 'model', type: 'string', required: false, constraint: 'The model tier your fresh revive will run on, present only when you changed it with --model.' },
|
|
1904
|
-
{ name: 'will_refresh', type: 'boolean', required: true, constraint: 'Always true: end your turn and you will revive fresh.' },
|
|
1905
|
-
],
|
|
1906
|
-
dynamicState: () => kindsStateBlock(),
|
|
1907
|
-
outputKind: 'object',
|
|
1908
|
-
effects: ['Sets intent=refresh; the stophook shuts the process down on next stop and the daemon revives it fresh.', '--kind and/or --model durably reshape the node (kind respecialization, model-tier pin) before the refresh-revive; --promote additionally flips mode→orchestrator and seeds a roadmap scaffold — mode is otherwise left exactly as it was.'],
|
|
1909
|
-
},
|
|
1910
|
-
run: async (input) => {
|
|
1911
|
-
const id = process.env['CRTR_NODE_ID'];
|
|
1912
|
-
if (id === undefined || id === '')
|
|
1913
|
-
throw new InputError({ error: 'no_node', message: 'no node to yield (CRTR_NODE_ID unset)', next: 'Run from inside a node.' });
|
|
1914
|
-
const kind = input['kind'];
|
|
1915
|
-
if (kind !== undefined)
|
|
1916
|
-
assertKind(kind);
|
|
1917
|
-
const model = input['model'];
|
|
1918
|
-
const doPromote = input['promote'] === true;
|
|
1919
|
-
const message = typeof input['message'] === 'string' ? input['message'].trim() : '';
|
|
1920
|
-
if (message === '') {
|
|
1921
|
-
throw new InputError({ error: 'empty_message', message: 'a yield message is required (stdin or positional)', next: 'Pass a note to your future self as an argument or pipe it on stdin.' });
|
|
1922
|
-
}
|
|
1923
|
-
// The server writes the yield note + sets intent=refresh (handleYield);
|
|
1924
|
-
// `promoted`/`will_refresh` are derived client-side (a yield always refreshes;
|
|
1925
|
-
// promoted iff --promote was passed).
|
|
1926
|
-
const detail = await cliClient()
|
|
1927
|
-
.yieldNode(id, { note: message, ...(kind !== undefined ? { kind } : {}), ...(model !== undefined ? { model } : {}), ...(doPromote ? { promote: true } : {}) })
|
|
1928
|
-
.catch((err) => rethrowAsCliError(err));
|
|
1929
|
-
return { node_id: detail.node_id, promoted: doPromote, ...(model !== undefined && detail.model_override != null ? { model: detail.model_override } : {}), will_refresh: true };
|
|
1930
|
-
},
|
|
1931
|
-
});
|
|
1932
|
-
// ---------------------------------------------------------------------------
|
|
1933
|
-
// node wait — declare one controller message as a durable stop dependency
|
|
1934
|
-
// ---------------------------------------------------------------------------
|
|
1935
|
-
const nodeWait = defineLeaf({
|
|
1936
|
-
name: 'controller',
|
|
1937
|
-
description: 'wait durably for a named controller message',
|
|
1938
|
-
whenToUse: 'you are a terminal node blocked on one specific live parent or controller and have no managed-child subscription whose report will wake you',
|
|
1939
|
-
help: {
|
|
1940
|
-
name: 'node wait controller',
|
|
1941
|
-
summary: 'declare that your next stop may dorm only until one live controller delivers an inbox message',
|
|
1942
|
-
params: [
|
|
1943
|
-
{ kind: 'positional', name: 'controller', type: 'string', required: true, constraint: 'Live node id expected to send the message. It may be your parent or another named controller, but cannot be yourself.' },
|
|
1944
|
-
],
|
|
1945
|
-
output: [
|
|
1946
|
-
{ name: 'node_id', type: 'string', required: true, constraint: 'The terminal node that declared the wait.' },
|
|
1947
|
-
{ name: 'controller', type: 'string', required: true, constraint: 'The named live sender whose next inbox delivery clears this wait.' },
|
|
1948
|
-
],
|
|
1949
|
-
outputKind: 'object',
|
|
1950
|
-
effects: ['Durably records the controller on this node. The stop guard permits dormancy only while that controller remains active or idle; its next inbox delivery clears the record before the delivery wakes this node.'],
|
|
1951
|
-
},
|
|
1952
|
-
run: async (input) => {
|
|
1953
|
-
const nodeId = process.env['CRTR_NODE_ID'];
|
|
1954
|
-
if (nodeId === undefined || nodeId === '')
|
|
1955
|
-
throw new InputError({ error: 'no_node', message: 'no node to wait (CRTR_NODE_ID unset)', next: 'Run from inside the terminal node that is blocked.' });
|
|
1956
|
-
const node = await getNodeOrNull(nodeId);
|
|
1957
|
-
if (node === null)
|
|
1958
|
-
throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, next: 'Run from a live canvas node.' });
|
|
1959
|
-
if (node.lifecycle !== 'terminal')
|
|
1960
|
-
throw new InputError({ error: 'resident_node', message: `node ${nodeId} is resident and may already dorm on its inbox`, next: 'Use this only from a terminal node that needs the stop guard to recognize a controller dependency.' });
|
|
1961
|
-
const controller = String(input['controller']).trim();
|
|
1962
|
-
if (controller === nodeId)
|
|
1963
|
-
throw new InputError({ error: 'self_controller', message: 'a node cannot wait for its own message', field: 'controller', next: 'Name the parent or external controller expected to send the message.' });
|
|
1964
|
-
// The server validates the controller exists + is live and records the wait
|
|
1965
|
-
// (handleWait); a 404/422 surfaces as the clean CLI error.
|
|
1966
|
-
await cliClient()
|
|
1967
|
-
.waitNode(nodeId, { controller })
|
|
1968
|
-
.catch((err) => rethrowAsCliError(err));
|
|
1969
|
-
return { node_id: nodeId, controller };
|
|
1970
|
-
},
|
|
1971
|
-
render: (r) => `Waiting for controller ${r['controller']}. End your turn to dorm; its next message wakes you and clears this wait.`,
|
|
1972
|
-
});
|
|
1973
|
-
const nodeWaitBranch = defineBranch({
|
|
1974
|
-
name: 'wait',
|
|
1975
|
-
description: 'declare a durable controller or deadline wait',
|
|
1976
|
-
whenToUse: 'a terminal node must dorm for one named controller, or race its inbox against a self-scheduled deadline',
|
|
1977
|
-
help: { name: 'node wait', summary: 'declare durable wait state; choose the event that settles it' },
|
|
1978
|
-
children: [nodeWait, nodeWaitDeadline],
|
|
1979
|
-
});
|
|
1980
|
-
// ── /pyramid slash command ──────────────────────────────────────────────────────────────────────────────
|
|
1981
|
-
// Rides on the `node` subtree, not because pyramid is a `node` verb, but because
|
|
1982
|
-
// (1) the workflow IS node orchestration — it fans out explore agents — and
|
|
1983
|
-
// (2) provisionExports lazy-loads only the invoked subtree (build-root.ts), so a
|
|
1984
|
-
// slash spec only gets (re)written to the host's command dirs when its host
|
|
1985
|
-
// subtree runs; `node` is the most-invoked subtree, so `/pyramid` provisions
|
|
1986
|
-
// reliably. The body is a self-contained agent workflow prompt (pi prompt
|
|
1987
|
-
// template / Claude Code command), exported one-way by host-exports.
|
|
1988
|
-
const pyramidSlash = {
|
|
1989
|
-
name: 'pyramid',
|
|
1990
|
-
description: 'Explain a topic as a live-building Minto pyramid diagram, backed by parallel explore agents.',
|
|
1991
|
-
argumentHint: '<topic to explain>',
|
|
1992
|
-
body: [
|
|
1993
|
-
'# Pyramid explanation',
|
|
1994
|
-
'',
|
|
1995
|
-
'Explain **$ARGUMENTS** as a *pyramid*: lead with the answer, support it with a few key pillars, ground each pillar in evidence. You build this as a LIVE termrender diagram that fills in as parallel research lands — the user watches the skeleton grow into a complete picture.',
|
|
1996
|
-
'',
|
|
1997
|
-
'## The pyramid (Minto principle)',
|
|
1998
|
-
'- **Apex** — ONE crisp sentence: the single governing answer/claim, the thing worth remembering.',
|
|
1999
|
-
'- **Pillars** — 2–4 MECE supporting arguments that together prove the apex.',
|
|
2000
|
-
'- **Base** — concrete evidence under each pillar (mechanisms, `file:line` refs, numbers, sources).',
|
|
2001
|
-
'',
|
|
2002
|
-
'Top-down: the reader gets the conclusion first, then descends for as much support as they want.',
|
|
2003
|
-
'',
|
|
2004
|
-
'## Workflow',
|
|
2005
|
-
'',
|
|
2006
|
-
'1. **Frame it.** Restate the topic in one line and commit to a *working* apex hypothesis (you will sharpen it). Name the 2–4 pillars you expect to need.',
|
|
2007
|
-
'',
|
|
2008
|
-
'2. **Scaffold the live diagram FIRST.** Write the termrender skeleton below to `./pyramid-<slug>.md` (apex = your hypothesis, pillars = titles, every base entry marked `[?]` pending). Then open it live so the user watches it build:',
|
|
2009
|
-
' ```bash',
|
|
2010
|
-
' termrender pane open ./pyramid-<slug>.md --watch # detached tmux side-pane; re-renders on every save',
|
|
2011
|
-
' ```',
|
|
2012
|
-
' Not in tmux? Tell the user to run `termrender doc watch ./pyramid-<slug>.md` in another terminal, then continue regardless.',
|
|
2013
|
-
'',
|
|
2014
|
-
'3. **Investigate in parallel — do not block.** Fan out ONE explore agent per pillar/unknown AND dig into what you can answer yourself, concurrently:',
|
|
2015
|
-
' ```bash',
|
|
2016
|
-
' crtr node new "Investigate: <focused question for this pillar>. Report findings with sources / file:line refs." --kind explore',
|
|
2017
|
-
' ```',
|
|
2018
|
-
' You auto-subscribe; each finish wakes you. Never poll or wait — kick them all off, then start filling in what you already know.',
|
|
2019
|
-
'',
|
|
2020
|
-
'4. **Live-update as findings land.** Every time you learn something — your own digging or an explore report — EDIT the diagram file: flip a `[?]` to `[x]` (or `[!]` for a caveat/risk), add evidence under the right pillar, sharpen or rewrite the apex, drop a pillar that did not hold. Each save re-renders the pane. The diagram visibly grows skeleton → complete.',
|
|
2021
|
-
'',
|
|
2022
|
-
'5. **Land it.** When the apex is one crisp sentence, the pillars are MECE, and the base is concrete, do a final pass for accuracy, then give the user a 3–4 sentence spoken summary that mirrors apex → pillars. Tell them the diagram file path.',
|
|
2023
|
-
'',
|
|
2024
|
-
'## termrender skeleton (write this verbatim, then keep editing it)',
|
|
2025
|
-
'',
|
|
2026
|
-
'Apex panel on top, a divider, then the pillars as a row of boxes below — a literal pyramid: 1 claim over N supports.',
|
|
2027
|
-
'```markdown',
|
|
2028
|
-
':::panel{title="<TOPIC>" color="cyan"}',
|
|
2029
|
-
'**<one-line apex answer — your governing claim>**',
|
|
2030
|
-
':::',
|
|
2031
|
-
'',
|
|
2032
|
-
':::divider{label="why"}',
|
|
2033
|
-
':::',
|
|
2034
|
-
'',
|
|
2035
|
-
'::::::columns',
|
|
2036
|
-
':::::col{width="33%"}',
|
|
2037
|
-
'::::panel{title="Pillar 1 — <claim>" color="green"}',
|
|
2038
|
-
':::tree',
|
|
2039
|
-
'evidence A [x]',
|
|
2040
|
-
'evidence B [?]',
|
|
2041
|
-
':::',
|
|
2042
|
-
'::::',
|
|
2043
|
-
':::::',
|
|
2044
|
-
':::::col{width="33%"}',
|
|
2045
|
-
'::::panel{title="Pillar 2 — <claim>" color="green"}',
|
|
2046
|
-
':::tree',
|
|
2047
|
-
'evidence C [?]',
|
|
2048
|
-
':::',
|
|
2049
|
-
'::::',
|
|
2050
|
-
':::::',
|
|
2051
|
-
':::::col{width="34%"}',
|
|
2052
|
-
'::::panel{title="Pillar 3 — <claim>" color="green"}',
|
|
2053
|
-
':::tree',
|
|
2054
|
-
'evidence D [?]',
|
|
2055
|
-
':::',
|
|
2056
|
-
'::::',
|
|
2057
|
-
':::::',
|
|
2058
|
-
'::::::',
|
|
2059
|
-
'```',
|
|
2060
|
-
'',
|
|
2061
|
-
'**Nesting rule (termrender will reject the file otherwise): an OUTER directive needs strictly MORE colons than the directive nested inside it.** Hence `::::::columns` (6) wraps `:::::col` (5) wraps `::::panel` (4) wraps `:::tree` (3). Every block closes with a bare run of its OWN colon count on its own line. Run `termrender doc check ./pyramid-<slug>.md` after each edit — if it errors, fix the colon counts before re-saving.',
|
|
2062
|
-
'',
|
|
2063
|
-
'Markers inside a tree: `[x]` → ✔ confirmed, `[!]` → ⚠ caveat/risk; `[?]` is not a marker and stays literal — use it for still-pending. Write structure only — termrender draws every box, border, and guide line; never hand-draw ASCII. Keep the apex to one sentence; push all detail into the base.',
|
|
2064
|
-
].join('\n'),
|
|
2065
|
-
};
|
|
2066
12
|
export function registerNode() {
|
|
2067
13
|
return defineBranch({
|
|
2068
14
|
name: 'node',
|
|
@@ -2070,7 +16,7 @@ export function registerNode() {
|
|
|
2070
16
|
rootEntry: {
|
|
2071
17
|
concept: 'a unit of the agent runtime — an agent on the canvas with its own context dir and pi vehicle. Spawning nodes is how work gets done here, not an optimization you reach for late',
|
|
2072
18
|
desc: 'spawn, inspect, and navigate nodes on the canvas',
|
|
2073
|
-
useWhen: 'delegating a genuinely independent subtask, or walking the subscription graph. Base nodes work hands-on; orchestrators fan out. Give each child a bounded outcome distinct from your whole assignment so delegation moves work toward completion instead of recursively passing it down. Once delegated, do not duplicate the child’s work; its finish wakes you. Use the help surface for the exact node contract before invoking it.'
|
|
19
|
+
useWhen: 'delegating a genuinely independent subtask, or walking the subscription graph. Base nodes work hands-on; orchestrators fan out. Give each child a bounded outcome distinct from your whole assignment so delegation moves work toward completion instead of recursively passing it down. Once delegated, do not duplicate the child’s work; its finish wakes you. Use the help surface for the exact node contract before invoking it.',
|
|
2074
20
|
},
|
|
2075
21
|
help: {
|
|
2076
22
|
name: 'node',
|