@crouton-kit/crouter 0.3.27 → 0.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-root.js +4 -2
- package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +20 -18
- package/dist/builtin-memory/design.md +1 -1
- package/dist/builtin-memory/development.md +1 -1
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/planning.md +1 -1
- package/dist/builtin-memory/spec.md +6 -4
- package/dist/builtin-personas/orchestration-kernel.md +4 -5
- package/dist/builtin-personas/runtime-base.md +8 -4
- package/dist/builtin-personas/spec/PERSONA.md +8 -4
- package/dist/builtin-personas/spec/orchestrator.md +5 -3
- package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
- package/dist/builtin-views/canvas/core.mjs +586 -0
- package/dist/builtin-views/canvas/text.mjs +58 -0
- package/dist/builtin-views/canvas/tui.mjs +165 -0
- package/dist/builtin-views/canvas/web.jsx +120 -0
- package/dist/builtin-views/git-pr/core.mjs +673 -0
- package/dist/builtin-views/git-pr/text.mjs +84 -0
- package/dist/builtin-views/git-pr/tui.mjs +302 -0
- package/dist/builtin-views/git-pr/web.jsx +216 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
- package/dist/builtin-views/inbox/core.mjs +1273 -0
- package/dist/builtin-views/inbox/text.mjs +73 -0
- package/dist/builtin-views/inbox/tui.mjs +312 -0
- package/dist/builtin-views/inbox/web.jsx +188 -0
- package/dist/builtin-views/linkedin/core.mjs +906 -0
- package/dist/builtin-views/linkedin/text.mjs +69 -0
- package/dist/builtin-views/linkedin/tui.mjs +427 -0
- package/dist/builtin-views/linkedin/web.jsx +206 -0
- package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
- package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
- package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
- package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
- package/dist/cli.js +3 -3
- package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
- package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
- package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
- package/dist/clients/attach/__tests__/git-info.test.js +31 -0
- package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
- package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
- package/dist/clients/attach/__tests__/titled-editor.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
- package/dist/clients/attach/attach-cmd.js +445 -41
- package/dist/clients/attach/auth-pickers.d.ts +9 -0
- package/dist/clients/attach/auth-pickers.js +194 -0
- package/dist/clients/attach/canvas-panels.d.ts +12 -0
- package/dist/clients/attach/canvas-panels.js +78 -0
- package/dist/clients/attach/chat-view.d.ts +27 -2
- package/dist/clients/attach/chat-view.js +98 -9
- package/dist/clients/attach/clipboard-image.d.ts +47 -8
- package/dist/clients/attach/clipboard-image.js +150 -26
- package/dist/clients/attach/clipboard-text.d.ts +3 -0
- package/dist/clients/attach/clipboard-text.js +39 -0
- package/dist/clients/attach/config-load.d.ts +54 -7
- package/dist/clients/attach/config-load.js +99 -3
- package/dist/clients/attach/context-message.d.ts +12 -0
- package/dist/clients/attach/context-message.js +72 -0
- package/dist/clients/attach/git-info.d.ts +20 -0
- package/dist/clients/attach/git-info.js +44 -0
- package/dist/clients/attach/graph-overlay.d.ts +43 -0
- package/dist/clients/attach/graph-overlay.js +306 -0
- package/dist/clients/attach/input-controller.d.ts +81 -13
- package/dist/clients/attach/input-controller.js +307 -67
- package/dist/clients/attach/pickers.d.ts +58 -0
- package/dist/clients/attach/pickers.js +171 -0
- package/dist/clients/attach/slash-commands.d.ts +44 -1
- package/dist/clients/attach/slash-commands.js +250 -27
- package/dist/clients/attach/titled-editor.d.ts +33 -0
- package/dist/clients/attach/titled-editor.js +84 -0
- package/dist/clients/attach/view-socket.d.ts +37 -2
- package/dist/clients/attach/view-socket.js +131 -7
- package/dist/clients/web/dev-server.d.ts +7 -0
- package/dist/clients/web/dev-server.js +59 -0
- package/dist/clients/web/events.d.ts +14 -0
- package/dist/clients/web/events.js +151 -0
- package/dist/clients/web/server.d.ts +18 -0
- package/dist/clients/web/server.js +450 -0
- package/dist/clients/web/web-cmd.d.ts +2 -0
- package/dist/clients/web/web-cmd.js +120 -0
- package/dist/commands/canvas.js +1 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/dashboard.js +5 -1
- package/dist/commands/human/prompts.js +3 -3
- package/dist/commands/human/queue.d.ts +17 -0
- package/dist/commands/human/queue.js +111 -4
- package/dist/commands/memory/__tests__/lint-schema.test.js +27 -4
- package/dist/commands/memory/lint.d.ts +5 -4
- package/dist/commands/memory/lint.js +23 -7
- package/dist/commands/memory/read.js +2 -2
- package/dist/commands/memory/shared.d.ts +7 -1
- package/dist/commands/memory/shared.js +18 -3
- package/dist/commands/memory/write.js +20 -7
- package/dist/commands/memory.js +4 -4
- package/dist/commands/node.d.ts +1 -1
- package/dist/commands/node.js +64 -65
- package/dist/commands/pkg.js +1 -2
- package/dist/commands/revive.js +11 -3
- package/dist/commands/sys/feedback.d.ts +1 -0
- package/dist/commands/sys/feedback.js +163 -0
- package/dist/commands/sys/sync.d.ts +1 -0
- package/dist/commands/sys/sync.js +187 -0
- package/dist/commands/sys.js +4 -2
- package/dist/commands/view-cycle.js +2 -2
- package/dist/commands/view-list.js +8 -8
- package/dist/commands/view-new.js +21 -17
- package/dist/commands/view-pick.js +1 -1
- package/dist/commands/view-run.js +35 -14
- package/dist/commands/view.js +8 -6
- package/dist/commands/workspace.d.ts +2 -0
- package/dist/commands/workspace.js +161 -0
- package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
- package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
- package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
- package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
- package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
- package/dist/core/__tests__/child-death-wake.test.js +56 -111
- package/dist/core/__tests__/close.test.js +14 -20
- package/dist/core/__tests__/context-intro.test.js +19 -19
- package/dist/core/__tests__/daemon-boot.test.js +30 -23
- package/dist/core/__tests__/draw-style.test.js +1 -1
- package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +32 -2
- package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
- package/dist/core/__tests__/focuses.test.js +25 -13
- package/dist/core/__tests__/fork.test.js +22 -5
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
- package/dist/core/__tests__/full/cascade-close.test.js +22 -8
- package/dist/core/__tests__/full/detach-focus.test.js +66 -169
- package/dist/core/__tests__/helpers/harness.d.ts +6 -9
- package/dist/core/__tests__/helpers/harness.js +19 -36
- package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
- package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
- package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
- package/dist/core/__tests__/lifecycle.test.js +1 -19
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
- package/dist/core/__tests__/memory-resolver.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
- package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
- package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch-root.test.js +114 -0
- package/dist/core/__tests__/reset.test.js +19 -79
- package/dist/core/__tests__/steer-note.test.js +9 -7
- package/dist/core/__tests__/tmux-surface.test.js +13 -7
- package/dist/core/bootstrap.d.ts +0 -3
- package/dist/core/bootstrap.js +1 -143
- package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
- package/dist/core/canvas/browse/app.js +131 -8
- package/dist/core/canvas/browse/model.d.ts +23 -5
- package/dist/core/canvas/browse/model.js +68 -13
- package/dist/core/canvas/browse/render.d.ts +7 -1
- package/dist/core/canvas/browse/render.js +178 -47
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/focuses.d.ts +4 -4
- package/dist/core/canvas/focuses.js +7 -7
- package/dist/core/canvas/nav-model.d.ts +162 -0
- package/dist/core/canvas/nav-model.js +487 -0
- package/dist/core/canvas/paths.d.ts +7 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/canvas/render.d.ts +68 -9
- package/dist/core/canvas/render.js +195 -61
- package/dist/core/canvas/types.d.ts +16 -23
- package/dist/core/config.js +1 -4
- package/dist/core/memory-resolver.js +43 -5
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +8 -8
- package/dist/core/runtime/branded-host.d.ts +16 -0
- package/dist/core/runtime/branded-host.js +127 -0
- package/dist/core/runtime/broker-protocol.d.ts +212 -6
- package/dist/core/runtime/broker-sdk.d.ts +0 -12
- package/dist/core/runtime/broker-sdk.js +77 -6
- package/dist/core/runtime/broker.d.ts +5 -1
- package/dist/core/runtime/broker.js +520 -59
- package/dist/core/runtime/close.d.ts +12 -2
- package/dist/core/runtime/close.js +37 -16
- package/dist/core/runtime/front-door.js +14 -7
- package/dist/core/runtime/host.d.ts +14 -34
- package/dist/core/runtime/host.js +14 -50
- package/dist/core/runtime/launch.d.ts +12 -9
- package/dist/core/runtime/launch.js +27 -15
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +15 -19
- package/dist/core/runtime/nodes.d.ts +0 -55
- package/dist/core/runtime/nodes.js +6 -74
- package/dist/core/runtime/placement.d.ts +91 -334
- package/dist/core/runtime/placement.js +262 -828
- package/dist/core/runtime/promote.d.ts +2 -0
- package/dist/core/runtime/promote.js +24 -6
- package/dist/core/runtime/recap.d.ts +8 -0
- package/dist/core/runtime/recap.js +107 -0
- package/dist/core/runtime/recycle.js +25 -61
- package/dist/core/runtime/reset.d.ts +43 -37
- package/dist/core/runtime/reset.js +131 -218
- package/dist/core/runtime/revive.d.ts +9 -29
- package/dist/core/runtime/revive.js +45 -135
- package/dist/core/runtime/spawn.d.ts +15 -11
- package/dist/core/runtime/spawn.js +168 -136
- package/dist/core/runtime/surface-bg.d.ts +11 -0
- package/dist/core/runtime/surface-bg.js +68 -0
- package/dist/core/runtime/tmux.d.ts +15 -43
- package/dist/core/runtime/tmux.js +38 -80
- package/dist/core/scope.d.ts +1 -2
- package/dist/core/scope.js +1 -5
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
- package/dist/core/skill-sync/builtins.d.ts +42 -0
- package/dist/core/skill-sync/builtins.js +112 -0
- package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
- package/dist/core/skill-sync/claude-plugins.js +71 -0
- package/dist/core/skill-sync/engine.d.ts +42 -0
- package/dist/core/skill-sync/engine.js +633 -0
- package/dist/core/skill-sync/export.d.ts +23 -0
- package/dist/core/skill-sync/export.js +86 -0
- package/dist/core/skill-sync/manifest.d.ts +64 -0
- package/dist/core/skill-sync/manifest.js +181 -0
- package/dist/core/skill-sync/profile.d.ts +76 -0
- package/dist/core/skill-sync/profile.js +173 -0
- package/dist/core/skill-sync/snapshot.d.ts +57 -0
- package/dist/core/skill-sync/snapshot.js +120 -0
- package/dist/core/spawn.d.ts +14 -0
- package/dist/core/spawn.js +29 -9
- package/dist/core/substrate/index.d.ts +2 -2
- package/dist/core/substrate/index.js +7 -7
- package/dist/core/substrate/injected-store.d.ts +10 -0
- package/dist/core/substrate/injected-store.js +55 -0
- package/dist/core/substrate/on-read.js +68 -6
- package/dist/core/substrate/render.d.ts +8 -11
- package/dist/core/substrate/render.js +29 -43
- package/dist/core/substrate/schema.d.ts +16 -11
- package/dist/core/substrate/schema.js +32 -31
- package/dist/core/tui/host.d.ts +12 -4
- package/dist/core/tui/host.js +280 -149
- package/dist/core/view/bridge.d.ts +10 -0
- package/dist/core/view/bridge.js +31 -0
- package/dist/core/view/chrome.d.ts +9 -0
- package/dist/core/view/chrome.js +22 -0
- package/dist/core/view/contract.d.ts +171 -0
- package/dist/core/view/contract.js +23 -0
- package/dist/core/view/loader.d.ts +31 -0
- package/dist/core/view/loader.js +188 -0
- package/dist/core/view/transport-local.d.ts +7 -0
- package/dist/core/view/transport-local.js +70 -0
- package/dist/core/view/transport.d.ts +4 -0
- package/dist/core/view/transport.js +15 -0
- package/dist/daemon/crtrd.d.ts +38 -27
- package/dist/daemon/crtrd.js +303 -376
- package/dist/daemon/manage.js +6 -1
- package/dist/index.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
- package/dist/pi-extensions/canvas-context-intro.js +4 -4
- package/dist/pi-extensions/canvas-doc-substrate.js +29 -20
- package/dist/pi-extensions/canvas-nav.js +35 -385
- package/dist/pi-extensions/canvas-recap.d.ts +37 -0
- package/dist/pi-extensions/canvas-recap.js +236 -0
- package/dist/pi-extensions/canvas-resume.js +2 -1
- package/dist/pi-extensions/canvas-stophook.d.ts +15 -13
- package/dist/pi-extensions/canvas-stophook.js +119 -103
- package/dist/pi-extensions/canvas-view.js +2 -1
- package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
- package/dist/pi-extensions/widget-order-bus.js +34 -0
- package/dist/prompts/view.d.ts +2 -2
- package/dist/prompts/view.js +145 -73
- package/dist/types.d.ts +1 -6
- package/dist/types.js +1 -3
- package/dist/web/ViewChrome.d.ts +7 -0
- package/dist/web/ViewChrome.js +28 -0
- package/dist/web/ViewPane.d.ts +39 -0
- package/dist/web/ViewPane.js +48 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +16 -0
- package/dist/web/runtime.d.ts +39 -0
- package/dist/web/runtime.js +133 -0
- package/dist/web/states.d.ts +24 -0
- package/dist/web/states.js +24 -0
- package/dist/web/transport-http.d.ts +5 -0
- package/dist/web/transport-http.js +28 -0
- package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
- package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
- package/dist/web-client/index.html +13 -0
- package/package.json +20 -6
- package/dist/builtin-views/canvas/client.mjs +0 -303
- package/dist/builtin-views/canvas/view.mjs +0 -576
- package/dist/builtin-views/git-pr/client.mjs +0 -440
- package/dist/builtin-views/git-pr/view.mjs +0 -675
- package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
- package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
- package/dist/builtin-views/inbox/view.mjs +0 -889
- package/dist/builtin-views/linkedin/client.mjs +0 -610
- package/dist/builtin-views/linkedin/view.mjs +0 -1171
- package/dist/commands/pkg/bridge.d.ts +0 -1
- package/dist/commands/pkg/bridge.js +0 -137
- package/dist/commands/skill/author.d.ts +0 -3
- package/dist/commands/skill/author.js +0 -140
- package/dist/commands/skill/shared.d.ts +0 -3
- package/dist/commands/skill/shared.js +0 -19
- package/dist/commands/skill.d.ts +0 -2
- package/dist/commands/skill.js +0 -21
- package/dist/commands/tmux-spread.d.ts +0 -2
- package/dist/commands/tmux-spread.js +0 -144
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
- package/dist/core/__tests__/full/placement-focus.test.js +0 -309
- package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
- package/dist/core/__tests__/full/placement-revive.test.js +0 -238
- package/dist/core/__tests__/full/placement-teardown.test.js +0 -221
- package/dist/core/__tests__/home-session.test.js +0 -186
- package/dist/core/__tests__/relaunch.test.js +0 -335
- package/dist/core/bridge-map.d.ts +0 -19
- package/dist/core/bridge-map.js +0 -73
- package/dist/core/tui/contract.d.ts +0 -83
- package/dist/core/tui/contract.js +0 -8
- package/dist/core/tui/loader.d.ts +0 -16
- package/dist/core/tui/loader.js +0 -94
- package/dist/prompts/skill.d.ts +0 -2
- package/dist/prompts/skill.js +0 -650
- /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
- /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/reconnect-giveup.test.d.ts} +0 -0
- /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// server.ts — the unified `crtr web serve` host: the crouter web UI server.
|
|
2
|
+
//
|
|
3
|
+
// ONE long-running server process on 127.0.0.1 that serves THREE concerns on one
|
|
4
|
+
// origin (the §2 unified server):
|
|
5
|
+
// • GET /* → the shell SPA (static dist/web-client/, or Vite
|
|
6
|
+
// middleware in --dev) + SPA fallback.
|
|
7
|
+
// • POST /__crtr/source → the source + command bridge: decode a SourceRequest
|
|
8
|
+
// and run it through the LOCAL transport (exec/file/
|
|
9
|
+
// http in this cwd). This serves views' READS and the
|
|
10
|
+
// command WRITES (`crtr …` subprocesses). Lifted here
|
|
11
|
+
// from the deleted `view serve`.
|
|
12
|
+
// • GET /__crtr/events → the SSE change-invalidation lane (events.ts).
|
|
13
|
+
// • WS /node/<id> → open that node's ALREADY-running broker `view.sock`
|
|
14
|
+
// and relay frames VERBATIM both directions. The relay
|
|
15
|
+
// adds NOTHING — the browser is the SAME protocol peer
|
|
16
|
+
// as `crtr attach`.
|
|
17
|
+
//
|
|
18
|
+
// §0 ONE-WRITER INVARIANT: the WS relay is ONLY a socket relay. It NEVER calls
|
|
19
|
+
// reviveNode, NEVER spawns `pi --session`, NEVER touches SessionManager, and
|
|
20
|
+
// NEVER opens/writes a `.jsonl`. It opens a `node:net` connection to an
|
|
21
|
+
// ALREADY-running broker's `view.sock`; if the broker is not running the WS is
|
|
22
|
+
// closed with a clear reason. The relay NEVER launches an engine. ALL writes go
|
|
23
|
+
// through the bridge running `crtr` SUBPROCESSES — this process is never the
|
|
24
|
+
// sanctioned writer; the CLI it shells out to is.
|
|
25
|
+
//
|
|
26
|
+
// One `view.sock` connection per browser-WS (N browsers of one node = N broker
|
|
27
|
+
// clients) — the broker fans out natively, so there is no bridge-side fan-out.
|
|
28
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
29
|
+
import { createConnection } from 'node:net';
|
|
30
|
+
import { createReadStream, existsSync, statSync } from 'node:fs';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { dirname, extname, join, normalize, resolve } from 'node:path';
|
|
33
|
+
import { WebSocketServer } from 'ws';
|
|
34
|
+
import { nodeDir } from '../../core/canvas/paths.js';
|
|
35
|
+
import { getNode } from '../../core/canvas/index.js';
|
|
36
|
+
import { BROKER_READ_CAPS, CLIENT_READ_CAPS, FrameDecoder, FrameOverflowError, } from '../../core/runtime/broker-protocol.js';
|
|
37
|
+
import { createLocalTransport } from '../../core/view/transport-local.js';
|
|
38
|
+
import { runSourceRequest } from '../../core/view/bridge.js';
|
|
39
|
+
import { startEventHub } from './events.js';
|
|
40
|
+
import { createDevServer } from './dev-server.js';
|
|
41
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
/** Candidate dirs to serve the shell SPA bundle from, in priority order: an
|
|
43
|
+
* explicit override, the compiled-module-relative `dist/web-client/` (what
|
|
44
|
+
* `vite build` emits and what we serve in production), and the placeholder
|
|
45
|
+
* copied from source. The first that exists wins. (--dev bypasses this entirely
|
|
46
|
+
* — Vite middleware owns asset serving in that mode.) */
|
|
47
|
+
function resolveClientDir() {
|
|
48
|
+
const candidates = [
|
|
49
|
+
process.env['CRTR_WEB_CLIENT_DIR'],
|
|
50
|
+
join(HERE, '../../web-client'),
|
|
51
|
+
resolve(HERE, '../../../dist/web-client'),
|
|
52
|
+
].filter((p) => p !== undefined && p !== '');
|
|
53
|
+
for (const dir of candidates) {
|
|
54
|
+
if (existsSync(dir))
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
// Fall back to the module-relative path even if absent; missing-file handling
|
|
58
|
+
// below returns a clear 503 so a not-yet-built shell is diagnosable.
|
|
59
|
+
return join(HERE, '../../web-client');
|
|
60
|
+
}
|
|
61
|
+
const CONTENT_TYPES = {
|
|
62
|
+
'.html': 'text/html; charset=utf-8',
|
|
63
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
64
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
65
|
+
'.css': 'text/css; charset=utf-8',
|
|
66
|
+
'.json': 'application/json; charset=utf-8',
|
|
67
|
+
'.svg': 'image/svg+xml',
|
|
68
|
+
'.png': 'image/png',
|
|
69
|
+
'.jpg': 'image/jpeg',
|
|
70
|
+
'.jpeg': 'image/jpeg',
|
|
71
|
+
'.gif': 'image/gif',
|
|
72
|
+
'.webp': 'image/webp',
|
|
73
|
+
'.ico': 'image/x-icon',
|
|
74
|
+
'.woff': 'font/woff',
|
|
75
|
+
'.woff2': 'font/woff2',
|
|
76
|
+
'.map': 'application/json; charset=utf-8',
|
|
77
|
+
};
|
|
78
|
+
function contentTypeFor(path) {
|
|
79
|
+
return CONTENT_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
|
80
|
+
}
|
|
81
|
+
/** Match a WS/GET path of the shape `/node/<id>` (with any number of leading
|
|
82
|
+
* segments, future-proofing a mount prefix). Returns the decoded node id, or
|
|
83
|
+
* null if the path doesn't match or the id is undecodable (malformed `%`). The
|
|
84
|
+
* caller must still VALIDATE the id (no separators / `..`) before building a
|
|
85
|
+
* filesystem path — see {@link isSafeNodeId}. */
|
|
86
|
+
function nodeIdFromPath(pathname) {
|
|
87
|
+
const m = /\/node\/([^/?#]+)\/?$/.exec(pathname);
|
|
88
|
+
if (m === null)
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
return decodeURIComponent(m[1]);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null; // malformed percent-encoding — treat as no match
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** A node id is used to build `nodeDir(id)/view.sock`, so it MUST NOT contain a
|
|
98
|
+
* path separator or `..` (e.g. a decoded `..%2f..` would escape `nodes/`). */
|
|
99
|
+
function isSafeNodeId(id) {
|
|
100
|
+
return id !== '' && !/[/\\]/.test(id) && !id.includes('..');
|
|
101
|
+
}
|
|
102
|
+
/** A WS close reason is capped at 123 UTF-8 bytes by the protocol. */
|
|
103
|
+
function clampReason(reason) {
|
|
104
|
+
const buf = Buffer.from(reason, 'utf8');
|
|
105
|
+
return buf.byteLength <= 123 ? reason : buf.subarray(0, 123).toString('utf8');
|
|
106
|
+
}
|
|
107
|
+
/** Read a request body fully into a string. Buffer.concat before decoding so a
|
|
108
|
+
* multibyte char split across chunks (UTF-8 in a write command's stdin) is
|
|
109
|
+
* never corrupted. */
|
|
110
|
+
function readBody(req) {
|
|
111
|
+
return new Promise((res, rej) => {
|
|
112
|
+
const chunks = [];
|
|
113
|
+
req.on('data', (c) => {
|
|
114
|
+
chunks.push(c);
|
|
115
|
+
});
|
|
116
|
+
req.on('end', () => res(Buffer.concat(chunks).toString('utf8')));
|
|
117
|
+
req.on('error', rej);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Serve a static file from the client dir, with SPA fallback to index.html for
|
|
121
|
+
* any unmatched (non-asset) GET — so `/` and `/node/<id>` both boot the app
|
|
122
|
+
* shell. Returns true if it wrote a response. */
|
|
123
|
+
function serveStatic(clientDir, req, res) {
|
|
124
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
125
|
+
let pathname;
|
|
126
|
+
try {
|
|
127
|
+
pathname = decodeURIComponent(url.pathname);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Malformed percent-encoding (e.g. `GET /%`) — never let it throw out of
|
|
131
|
+
// the request listener and crash the whole multiplexing server (C1).
|
|
132
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
|
|
133
|
+
res.end('bad request\n');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Resolve within clientDir; reject traversal.
|
|
137
|
+
const rel = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, '');
|
|
138
|
+
let filePath = join(clientDir, rel);
|
|
139
|
+
if (!filePath.startsWith(clientDir)) {
|
|
140
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
141
|
+
res.end('forbidden\n');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Directory or root → index.html. A path with no file extension that does not
|
|
145
|
+
// exist → SPA fallback to index.html (so deep links like /node/<id> boot).
|
|
146
|
+
const isFile = existsSync(filePath) && statSync(filePath).isFile();
|
|
147
|
+
if (!isFile) {
|
|
148
|
+
filePath = join(clientDir, 'index.html');
|
|
149
|
+
}
|
|
150
|
+
if (!existsSync(filePath)) {
|
|
151
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
|
152
|
+
res.end('crtr web shell bundle not found — run `npm run build` to emit it into ' +
|
|
153
|
+
`${clientDir} (or pass --dev for Vite middleware)\n`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
res.writeHead(200, { 'content-type': contentTypeFor(filePath), 'cache-control': 'no-cache' });
|
|
157
|
+
if (req.method === 'HEAD') {
|
|
158
|
+
res.end();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const stream = createReadStream(filePath);
|
|
162
|
+
stream.on('error', () => {
|
|
163
|
+
if (!res.headersSent)
|
|
164
|
+
res.writeHead(500);
|
|
165
|
+
res.end();
|
|
166
|
+
});
|
|
167
|
+
stream.pipe(res);
|
|
168
|
+
}
|
|
169
|
+
/** Relay one browser WS ⇄ one node broker `view.sock`. Opens the socket on
|
|
170
|
+
* connect; forwards each complete frame socket→WS (bounded decode, one WS
|
|
171
|
+
* message per frame) and re-frames each WS message WS→socket (trailing
|
|
172
|
+
* newline). The relay is semantically verbatim — it never INTERPRETS a frame
|
|
173
|
+
* (it only splits/joins the newline framing). Closes the WS on socket
|
|
174
|
+
* gone/no-broker, destroys the socket on WS close; idempotent teardown, no
|
|
175
|
+
* leaks. Validates the node id first (path-safety + existence). */
|
|
176
|
+
function bridgeConnection(ws, nodeId) {
|
|
177
|
+
if (!isSafeNodeId(nodeId)) {
|
|
178
|
+
try {
|
|
179
|
+
ws.close(1008, clampReason(`invalid node id: ${nodeId}`));
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* ignore */
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (getNode(nodeId) === null) {
|
|
187
|
+
try {
|
|
188
|
+
ws.close(1011, clampReason(`no node ${nodeId}`));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* ignore */
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const sockPath = join(nodeDir(nodeId), 'view.sock');
|
|
196
|
+
const socket = createConnection(sockPath);
|
|
197
|
+
const decoder = new FrameDecoder(CLIENT_READ_CAPS);
|
|
198
|
+
let closed = false;
|
|
199
|
+
const teardown = (wsCode, reason) => {
|
|
200
|
+
if (closed)
|
|
201
|
+
return;
|
|
202
|
+
closed = true;
|
|
203
|
+
if (!socket.destroyed) {
|
|
204
|
+
try {
|
|
205
|
+
socket.destroy();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* ignore */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
ws.close(wsCode, clampReason(reason));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* ignore */
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
// socket → WS: bounded decode, one WS text message per complete frame.
|
|
219
|
+
socket.on('data', (chunk) => {
|
|
220
|
+
let frames;
|
|
221
|
+
try {
|
|
222
|
+
frames = decoder.push(chunk);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
const msg = err instanceof FrameOverflowError
|
|
226
|
+
? `broker sent an oversized frame (${err.message})`
|
|
227
|
+
: `failed to decode a broker frame for ${nodeId}`;
|
|
228
|
+
teardown(1009, msg);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const frame of frames) {
|
|
232
|
+
if (ws.readyState === ws.OPEN)
|
|
233
|
+
ws.send(JSON.stringify(frame));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
socket.on('error', (err) => {
|
|
237
|
+
const reason = err.code === 'ECONNREFUSED' || err.code === 'ENOENT'
|
|
238
|
+
? `no running broker for ${nodeId}`
|
|
239
|
+
: `view socket error for ${nodeId}: ${err.message}`;
|
|
240
|
+
teardown(1011, reason);
|
|
241
|
+
});
|
|
242
|
+
socket.on('close', () => teardown(1000, `broker for ${nodeId} closed`));
|
|
243
|
+
// WS → socket: re-frame each message as one newline-terminated JSON line.
|
|
244
|
+
// VERBATIM (no parse) — the broker validates and bounds its own reads.
|
|
245
|
+
ws.on('message', (data) => {
|
|
246
|
+
if (closed || socket.destroyed)
|
|
247
|
+
return;
|
|
248
|
+
const buf = Array.isArray(data)
|
|
249
|
+
? Buffer.concat(data)
|
|
250
|
+
: Buffer.isBuffer(data)
|
|
251
|
+
? data
|
|
252
|
+
: Buffer.from(new Uint8Array(data));
|
|
253
|
+
const text = buf.toString('utf8');
|
|
254
|
+
const line = text.endsWith('\n') ? text : text + '\n';
|
|
255
|
+
try {
|
|
256
|
+
socket.write(line);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
/* dead socket — its 'close' drives teardown */
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
ws.on('close', () => teardown(1000, 'browser closed'));
|
|
263
|
+
ws.on('error', () => teardown(1011, 'browser ws error'));
|
|
264
|
+
}
|
|
265
|
+
/** Build + start the unified web server. Binds 127.0.0.1 only (remote access is
|
|
266
|
+
* out of scope — §9). Resolves once the server is listening. Async because
|
|
267
|
+
* --dev awaits a Vite middleware server before listening. */
|
|
268
|
+
export async function startWebServer(opts) {
|
|
269
|
+
const host = opts.host ?? '127.0.0.1';
|
|
270
|
+
const dev = opts.dev ?? false;
|
|
271
|
+
const clientDir = resolveClientDir();
|
|
272
|
+
// The bridge runs view sources + command verbs locally, in the cwd crtr was
|
|
273
|
+
// invoked from (a git-pr view inspects THIS repo; a `crtr node new` lands here).
|
|
274
|
+
const transport = createLocalTransport({ cwd: process.cwd() });
|
|
275
|
+
// The SSE change lane — one hub, fanned to every connected EventSource.
|
|
276
|
+
const eventHub = startEventHub();
|
|
277
|
+
// Same-origin allowlist (M1): a browser bypasses same-origin policy on both
|
|
278
|
+
// WebSockets AND cross-origin POSTs, so any web page the user visits could
|
|
279
|
+
// otherwise open ws://127.0.0.1:PORT/node/<id> and DRIVE their agents, OR POST
|
|
280
|
+
// /__crtr/source to run ARBITRARY exec. The same gate guards all three write/
|
|
281
|
+
// drive surfaces (WS upgrade, bridge POST, SSE GET). Populated once we know the
|
|
282
|
+
// bound port. A request with NO Origin (a CLI/curl/test client, not a browser)
|
|
283
|
+
// is allowed — the threat model is foreign browser pages, which always send it.
|
|
284
|
+
let allowedOrigins = new Set();
|
|
285
|
+
const originAllowed = (origin) => {
|
|
286
|
+
if (origin === undefined || origin === '')
|
|
287
|
+
return true;
|
|
288
|
+
return allowedOrigins.has(origin);
|
|
289
|
+
};
|
|
290
|
+
// Assigned before listen() when --dev; the request handler closes over it.
|
|
291
|
+
let vite;
|
|
292
|
+
const httpServer = createHttpServer((req, res) => {
|
|
293
|
+
try {
|
|
294
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
295
|
+
const pathname = url.pathname;
|
|
296
|
+
// The source + command bridge: POST /__crtr/source → run the SourceRequest
|
|
297
|
+
// through the local transport. Origin-gated (same M1 gate as the WS relay):
|
|
298
|
+
// arbitrary exec from a foreign browser page is exactly what M1 prevents.
|
|
299
|
+
if (pathname === '/__crtr/source') {
|
|
300
|
+
if (req.method !== 'POST') {
|
|
301
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'POST' });
|
|
302
|
+
res.end('method not allowed\n');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!originAllowed(req.headers.origin)) {
|
|
306
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
307
|
+
res.end('forbidden\n');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
void readBody(req)
|
|
311
|
+
.then((body) => runSourceRequest(transport, body))
|
|
312
|
+
.then(({ status, body }) => {
|
|
313
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
314
|
+
res.end(body);
|
|
315
|
+
})
|
|
316
|
+
.catch((e) => {
|
|
317
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
318
|
+
res.end(JSON.stringify({ ok: false, stdout: '', stderr: e instanceof Error ? e.message : String(e) }));
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// The SSE change-invalidation lane: GET /__crtr/events. Origin-gated too —
|
|
323
|
+
// a foreign page should not even learn the user's graph is mutating.
|
|
324
|
+
if (pathname === '/__crtr/events') {
|
|
325
|
+
if (req.method !== 'GET') {
|
|
326
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET' });
|
|
327
|
+
res.end('method not allowed\n');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!originAllowed(req.headers.origin)) {
|
|
331
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
332
|
+
res.end('forbidden\n');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
eventHub.addClient(res);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// --dev: Vite middleware owns all remaining asset/HTML serving (incl. its
|
|
339
|
+
// own SPA fallback). It is mounted AFTER the bridge + SSE checks above, so
|
|
340
|
+
// those never fall through to Vite.
|
|
341
|
+
if (vite !== undefined) {
|
|
342
|
+
vite.middlewares(req, res);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// Shipped: static shell bundle + SPA fallback.
|
|
346
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
347
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET, HEAD' });
|
|
348
|
+
res.end('method not allowed\n');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
serveStatic(clientDir, req, res);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Belt-and-suspenders: nothing in the handler should throw now (C1), but a
|
|
355
|
+
// request listener that throws crashes the whole daemon — never allow it.
|
|
356
|
+
if (!res.headersSent)
|
|
357
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
358
|
+
res.end('internal error\n');
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
// noServer: we route the upgrade ourselves so only `/node/<id>` upgrades.
|
|
362
|
+
// maxPayload bounds the INBOUND browser→broker path, so it tracks the broker's
|
|
363
|
+
// own read cap (BROKER_READ_CAPS), not the generous client cap — otherwise a
|
|
364
|
+
// browser could buffer 256MiB/message before the broker's 24MiB decoder rejects
|
|
365
|
+
// it (memory amplification, M3).
|
|
366
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
|
|
367
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
368
|
+
// A raw upgrade socket with no 'error' listener throws on a write to a reset
|
|
369
|
+
// peer — attach one before we ever write/destroy it (n8).
|
|
370
|
+
socket.on('error', () => {
|
|
371
|
+
/* peer reset mid-handshake — destroy below or already destroyed */
|
|
372
|
+
});
|
|
373
|
+
try {
|
|
374
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
375
|
+
const nodeId = nodeIdFromPath(url.pathname);
|
|
376
|
+
if (nodeId === null) {
|
|
377
|
+
// --dev: Vite's HMR WebSocket shares this HTTP server. Vite registers its
|
|
378
|
+
// OWN 'upgrade' listener (via server.hmr.server); leave every non-`/node/`
|
|
379
|
+
// upgrade for it instead of destroying the socket.
|
|
380
|
+
if (vite !== undefined)
|
|
381
|
+
return;
|
|
382
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
383
|
+
socket.destroy();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (!originAllowed(req.headers.origin)) {
|
|
387
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
388
|
+
socket.destroy();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
wss.handleUpgrade(req, socket, head, (ws) => bridgeConnection(ws, nodeId));
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Never let an upgrade-handler throw escape and crash the daemon (C1).
|
|
395
|
+
try {
|
|
396
|
+
socket.destroy();
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
/* ignore */
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
// --dev: create the Vite middleware server BEFORE listen so the request
|
|
404
|
+
// handler's `vite` is set and Vite's HMR upgrade listener is attached (ours
|
|
405
|
+
// was added first, so a vite-hmr upgrade hits ours → nodeId null → returns →
|
|
406
|
+
// Vite's listener handles it).
|
|
407
|
+
if (dev) {
|
|
408
|
+
vite = await createDevServer(httpServer);
|
|
409
|
+
}
|
|
410
|
+
return new Promise((resolveListening, reject) => {
|
|
411
|
+
httpServer.once('error', reject);
|
|
412
|
+
httpServer.listen(opts.port, host, () => {
|
|
413
|
+
httpServer.off('error', reject);
|
|
414
|
+
// A server error AFTER listen (e.g. an accept failure) must not be an
|
|
415
|
+
// unhandled 'error' that crashes the process (n9) — log + carry on.
|
|
416
|
+
httpServer.on('error', (err) => {
|
|
417
|
+
process.stderr.write(`crtr web: server error: ${err.message}\n`);
|
|
418
|
+
});
|
|
419
|
+
const addr = httpServer.address();
|
|
420
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
|
|
421
|
+
const url = `http://${host}:${port}`;
|
|
422
|
+
allowedOrigins = new Set([
|
|
423
|
+
`http://${host}:${port}`,
|
|
424
|
+
`http://127.0.0.1:${port}`,
|
|
425
|
+
`http://localhost:${port}`,
|
|
426
|
+
]);
|
|
427
|
+
const close = () => new Promise((done) => {
|
|
428
|
+
eventHub.close();
|
|
429
|
+
void vite?.close().catch(() => {
|
|
430
|
+
/* best-effort */
|
|
431
|
+
});
|
|
432
|
+
wss.clients.forEach((c) => {
|
|
433
|
+
try {
|
|
434
|
+
c.terminate();
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
/* ignore */
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
wss.close(() => {
|
|
441
|
+
httpServer.close(() => done());
|
|
442
|
+
// Idle keep-alive HTTP connections would otherwise keep close()'s
|
|
443
|
+
// callback from ever firing — drop them so shutdown completes (m4).
|
|
444
|
+
httpServer.closeAllConnections();
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
resolveListening({ url, port, clientDir, close });
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// web-cmd.ts — the `crtr web` command leaf + registration.
|
|
2
|
+
//
|
|
3
|
+
// Starts the ONE long-running unified web server (server.ts): the crouter web UI
|
|
4
|
+
// host. It serves the shell SPA, the source + command bridge (POST
|
|
5
|
+
// /__crtr/source), the SSE change lane (GET /__crtr/events), and a VERBATIM
|
|
6
|
+
// browser ⇄ broker relay on `ws://127.0.0.1:PORT/node/<id>` (the browser becomes
|
|
7
|
+
// the SAME protocol peer as `crtr attach`). The node is selected by the URL, so
|
|
8
|
+
// there is NO positional node here.
|
|
9
|
+
//
|
|
10
|
+
// Command shape (plan §3.6): the CLI path-walker forbids a flat top-level leaf
|
|
11
|
+
// (`defineRoot.subtrees` is `BranchDef[]`), so `web` is a BRANCH wrapping a
|
|
12
|
+
// single leaf: `crtr web serve`.
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { defineBranch, defineLeaf } from '../../core/command.js';
|
|
15
|
+
import { startWebServer } from './server.js';
|
|
16
|
+
const DEFAULT_PORT = 7878;
|
|
17
|
+
/** Best-effort open of the default browser to `url` (the `--open` flag). Never
|
|
18
|
+
* throws — a failed open just leaves the user to click the printed URL. */
|
|
19
|
+
function openBrowser(url) {
|
|
20
|
+
const platform = process.platform;
|
|
21
|
+
const [cmd, args] = platform === 'darwin'
|
|
22
|
+
? ['open', [url]]
|
|
23
|
+
: platform === 'win32'
|
|
24
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
25
|
+
: ['xdg-open', [url]];
|
|
26
|
+
try {
|
|
27
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
28
|
+
child.on('error', () => {
|
|
29
|
+
/* no browser opener available — the printed URL is the fallback */
|
|
30
|
+
});
|
|
31
|
+
child.unref();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* ignore */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const webServeLeaf = defineLeaf({
|
|
38
|
+
name: 'serve',
|
|
39
|
+
description: 'start the crouter web UI server — the shell, the source/command bridge, and a browser ⇄ broker relay',
|
|
40
|
+
whenToUse: 'you want the crouter web UI: a browser shell to watch the canvas and drive headless nodes, instead of a terminal pane. It starts ONE long-running local server (127.0.0.1) that serves the shell SPA, the source + command bridge (POST /__crtr/source — views\' reads AND `crtr` command writes), an SSE change-invalidation lane (GET /__crtr/events), and a VERBATIM browser ⇄ broker relay on /node/<id> (the browser becomes the same protocol peer as `crtr attach`: one controller drives, extra viewers follow read-only). The relay NEVER starts an engine — each node must already have a running headless broker (focus or revive it first); all writes flow through `crtr` subprocesses the bridge runs. Pass --dev for Vite middleware (HMR) while iterating on the shell. The server runs until ctrl+c; the node is picked in the browser via the URL, not on the command line.',
|
|
41
|
+
help: {
|
|
42
|
+
name: 'web serve',
|
|
43
|
+
summary: 'start the long-running crouter web UI server on 127.0.0.1: serves the shell SPA, the POST /__crtr/source bridge, the GET /__crtr/events SSE lane, and a VERBATIM browser ⇄ node-broker relay (browser picks the node via /node/<id>)',
|
|
44
|
+
params: [
|
|
45
|
+
{
|
|
46
|
+
kind: 'flag',
|
|
47
|
+
name: 'port',
|
|
48
|
+
type: 'int',
|
|
49
|
+
required: false,
|
|
50
|
+
default: DEFAULT_PORT,
|
|
51
|
+
constraint: `TCP port to bind on 127.0.0.1. Default ${DEFAULT_PORT}.`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
kind: 'flag',
|
|
55
|
+
name: 'open',
|
|
56
|
+
type: 'bool',
|
|
57
|
+
required: false,
|
|
58
|
+
default: false,
|
|
59
|
+
constraint: 'Auto-open the default browser to the served URL after the server starts.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
kind: 'flag',
|
|
63
|
+
name: 'dev',
|
|
64
|
+
type: 'bool',
|
|
65
|
+
required: false,
|
|
66
|
+
default: false,
|
|
67
|
+
constraint: 'Serve shell assets via a Vite dev server (middleware mode, HMR) instead of the prebuilt dist/web-client/ bundle. For contributors iterating on the shell or a builtin view. The bridge, SSE lane, and WS relay are identical in both modes.',
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
output: [],
|
|
71
|
+
outputKind: 'object',
|
|
72
|
+
effects: [
|
|
73
|
+
'Binds a long-running HTTP+WebSocket server on 127.0.0.1:<port> and keeps serving until interrupted (ctrl+c / SIGTERM). Safe to run non-interactively (daemon-like): it prints the URL and serves.',
|
|
74
|
+
'Serves the shell SPA (static dist/web-client/, or Vite middleware under --dev), runs the POST /__crtr/source bridge (exec/file/http in this cwd), streams the GET /__crtr/events SSE change lane, and on a browser ws://127.0.0.1:<port>/node/<id> connection opens that node\'s running broker view.sock and relays frames VERBATIM both directions.',
|
|
75
|
+
'The relay NEVER spawns pi and NEVER writes any session — it only opens a socket to an already-running broker; if the broker is not running, the browser connection is closed with a clear reason. All graph mutations go through `crtr` subprocesses the bridge runs.',
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
run: async (input) => {
|
|
79
|
+
const port = input['port'] ?? DEFAULT_PORT;
|
|
80
|
+
const open = input['open'] ?? false;
|
|
81
|
+
const dev = input['dev'] ?? false;
|
|
82
|
+
const server = await startWebServer({ port, dev });
|
|
83
|
+
const nodeUrlShape = `${server.url}/node/<id>`;
|
|
84
|
+
process.stdout.write(`crtr web serving on ${server.url}${dev ? ' (--dev: Vite middleware)' : ''}\n` +
|
|
85
|
+
` open a node: ${nodeUrlShape}\n` +
|
|
86
|
+
` bridge: POST ${server.url}/__crtr/source\n` +
|
|
87
|
+
` events (SSE): GET ${server.url}/__crtr/events\n` +
|
|
88
|
+
` shell assets: ${dev ? 'Vite middleware (HMR)' : server.clientDir}\n` +
|
|
89
|
+
` (ctrl+c to stop)\n`);
|
|
90
|
+
if (open)
|
|
91
|
+
openBrowser(server.url);
|
|
92
|
+
// Long-running: resolve only on a shutdown signal, tearing down cleanly.
|
|
93
|
+
await new Promise((resolveShutdown) => {
|
|
94
|
+
const shutdown = () => {
|
|
95
|
+
void server.close().then(() => resolveShutdown());
|
|
96
|
+
};
|
|
97
|
+
process.once('SIGINT', shutdown);
|
|
98
|
+
process.once('SIGTERM', shutdown);
|
|
99
|
+
process.once('SIGHUP', shutdown);
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
},
|
|
103
|
+
render: () => '',
|
|
104
|
+
});
|
|
105
|
+
export function registerWeb() {
|
|
106
|
+
return defineBranch({
|
|
107
|
+
name: 'web',
|
|
108
|
+
rootEntry: {
|
|
109
|
+
concept: 'the crouter web UI server — one host serving the browser shell, the source/command bridge, an SSE change lane, and a VERBATIM relay to nodes\' running brokers',
|
|
110
|
+
desc: 'serve the crouter web UI for the canvas and headless nodes',
|
|
111
|
+
useWhen: 'you want the crouter web UI in a browser instead of a tmux pane: watch the canvas and drive headless nodes. It runs one long-lived local server that serves the shell SPA, runs view sources + `crtr` command writes over POST /__crtr/source, streams canvas-change invalidations over GET /__crtr/events (SSE), and relays a browser connection VERBATIM to a node\'s already-running broker on /node/<id> — same protocol peer as `crtr attach`. The node is selected in the browser via the URL; the relay never starts an engine, so each node must already have a running headless broker.',
|
|
112
|
+
},
|
|
113
|
+
help: {
|
|
114
|
+
name: 'web',
|
|
115
|
+
summary: 'serve the crouter web UI: the shell SPA, the source/command bridge, the SSE change lane, and a VERBATIM relay to headless nodes\' brokers',
|
|
116
|
+
model: '`serve` starts the ONE long-running unified server on 127.0.0.1 (--port, default 7878; --open to launch the browser; --dev for Vite middleware/HMR). It serves the shell SPA (static dist/web-client/ shipped, Vite middleware in --dev), the POST /__crtr/source bridge (views\' reads AND `crtr` command writes via the local exec/file/http transport), the GET /__crtr/events SSE change-invalidation lane, and on a browser ws://127.0.0.1:PORT/node/<id> connection opens that node\'s broker view.sock and relays frames VERBATIM both ways — the browser is the same protocol peer as `crtr attach` (one controller drives, extra viewers are read-only; arbitration + dialogs ride the same frames). The relay NEVER spawns pi or writes a session: the node must already have a running headless broker (focus or revive it first); all graph mutations flow through `crtr` subprocesses the bridge runs. The server keeps running until ctrl+c; the node is chosen in the browser via the URL, not on the command line.',
|
|
117
|
+
},
|
|
118
|
+
children: [webServeLeaf],
|
|
119
|
+
});
|
|
120
|
+
}
|
package/dist/commands/canvas.js
CHANGED
|
@@ -12,7 +12,6 @@ import { browseLeaf } from './canvas-browse.js';
|
|
|
12
12
|
import { reviveLeaf } from './revive.js';
|
|
13
13
|
import { attentionBranch } from './attention.js';
|
|
14
14
|
import { daemonBranch } from './daemon.js';
|
|
15
|
-
import { tmuxSpreadLeaf } from './tmux-spread.js';
|
|
16
15
|
import { chordLeaf } from './chord.js';
|
|
17
16
|
import { canvasPruneLeaf } from './canvas-prune.js';
|
|
18
17
|
import { historyBranch } from './canvas-history.js';
|
|
@@ -29,6 +28,6 @@ export function registerCanvas() {
|
|
|
29
28
|
summary: 'observe and supervise the whole agent graph',
|
|
30
29
|
model: 'Canvas-wide operations, distinct from per-node work (`node`) and a node\'s own spine I/O (`push`/`feed`). `dashboard` renders the subscription forest as a tree; `browse` opens an interactive full-screen navigator (tabs/tree/search) over the whole canvas and resumes the chosen node; `attention` aggregates pending human asks across the graph; `revive` reopens a window for a done/idle/dead/canceled node; `history` searches and recalls the content record (reports + context docs) of past work in a cwd; `daemon` manages the thin crtrd supervisor that auto-revives nodes on window exit; `prune` bounds growth by deleting terminal nodes past a TTL.',
|
|
31
30
|
},
|
|
32
|
-
children: [dashboardLeaf, browseLeaf, attentionBranch, reviveLeaf, historyBranch,
|
|
31
|
+
children: [dashboardLeaf, browseLeaf, attentionBranch, reviveLeaf, historyBranch, daemonBranch, chordLeaf, canvasPruneLeaf],
|
|
33
32
|
});
|
|
34
33
|
}
|
package/dist/commands/chord.js
CHANGED
|
@@ -86,7 +86,7 @@ export const chordLeaf = defineLeaf({
|
|
|
86
86
|
{ name: 'action', type: 'string', required: false, constraint: 'What ran: a crtr argv string, "graph-toggle", or "noop".' },
|
|
87
87
|
],
|
|
88
88
|
outputKind: 'object',
|
|
89
|
-
effects: ['Runs a `crtr` subcommand (focus/close
|
|
89
|
+
effects: ['Runs a `crtr` subcommand (focus/close/…) or sends `/graph` into the pane, per the matched bind.'],
|
|
90
90
|
},
|
|
91
91
|
run: async (input) => {
|
|
92
92
|
const pane = input['pane'] ?? process.env['TMUX_PANE'];
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { defineLeaf } from '../core/command.js';
|
|
9
9
|
import { InputError } from '../core/io.js';
|
|
10
10
|
import { getNode, listNodes } from '../core/canvas/index.js';
|
|
11
|
-
import { renderTree, renderForest, dashboardRows, dashboardRowsAll } from '../core/canvas/render.js';
|
|
11
|
+
import { renderTree, renderForest, dashboardRows, dashboardRowsAll, enrichRows } from '../core/canvas/render.js';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// dashboard show — the main leaf
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -57,7 +57,11 @@ export const dashboardLeaf = defineLeaf({
|
|
|
57
57
|
}
|
|
58
58
|
// Full forest: all nodes on the canvas.
|
|
59
59
|
const allNodes = listNodes();
|
|
60
|
+
// dashboardRowsAll is the cheap-boot builder: ctx_tokens/asks are 0 and `name`
|
|
61
|
+
// is the handle only until enriched. The JSON consumer wants the real values,
|
|
62
|
+
// so fold the deferred fields in before emitting (the rendered TREE is unaffected).
|
|
60
63
|
const rows = dashboardRowsAll();
|
|
64
|
+
enrichRows(rows);
|
|
61
65
|
return {
|
|
62
66
|
tree: renderForest(),
|
|
63
67
|
nodes: allNodes.length,
|
|
@@ -2,7 +2,7 @@ import { defineLeaf } from '../../core/command.js';
|
|
|
2
2
|
import { InputError } from '../../core/io.js';
|
|
3
3
|
import { spawnNode } from '../../core/runtime/nodes.js';
|
|
4
4
|
import { interactionDir } from '../../core/artifact.js';
|
|
5
|
-
import {
|
|
5
|
+
import { tmuxServerReachable } from '../../core/spawn.js';
|
|
6
6
|
import { mkdirSync, existsSync } from 'node:fs';
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
8
|
import { randomBytes } from 'node:crypto';
|
|
@@ -211,7 +211,7 @@ export const humanNotify = defineLeaf({
|
|
|
211
211
|
const rc = { mode: 'notify' };
|
|
212
212
|
atomicWriteJson(join(idir, 'run.json'), rc);
|
|
213
213
|
let shown = false;
|
|
214
|
-
if (
|
|
214
|
+
if (tmuxServerReachable()) {
|
|
215
215
|
shown = detachHumanTui(idir, cwd).status === 'spawned';
|
|
216
216
|
}
|
|
217
217
|
return { shown, dir: idir };
|
|
@@ -256,7 +256,7 @@ export const humanShow = defineLeaf({
|
|
|
256
256
|
if (paneId !== undefined) {
|
|
257
257
|
return { pane_id: paneId, reason: null };
|
|
258
258
|
}
|
|
259
|
-
const reason =
|
|
259
|
+
const reason = tmuxServerReachable()
|
|
260
260
|
? 'renderer unavailable (termrender/uv missing)'
|
|
261
261
|
: 'not in tmux';
|
|
262
262
|
return { pane_id: null, reason };
|