@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
|
@@ -20,11 +20,22 @@
|
|
|
20
20
|
// tears the broker down, and boots a fresh BROKER root the pane re-attaches
|
|
21
21
|
// to. LOCKED: the recycled node is finalized (done) and the fresh root is
|
|
22
22
|
// broker-hosted, not tmux.
|
|
23
|
+
//
|
|
24
|
+
// (3) Gap 3 (tests-lens MAJOR-1) — the §A.4 viewer-DEDUP, the headline new
|
|
25
|
+
// behavior of the broker-universal cut, had ZERO end-to-end coverage: a
|
|
26
|
+
// SECOND `node focus` on a node must REUSE/move its one viewer, never STACK a
|
|
27
|
+
// second. Only the DB UNIQUE(node_id)/GC layer was tested; `focus()` itself
|
|
28
|
+
// (placement.ts) was unexercised. LOCKED here over a REAL broker viewer: two
|
|
29
|
+
// `node focus` calls leave EXACTLY ONE viewer pane + ONE focus row for the
|
|
30
|
+
// node, the second reused IN PLACE (in_place=true). A focus() that always
|
|
31
|
+
// opened a fresh pane would yield two tagged panes (or crash on the UNIQUE
|
|
32
|
+
// constraint) — either way this goes RED.
|
|
23
33
|
import { test, before, after } from 'node:test';
|
|
24
34
|
import assert from 'node:assert/strict';
|
|
25
35
|
import { spawnSync } from 'node:child_process';
|
|
26
36
|
import { createHarness, hasTmux } from '../helpers/harness.js';
|
|
27
37
|
import { closeDb } from '../../canvas/db.js';
|
|
38
|
+
import { getFocusByNode, listFocuses } from '../../canvas/focuses.js';
|
|
28
39
|
import { nodeInPane } from '../../../commands/node.js';
|
|
29
40
|
import { recycleNode } from '../../runtime/recycle.js';
|
|
30
41
|
let h;
|
|
@@ -47,6 +58,43 @@ function makePane() {
|
|
|
47
58
|
function tagPane(pane, value) {
|
|
48
59
|
spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], { stdio: 'ignore' });
|
|
49
60
|
}
|
|
61
|
+
/** Parse `crtr`'s human-list stdout (`- key: value` lines) into a map. */
|
|
62
|
+
function parseList(stdout) {
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const line of stdout.split('\n')) {
|
|
65
|
+
const m = /^- ([^:]+):\s*(.*)$/.exec(line.trim());
|
|
66
|
+
if (m)
|
|
67
|
+
out[m[1].trim()] = m[2].trim();
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** Read a pane's `@crtr_node` option ('' when unset / pane gone). */
|
|
72
|
+
function paneTag(pane) {
|
|
73
|
+
const r = spawnSync('tmux', ['show-options', '-p', '-v', '-t', pane, '@crtr_node'], { encoding: 'utf8' });
|
|
74
|
+
return (r.stdout ?? '').trim();
|
|
75
|
+
}
|
|
76
|
+
/** Every pane in the harness session whose `@crtr_node` tag names `id` — the live
|
|
77
|
+
* on-screen viewer panes for that node. A correct dedup keeps this at exactly 1. */
|
|
78
|
+
function taggedViewerPanes(session, id) {
|
|
79
|
+
const r = spawnSync('tmux', ['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{@crtr_node}'], { encoding: 'utf8' });
|
|
80
|
+
return (r.stdout ?? '')
|
|
81
|
+
.split('\n')
|
|
82
|
+
.map((l) => l.trim())
|
|
83
|
+
.filter((l) => l.endsWith(` ${id}`))
|
|
84
|
+
.map((l) => l.split(' ')[0]);
|
|
85
|
+
}
|
|
86
|
+
/** Wait until the attach client running in `pane` has self-tagged it `@crtr_node=id`
|
|
87
|
+
* (the tag is set on CONNECT, not synchronously by openViewerWindow — the second
|
|
88
|
+
* focus's reuse branch keys on it, so the test must let attach connect first). */
|
|
89
|
+
async function waitForTag(pane, id, timeoutMs = 15_000) {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
if (paneTag(pane) === id)
|
|
93
|
+
return;
|
|
94
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`viewer pane ${pane} never tagged @crtr_node=${id} within ${timeoutMs}ms`);
|
|
97
|
+
}
|
|
50
98
|
test('Gap 1 — nodeInPane resolves a broker node from its tagged viewer pane (undefined when untagged)', async (t) => {
|
|
51
99
|
if (!hasTmux())
|
|
52
100
|
return t.skip('tmux unavailable');
|
|
@@ -82,3 +130,45 @@ test('Gap 2 — recycle preserves the broker host: finalizes the node, boots a f
|
|
|
82
130
|
assert.equal(fresh.host_kind, 'broker', 'fresh root PRESERVES the broker host (not a tmux pi root)');
|
|
83
131
|
assert.equal(fresh.parent ?? null, null, 'fresh root is a root (parent=null)');
|
|
84
132
|
});
|
|
133
|
+
test('Gap 3 — a second `node focus` REUSES the one viewer (in place), never stacks a second pane/row', async (t) => {
|
|
134
|
+
if (!hasTmux())
|
|
135
|
+
return t.skip('tmux unavailable');
|
|
136
|
+
const broker = await h.spawnHeadlessChild(root, 'broker to focus twice');
|
|
137
|
+
closeDb();
|
|
138
|
+
assert.equal(h.node(broker).host_kind, 'broker', 'precondition: broker-hosted node');
|
|
139
|
+
// A caller pane in the harness session to focus BESIDE (the user's pane the
|
|
140
|
+
// viewer splits next to). The viewer lands in this same session, so the SECOND
|
|
141
|
+
// focus's reuse takes the in-session NAVIGATE path (in_place), not the
|
|
142
|
+
// cross-session MOVE path.
|
|
143
|
+
const caller = makePane();
|
|
144
|
+
// FOCUS #1 — a managed child boots with NO viewer (spawn no longer auto-opens
|
|
145
|
+
// one), so this OPENS the broker's single viewer beside the caller.
|
|
146
|
+
const f1 = h.cli(broker, ['node', 'focus', broker, '--pane', caller]);
|
|
147
|
+
assert.equal(f1.code, 0, `first focus exit 0\n${f1.stderr}`);
|
|
148
|
+
assert.equal(parseList(f1.stdout).focused, 'true', 'first focus brought the viewer on screen');
|
|
149
|
+
closeDb();
|
|
150
|
+
const row1 = getFocusByNode(broker);
|
|
151
|
+
assert.ok(row1 !== null && row1.pane !== null, 'focus #1 registered one viewer focus row');
|
|
152
|
+
const pane1 = row1.pane;
|
|
153
|
+
assert.equal(listFocuses().filter((f) => f.node_id === broker).length, 1, 'exactly ONE focus row for the node after focus #1');
|
|
154
|
+
// Let the attach client connect + self-tag the pane BEFORE the second focus —
|
|
155
|
+
// the reuse branch keys on `@crtr_node`; without the tag focus() would (correctly)
|
|
156
|
+
// treat the row as stale and open fresh. This wait is a test-timing concern, not
|
|
157
|
+
// a product gap (in real use the second focus is a much-later user action).
|
|
158
|
+
await waitForTag(pane1, broker);
|
|
159
|
+
assert.deepEqual(taggedViewerPanes(h.session, broker), [pane1], 'exactly ONE tagged viewer pane on screen after focus #1');
|
|
160
|
+
// FOCUS #2 — the headline dedup: the node already has a live, tagged viewer in
|
|
161
|
+
// the caller's session, so focus NAVIGATES to it (no new pane), never stacks.
|
|
162
|
+
const f2 = h.cli(broker, ['node', 'focus', broker, '--pane', caller]);
|
|
163
|
+
assert.equal(f2.code, 0, `second focus exit 0\n${f2.stderr}`);
|
|
164
|
+
const f2out = parseList(f2.stdout);
|
|
165
|
+
assert.equal(f2out.focused, 'true', 'second focus also on screen');
|
|
166
|
+
assert.equal(f2out.in_place, 'true', 'second focus REUSED the viewer in place (navigate, no new pane)');
|
|
167
|
+
// The invariant: STILL exactly one viewer pane + one focus row, the SAME pane.
|
|
168
|
+
closeDb();
|
|
169
|
+
const row2 = getFocusByNode(broker);
|
|
170
|
+
assert.ok(row2 !== null, 'the node still has its viewer focus row after focus #2');
|
|
171
|
+
assert.equal(row2.pane, pane1, 'focus #2 reused the SAME viewer pane (not a fresh one)');
|
|
172
|
+
assert.equal(listFocuses().filter((f) => f.node_id === broker).length, 1, 'STILL exactly ONE focus row for the node after focus #2 (never stacked a second)');
|
|
173
|
+
assert.deepEqual(taggedViewerPanes(h.session, broker), [pane1], 'STILL exactly ONE tagged viewer pane after focus #2 (reused, not stacked)');
|
|
174
|
+
});
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
// MIDDLE node with a live sideways-sibling subtree. This test fills exactly that
|
|
15
15
|
// gap: it builds a real tree, closes the middle, and proves the reap is EXACTLY
|
|
16
16
|
// the closed node's own subtree — nothing above (the ancestor) or sideways (a
|
|
17
|
-
// sibling subtree) is touched, and real
|
|
17
|
+
// sibling subtree) is touched, and the real broker engines actually die.
|
|
18
|
+
//
|
|
19
|
+
// VIEWER MODEL (a09b71f): a managed child boots as a detached headless BROKER
|
|
20
|
+
// with NO viewer pane on spawn (a viewer is opened on demand via `node focus`).
|
|
21
|
+
// So "the node is up" is broker-engine liveness — its recorded `pi_pid` is alive
|
|
22
|
+
// — NOT pane presence. close tears that broker engine down (headlessBrokerHost
|
|
23
|
+
// .teardown over view.sock), so we assert pid liveness, not panes.
|
|
18
24
|
//
|
|
19
25
|
// The tree (covers BOTH required shapes in one topology):
|
|
20
26
|
//
|
|
@@ -47,6 +53,7 @@ import { test } from 'node:test';
|
|
|
47
53
|
import assert from 'node:assert/strict';
|
|
48
54
|
import { spawnSync } from 'node:child_process';
|
|
49
55
|
import { createHarness, hasTmux } from '../helpers/harness.js';
|
|
56
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
50
57
|
function sessionExists(session) {
|
|
51
58
|
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
52
59
|
}
|
|
@@ -72,7 +79,8 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
|
|
|
72
79
|
{
|
|
73
80
|
for (const id of [B, C, D, E, S, G]) {
|
|
74
81
|
assert.equal(h.status(id), 'active', `${id} active before close`);
|
|
75
|
-
|
|
82
|
+
const pid = h.node(id).pi_pid;
|
|
83
|
+
assert.ok(pid != null && isPidAlive(pid), `${id} broker engine alive before close`);
|
|
76
84
|
}
|
|
77
85
|
assert.equal(h.status(A), 'active', 'A (root) active before close');
|
|
78
86
|
// Spine: A→{B,S}, B→{C,D}, C→{E}, S→{G} (all active spawn-seed edges).
|
|
@@ -124,12 +132,17 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
|
|
|
124
132
|
assert.deepEqual({ status: e.status, intent: e.intent ?? null }, { status: 'canceled', intent: null }, "deepest reaped descendant E: (status,intent) === ('canceled', null)");
|
|
125
133
|
}
|
|
126
134
|
// ===================================================================
|
|
127
|
-
// ASSERT 2 —
|
|
128
|
-
// a
|
|
135
|
+
// ASSERT 2 — broker ENGINE torn down for the ENTIRE subtree (the real
|
|
136
|
+
// "node is up" invariant now: a closed node owns no live broker process).
|
|
137
|
+
// close → headlessBrokerHost.teardown shut each broker down over view.sock.
|
|
129
138
|
// ===================================================================
|
|
130
139
|
for (const id of subtree) {
|
|
131
|
-
await h.
|
|
132
|
-
|
|
140
|
+
await h.waitFor(() => {
|
|
141
|
+
const pid = h.node(id)?.pi_pid ?? null;
|
|
142
|
+
return pid === null || !isPidAlive(pid);
|
|
143
|
+
}, { label: `${id} broker engine torn down by close` });
|
|
144
|
+
const pid = h.node(id)?.pi_pid ?? null;
|
|
145
|
+
assert.ok(pid === null || !isPidAlive(pid), `${id} broker engine destroyed by close`);
|
|
133
146
|
}
|
|
134
147
|
// ===================================================================
|
|
135
148
|
// ASSERT 3 — EXCLUSIVE-SUBTREE OWNERSHIP: nothing ABOVE (A) and nothing
|
|
@@ -153,10 +166,11 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
|
|
|
153
166
|
assert.equal(added[0].from, B, 'the new entry is from the closed child B');
|
|
154
167
|
assert.match(added[0].label, /closed/i, 'the new entry tells A its child B was closed');
|
|
155
168
|
}
|
|
156
|
-
// Sideways subtree S→G — wholly alive: status active AND
|
|
169
|
+
// Sideways subtree S→G — wholly alive: status active AND broker engine alive.
|
|
157
170
|
for (const id of sideways) {
|
|
158
171
|
assert.equal(h.status(id), 'active', `${id} (sideways) still active — not reaped`);
|
|
159
|
-
|
|
172
|
+
const pid = h.node(id).pi_pid;
|
|
173
|
+
assert.ok(pid != null && isPidAlive(pid), `${id} (sideways) broker engine still alive`);
|
|
160
174
|
}
|
|
161
175
|
// The strong "EXACTLY its own subtree" closure: every node in the graph is
|
|
162
176
|
// either in B's subtree (canceled) or untouched (active) — no over-reach.
|
|
@@ -1,204 +1,101 @@
|
|
|
1
|
-
// Run with: node --import tsx/esm --test src/core/__tests__/detach-focus.test.ts
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/detach-focus.test.ts
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// --detach (A3: orphaned-focus-row hazard)". An END-TO-END test that drives the
|
|
5
|
-
// REAL `crtr node demote --detach` verb (subprocess, AS the node) — the actual
|
|
6
|
-
// Alt+C → D menu path — against a live fake-pi node genuinely FOCUSED in a user
|
|
7
|
-
// viewport, and
|
|
8
|
-
// proves the three things the A3 hazard is about:
|
|
9
|
-
// (a) lifecycle flips terminal IN PLACE,
|
|
10
|
-
// (b) the still-running pi's pane is RELOCATED to the backstage crtr session
|
|
11
|
-
// (the pi keeps generating off-screen, not killed),
|
|
12
|
-
// (c) the focus row it occupied is CLOSED — no orphaned/phantom viewport row
|
|
13
|
-
// lingering on the relocated %pane_id (Invariant P / Invariant F5).
|
|
3
|
+
// `crtr node demote --detach` on a node with a live viewer (Alt+C → D).
|
|
14
4
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
5
|
+
// BROKER-CUT ADAPTATION (taste/broker-is-the-host, 2026-06-10): every node is a
|
|
6
|
+
// detached headless broker. After a09b71f a managed child opens NO viewer on
|
|
7
|
+
// spawn — a viewer is opened on demand via `node focus`, registering its
|
|
8
|
+
// `crtr attach` VIEWER pane in the focuses table. The engine is NOT in a pane, so the
|
|
9
|
+
// pre-cut "relocate the still-running pi pane to the backstage" behavior —
|
|
10
|
+
// detachToBackground's engine break-pane/release path, and its isGenerating()
|
|
11
|
+
// gate that released a non-generating node to dormant — is DELETED. The Bug-1
|
|
12
|
+
// non-generating-release test that locked that gate is deleted with it (the
|
|
13
|
+
// behavior no longer exists; nothing to invent coverage for).
|
|
22
14
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
15
|
+
// What SURVIVES and is locked here: `node demote --detach` flips the node
|
|
16
|
+
// terminal IN PLACE and CLOSES its viewer pane + focus row, leaving the broker
|
|
17
|
+
// ENGINE running untouched (reconnectable by a later `focus`). This drives the
|
|
18
|
+
// REAL verb (subprocess, AS the node) — the actual Alt+C → D menu path — against
|
|
19
|
+
// a live broker node genuinely focused in a viewer pane, and proves:
|
|
20
|
+
// (a) lifecycle flips terminal in place,
|
|
21
|
+
// (b) the broker engine keeps running (pi_pid alive, unchanged — NOT killed),
|
|
22
|
+
// (c) the viewer pane is closed + its focus row dropped (no orphaned/phantom
|
|
23
|
+
// viewport row — Invariant P / F5).
|
|
32
24
|
import { test } from 'node:test';
|
|
33
25
|
import assert from 'node:assert/strict';
|
|
34
26
|
import { spawnSync } from 'node:child_process';
|
|
35
27
|
import { createHarness, hasTmux } from '../helpers/harness.js';
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
28
|
+
import { getNode } from '../../canvas/canvas.js';
|
|
29
|
+
import { getFocusByNode, getFocusByPane, listFocuses } from '../../canvas/focuses.js';
|
|
38
30
|
import { closeDb } from '../../canvas/db.js';
|
|
39
|
-
import {
|
|
31
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
40
32
|
const SKIP = !hasTmux() ? 'tmux unavailable' : false;
|
|
41
33
|
function sessionExists(session) {
|
|
42
34
|
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
43
35
|
}
|
|
44
|
-
function tmuxOut(args) {
|
|
45
|
-
return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
|
|
46
|
-
}
|
|
47
|
-
/** The first %pane_id of a tmux window (spawn records window+session, not pane). */
|
|
48
|
-
function firstPaneOf(window) {
|
|
49
|
-
const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
|
|
50
|
-
if (r.status !== 0)
|
|
51
|
-
return null;
|
|
52
|
-
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
53
|
-
}
|
|
54
|
-
/** A pane's CURRENT session/window (display-message on its durable %id). */
|
|
55
|
-
function paneLoc(pane) {
|
|
56
|
-
const out = tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
|
|
57
|
-
const [session, window] = out.split('\t');
|
|
58
|
-
if (!session || !window)
|
|
59
|
-
return null;
|
|
60
|
-
return { session, window };
|
|
61
|
-
}
|
|
62
|
-
function paneSessionReal(pane) {
|
|
63
|
-
return tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}']);
|
|
64
|
-
}
|
|
65
36
|
function paneExistsReal(pane) {
|
|
66
|
-
return
|
|
37
|
+
return ((spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], { encoding: 'utf8' }).stdout ?? '').trim() ===
|
|
38
|
+
pane);
|
|
39
|
+
}
|
|
40
|
+
// Split a throwaway pane in the harness session; return its %id. The caller pane
|
|
41
|
+
// `node focus` opens the viewer beside (a09b71f: spawn no longer auto-opens one).
|
|
42
|
+
function makePane(session) {
|
|
43
|
+
const r = spawnSync('tmux', ['split-window', '-d', '-t', session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
|
|
44
|
+
const pane = (r.stdout ?? '').trim();
|
|
45
|
+
assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
|
|
46
|
+
return pane;
|
|
67
47
|
}
|
|
68
48
|
// ===========================================================================
|
|
69
|
-
// `crtr node demote --detach` on a
|
|
49
|
+
// `crtr node demote --detach` on a node with a live viewer (Alt+C → D).
|
|
70
50
|
// ===========================================================================
|
|
71
|
-
test('node demote --detach
|
|
51
|
+
test('node demote --detach: flips terminal in place, CLOSES the viewer pane + focus row, broker engine keeps running', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
72
52
|
const h = await createHarness({ sessionPrefix: 'crtr-detach-focus' });
|
|
73
|
-
const user = `crtr-detach-user-${process.pid}-${Date.now().toString(36)}`;
|
|
74
53
|
try {
|
|
75
|
-
// A resident root + a live terminal child B
|
|
76
|
-
//
|
|
77
|
-
// to nothing extra — it just needs to be a live, focusable terminal node.
|
|
54
|
+
// A resident root + a live terminal child B. spawnChild launches B's broker
|
|
55
|
+
// but opens NO viewer (a09b71f) — so B has no focus row yet.
|
|
78
56
|
const A = h.spawnRoot('resident root');
|
|
79
57
|
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
80
58
|
const b0 = h.node(B);
|
|
81
59
|
assert.equal(b0.lifecycle, 'terminal', 'B born terminal');
|
|
82
60
|
assert.equal(b0.status, 'active', 'B active after boot');
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
|
|
94
|
-
const moved = paneLoc(bPane);
|
|
95
|
-
assert.equal(moved?.session, user, 'B pane physically in the user session now');
|
|
96
|
-
assert.equal(paneExistsReal(bPane), true, "B's pi is still alive after the move");
|
|
61
|
+
const brokerPid = b0.pi_pid;
|
|
62
|
+
assert.ok(brokerPid != null && isPidAlive(brokerPid), 'B broker engine alive before detach');
|
|
63
|
+
// Put B genuinely "on screen": `node focus` opens its one `crtr attach`
|
|
64
|
+
// VIEWER pane beside a caller pane in the harness session, registering the
|
|
65
|
+
// viewer focus row (§A.3) — the on-demand viewer the detach then tears down.
|
|
66
|
+
const caller = makePane(h.session);
|
|
67
|
+
const focusRes = h.cli(B, ['node', 'focus', B, '--pane', caller]);
|
|
68
|
+
assert.equal(focusRes.code, 0, `node focus B exit 0\n${focusRes.stderr}`);
|
|
69
|
+
// The viewer pane is the node's on-screen presence (the focuses row `focus`
|
|
70
|
+
// registered) — NOT a node-row pane (a broker carries null placement).
|
|
97
71
|
closeDb();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
// fired, so set the busy marker to model an agent genuinely mid-turn —
|
|
105
|
-
// otherwise the new gate would RELEASE B (the non-generating path below).
|
|
106
|
-
markBusy(B);
|
|
107
|
-
// --- Drive the REAL verb on the FOCUSED node. The subprocess inherits
|
|
108
|
-
// CRTR_NODE_SESSION = h.session (the backstage), so detachToBackground
|
|
109
|
-
// breaks the pane back into h.session.
|
|
110
|
-
const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
|
|
72
|
+
const viewerRow = getFocusByNode(B);
|
|
73
|
+
assert.ok(viewerRow !== null && viewerRow.pane !== null, 'B has a registered viewer focus row');
|
|
74
|
+
const viewerPane = viewerRow.pane;
|
|
75
|
+
assert.equal(paneExistsReal(viewerPane), true, 'B viewer pane is live on screen');
|
|
76
|
+
// --- Drive the REAL verb on the focused node (the Alt+C → D menu path). ---
|
|
77
|
+
const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', viewerPane, '--detach']);
|
|
111
78
|
assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
|
|
112
|
-
assert.match(res.stdout, /
|
|
79
|
+
assert.match(res.stdout, /viewer closed \(broker still running off-screen\)/, `the viewer was closed\n${res.stdout}`);
|
|
113
80
|
closeDb();
|
|
114
81
|
const b = getNode(B);
|
|
115
|
-
// (a) lifecycle flipped to terminal IN PLACE
|
|
82
|
+
// (a) lifecycle flipped to terminal IN PLACE (it was already terminal; the
|
|
83
|
+
// demote is idempotent on lifecycle — the point is it does not end B).
|
|
116
84
|
assert.equal(b.lifecycle, 'terminal', '(a) B lifecycle=terminal after the flip');
|
|
117
|
-
assert.equal(b.status, 'active',
|
|
118
|
-
// (b) the
|
|
119
|
-
// (
|
|
120
|
-
assert.equal(
|
|
121
|
-
assert.equal(
|
|
122
|
-
|
|
123
|
-
//
|
|
85
|
+
assert.equal(b.status, 'active', '(a) detach does NOT end the node — B stays active');
|
|
86
|
+
// (b) the broker ENGINE keeps running — detach closes only the viewer, never
|
|
87
|
+
// the engine (the one-host invariant: the engine was never in a pane).
|
|
88
|
+
assert.equal(b.pi_pid, brokerPid, "(b) B's broker pid is UNCHANGED (engine not killed/respawned)");
|
|
89
|
+
assert.equal(isPidAlive(brokerPid), true, '(b) B broker engine still alive off-screen');
|
|
90
|
+
// (c) the viewer pane is CLOSED and its focus row dropped — no orphaned /
|
|
91
|
+
// phantom viewport (A3 hazard / Invariant P).
|
|
92
|
+
await h.waitForPaneGone(B);
|
|
93
|
+
assert.equal(paneExistsReal(viewerPane), false, '(c) B viewer pane closed');
|
|
124
94
|
assert.equal(getFocusByNode(B), null, '(c) B no longer occupies any focus (row CLOSED — Invariant P)');
|
|
125
|
-
assert.equal(getFocusByPane(
|
|
95
|
+
assert.equal(getFocusByPane(viewerPane), null, '(c) NO phantom focus resolves on the closed viewer %id');
|
|
126
96
|
assert.equal(listFocuses().length, 0, '(c) no dangling focus rows remain');
|
|
127
97
|
}
|
|
128
98
|
finally {
|
|
129
|
-
spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
|
|
130
|
-
const session = h.session;
|
|
131
|
-
await h.dispose();
|
|
132
|
-
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
// ===========================================================================
|
|
136
|
-
// Bug 1 (Invariant P / detach-idle-node): `crtr node demote --detach` on a
|
|
137
|
-
// NON-GENERATING focused node must NOT park it alive in the backstage — it must
|
|
138
|
-
// be RELEASED to dormant and its pane reaped.
|
|
139
|
-
//
|
|
140
|
-
// The backstage holds ONLY generating-but-unfocused nodes (Invariant P).
|
|
141
|
-
// detachToBackground used to break-pane to the backstage UNCONDITIONALLY, with no
|
|
142
|
-
// generating-ness check: a resident root parked idle between turns (pi alive,
|
|
143
|
-
// idle, NOT mid-turn) detached via Alt+C → D became unfocused + pi-alive-idle +
|
|
144
|
-
// holding a backstage pane forever — nothing re-enters agent_end to release it
|
|
145
|
-
// and, with no live subscription, nothing wakes it → a leaked live-idle pi that
|
|
146
|
-
// never reaps. The fix gates the break-pane on isGenerating() and RELEASES a
|
|
147
|
-
// non-generating node to dormant instead (mirrors retargetFocus's parked-viewer
|
|
148
|
-
// release on focus-away).
|
|
149
|
-
//
|
|
150
|
-
// This drives the SAME real verb as the happy-path test above, but on a node
|
|
151
|
-
// with NO busy marker (not mid-turn). isGenerating() = busy && pidAlive, so a
|
|
152
|
-
// freshly-booted node (pi_pid recorded at session_start, no turn fired) reads as
|
|
153
|
-
// NOT generating.
|
|
154
|
-
// ===========================================================================
|
|
155
|
-
test('node demote --detach on a NON-GENERATING focused node: RELEASES to dormant (idle + idle-release), reaps the pane, closes the focus row — NOT parked in the backstage (Bug 1 — Invariant P)', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
156
|
-
const h = await createHarness({ sessionPrefix: 'crtr-detach-idle' });
|
|
157
|
-
const user = `crtr-detach-idle-user-${process.pid}-${Date.now().toString(36)}`;
|
|
158
|
-
try {
|
|
159
|
-
// A resident root + a live terminal child B. B is freshly booted: pi alive
|
|
160
|
-
// (pi_pid recorded at session_start), status active, and — critically — NO
|
|
161
|
-
// busy marker, since no turn ever fired. It is parked idle, not mid-turn:
|
|
162
|
-
// exactly the non-generating occupant Bug 1 is about.
|
|
163
|
-
const A = h.spawnRoot('resident root');
|
|
164
|
-
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
165
|
-
const b0 = h.node(B);
|
|
166
|
-
assert.equal(b0.status, 'active', 'B active after boot');
|
|
167
|
-
const bPane = firstPaneOf(b0.window);
|
|
168
|
-
assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
|
|
169
|
-
// --- Put B into a USER viewport, FOCUSED (same precondition as the happy
|
|
170
|
-
// path), but DO NOT mark it busy: it stays NOT generating.
|
|
171
|
-
spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
|
|
172
|
-
assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
|
|
173
|
-
const moved = paneLoc(bPane);
|
|
174
|
-
assert.equal(moved?.session, user, 'B pane physically in the user session now');
|
|
175
|
-
assert.equal(paneExistsReal(bPane), true, "B's pi is alive (parked idle, not mid-turn)");
|
|
176
|
-
closeDb();
|
|
177
|
-
setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
|
|
178
|
-
openFocusRow('f-detach-idle', bPane, user, B);
|
|
179
|
-
assert.equal(getFocusByNode(B)?.focus_id, 'f-detach-idle', 'precondition: B is FOCUSED, NON-generating');
|
|
180
|
-
// --- Drive the REAL verb on the non-generating focused node.
|
|
181
|
-
const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
|
|
182
|
-
assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
|
|
183
|
-
assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
|
|
184
|
-
closeDb();
|
|
185
|
-
const b = getNode(B);
|
|
186
|
-
// (b) RELEASED to dormant — idle + intent='idle-release' (revivable via inbox
|
|
187
|
-
// / re-focus), NOT left active in the backstage.
|
|
188
|
-
assert.equal(b.status, 'idle', '(b) B released to dormant (status idle)');
|
|
189
|
-
assert.equal(b.intent, 'idle-release', "(b) B's intent is idle-release (daemon revives on inbox)");
|
|
190
|
-
// (c) the pane is REAPED / presence nulled — NOT relocated to the backstage
|
|
191
|
-
// crtr session. The leaked live-idle pi must be gone.
|
|
192
|
-
assert.equal(paneExistsReal(bPane), false, '(c) B pane reaped (NOT parked alive in the backstage)');
|
|
193
|
-
assert.equal(b.pane, null, "(c) B's presence pane is nulled");
|
|
194
|
-
assert.equal(b.tmux_session, null, "(c) B's presence session is nulled (NOT the backstage crtr session)");
|
|
195
|
-
// (a) the focus row is CLOSED — no orphaned/phantom viewport.
|
|
196
|
-
assert.equal(getFocusByNode(B), null, '(a) B no longer occupies any focus (row CLOSED — Invariant P)');
|
|
197
|
-
assert.equal(getFocusByPane(bPane), null, '(a) NO phantom focus resolves on the reaped %id');
|
|
198
|
-
assert.equal(listFocuses().length, 0, '(a) no dangling focus rows remain');
|
|
199
|
-
}
|
|
200
|
-
finally {
|
|
201
|
-
spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
|
|
202
99
|
const session = h.session;
|
|
203
100
|
await h.dispose();
|
|
204
101
|
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
@@ -33,14 +33,11 @@ export interface HarnessOpts {
|
|
|
33
33
|
/** HEADLESS mode: drive ONLY broker-hosted (paneless) nodes. When set the
|
|
34
34
|
* harness does NOT gate on tmux, does NOT eagerly create the isolated tmux
|
|
35
35
|
* session, and skips the per-pane `set-environment` seeding — nothing in a
|
|
36
|
-
* headless run opens a window. CRTR_NODE_SESSION
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* side effect, but never a window/pane; the broker boots detached, supervised
|
|
42
|
-
* by pid. Headless tests must use `spawnHeadlessChild`, never `spawnChild`
|
|
43
|
-
* (which opens a real window). See createHeadlessHarness(). */
|
|
36
|
+
* headless run opens a window. CRTR_NODE_SESSION/CRTR_ROOT_SESSION are still
|
|
37
|
+
* scrubbed/restored for env hygiene, but no longer route placement: a managed
|
|
38
|
+
* child opens NO viewer (it boots a detached broker, supervised by pid), and a
|
|
39
|
+
* --root opens one viewer in the caller's current session. Headless tests must
|
|
40
|
+
* use `spawnHeadlessChild`, never `spawnChild`. See createHeadlessHarness(). */
|
|
44
41
|
headless?: boolean;
|
|
45
42
|
}
|
|
46
43
|
export interface SpawnOpts {
|
|
@@ -66,6 +63,7 @@ export interface FabricateOpts {
|
|
|
66
63
|
intent?: ExitIntent;
|
|
67
64
|
pi_pid?: number | null;
|
|
68
65
|
pi_session_id?: string | null;
|
|
66
|
+
pi_session_file?: string | null;
|
|
69
67
|
id?: string;
|
|
70
68
|
}
|
|
71
69
|
export interface Harness {
|
|
@@ -80,7 +78,6 @@ export interface Harness {
|
|
|
80
78
|
turn(nodeId: string, text?: string): Promise<void>;
|
|
81
79
|
stop(nodeId: string, reason?: 'stop' | 'length' | 'aborted' | 'error'): Promise<void>;
|
|
82
80
|
finish(nodeId: string, finalText: string): Promise<void>;
|
|
83
|
-
yieldNode(nodeId: string, note: string): Promise<void>;
|
|
84
81
|
tick(now?: number): Promise<void>;
|
|
85
82
|
awaitBoot(nodeId: string, o?: {
|
|
86
83
|
minCount?: number;
|