@crouton-kit/crouter 0.3.28 → 0.3.30
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.d.ts +15 -7
- package/dist/build-root.js +48 -34
- 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 +5 -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 +22 -17
- 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__/slash-quit-copy.test.js +42 -0
- package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
- package/dist/clients/attach/attach-cmd.js +282 -46
- 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 +4 -2
- package/dist/clients/attach/canvas-panels.js +45 -17
- 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 +13 -4
- package/dist/clients/attach/graph-overlay.js +59 -19
- package/dist/clients/attach/input-controller.d.ts +75 -13
- package/dist/clients/attach/input-controller.js +305 -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 +22 -0
- package/dist/clients/attach/slash-commands.js +91 -25
- 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 +18 -1
- package/dist/clients/attach/view-socket.js +70 -1
- 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 +4 -25
- package/dist/commands/dashboard.js +5 -1
- package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
- package/dist/commands/memory/lint.js +14 -2
- 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 +8 -4
- 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/sync.d.ts +1 -0
- package/dist/commands/sys/sync.js +187 -0
- package/dist/commands/sys.js +3 -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__/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 +17 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +22 -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__/lifecycle.test.js +1 -19
- package/dist/core/__tests__/listing-completeness.test.js +2 -2
- 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.js +2 -2
- 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.js +8 -7
- 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.d.ts +4 -1
- package/dist/core/runtime/broker.js +494 -58
- package/dist/core/runtime/close.d.ts +12 -2
- package/dist/core/runtime/close.js +37 -16
- package/dist/core/runtime/front-door.js +6 -14
- 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 -335
- package/dist/core/runtime/placement.js +291 -858
- 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 +40 -139
- package/dist/core/runtime/spawn.d.ts +15 -16
- package/dist/core/runtime/spawn.js +165 -194
- 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 -52
- package/dist/core/runtime/tmux.js +35 -99
- 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/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +1 -1
- package/dist/core/substrate/injected-store.js +3 -3
- 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 +10 -3
- package/dist/core/substrate/schema.js +6 -3
- 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 +16 -16
- package/dist/pi-extensions/canvas-nav.js +5 -0
- 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 +14 -12
- package/dist/pi-extensions/canvas-stophook.js +87 -101
- 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 -297
- 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__/slash-quit-copy.test.d.ts} +0 -0
- /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
|
@@ -1,92 +1,106 @@
|
|
|
1
|
-
// placement.ts — the
|
|
1
|
+
// placement.ts — the VIEWER REGISTRY + the model-over-driver tmux front door.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// is
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// After the broker-is-the-host cut the engine NEVER lives in a tmux pane: every
|
|
4
|
+
// node is a detached `headlessBrokerHost` process that binds `view.sock`. A tmux
|
|
5
|
+
// pane is only a `crtr attach` VIEWER of that broker, exactly like a web tab. So
|
|
6
|
+
// the whole engine-in-pane placement machinery (hot-swap, resume-into-focus,
|
|
7
|
+
// backstage relocation, remain-on-exit freeze, spread, node-row reconcile) is
|
|
8
|
+
// gone. What remains is:
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// •
|
|
18
|
-
//
|
|
19
|
-
// •
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
// The robustness contract: a manual `move-pane`/`join-pane`/`break-pane` must
|
|
26
|
-
// NEVER read as a node death. Liveness is pane-existence, not window-existence,
|
|
27
|
-
// and reconcile makes crtr follow a move instead of fighting it.
|
|
28
|
-
import { spawnSync } from 'node:child_process';
|
|
10
|
+
// • The VIEWER REGISTRY — the `focuses` table, schema unchanged, now read as
|
|
11
|
+
// "node_id -> the node's one viewer %pane in `session`" (UNIQUE(node_id) =
|
|
12
|
+
// one viewer per node). focusOf/isFocused/focusByPane/focusedNodes/
|
|
13
|
+
// listFocuses read it (lazily GC'ing rows whose pane is gone or no longer
|
|
14
|
+
// carries this node's `@crtr_node` tag); registerViewerFocus writes it.
|
|
15
|
+
// • focus(nodeId) — ensure the broker engine is alive (injected reviver) +
|
|
16
|
+
// wait view.sock, then ensure the node's ONE viewer pane is forefront: reuse
|
|
17
|
+
// it (navigate) if it is in the caller's session, MOVE it (close + reopen
|
|
18
|
+
// beside the caller) if it is elsewhere, else open one beside the caller.
|
|
19
|
+
// • openViewerWindow / registerViewerFocus — the shared viewer-pane opener +
|
|
20
|
+
// row registrar used by BOTH spawn (a background viewer window) and focus.
|
|
21
|
+
// • tearDownNode / detachToBackground — close a node's viewer pane + row.
|
|
22
|
+
// • The chrome/viewer tmux re-exports (§2.1): placement stays the sanctioned
|
|
23
|
+
// model-over-driver so the §5.1 import-lint ("only placement.ts /
|
|
24
|
+
// tmux-chrome.ts import tmux.ts") holds — every other module reaches the
|
|
25
|
+
// driver verbs through here.
|
|
26
|
+
import { Worker } from 'node:worker_threads';
|
|
29
27
|
import { join } from 'node:path';
|
|
30
|
-
import {
|
|
31
|
-
import { paneExists,
|
|
32
|
-
import {
|
|
28
|
+
import { getNode, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, view, } from '../canvas/index.js';
|
|
29
|
+
import { paneExists, paneLocation, ensureSession, openNodeWindow, splitWindow, closePane, currentTmux, switchClient, selectWindow, getPaneOption, } from './tmux.js';
|
|
30
|
+
import { newNodeId, rootOfSpine } from './nodes.js';
|
|
33
31
|
import { nodeDir } from '../canvas/paths.js';
|
|
34
|
-
import { isBusy } from './busy.js';
|
|
35
32
|
import { isPidAlive } from '../canvas/pid.js';
|
|
36
|
-
import { transition } from './lifecycle.js';
|
|
37
|
-
// Re-export the durable REVIVE-HOME read so placement is the one front door for
|
|
38
|
-
// "where does this node live."
|
|
39
|
-
export { homeSessionOf, childBackstageOf };
|
|
40
33
|
// Placement is the sanctioned model-over-driver (§2.1): non-placement runtime /
|
|
41
34
|
// command modules that legitimately need a raw driver verb get it from here, so
|
|
42
35
|
// the §5.1 lint can hold "only placement.ts / tmux-chrome.ts import tmux.ts".
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
// These are the chrome + viewer verbs that survive the cut (the engine-in-pane
|
|
37
|
+
// verbs — respawnPane, respawnPaneDetached, swapPaneInPlace, breakPaneToSession,
|
|
38
|
+
// joinPane, selectLayout, setWindowOption, setRemainOnExit — are deleted).
|
|
39
|
+
// `piCommand` + `paneCurrentPath` are KEPT: the sessionless `crtr view` system
|
|
40
|
+
// (view-cycle.ts / view-run.ts) builds its pane commands with them, and the §5.1
|
|
41
|
+
// import-lint forbids those commands importing tmux.ts directly — they must reach
|
|
42
|
+
// the driver through placement. (They are NOT engine-in-pane; the design's
|
|
43
|
+
// "nothing keeps" classification missed the view consumers.)
|
|
44
|
+
export { splitWindow, focusWindow, selectWindow, switchClient, openNodeWindow, ensureSession, paneLocation, paneExists, paneRunning, paneCurrentPath, currentTmux, inTmux, installMenuBinding, setPaneOption, getPaneOption, piCommand, respawnPaneSync, closePane, windowOfPane, renameWindow, paneOfWindow, windowAlive, listLivePanes, } from './tmux.js';
|
|
45
45
|
// ---------------------------------------------------------------------------
|
|
46
|
-
//
|
|
46
|
+
// Viewer registry reads — COMPOSE over the canvas focuses table (§2.3/§4).
|
|
47
47
|
//
|
|
48
|
-
// placement is the front door for "which nodes
|
|
49
|
-
// in the canvas layer (canvas/focuses.ts) and placement just reads it
|
|
50
|
-
//
|
|
51
|
-
// node_id, Q5), so focusOf returns a single row.
|
|
48
|
+
// placement is the front door for "which nodes have a viewer on screen"; the SQL
|
|
49
|
+
// lives in the canvas layer (canvas/focuses.ts) and placement just reads it. A
|
|
50
|
+
// node has at most one viewer (UNIQUE node_id), so focusOf returns a single row.
|
|
52
51
|
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
52
|
+
// GC is lazy on read: a row whose viewer pane no longer exists (the user closed
|
|
53
|
+
// the window, the attach client died) is pruned the next time it is read, so the
|
|
54
|
+
// registry self-heals without a sweeper. (graphSurfaceTarget stays pure — no
|
|
55
|
+
// tmux probe — by design; its caller liveness-checks the pane it returns.)
|
|
57
56
|
// ---------------------------------------------------------------------------
|
|
58
|
-
/**
|
|
57
|
+
/** Prune a viewer row whose pane is gone; return it iff its pane is still live. */
|
|
58
|
+
function liveOrPrune(f) {
|
|
59
|
+
if (f === null)
|
|
60
|
+
return null;
|
|
61
|
+
if (f.pane !== null && !paneExists(f.pane)) {
|
|
62
|
+
closeFocusRow(f.focus_id);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return f;
|
|
66
|
+
}
|
|
67
|
+
/** The viewer a node has on screen, or null. UNIQUE(node_id) ⇒ at most one. */
|
|
59
68
|
export function focusOf(nodeId) {
|
|
60
|
-
return getFocusByNode(nodeId);
|
|
69
|
+
return liveOrPrune(getFocusByNode(nodeId));
|
|
61
70
|
}
|
|
62
|
-
/**
|
|
71
|
+
/** Does this node have a live viewer on screen? */
|
|
63
72
|
export function isFocused(nodeId) {
|
|
64
|
-
return
|
|
73
|
+
return focusOf(nodeId) !== null;
|
|
65
74
|
}
|
|
66
|
-
/** The
|
|
75
|
+
/** The viewer realized by a given pane (`%id`), or null. */
|
|
67
76
|
export function focusByPane(pane) {
|
|
68
|
-
return getFocusByPane(pane);
|
|
77
|
+
return liveOrPrune(getFocusByPane(pane));
|
|
69
78
|
}
|
|
70
|
-
/** The set of node ids currently
|
|
79
|
+
/** The set of node ids that currently have a live viewer. */
|
|
71
80
|
export function focusedNodes() {
|
|
72
|
-
return new Set(
|
|
81
|
+
return new Set(listFocuses().map((f) => f.node_id));
|
|
73
82
|
}
|
|
74
|
-
/** Every
|
|
83
|
+
/** Every live viewer row (GC'ing any whose pane has gone). */
|
|
75
84
|
export function listFocuses() {
|
|
76
|
-
|
|
85
|
+
const live = [];
|
|
86
|
+
for (const f of listFocusRows()) {
|
|
87
|
+
if (liveOrPrune(f) !== null)
|
|
88
|
+
live.push(f);
|
|
89
|
+
}
|
|
90
|
+
return live;
|
|
77
91
|
}
|
|
78
92
|
// ---------------------------------------------------------------------------
|
|
79
|
-
// Graph →
|
|
93
|
+
// Graph → viewer routing (for surfacing human-in-the-loop prompts)
|
|
80
94
|
// ---------------------------------------------------------------------------
|
|
81
|
-
/** The on-screen
|
|
82
|
-
* surface into: the HIGHEST
|
|
83
|
-
* closest to the graph root, i.e. the session/window the user is actually
|
|
95
|
+
/** The on-screen viewer a human-in-the-loop prompt raised by `nodeId` should
|
|
96
|
+
* surface into: the HIGHEST node of nodeId's graph that has a viewer — the
|
|
97
|
+
* viewer closest to the graph root, i.e. the session/window the user is actually
|
|
84
98
|
* watching this work in. Walks nodeId's spine to its root, enumerates the whole
|
|
85
|
-
* tree root-first (`view` is BFS ⇒ shallowest first), and returns the
|
|
86
|
-
* of the first node that
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
99
|
+
* tree root-first (`view` is BFS ⇒ shallowest first), and returns the viewer row
|
|
100
|
+
* of the first node that has one. null when nothing in the graph is on screen —
|
|
101
|
+
* the caller then surfaces in the user's attached pane. PURE (db reads only): no
|
|
102
|
+
* tmux probe, so the pane may be stale; the caller liveness-checks before
|
|
103
|
+
* targeting it. */
|
|
90
104
|
export function graphSurfaceTarget(nodeId) {
|
|
91
105
|
const root = rootOfSpine(nodeId);
|
|
92
106
|
for (const id of [root, ...view(root)]) {
|
|
@@ -96,561 +110,22 @@ export function graphSurfaceTarget(nodeId) {
|
|
|
96
110
|
}
|
|
97
111
|
return null;
|
|
98
112
|
}
|
|
99
|
-
/** PURE reconciliation decision (§2.4) — unit-testable without a live tmux.
|
|
100
|
-
* Given the cached row LOCATION and what tmux currently reports, decide the
|
|
101
|
-
* presence patch. Mirrors the pure-core/impure-shell split (cf. `livenessVerdict`
|
|
102
|
-
* vs `handleLiveWindow`): this is the decision, `reconcile` wires it to the
|
|
103
|
-
* driver reads + `setPresence`. */
|
|
104
|
-
export function reconcileDecision(cached, live) {
|
|
105
|
-
if (cached.pane == null) {
|
|
106
|
-
// Legacy / no-pane row: lazily backfill the durable pane from the live
|
|
107
|
-
// window's active pane. Requires a complete, live window to anchor on.
|
|
108
|
-
if (cached.tmux_session != null && cached.window != null && live.windowPane != null) {
|
|
109
|
-
return {
|
|
110
|
-
kind: 'backfill',
|
|
111
|
-
pane: live.windowPane,
|
|
112
|
-
tmux_session: cached.tmux_session,
|
|
113
|
-
window: cached.window,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
return { kind: 'none' };
|
|
117
|
-
}
|
|
118
|
-
// Pane-anchored row: resolve the pane's CURRENT location.
|
|
119
|
-
if (live.paneLoc === null) {
|
|
120
|
-
// The pane itself is gone — the node's pane truly closed. Null the LOCATION.
|
|
121
|
-
return { kind: 'gone' };
|
|
122
|
-
}
|
|
123
|
-
if (live.paneLoc.session !== cached.tmux_session || live.paneLoc.window !== cached.window) {
|
|
124
|
-
// The pane drifted (a manual move-pane/join-pane/break-pane). FOLLOW it:
|
|
125
|
-
// same pane id, new derived window/session.
|
|
126
|
-
return {
|
|
127
|
-
kind: 'follow',
|
|
128
|
-
pane: cached.pane,
|
|
129
|
-
tmux_session: live.paneLoc.session,
|
|
130
|
-
window: live.paneLoc.window,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
// Cache already matches the pane's reality.
|
|
134
|
-
return { kind: 'none' };
|
|
135
|
-
}
|
|
136
|
-
/** Reconcile a node's LOCATION against tmux reality (§2.4) — the impure shell.
|
|
137
|
-
* Reads `row.pane`, resolves its CURRENT session/window via the driver, and
|
|
138
|
-
* writes the resulting presence patch through `setPresence` (never a raw UPDATE):
|
|
139
|
-
* - pane moved → FOLLOW (re-point window/session, keep the pane id)
|
|
140
|
-
* - pane gone → null the whole LOCATION
|
|
141
|
-
* - legacy/no pane + live window → backfill the pane from `paneOfWindow`
|
|
142
|
-
* A no-op when there's nothing to resolve (genuinely no pane, or the cache is
|
|
143
|
-
* already current). Call this before any swap/kill/focus/revive so the act lands
|
|
144
|
-
* on the pane's current window, never a stale one. */
|
|
145
|
-
export function reconcile(nodeId) {
|
|
146
|
-
const row = getRow(nodeId);
|
|
147
|
-
if (row === null)
|
|
148
|
-
return;
|
|
149
|
-
const cached = {
|
|
150
|
-
pane: row.pane,
|
|
151
|
-
tmux_session: row.tmux_session,
|
|
152
|
-
window: row.window,
|
|
153
|
-
};
|
|
154
|
-
// Only the read the decision needs: paneLocation when anchored on a pane, else
|
|
155
|
-
// paneOfWindow for the legacy backfill. Skip the driver call that can't apply.
|
|
156
|
-
const paneLoc = cached.pane != null ? paneLocation(cached.pane) : null;
|
|
157
|
-
const windowPane = cached.pane == null && cached.tmux_session != null && cached.window != null
|
|
158
|
-
? paneOfWindow(cached.tmux_session, cached.window)
|
|
159
|
-
: null;
|
|
160
|
-
const decision = reconcileDecision(cached, { paneLoc, windowPane });
|
|
161
|
-
switch (decision.kind) {
|
|
162
|
-
case 'none':
|
|
163
|
-
return;
|
|
164
|
-
case 'gone':
|
|
165
|
-
setPresence(nodeId, { pane: null, tmux_session: null, window: null });
|
|
166
|
-
return;
|
|
167
|
-
case 'follow':
|
|
168
|
-
case 'backfill':
|
|
169
|
-
setPresence(nodeId, {
|
|
170
|
-
pane: decision.pane,
|
|
171
|
-
tmux_session: decision.tmux_session,
|
|
172
|
-
window: decision.window,
|
|
173
|
-
});
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
113
|
// ---------------------------------------------------------------------------
|
|
178
|
-
//
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
/** Reconcile a FOCUS's derived `session` cache against tmux reality (§2.4, Q4) —
|
|
181
|
-
* the focus-row analogue of `reconcile`. A focus is anchored on its durable
|
|
182
|
-
* `%pane_id`; `session` is a derived cache. If the user moved the focus pane to
|
|
183
|
-
* another session, re-point the cache so a resume-into-focus lands in the pane's
|
|
184
|
-
* CURRENT session. A no-op when the focus has no pane, the cache is already
|
|
185
|
-
* current, or the pane is GONE — in the gone case reconcileFocus does NOT null
|
|
186
|
-
* the row; the caller (reviveIntoPlacement) instead falls to the backstage
|
|
187
|
-
* branch via `paneExists(pane)` being false. */
|
|
188
|
-
export function reconcileFocus(focusId) {
|
|
189
|
-
const f = getFocusById(focusId);
|
|
190
|
-
if (f === null || f.pane === null)
|
|
191
|
-
return;
|
|
192
|
-
const live = paneLocation(f.pane);
|
|
193
|
-
if (live === null)
|
|
194
|
-
return; // pane gone — backstage fall-through handles it
|
|
195
|
-
if (live.session !== f.session) {
|
|
196
|
-
setFocusPane(f.focus_id, f.pane, live.session);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
114
|
+
// view.sock readiness — the one shared broker-cold-start primitive
|
|
199
115
|
// ---------------------------------------------------------------------------
|
|
200
|
-
// Liveness — pane-existence (§1.2, Q6)
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
/** Is this node's pane (its LOCATION) alive? The v3 PRIMARY liveness probe,
|
|
203
|
-
* PURE / non-mutating so the daemon can gate on it without side effects:
|
|
204
|
-
* - `pane != null` → `paneExists(pane)` (display-message on the `%id`), so a
|
|
205
|
-
* user moving the pane to another window/session never reads as "gone".
|
|
206
|
-
* - `pane == null` → window-keyed FALLBACK (`windowAlive`) for legacy/no-pane
|
|
207
|
-
* rows that haven't been backfilled yet.
|
|
208
|
-
* Accepts a node id (re-reads the row) or a `NodeRow` already in hand. */
|
|
209
|
-
export function isNodePaneAlive(node) {
|
|
210
|
-
const row = typeof node === 'string' ? getRow(node) : node;
|
|
211
|
-
if (row === null)
|
|
212
|
-
return false;
|
|
213
|
-
return row.pane != null
|
|
214
|
-
? paneExists(row.pane)
|
|
215
|
-
: windowAlive(row.tmux_session, row.window);
|
|
216
|
-
}
|
|
217
|
-
export function reviveTarget(focus, focusPaneAlive, homeSession) {
|
|
218
|
-
if (focus !== null && focus.pane !== null && focusPaneAlive) {
|
|
219
|
-
return { kind: 'focus-pane', pane: focus.pane, session: focus.session ?? homeSession };
|
|
220
|
-
}
|
|
221
|
-
return { kind: 'backstage', session: homeSession };
|
|
222
|
-
}
|
|
223
|
-
/** Place a reviving node into its CORRECT location (§1.4) — the single decision
|
|
224
|
-
* that replaces revive.ts's old `session = meta.tmux_session ?? nodeSession()` +
|
|
225
|
-
* `openNodeWindow`. Reconcile first (§2.4), then dispatch on `reviveTarget`:
|
|
226
|
-
* - the node occupies a LIVE focus → `reconcileFocus` (resolve the pane's
|
|
227
|
-
* CURRENT session, Q4) and `respawn-pane -k` the pi INTO that focus pane —
|
|
228
|
-
* no new window (F3 resume-in-place).
|
|
229
|
-
* - otherwise → the node is NOT focused (or its focus pane already collapsed,
|
|
230
|
-
* the Step-5 limitation: remain-on-exit lands in Step 6), so it may ONLY
|
|
231
|
-
* (re)appear in its durable REVIVE-HOME: a fresh window in `homeSession`.
|
|
232
|
-
* **There is NO code path here by which a non-focused node's new-window
|
|
233
|
-
* targets a user session** — `openNodeWindow`'s session is `homeSession` and
|
|
234
|
-
* nothing else. That is the structural bug-kill.
|
|
235
|
-
*
|
|
236
|
-
* `setPresence` (the one atomic LOCATION write) records where the node landed.
|
|
237
|
-
* CRTR_ROOT_SESSION is forced to the node's children-backstage (childBackstageOf)
|
|
238
|
-
* in BOTH branches so the node's children always flow to the backstage, never
|
|
239
|
-
* into the focus session — and, for a front-door ROOT, never into the user
|
|
240
|
-
* session its own pane adopted. */
|
|
241
|
-
export function reviveIntoPlacement(nodeId, launch) {
|
|
242
|
-
// §2.4 — follow any manual pane move before acting.
|
|
243
|
-
reconcile(nodeId);
|
|
244
|
-
const focus = focusOf(nodeId);
|
|
245
|
-
const focusPaneAlive = focus !== null && focus.pane !== null && paneExists(focus.pane);
|
|
246
|
-
const homeSession = homeSessionOf(nodeId);
|
|
247
|
-
const decision = reviveTarget(focus, focusPaneAlive, homeSession);
|
|
248
|
-
// The node's children always spawn into the backstage, never the focus session
|
|
249
|
-
// — force it regardless of which branch the node itself takes. childBackstageOf
|
|
250
|
-
// is home_session for a managed child but `nodeSession()` for a root (whose
|
|
251
|
-
// home_session may be a user session it adopted), so a refreshed front-door
|
|
252
|
-
// root never re-points its subtree into the user's session.
|
|
253
|
-
const env = { ...launch.env, CRTR_ROOT_SESSION: childBackstageOf(nodeId), CRTR_SUBTREE: rootOfSpine(nodeId) };
|
|
254
|
-
if (decision.kind === 'focus-pane') {
|
|
255
|
-
// F3: resume the pi INTO the live focus pane, in its CURRENT session (Q4 —
|
|
256
|
-
// reconcileFocus follows a user move of the focus pane). No new window.
|
|
257
|
-
reconcileFocus(focus.focus_id);
|
|
258
|
-
const f = focusOf(nodeId) ?? focus;
|
|
259
|
-
const pane = f.pane;
|
|
260
|
-
respawnPaneSync({ pane, cwd: launch.cwd, env, command: launch.command });
|
|
261
|
-
const window = windowOfPane(pane);
|
|
262
|
-
const session = f.session ?? homeSession;
|
|
263
|
-
setPresence(nodeId, { pane, tmux_session: session, window });
|
|
264
|
-
return { window, session, pane };
|
|
265
|
-
}
|
|
266
|
-
// Backstage branch — the ONLY new-window target is `homeSession` (the
|
|
267
|
-
// backstage `crtr` for a child). A non-focused node has NO path to a user
|
|
268
|
-
// session here: the bug is structurally unreachable.
|
|
269
|
-
const session = decision.session; // === homeSession
|
|
270
|
-
ensureSession(session, launch.cwd);
|
|
271
|
-
const opened = openNodeWindow({
|
|
272
|
-
session,
|
|
273
|
-
name: launch.name,
|
|
274
|
-
cwd: launch.cwd,
|
|
275
|
-
env,
|
|
276
|
-
command: launch.command,
|
|
277
|
-
});
|
|
278
|
-
const window = opened?.window ?? null;
|
|
279
|
-
const pane = opened?.pane ?? null;
|
|
280
|
-
setPresence(nodeId, { pane, window, tmux_session: session });
|
|
281
|
-
return { window, session, pane };
|
|
282
|
-
}
|
|
283
|
-
// ---------------------------------------------------------------------------
|
|
284
|
-
// Detach to background — send a still-running agent off the foreground pane into
|
|
285
|
-
// the backstage `crtr` session WITHOUT ending it (the `node lifecycle --detach`
|
|
286
|
-
// / Alt+C → D half).
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
/** Relocate a node's still-running agent to the background `crtr` session,
|
|
289
|
-
* freeing the foreground pane WITHOUT killing the pi. `break-pane` moves the
|
|
290
|
-
* pane out of the foreground window into a fresh window in the shared backstage
|
|
291
|
-
* (the pi keeps generating); the node becomes a background window — switchable
|
|
292
|
-
* but not rendered, like any other node. Reconcile first (act on the pane's
|
|
293
|
-
* CURRENT location, §2.4) and again after (presence FOLLOWS the move). No-op
|
|
294
|
-
* (false) when there is no live pane to relocate or tmux refuses the break.
|
|
295
|
-
* `pane` is the authoritative node pane the caller acts on (the Alt+C menu's
|
|
296
|
-
* `#{pane_id}`); falls back to the node's durable handle.
|
|
297
|
-
*
|
|
298
|
-
* Invariant P gate (Bug 1): the backstage holds ONLY generating-but-unfocused
|
|
299
|
-
* nodes. A node that is NOT mid-turn (e.g. a resident root parked idle between
|
|
300
|
-
* turns, detached via Alt+C → D) must never be parked there as a live-idle pi —
|
|
301
|
-
* nothing would re-enter agent_end to release it and, with no live subscription,
|
|
302
|
-
* nothing would wake it, so it would hold a backstage pane forever, reaped by
|
|
303
|
-
* nothing. Such a node is RELEASED to dormant instead (mirrors the parked-viewer
|
|
304
|
-
* release on focus-away in retargetFocus). */
|
|
305
|
-
export function detachToBackground(nodeId, pane) {
|
|
306
|
-
reconcile(nodeId);
|
|
307
|
-
const row = getRow(nodeId);
|
|
308
|
-
if (row === null)
|
|
309
|
-
return false;
|
|
310
|
-
// A broker node has NO engine pane — it is ALREADY headless, so the only thing
|
|
311
|
-
// on screen is a `crtr attach` VIEWER pane. "Detach" (the Alt+C → D let-go)
|
|
312
|
-
// therefore means "stop foregrounding it": close the viewer pane and leave the
|
|
313
|
-
// broker running untouched. The engine-in-pane relocate/release below would
|
|
314
|
-
// mis-anchor the live engine row to the viewer pane and (when idle) flip a
|
|
315
|
-
// LIVE broker to idle-release — corrupting state. Never reach it for a broker.
|
|
316
|
-
if (row.host_kind === 'broker') {
|
|
317
|
-
const viewer = pane ?? null;
|
|
318
|
-
if (viewer !== null && paneExists(viewer))
|
|
319
|
-
return closePane(viewer);
|
|
320
|
-
return false;
|
|
321
|
-
}
|
|
322
|
-
const target = pane ?? row.pane;
|
|
323
|
-
if (target === null || !paneExists(target))
|
|
324
|
-
return false;
|
|
325
|
-
// Anchor the durable handle on the pane we relocate so the post-move reconcile
|
|
326
|
-
// follows the right pane.
|
|
327
|
-
if (row.pane !== target)
|
|
328
|
-
setPresence(nodeId, { pane: target });
|
|
329
|
-
// Invariant P (Bug 1): a node that is NOT mid-turn must never be parked in the
|
|
330
|
-
// backstage as a live-idle pi (it would hold a backstage pane forever, reaped
|
|
331
|
-
// by nothing). Detaching such a node RELEASES it to dormant instead: close its
|
|
332
|
-
// focus, then — crash-safety, as in retargetFocus's release path — flip it to
|
|
333
|
-
// dormant (idle + intent='idle-release', revivable via inbox / re-focus) and
|
|
334
|
-
// null its LOCATION BEFORE reaping the pane, so a daemon tick can never catch a
|
|
335
|
-
// window-gone live node mid-reap and race to revive it. Unlike retargetFocus's
|
|
336
|
-
// outgoingDisposition (which keeps a RESIDENT node warm on focus-away), detach
|
|
337
|
-
// is a deliberate let-go: a non-generating node is released regardless of
|
|
338
|
-
// lifecycle. The status guard skips the transition for a done/dead/canceled
|
|
339
|
-
// node ('release' is legal only from active|idle) — it still reaps + nulls.
|
|
340
|
-
if (!isGenerating(nodeId)) {
|
|
341
|
-
const f = focusOf(nodeId);
|
|
342
|
-
if (f !== null)
|
|
343
|
-
closeFocusRow(f.focus_id);
|
|
344
|
-
if (row.status === 'active' || row.status === 'idle')
|
|
345
|
-
transition(nodeId, 'release');
|
|
346
|
-
setPresence(nodeId, { pane: null, tmux_session: null, window: null });
|
|
347
|
-
closePane(target);
|
|
348
|
-
return true;
|
|
349
|
-
}
|
|
350
|
-
// Generating → park in the backstage, keep running off-screen.
|
|
351
|
-
const session = nodeSession();
|
|
352
|
-
ensureSession(session, row.cwd);
|
|
353
|
-
const ok = breakPaneToSession(target, session);
|
|
354
|
-
if (ok) {
|
|
355
|
-
// The node left its viewport for the backstage: it is now generating but
|
|
356
|
-
// UNFOCUSED (Invariant P / §1.3 "evicted from a focus … generating →
|
|
357
|
-
// backstage, no focus"). Close any focus row it held so it does not linger
|
|
358
|
-
// as a phantom viewport — the pane %id survives the break, so the row would
|
|
359
|
-
// otherwise keep resolving (`focusByPane`/`listFocuses`). A pure detach has
|
|
360
|
-
// no successor, so it closes the row (cf. `demote`, which hands it off).
|
|
361
|
-
const f = focusOf(nodeId);
|
|
362
|
-
if (f !== null)
|
|
363
|
-
closeFocusRow(f.focus_id);
|
|
364
|
-
}
|
|
365
|
-
reconcile(nodeId); // presence now points at the crtr window
|
|
366
|
-
return ok;
|
|
367
|
-
}
|
|
368
|
-
/** A reserved, non-node occupant for a freshly-opened viewport that has no node
|
|
369
|
-
* yet: openFocus splits a HOLDER pane, but `node_id` is NOT NULL, so the row
|
|
370
|
-
* needs a placeholder until retargetFocus swaps a real node in. retargetFocus
|
|
371
|
-
* REAPS a holder pane (getRow(holder) === null ⇒ not generating ⇒ kill) instead
|
|
372
|
-
* of backstaging it. */
|
|
373
|
-
function holderId(focusId) {
|
|
374
|
-
return `__hold_${focusId}__`;
|
|
375
|
-
}
|
|
376
|
-
function newFocusId() {
|
|
377
|
-
return `f-${newNodeId()}`;
|
|
378
|
-
}
|
|
379
|
-
/** Is a focus's OUTGOING occupant still GENERATING (a live pi actually MID-TURN)?
|
|
380
|
-
* A still-generating node is moved to backstage by a retarget (F2 — it keeps
|
|
381
|
-
* running off-screen); a holder / done / dormant / merely-parked node has its
|
|
382
|
-
* pane reaped or released (Invariant P). A holder or vanished node (row null) is
|
|
383
|
-
* never generating.
|
|
384
|
-
*
|
|
385
|
-
* The signal is the mid-turn `busy` marker AND a live pid — NOT pid-alive
|
|
386
|
-
* alone. A node revived only for VIEWING is parked at its prompt with a live
|
|
387
|
-
* pid between turns; pid-alive would misclassify it as "doing work" and leave
|
|
388
|
-
* it stuck backstaged-active forever. `isBusy` is true only inside a turn, so a
|
|
389
|
-
* parked viewer reads as not-generating and is released to dormant on
|
|
390
|
-
* focus-away. The AND with `isPidAlive` makes a stale marker (a pi that crashed
|
|
391
|
-
* mid-turn) harmless. */
|
|
392
|
-
function isGenerating(nodeId) {
|
|
393
|
-
const row = getRow(nodeId);
|
|
394
|
-
if (row === null)
|
|
395
|
-
return false;
|
|
396
|
-
if (row.status !== 'active' && row.status !== 'idle')
|
|
397
|
-
return false;
|
|
398
|
-
return isBusy(nodeId) && isPidAlive(row.pi_pid);
|
|
399
|
-
}
|
|
400
|
-
export function outgoingDisposition(o) {
|
|
401
|
-
if (!o.exists)
|
|
402
|
-
return { kind: 'kill' }; // holder pane
|
|
403
|
-
if (!o.live)
|
|
404
|
-
return { kind: 'kill' }; // done/dead/canceled — reap the pane
|
|
405
|
-
if (o.resident)
|
|
406
|
-
return { kind: 'backstage' }; // human-driven node: keep warm
|
|
407
|
-
if (o.generating)
|
|
408
|
-
return { kind: 'backstage' }; // mid-turn terminal worker (F2)
|
|
409
|
-
return { kind: 'release' }; // parked terminal viewer → dormant
|
|
410
|
-
}
|
|
411
|
-
/** The node's pane iff it is a LIVE pane (a generating-unfocused backstage pane,
|
|
412
|
-
* or a still-live focus pane), else null. The retarget swaps THIS pane into the
|
|
413
|
-
* viewport; null means the node is dormant and must be revived first. */
|
|
414
|
-
function livePinPane(nodeId) {
|
|
415
|
-
const row = getRow(nodeId);
|
|
416
|
-
return row?.pane != null && paneExists(row.pane) ? row.pane : null;
|
|
417
|
-
}
|
|
418
|
-
/** remain-on-exit on a focus's viewport window (F3 freeze/resume) — best-effort. */
|
|
419
|
-
function armRemainOnExit(window) {
|
|
420
|
-
if (window != null && window !== '')
|
|
421
|
-
setRemainOnExit(window, true);
|
|
422
|
-
}
|
|
423
|
-
/** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
|
|
424
|
-
* session. Default: `splitWindow(callerPane)` beside (Q3); `newWindow` opens a
|
|
425
|
-
* fresh window in the caller pane's session instead. Arms `remain-on-exit` on
|
|
426
|
-
* the new pane's window (F3) and inserts a focuses row anchored on it, occupied
|
|
427
|
-
* by a HOLDER until retargetFocus swaps a real node in. A benign long-sleep
|
|
428
|
-
* holds the pane open until the swap; retargetFocus reaps it. Returns the row,
|
|
429
|
-
* or null if tmux failed. */
|
|
430
|
-
export function openFocus(callerPane, opts = {}) {
|
|
431
|
-
const HOLD = 'sleep 2147483647';
|
|
432
|
-
let pane;
|
|
433
|
-
let session;
|
|
434
|
-
if (opts.newWindow === true) {
|
|
435
|
-
const sess = paneLocation(callerPane)?.session;
|
|
436
|
-
if (sess === undefined)
|
|
437
|
-
return null;
|
|
438
|
-
const opened = openNodeWindow({ session: sess, name: 'focus', cwd: process.cwd(), env: {}, command: HOLD });
|
|
439
|
-
if (opened === null)
|
|
440
|
-
return null;
|
|
441
|
-
pane = opened.pane;
|
|
442
|
-
session = sess;
|
|
443
|
-
}
|
|
444
|
-
else {
|
|
445
|
-
pane = splitWindow(callerPane, { cwd: process.cwd(), env: {}, command: HOLD });
|
|
446
|
-
if (pane === null)
|
|
447
|
-
return null;
|
|
448
|
-
session = paneLocation(pane)?.session ?? null;
|
|
449
|
-
}
|
|
450
|
-
armRemainOnExit(paneLocation(pane)?.window);
|
|
451
|
-
const focusId = newFocusId();
|
|
452
|
-
openFocusRow(focusId, pane, session, holderId(focusId));
|
|
453
|
-
return getFocusById(focusId);
|
|
454
|
-
}
|
|
455
|
-
/** Register the FOREGROUND root's pane as focus #1 at boot (§2.6). The inline
|
|
456
|
-
* root owns the user's viewport, so its own pane becomes a durable focus — with
|
|
457
|
-
* `remain-on-exit` so a clean exit FREEZES the pane rather than detaching the
|
|
458
|
-
* terminal (F1). A background `--root` does NOT call this (§6): it stays a plain
|
|
459
|
-
* window until the user `node focus`es it. No-op when the pane or this node is
|
|
460
|
-
* already a focus. The focus row IS the record — no pointer to mirror. */
|
|
461
|
-
export function registerRootFocus(nodeId, pane, session, window) {
|
|
462
|
-
const byPane = getFocusByPane(pane);
|
|
463
|
-
if (byPane !== null)
|
|
464
|
-
return byPane;
|
|
465
|
-
const byNode = getFocusByNode(nodeId);
|
|
466
|
-
if (byNode !== null)
|
|
467
|
-
return byNode;
|
|
468
|
-
const focusId = newFocusId();
|
|
469
|
-
openFocusRow(focusId, pane, session, nodeId);
|
|
470
|
-
armRemainOnExit(window);
|
|
471
|
-
return getFocusById(focusId);
|
|
472
|
-
}
|
|
473
|
-
/** retargetFocus — the unified hot-swap (§2.5, Invariant P + Q5). Swap `incoming`
|
|
474
|
-
* onto focus `focusId`'s viewport, keeping the screen position invariant (no new
|
|
475
|
-
* window). One sqlite txn updates the focus row + BOTH nodes' presence:
|
|
476
|
-
* - Q5: if `incoming` already occupies ANOTHER focus, VACATE it first (close
|
|
477
|
-
* its row + kill its pane — the node MOVES here, no auto-retarget).
|
|
478
|
-
* - resolve `incoming`'s live pin pane (a backstage pane), else `revive` it
|
|
479
|
-
* into the backstage and read back its pane.
|
|
480
|
-
* - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
|
|
481
|
-
* outgoing occupant → incoming's old (backstage) slot, %ids preserved
|
|
482
|
-
* (cross-session swap confirmed by the spike).
|
|
483
|
-
* - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
|
|
484
|
-
* terminal viewer → RELEASE (status → idle, pane reaped); a holder or
|
|
485
|
-
* done/dormant occupant → reap its now-backstage pane (Invariant P).
|
|
486
|
-
* Arms remain-on-exit on the viewport (F3); the focus row is the record. */
|
|
487
|
-
export function retargetFocus(focusId, incoming, revive) {
|
|
488
|
-
let f = getFocusById(focusId);
|
|
489
|
-
if (f === null)
|
|
490
|
-
return { focused: false, session: null, inPlace: false, revived: false };
|
|
491
|
-
reconcileFocus(f.focus_id);
|
|
492
|
-
reconcile(incoming);
|
|
493
|
-
f = getFocusById(focusId) ?? f;
|
|
494
|
-
const outgoing = f.node_id;
|
|
495
|
-
// Already showing this node — a no-op (focusing yourself / the live occupant).
|
|
496
|
-
if (outgoing === incoming) {
|
|
497
|
-
return { focused: true, session: f.session, inPlace: true, revived: false };
|
|
498
|
-
}
|
|
499
|
-
// Q5 vacate: incoming occupies a DIFFERENT focus — close it + kill its pane;
|
|
500
|
-
// the node moves here. reconcile then nulls its now-dead LOCATION.
|
|
501
|
-
const other = getFocusByNode(incoming);
|
|
502
|
-
if (other !== null && other.focus_id !== f.focus_id) {
|
|
503
|
-
if (other.pane !== null)
|
|
504
|
-
closePane(other.pane);
|
|
505
|
-
closeFocusRow(other.focus_id);
|
|
506
|
-
reconcile(incoming);
|
|
507
|
-
}
|
|
508
|
-
// Resolve incoming's live pin pane; revive into backstage if dormant.
|
|
509
|
-
let revived = false;
|
|
510
|
-
let pin = livePinPane(incoming);
|
|
511
|
-
if (pin === null) {
|
|
512
|
-
revive(incoming);
|
|
513
|
-
revived = true;
|
|
514
|
-
reconcile(incoming);
|
|
515
|
-
pin = livePinPane(incoming);
|
|
516
|
-
}
|
|
517
|
-
if (pin === null) {
|
|
518
|
-
return { focused: false, session: f.session, inPlace: false, revived };
|
|
519
|
-
}
|
|
520
|
-
const focusPane = f.pane;
|
|
521
|
-
// The focus has no physical pane yet (an unplaced/bridge row) OR incoming is
|
|
522
|
-
// already in it — adopt pin directly, no swap.
|
|
523
|
-
if (focusPane === null || focusPane === pin) {
|
|
524
|
-
const loc = paneLocation(pin);
|
|
525
|
-
commitFocusTxn(f.focus_id, incoming, pin, loc, outgoing, { kind: 'kill' }, null, null);
|
|
526
|
-
armRemainOnExit(loc?.window);
|
|
527
|
-
return { focused: true, session: loc?.session ?? f.session, inPlace: true, revived };
|
|
528
|
-
}
|
|
529
|
-
// The hot-swap: incoming's pane → the viewport slot; outgoing's pane →
|
|
530
|
-
// incoming's old (backstage) slot. %ids survive (spike-confirmed).
|
|
531
|
-
if (!swapPaneInPlace(pin, focusPane)) {
|
|
532
|
-
return { focused: false, session: f.session, inPlace: false, revived };
|
|
533
|
-
}
|
|
534
|
-
const pinLoc = paneLocation(pin); // now the viewport
|
|
535
|
-
const outLoc = paneLocation(focusPane); // now backstage (outgoing's new home)
|
|
536
|
-
const oRow = getRow(outgoing);
|
|
537
|
-
const action = outgoingDisposition({
|
|
538
|
-
exists: oRow !== null,
|
|
539
|
-
live: oRow?.status === 'active' || oRow?.status === 'idle',
|
|
540
|
-
resident: oRow?.lifecycle === 'resident',
|
|
541
|
-
generating: isGenerating(outgoing),
|
|
542
|
-
});
|
|
543
|
-
commitFocusTxn(f.focus_id, incoming, pin, pinLoc, outgoing, action, outLoc, focusPane);
|
|
544
|
-
// Crash-safety: flip a released (parked terminal viewer) node to dormant
|
|
545
|
-
// (idle + intent='idle-release') BEFORE reaping its pane, so the daemon never
|
|
546
|
-
// sees a window-gone live node and races to revive it. Then reap the
|
|
547
|
-
// outgoing/holder pane (now backstage) for both kill (done/dormant/holder) and
|
|
548
|
-
// release (parked viewer) — AFTER commit (a tmux side effect, outside the txn).
|
|
549
|
-
// A still-generating worker or a resident node is backstaged, untouched here.
|
|
550
|
-
if (action.kind === 'release')
|
|
551
|
-
transition(outgoing, 'release');
|
|
552
|
-
if (action.kind === 'kill' || action.kind === 'release')
|
|
553
|
-
closePane(focusPane);
|
|
554
|
-
armRemainOnExit(pinLoc?.window);
|
|
555
|
-
return { focused: true, session: pinLoc?.session ?? f.session, inPlace: true, revived };
|
|
556
|
-
}
|
|
557
|
-
/** The ONE atomic txn (§2.5): point the focus row at `pin`, set its occupant to
|
|
558
|
-
* `incoming`, and write BOTH nodes' presence — incoming into the viewport, the
|
|
559
|
-
* outgoing either backstaged (still generating) or null (its pane is reaped by
|
|
560
|
-
* the caller). A holder/vanished outgoing (no row) gets no presence write. */
|
|
561
|
-
function commitFocusTxn(focusId, incoming, pin, pinLoc, outgoing, action, outLoc, outgoingPane) {
|
|
562
|
-
const db = openDb();
|
|
563
|
-
db.exec('BEGIN');
|
|
564
|
-
try {
|
|
565
|
-
setFocusPane(focusId, pin, pinLoc?.session ?? null);
|
|
566
|
-
setFocusOccupant(focusId, incoming);
|
|
567
|
-
setPresence(incoming, { pane: pin, tmux_session: pinLoc?.session ?? null, window: pinLoc?.window ?? null });
|
|
568
|
-
if (getRow(outgoing) !== null) {
|
|
569
|
-
if (action.kind === 'backstage') {
|
|
570
|
-
// The outgoing pi kept its pane id (`outgoingPane`), now in the backstage.
|
|
571
|
-
setPresence(outgoing, { pane: outgoingPane, tmux_session: outLoc?.session ?? null, window: outLoc?.window ?? null });
|
|
572
|
-
}
|
|
573
|
-
else {
|
|
574
|
-
// kill | release — the pane is reaped by the caller; null the LOCATION.
|
|
575
|
-
setPresence(outgoing, { pane: null, tmux_session: null, window: null });
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
db.exec('COMMIT');
|
|
579
|
-
}
|
|
580
|
-
catch (e) {
|
|
581
|
-
db.exec('ROLLBACK');
|
|
582
|
-
throw e;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
/** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
|
|
586
|
-
* caller's pane acts on, then retarget `nodeId` onto it.
|
|
587
|
-
* - `newPane` → `openFocus` a fresh viewport beside the caller (F4), then
|
|
588
|
-
* retarget into it.
|
|
589
|
-
* - else → retarget the caller pane's focus IN PLACE (`focusByPane`); if the
|
|
590
|
-
* caller's pane is not yet a viewport, adopt it as one (occupied by whatever
|
|
591
|
-
* node sits there now — `callerNode`, else resolved by pane).
|
|
592
|
-
* - no caller pane (not in tmux) → best-effort: reconcile + report status,
|
|
593
|
-
* not-in-place (no viewport to swap into). */
|
|
594
|
-
export function focus(nodeId, opts) {
|
|
595
|
-
const meta = getNode(nodeId);
|
|
596
|
-
if (meta === null)
|
|
597
|
-
return { focused: false, session: null, inPlace: false, revived: false };
|
|
598
|
-
// ── Phase-4 broker-focus opt-in (headless-broker model) ────────────────────
|
|
599
|
-
// A broker node has NO engine pane: headlessBrokerHost.launch spawns a
|
|
600
|
-
// DETACHED broker that hosts the single engine + binds view.sock, opening no
|
|
601
|
-
// tmux window. So focus cannot swap an engine pane into the caller's viewport
|
|
602
|
-
// — there is none. It instead (a) ensures the broker/engine is ALIVE via the
|
|
603
|
-
// sanctioned reviver, then (b) opens a tmux pane running the VIEWER
|
|
604
|
-
// `crtr attach to <id>`, which connects to view.sock. The viewer NEVER
|
|
605
|
-
// launches the engine (the one-writer invariant) — placement does, here. This
|
|
606
|
-
// is an ISOLATED opt-in BESIDE the engine-in-pane path: it fires ONLY for
|
|
607
|
-
// host_kind==='broker', so the tmux-host placement below stays byte-identical.
|
|
608
|
-
if (meta.host_kind === 'broker')
|
|
609
|
-
return focusBroker(nodeId, meta, opts);
|
|
610
|
-
const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
611
|
-
if (callerPane === undefined || callerPane === '') {
|
|
612
|
-
// Not in tmux — no viewport to swap into. Reconcile and report status.
|
|
613
|
-
reconcile(nodeId);
|
|
614
|
-
return { focused: isNodePaneAlive(nodeId), session: meta.tmux_session ?? null, inPlace: false, revived: false };
|
|
615
|
-
}
|
|
616
|
-
if (opts.newPane === true) {
|
|
617
|
-
const opened = openFocus(callerPane, {});
|
|
618
|
-
if (opened === null)
|
|
619
|
-
return { focused: false, session: null, inPlace: false, revived: false };
|
|
620
|
-
const res = retargetFocus(opened.focus_id, nodeId, opts.revive);
|
|
621
|
-
// Failed to place the incoming node — reap the just-opened HOLDER pane + its
|
|
622
|
-
// focus row so a failed `--new-pane` leaves no orphan viewport (F4 / Invariant
|
|
623
|
-
// P). The success path already reaps the holder via the hot-swap.
|
|
624
|
-
if (!res.focused) {
|
|
625
|
-
if (opened.pane !== null)
|
|
626
|
-
closePane(opened.pane);
|
|
627
|
-
closeFocusRow(opened.focus_id);
|
|
628
|
-
}
|
|
629
|
-
return res;
|
|
630
|
-
}
|
|
631
|
-
let f = focusByPane(callerPane);
|
|
632
|
-
if (f === null)
|
|
633
|
-
f = ensureFocusAtPane(callerPane, opts.callerNode);
|
|
634
|
-
if (f === null) {
|
|
635
|
-
return { focused: false, session: meta.tmux_session ?? null, inPlace: false, revived: false };
|
|
636
|
-
}
|
|
637
|
-
return retargetFocus(f.focus_id, nodeId, opts.revive);
|
|
638
|
-
}
|
|
639
116
|
const BROKER_FOCUS_SOCKET_WAIT_MS = 30_000;
|
|
640
117
|
const BROKER_FOCUS_SOCKET_RETRY_MS = 100;
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
* yet, because only an accepting listener exits 0. */
|
|
647
|
-
export function waitForBrokerViewSocket(nodeId) {
|
|
648
|
-
const sockPath = join(nodeDir(nodeId), 'view.sock');
|
|
649
|
-
const probe = `
|
|
118
|
+
// The probe runs inside a worker thread (see waitForBrokerViewSocket). Async net
|
|
119
|
+
// events drive the same poll loop; the worker reports its verdict into a shared
|
|
120
|
+
// Int32 (0 = pending, 1 = accepted, 2 = gave up) and Atomics.notify wakes the
|
|
121
|
+
// blocked main thread. eval:true ⇒ CommonJS, so `require` and workerData apply.
|
|
122
|
+
const VIEW_SOCKET_PROBE_WORKER = `
|
|
650
123
|
const net = require('node:net');
|
|
651
|
-
const
|
|
652
|
-
const
|
|
653
|
-
const
|
|
124
|
+
const { workerData, parentPort } = require('node:worker_threads');
|
|
125
|
+
const { buffer, sockPath, waitMs, retryMs } = workerData;
|
|
126
|
+
const result = new Int32Array(buffer);
|
|
127
|
+
const deadline = Date.now() + waitMs;
|
|
128
|
+
const report = (code) => { Atomics.store(result, 0, code); Atomics.notify(result, 0); };
|
|
654
129
|
function attempt() {
|
|
655
130
|
let socket;
|
|
656
131
|
let settled = false;
|
|
@@ -658,9 +133,9 @@ function attempt() {
|
|
|
658
133
|
if (settled) return;
|
|
659
134
|
settled = true;
|
|
660
135
|
if (socket !== undefined) socket.destroy();
|
|
661
|
-
if (ok)
|
|
662
|
-
if (Date.now() >= deadline)
|
|
663
|
-
setTimeout(attempt,
|
|
136
|
+
if (ok) { report(1); return; }
|
|
137
|
+
if (Date.now() >= deadline) { report(2); return; }
|
|
138
|
+
setTimeout(attempt, retryMs);
|
|
664
139
|
};
|
|
665
140
|
try {
|
|
666
141
|
socket = net.createConnection(sockPath);
|
|
@@ -670,268 +145,226 @@ function attempt() {
|
|
|
670
145
|
}
|
|
671
146
|
socket.once('connect', () => finish(true));
|
|
672
147
|
socket.once('error', () => finish(false));
|
|
673
|
-
socket.setTimeout(
|
|
148
|
+
socket.setTimeout(retryMs, () => finish(false));
|
|
674
149
|
}
|
|
675
150
|
attempt();
|
|
151
|
+
void parentPort;
|
|
676
152
|
`;
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
153
|
+
/** Synchronously wait until a broker's view.sock accepts a connection. The spawn
|
|
154
|
+
* and focus flows are sync (the command layer calls them directly), so the async
|
|
155
|
+
* net poll runs in a worker THREAD while this thread blocks on `Atomics.wait` —
|
|
156
|
+
* in-process, no child Node cold-start. Success proves more than file existence:
|
|
157
|
+
* it is robust to a stale leftover socket the launching broker has not unlinked
|
|
158
|
+
* yet, because only an accepting listener yields a `connect`. */
|
|
159
|
+
export function waitForBrokerViewSocket(nodeId) {
|
|
160
|
+
const sockPath = join(nodeDir(nodeId), 'view.sock');
|
|
161
|
+
const buffer = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
|
|
162
|
+
const result = new Int32Array(buffer);
|
|
163
|
+
const worker = new Worker(VIEW_SOCKET_PROBE_WORKER, {
|
|
164
|
+
eval: true,
|
|
165
|
+
workerData: { buffer, sockPath, waitMs: BROKER_FOCUS_SOCKET_WAIT_MS, retryMs: BROKER_FOCUS_SOCKET_RETRY_MS },
|
|
683
166
|
});
|
|
684
|
-
|
|
167
|
+
worker.unref();
|
|
168
|
+
// Block until the worker reports (or a hard timeout matching the old
|
|
169
|
+
// spawnSync). 'not-equal' covers the worker finishing before we wait.
|
|
170
|
+
Atomics.wait(result, 0, 0, BROKER_FOCUS_SOCKET_WAIT_MS + 1_000);
|
|
171
|
+
void worker.terminate();
|
|
172
|
+
return Atomics.load(result, 0) === 1;
|
|
173
|
+
}
|
|
174
|
+
function newFocusId() {
|
|
175
|
+
return `f-${newNodeId()}`;
|
|
176
|
+
}
|
|
177
|
+
/** Env for a `crtr attach` VIEWER pane (the focus split, the spawn viewer
|
|
178
|
+
* window, AND recycle's re-attach respawn): propagate CRTR_HOME so the viewer
|
|
179
|
+
* resolves the SAME canvas home — and thus the right view.sock — under a
|
|
180
|
+
* non-default override; otherwise an empty env (the pane inherits the tmux
|
|
181
|
+
* server env, like every other crtr chrome pane). One helper so the viewer-pane
|
|
182
|
+
* sites can't drift. */
|
|
183
|
+
export function viewerSplitEnv() {
|
|
184
|
+
const crtrHome = process.env['CRTR_HOME'];
|
|
185
|
+
return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
|
|
186
|
+
}
|
|
187
|
+
/** Register a `crtr attach` VIEWER pane as the node's one viewer focus row.
|
|
188
|
+
* UNIQUE(node_id) ⇒ one viewer per node: returns
|
|
189
|
+
* the existing row (no insert) when the pane or the node is already registered.
|
|
190
|
+
* `window` is accepted for the call contract (spawn passes it) but not stored —
|
|
191
|
+
* the focuses table keys on pane + session only. The attach client self-tags the
|
|
192
|
+
* pane `@crtr_node` on connect, so `nodeInPane` resolves it either way. */
|
|
193
|
+
export function registerViewerFocus(nodeId, pane, session, window) {
|
|
194
|
+
void window;
|
|
195
|
+
const byPane = getFocusByPane(pane);
|
|
196
|
+
if (byPane !== null)
|
|
197
|
+
return byPane;
|
|
198
|
+
const byNode = getFocusByNode(nodeId);
|
|
199
|
+
if (byNode !== null)
|
|
200
|
+
return byNode;
|
|
201
|
+
const focusId = newFocusId();
|
|
202
|
+
openFocusRow(focusId, pane, session, nodeId);
|
|
203
|
+
return getFocusById(focusId);
|
|
204
|
+
}
|
|
205
|
+
/** Open a `crtr attach` VIEWER for `nodeId` and register its viewer focus row —
|
|
206
|
+
* the shared opener used by `focus` (a split beside the caller) and by a --root
|
|
207
|
+
* spawn (a fresh window in the caller's current session). Wraps `ensureSession`
|
|
208
|
+
* + `openNodeWindow`/`splitWindow` + `registerViewerFocus`. The attach client
|
|
209
|
+
* connects to the node's `view.sock` and renders/drives the live broker engine
|
|
210
|
+
* — it NEVER launches the engine (the one-writer invariant; the caller ensures
|
|
211
|
+
* the broker is alive first). Returns the registered viewer row, or null if tmux
|
|
212
|
+
* refused.
|
|
213
|
+
* - default → a fresh WINDOW in `session` (the --root spawn-viewer path).
|
|
214
|
+
* - `besidePane` → a SPLIT beside that pane in its own session (the focus
|
|
215
|
+
* open-beside-the-caller path); `session` is then only a fallback. */
|
|
216
|
+
export function openViewerWindow(nodeId, session, opts = {}) {
|
|
217
|
+
const command = `crtr attach to ${nodeId}`;
|
|
218
|
+
const env = viewerSplitEnv();
|
|
219
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
220
|
+
let pane;
|
|
221
|
+
let sess;
|
|
222
|
+
let window;
|
|
223
|
+
if (opts.besidePane !== undefined) {
|
|
224
|
+
pane = splitWindow(opts.besidePane, { cwd, env, command });
|
|
225
|
+
if (pane === null)
|
|
226
|
+
return null;
|
|
227
|
+
const loc = paneLocation(pane);
|
|
228
|
+
sess = loc?.session ?? session;
|
|
229
|
+
window = loc?.window ?? null;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
ensureSession(session, cwd);
|
|
233
|
+
const opened = openNodeWindow({ session, name: opts.name ?? nodeId, cwd, env, command });
|
|
234
|
+
if (opened === null)
|
|
235
|
+
return null;
|
|
236
|
+
pane = opened.pane;
|
|
237
|
+
window = opened.window;
|
|
238
|
+
sess = session;
|
|
239
|
+
}
|
|
240
|
+
return registerViewerFocus(nodeId, pane, sess, window);
|
|
685
241
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// focus — the front door for `node focus` / `node cycle` / nav (§A.4)
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
/** Bring a node's ONE viewer pane forefront (§A.4). Every node is a detached
|
|
246
|
+
* broker, so there is no engine pane to swap into the caller's viewport — focus
|
|
247
|
+
* instead ensures the node's single `crtr attach` viewer is on screen beside (or
|
|
248
|
+
* navigated to) the caller:
|
|
249
|
+
* (a) ensure the broker ENGINE is alive (idempotent injected reviver) and its
|
|
250
|
+
* view.sock is ACCEPTING (closes the cold-start race against attach's
|
|
251
|
+
* single connect) — without this the freshly-opened viewer would exit
|
|
252
|
+
* "no broker".
|
|
253
|
+
* (b) `--new-pane` → always a fresh viewer beside the caller (e.g. two
|
|
254
|
+
* different nodes side by side); drop any prior viewer first (UNIQUE).
|
|
255
|
+
* (c) the node already has a live viewer:
|
|
256
|
+
* - in the caller's session ⇒ navigate to it (switchClient+selectWindow),
|
|
257
|
+
* no new pane (inPlace).
|
|
258
|
+
* - elsewhere (e.g. a --root's viewer in another session) ⇒ MOVE it: close the old
|
|
259
|
+
* viewer pane (the broker runs on; the fresh viewer replays full
|
|
260
|
+
* scrollback from the broker `welcome` snapshot), drop its row, reopen
|
|
261
|
+
* beside the caller.
|
|
262
|
+
* (d) no live viewer ⇒ open one beside the caller and register the row.
|
|
699
263
|
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
* step (a) goes through the injected `revive`, step (b) through the existing
|
|
707
|
-
* splitWindow driver verb. `--new-pane` is moot — a broker focus is ALWAYS a
|
|
708
|
-
* fresh viewer pane — so the flag is ignored. */
|
|
709
|
-
function focusBroker(nodeId, meta, opts) {
|
|
264
|
+
* crtr is tmux-only: with no caller pane there is no viewport to open and no
|
|
265
|
+
* non-tmux fallback — report not-focused. */
|
|
266
|
+
export function focus(nodeId, opts) {
|
|
267
|
+
const meta = getNode(nodeId);
|
|
268
|
+
if (meta === null)
|
|
269
|
+
return { focused: false, session: null, inPlace: false, revived: false };
|
|
710
270
|
const callerPane = opts.pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
711
271
|
if (callerPane === undefined || callerPane === '') {
|
|
712
|
-
// crtr is tmux-only: no viewport to open and no non-tmux fallback.
|
|
713
272
|
return { focused: false, session: null, inPlace: false, revived: false };
|
|
714
273
|
}
|
|
715
|
-
// (a) Ensure the broker is alive
|
|
716
|
-
//
|
|
717
|
-
const wasAlive = isPidAlive(
|
|
274
|
+
// (a) Ensure the broker engine is alive + its view.sock accepts. Capture prior
|
|
275
|
+
// liveness so the result reports whether THIS focus had to launch it.
|
|
276
|
+
const wasAlive = isPidAlive(meta.pi_pid ?? null);
|
|
718
277
|
opts.revive(nodeId);
|
|
719
278
|
const revived = !wasAlive;
|
|
720
|
-
// Close the cold-start race: a freshly launched broker records pid during
|
|
721
|
-
// extension bind, then opens view.sock later; attach connects once and exits
|
|
722
|
-
// "no broker" if we split before listen(). Probe for an ACCEPTING socket (not
|
|
723
|
-
// mere path existence) before opening the viewer pane.
|
|
724
279
|
if (!waitForBrokerViewSocket(nodeId)) {
|
|
725
280
|
return { focused: false, session: null, inPlace: false, revived };
|
|
726
281
|
}
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
282
|
+
// focus always opens BESIDE the caller pane, so `fallbackSession` is only the
|
|
283
|
+
// session name openViewerWindow falls back to if it cannot read the freshly
|
|
284
|
+
// split pane's location — the caller's own session.
|
|
285
|
+
const callerSession = paneLocation(callerPane)?.session ?? null;
|
|
286
|
+
const fallbackSession = callerSession ?? '';
|
|
287
|
+
// (b) --new-pane → always a fresh viewer beside the caller. Drop any prior
|
|
288
|
+
// viewer this node holds elsewhere (UNIQUE(node_id): one viewer per node).
|
|
289
|
+
if (opts.newPane === true) {
|
|
290
|
+
const prior = focusOf(nodeId);
|
|
291
|
+
if (prior !== null) {
|
|
292
|
+
if (prior.pane !== null && paneExists(prior.pane))
|
|
293
|
+
closePane(prior.pane);
|
|
294
|
+
closeFocusRow(prior.focus_id);
|
|
295
|
+
}
|
|
296
|
+
const opened = openViewerWindow(nodeId, fallbackSession, { cwd: meta.cwd, besidePane: callerPane });
|
|
297
|
+
if (opened === null)
|
|
298
|
+
return { focused: false, session: null, inPlace: false, revived };
|
|
299
|
+
return { focused: true, session: opened.session, inPlace: false, revived };
|
|
300
|
+
}
|
|
301
|
+
// (c) Reuse / move the node's one live viewer — but ONLY a pane that still
|
|
302
|
+
// carries THIS node's `@crtr_node` tag. `paneExists` is not enough: the
|
|
303
|
+
// front-door root registers the user's own terminal as its viewer pane and
|
|
304
|
+
// runs `crtr attach` inline; on a clean detach the pane returns to the
|
|
305
|
+
// user's shell (attach clears the tag) yet the pane — and the row — survive,
|
|
306
|
+
// and a reused pane carries some OTHER node's tag. Navigating to / closing
|
|
307
|
+
// such a pane would strand the user on (or kill) their shell. So verify the
|
|
308
|
+
// tag names this node; on mismatch the row is stale → prune it and fall
|
|
309
|
+
// through to open a fresh viewer beside the caller.
|
|
310
|
+
const existing = focusOf(nodeId);
|
|
311
|
+
if (existing !== null && existing.pane !== null) {
|
|
312
|
+
if (getPaneOption(existing.pane, '@crtr_node') === nodeId) {
|
|
313
|
+
const loc = paneLocation(existing.pane);
|
|
314
|
+
if (loc !== null && loc.session === callerSession) {
|
|
315
|
+
// Viewer is in the caller's session — just navigate to it.
|
|
316
|
+
switchClient(loc.session);
|
|
317
|
+
selectWindow(loc.session, loc.window);
|
|
318
|
+
return { focused: true, session: loc.session, inPlace: true, revived };
|
|
319
|
+
}
|
|
320
|
+
// Viewer lives elsewhere — MOVE it: close the old pane (broker runs on; the
|
|
321
|
+
// fresh viewer replays scrollback from the welcome snapshot), drop the row,
|
|
322
|
+
// then fall through to open one beside the caller.
|
|
323
|
+
closePane(existing.pane);
|
|
324
|
+
closeFocusRow(existing.focus_id);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// Pane survived but no longer hosts this node's viewer (clean detach left
|
|
328
|
+
// the user's shell, or the pane was reused) — drop the stale row only; do
|
|
329
|
+
// NOT closePane (it is the user's shell / another node's viewer).
|
|
330
|
+
closeFocusRow(existing.focus_id);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// (d) No live viewer → open one beside the caller and register the row.
|
|
334
|
+
const opened = openViewerWindow(nodeId, fallbackSession, { cwd: meta.cwd, besidePane: callerPane });
|
|
335
|
+
if (opened === null)
|
|
730
336
|
return { focused: false, session: null, inPlace: false, revived };
|
|
731
|
-
return { focused: true, session:
|
|
732
|
-
}
|
|
733
|
-
/** Env for a `crtr attach` VIEWER pane (the focusBroker split AND recycle's
|
|
734
|
-
* re-attach respawn): propagate CRTR_HOME so the viewer resolves the SAME canvas
|
|
735
|
-
* home — and thus the right view.sock — under a non-default override; otherwise
|
|
736
|
-
* an empty env (the pane inherits the tmux server env, like every other crtr
|
|
737
|
-
* chrome pane). One helper so the two viewer-pane sites can't drift. */
|
|
738
|
-
export function viewerSplitEnv() {
|
|
739
|
-
const crtrHome = process.env['CRTR_HOME'];
|
|
740
|
-
return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
|
|
741
|
-
}
|
|
742
|
-
/** Register the caller's CURRENT pane as a focus so a `node focus`/`cycle` from a
|
|
743
|
-
* pane that isn't yet a viewport retargets IN PLACE. Occupied by whatever node
|
|
744
|
-
* sits in the pane now (`callerNode`, else resolved by pane→row), or a HOLDER
|
|
745
|
-
* when none is resolvable / it is already focused elsewhere (UNIQUE node_id). */
|
|
746
|
-
function ensureFocusAtPane(pane, callerNode) {
|
|
747
|
-
const existing = getFocusByPane(pane);
|
|
748
|
-
if (existing !== null)
|
|
749
|
-
return existing;
|
|
750
|
-
const loc = paneLocation(pane);
|
|
751
|
-
const focusId = newFocusId();
|
|
752
|
-
const resolved = callerNode ?? getRowByPane(pane)?.node_id;
|
|
753
|
-
const occupant = resolved !== undefined && resolved !== '' && getFocusByNode(resolved) === null
|
|
754
|
-
? resolved
|
|
755
|
-
: holderId(focusId);
|
|
756
|
-
openFocusRow(focusId, pane, loc?.session ?? null, occupant);
|
|
757
|
-
armRemainOnExit(loc?.window);
|
|
758
|
-
return getFocusById(focusId);
|
|
337
|
+
return { focused: true, session: opened.session, inPlace: false, revived };
|
|
759
338
|
}
|
|
760
339
|
// ---------------------------------------------------------------------------
|
|
761
|
-
// Teardown /
|
|
762
|
-
// close/demote/reset entry points + the truly-done focus successor.
|
|
340
|
+
// Teardown / detach — close a node's viewer pane + row
|
|
763
341
|
// ---------------------------------------------------------------------------
|
|
764
|
-
/** Tear a node off its
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
* DB writes always land. The pane kill is the sole teardown unit (Q1/§6: a
|
|
770
|
-
* split-pane focus returns its space to the surviving split; a standalone-window
|
|
771
|
-
* focus closes the window). */
|
|
342
|
+
/** Tear a node off its viewer (close/reset/cancel teardown). The broker engine
|
|
343
|
+
* is killed by its own host teardown over view.sock — this only closes the
|
|
344
|
+
* on-screen viewer: kill its pane (the window collapses once its last pane goes)
|
|
345
|
+
* and drop its registry row. Best-effort tmux; the DB write always lands. No-op
|
|
346
|
+
* when the node has no viewer. */
|
|
772
347
|
export function tearDownNode(nodeId) {
|
|
773
|
-
reconcile(nodeId);
|
|
774
348
|
const f = focusOf(nodeId);
|
|
349
|
+
if (f === null)
|
|
350
|
+
return; // no viewer (focusOf already GC'd a gone one)
|
|
351
|
+
if (f.pane !== null && paneExists(f.pane))
|
|
352
|
+
closePane(f.pane);
|
|
353
|
+
closeFocusRow(f.focus_id);
|
|
354
|
+
}
|
|
355
|
+
/** Detach a node from the foreground (the `node lifecycle --detach` / Alt+C → D
|
|
356
|
+
* half). Every node is a headless broker — the only thing on screen is its
|
|
357
|
+
* `crtr attach` VIEWER pane — so "detach" means STOP FOREGROUNDING it: close the
|
|
358
|
+
* viewer pane (and drop its row) and leave the broker engine running untouched,
|
|
359
|
+
* reconnectable by a later `focus`. `pane` is the authoritative pane the caller
|
|
360
|
+
* acts on (the Alt+C menu's `#{pane_id}`); falls back to the node's registered
|
|
361
|
+
* viewer pane. No-op (false) when there is no live viewer pane to close. */
|
|
362
|
+
export function detachToBackground(nodeId, pane) {
|
|
363
|
+
const viewer = pane ?? focusOf(nodeId)?.pane ?? null;
|
|
364
|
+
if (viewer === null || !paneExists(viewer))
|
|
365
|
+
return false;
|
|
366
|
+
const f = focusByPane(viewer);
|
|
775
367
|
if (f !== null)
|
|
776
368
|
closeFocusRow(f.focus_id);
|
|
777
|
-
|
|
778
|
-
const pane = row?.pane ?? f?.pane ?? null;
|
|
779
|
-
if (pane !== null && paneExists(pane))
|
|
780
|
-
closePane(pane);
|
|
781
|
-
setPresence(nodeId, { pane: null, tmux_session: null, window: null });
|
|
782
|
-
}
|
|
783
|
-
/** Demote's in-pane relaunch (§2.3, flow (e)): respawn `nodeId`'s launch into an
|
|
784
|
-
* EXISTING `pane`, keeping the durable `%id` (respawn-pane -k), and record its
|
|
785
|
-
* presence keyed on that pane. The session/window are DERIVED from the pane
|
|
786
|
-
* itself (paneLocation), so the recycled node's LOCATION follows the pane it was
|
|
787
|
-
* recycled into. `launch.env` is passed through verbatim — the caller (demote)
|
|
788
|
-
* already sets CRTR_ROOT_SESSION (children → backstage) + FRONT_DOOR. Detached
|
|
789
|
-
* respawn, since the pane is often the caller's own. Returns whether the respawn
|
|
790
|
-
* dispatched. */
|
|
791
|
-
export function recycleFocusPane(nodeId, pane, launch) {
|
|
792
|
-
reconcile(nodeId);
|
|
793
|
-
const loc = paneLocation(pane);
|
|
794
|
-
const session = loc?.session ?? homeSessionOf(nodeId);
|
|
795
|
-
const ok = respawnPaneDetached({ pane, cwd: launch.cwd, env: launch.env, command: launch.command });
|
|
796
|
-
if (ok)
|
|
797
|
-
setPresence(nodeId, { pane, tmux_session: session, window: loc?.window ?? windowOfPane(pane) });
|
|
798
|
-
return ok;
|
|
799
|
-
}
|
|
800
|
-
/** §1.6 lifecycle successor — hand a truly-done focused node's viewport to its
|
|
801
|
-
* manager. Repoints the focus row `focusId` to `managerId` (a DB swap of the
|
|
802
|
-
* occupant). Two takeover realizations, split on the manager's liveness:
|
|
803
|
-
* - DORMANT manager (dead pi) — ONLY when it is idle + idle-release: the row
|
|
804
|
-
* repoint is all this does; the manager, woken by the finished node's `push
|
|
805
|
-
* final` landing in its inbox, is revived by the external daemon INTO this
|
|
806
|
-
* node's now-frozen focus pane (remain-on-exit), where reviveIntoPlacement's
|
|
807
|
-
* focus-pane branch resumes it in place — no new window, no taint. A dormant
|
|
808
|
-
* manager that is NOT idle-release (done/dead/canceled, or idle with another
|
|
809
|
-
* intent) is one the daemon will NEVER revive, so this returns false WITHOUT
|
|
810
|
-
* repointing — the caller then disarms the freeze (see below).
|
|
811
|
-
* - RUNNING backstage pane (a live pi — the normal multi-child state — OR a
|
|
812
|
-
* pi still BOOTING because the daemon already revived the manager off the
|
|
813
|
-
* `push final` before this handoff ran): the daemon never (re)revives it,
|
|
814
|
-
* so we must bring it into the viewport SYNCHRONOUSLY here — swap its
|
|
815
|
-
* backstage pane into the focus slot (MAJOR 1). Otherwise the manager runs
|
|
816
|
-
* off-screen forever while %m sits orphaned in the viewport and the focus
|
|
817
|
-
* row lies about LOCATION.
|
|
818
|
-
* Returns false — the caller closes the focus (Q1: closeFocusToShell disarms
|
|
819
|
-
* remain-on-exit so the pane REAPS on exit) — whenever NO successor will claim
|
|
820
|
-
* the frozen pane: no manager, the manager IS this node, the manager already
|
|
821
|
-
* occupies another viewport (UNIQUE node_id: do NOT move it, §1.6 edge), the
|
|
822
|
-
* manager row is missing, or it is a dormant manager the daemon won't revive
|
|
823
|
-
* (not idle-release) / a live-but-paneless inline root. It does NOT repoint the
|
|
824
|
-
* occupant on any false path — repointing while returning false would strand the
|
|
825
|
-
* pane frozen forever with no reaper (the dead-focus-pane bug).
|
|
826
|
-
*
|
|
827
|
-
* Why the live swap is NOT the forbidden self-saw: `swap-pane -d` only EXCHANGES
|
|
828
|
-
* two panes' slot positions; it never respawns or kills the finishing node's own
|
|
829
|
-
* pi. The forbidden move is a synchronous `respawn-pane -k %m` from inside %m —
|
|
830
|
-
* we never do that here. After the swap, %m (the dying node's pane) sits in the
|
|
831
|
-
* manager's old backstage slot; the caller nulls this node's presence so nothing
|
|
832
|
-
* tracks the corpse. */
|
|
833
|
-
export function handFocusToManager(focusId, managerId) {
|
|
834
|
-
if (managerId === null)
|
|
835
|
-
return false;
|
|
836
|
-
const f = getFocusById(focusId);
|
|
837
|
-
if (f === null || managerId === f.node_id)
|
|
838
|
-
return false;
|
|
839
|
-
if (getFocusByNode(managerId) !== null)
|
|
840
|
-
return false; // manager already focused elsewhere
|
|
841
|
-
const mgr = getRow(managerId);
|
|
842
|
-
if (mgr === null)
|
|
843
|
-
return false; // no row to hand to
|
|
844
|
-
// MAJOR 1 — manager with a RUNNING backstage pane → swap it into the focus
|
|
845
|
-
// slot NOW. The daemon never revives a live node (it only respawns dead-pi
|
|
846
|
-
// nodes), so this synchronous swap is the ONLY way it claims the frozen %m.
|
|
847
|
-
// Commit the occupant repoint only here, on a path that genuinely takes the
|
|
848
|
-
// pane. The gate is the PANE (`paneRunning`: exists + command running, i.e.
|
|
849
|
-
// not a remain-on-exit corpse), NOT `isPidAlive(mgr.pi_pid)` — the recorded
|
|
850
|
-
// pid is a STALE proxy in the lost-pane race: the finishing child's `push
|
|
851
|
-
// final` seeds the manager's inbox mid-turn, the daemon's second pass revives
|
|
852
|
-
// the still-unfocused manager BACKSTAGE before this agent_end runs, and the
|
|
853
|
-
// fresh pi records its pid only at session_start, seconds into boot. Gating on
|
|
854
|
-
// the old dead pid made this branch miss the booting manager, the dormant
|
|
855
|
-
// branch miss it too (revive already flipped it active), and the caller reap
|
|
856
|
-
// the user's viewport while the manager ran on invisibly backstage. A booting
|
|
857
|
-
// pane is as swappable as a live one — swap it in; only a frozen corpse
|
|
858
|
-
// (pane_dead=1) is excluded, preserving the dead-focus-pane guarantees.
|
|
859
|
-
if (mgr.pane != null && paneRunning(mgr.pane) && f.pane != null) {
|
|
860
|
-
setFocusOccupant(focusId, managerId);
|
|
861
|
-
const focusLoc = paneLocation(f.pane); // F2's window/session — the slot mgr swaps INTO (%m is currently there)
|
|
862
|
-
if (swapPaneInPlace(mgr.pane, f.pane) && focusLoc !== null) {
|
|
863
|
-
setFocusPane(f.focus_id, mgr.pane, focusLoc.session); // re-anchor the focus row to mgr's pane (now in F2)
|
|
864
|
-
setPresence(managerId, { pane: mgr.pane, tmux_session: focusLoc.session, window: focusLoc.window });
|
|
865
|
-
}
|
|
866
|
-
return true; // took focus (live swap) — caller doesn't disarm
|
|
867
|
-
}
|
|
868
|
-
// DORMANT manager → it can only enter the frozen %m via crtrd.superviseTick's
|
|
869
|
-
// SECOND PASS, which revives a node ONLY when status==='idle' && intent===
|
|
870
|
-
// 'idle-release' (AND pi dead + unseen inbox). We mirror just status+intent: the
|
|
871
|
-
// other two are guaranteed by lifecycle invariants — every transition(_,
|
|
872
|
-
// 'release') site kills pi immediately, so idle-release ⟹ pi-dead; and the
|
|
873
|
-
// `push --final` that drove us into this done-branch just seeded the manager's
|
|
874
|
-
// inbox. (If a future idle-release path ever leaves pi LIVE, revisit this.)
|
|
875
|
-
// Mirror that predicate EXACTLY: a done/dead/
|
|
876
|
-
// canceled, an idle-but-not-idle-release, or a live-but-paneless (inline root)
|
|
877
|
-
// manager will NEVER be brought into this pane. Repointing for one of those
|
|
878
|
-
// makes the caller SKIP closeFocusToShell ⇒ remain-on-exit stays ON ⇒ the pane
|
|
879
|
-
// freezes forever with no reaper (the dead-focus-pane bug). So ONLY repoint +
|
|
880
|
-
// return true when the daemon will genuinely revive it; otherwise return false
|
|
881
|
-
// WITHOUT repointing so the caller disarms the freeze and the pane reaps on exit
|
|
882
|
-
// (§1.6 flow (b), close-out-completely).
|
|
883
|
-
const daemonWillRevive = mgr.status === 'idle' && mgr.intent === 'idle-release';
|
|
884
|
-
if (!daemonWillRevive)
|
|
885
|
-
return false;
|
|
886
|
-
setFocusOccupant(focusId, managerId); // repoint only; the daemon revives mgr INTO the frozen %m
|
|
887
|
-
return true;
|
|
888
|
-
}
|
|
889
|
-
/** Q1 close-to-shell for a truly-done focused node with no successor (§1.6 /
|
|
890
|
-
* flow (b)): close its focus row and DISARM the pane's
|
|
891
|
-
* freeze (`remain-on-exit` off) so it reaps when the finishing pi exits. The
|
|
892
|
-
* stophook calls this instead of `tearDownNode` because it runs INSIDE the pane
|
|
893
|
-
* it is closing: it cannot `closePane` its own pane (self-saw), but it is still
|
|
894
|
-
* alive to disarm the freeze, so the pane closes on exit (return-to-shell)
|
|
895
|
-
* rather than freezing into an orphan. (Keeps the stophook off the tmux driver,
|
|
896
|
-
* §2.1.) */
|
|
897
|
-
export function closeFocusToShell(focusId, nodeId) {
|
|
898
|
-
closeFocusRow(focusId);
|
|
899
|
-
const win = getNode(nodeId)?.window;
|
|
900
|
-
if (win != null && win !== '')
|
|
901
|
-
setRemainOnExit(win, false);
|
|
902
|
-
}
|
|
903
|
-
/** Join each of `childIds`' live panes into `targetId`'s window, lay them out
|
|
904
|
-
* (target wide on the left, children stacked right), and focus it. Reconcile
|
|
905
|
-
* drives both the target resolution and the per-join fix-up (a joined pane keeps
|
|
906
|
-
* its `%id` but changes window, so its LOCATION must FOLLOW — else the daemon
|
|
907
|
-
* reads it dormant). Caller revives dormant nodes first so they have live panes.
|
|
908
|
-
* No-op result when the target has no live pane. */
|
|
909
|
-
export function spreadNode(targetId, childIds, opts = {}) {
|
|
910
|
-
reconcile(targetId);
|
|
911
|
-
const trow = getRow(targetId);
|
|
912
|
-
if (trow === null || trow.pane === null || !paneExists(trow.pane)) {
|
|
913
|
-
return { window: null, session: null, joined: [], focused: false };
|
|
914
|
-
}
|
|
915
|
-
const tloc = paneLocation(trow.pane);
|
|
916
|
-
if (tloc === null)
|
|
917
|
-
return { window: null, session: null, joined: [], focused: false };
|
|
918
|
-
const { window: targetWindow, session: targetSession } = tloc;
|
|
919
|
-
const targetPane = trow.pane;
|
|
920
|
-
const joined = [];
|
|
921
|
-
for (const cid of childIds) {
|
|
922
|
-
reconcile(cid);
|
|
923
|
-
const crow = getRow(cid);
|
|
924
|
-
if (crow === null || crow.pane === null || !paneExists(crow.pane) || crow.pane === targetPane)
|
|
925
|
-
continue;
|
|
926
|
-
if (!joinPane(crow.pane, targetWindow))
|
|
927
|
-
continue;
|
|
928
|
-
reconcile(cid); // fix-up: presence FOLLOWS the joined pane to the target window
|
|
929
|
-
joined.push(cid);
|
|
930
|
-
}
|
|
931
|
-
if (joined.length > 0) {
|
|
932
|
-
setWindowOption(targetWindow, 'main-pane-width', opts.mainPaneWidth ?? '60%');
|
|
933
|
-
selectLayout(targetWindow, 'main-vertical');
|
|
934
|
-
}
|
|
935
|
-
const focused = switchClient(targetSession) && selectWindow(targetSession, targetWindow);
|
|
936
|
-
return { window: targetWindow, session: targetSession, joined, focused };
|
|
369
|
+
return closePane(viewer);
|
|
937
370
|
}
|