@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,292 +0,0 @@
|
|
|
1
|
-
// Run with: node --import tsx/esm --test src/core/__tests__/full/daemon-liveness-pane.full.test.ts
|
|
2
|
-
//
|
|
3
|
-
// FULL-TIER (genuine tmux) half of the daemon liveness suite — split out of
|
|
4
|
-
// daemon-liveness.test.ts (foundation-spec §C.8 + §E). These lock the daemon's
|
|
5
|
-
// PANE-existence reconciliation: a live pane vs a stale window cache, the gone-
|
|
6
|
-
// branch routing (crash / finalize / release), and the frozen-pane idle-release
|
|
7
|
-
// revive. Every one of them needs a REAL live tmux window+pane to be faithful —
|
|
8
|
-
// the verdict turns on `isNodePaneAlive → paneExists/paneLocation/listLivePanes`
|
|
9
|
-
// probing the tmux server — so it cannot be model-fabricated; it stays CI-only.
|
|
10
|
-
// (The pure decision primitives — livenessVerdict, isPidAlive — are the fast
|
|
11
|
-
// half, in src/core/__tests__/daemon-liveness.test.ts.)
|
|
12
|
-
import { test, before, after, beforeEach } from 'node:test';
|
|
13
|
-
import assert from 'node:assert/strict';
|
|
14
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
-
import { tmpdir } from 'node:os';
|
|
16
|
-
import { join } from 'node:path';
|
|
17
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
18
|
-
import { createNode, getNode, subscribe } from '../../canvas/canvas.js';
|
|
19
|
-
import { closeDb } from '../../canvas/db.js';
|
|
20
|
-
import { appendInbox } from '../../feed/inbox.js';
|
|
21
|
-
import { markBusy, clearBusy } from '../../runtime/busy.js';
|
|
22
|
-
import { superviseTick } from '../../../daemon/crtrd.js';
|
|
23
|
-
let home;
|
|
24
|
-
function node(id, over = {}) {
|
|
25
|
-
return {
|
|
26
|
-
node_id: id,
|
|
27
|
-
name: id,
|
|
28
|
-
created: new Date().toISOString(),
|
|
29
|
-
cwd: '/tmp/work',
|
|
30
|
-
kind: 'general',
|
|
31
|
-
mode: 'base',
|
|
32
|
-
lifecycle: 'terminal',
|
|
33
|
-
status: 'active',
|
|
34
|
-
...over,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
function hasTmux() {
|
|
38
|
-
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
39
|
-
}
|
|
40
|
-
/** A pid that is guaranteed dead: spawn a no-op and wait for it to exit. */
|
|
41
|
-
function deadPid() {
|
|
42
|
-
const r = spawnSync('true', [], { stdio: 'ignore' });
|
|
43
|
-
return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
|
|
44
|
-
}
|
|
45
|
-
/** A pid that is ALIVE for the test's duration but expendable. Pane-gone
|
|
46
|
-
* fixtures must NEVER use process.pid for an alive pi: the gone-pane zombie
|
|
47
|
-
* sweep (b92082e) SIGKILLs a pane-gone node's live pid, which took the whole
|
|
48
|
-
* test runner down with it. The daemon (or the after hook) reaps these. */
|
|
49
|
-
const livePids = [];
|
|
50
|
-
function disposableLivePid() {
|
|
51
|
-
const child = spawn('sleep', ['600'], { stdio: 'ignore', detached: true });
|
|
52
|
-
child.unref();
|
|
53
|
-
livePids.push(child.pid);
|
|
54
|
-
return child.pid;
|
|
55
|
-
}
|
|
56
|
-
before(() => {
|
|
57
|
-
home = mkdtempSync(join(tmpdir(), 'crtr-daemon-liveness-pane-'));
|
|
58
|
-
process.env['CRTR_HOME'] = home;
|
|
59
|
-
});
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
closeDb();
|
|
62
|
-
rmSync(home, { recursive: true, force: true });
|
|
63
|
-
});
|
|
64
|
-
after(() => {
|
|
65
|
-
closeDb();
|
|
66
|
-
rmSync(home, { recursive: true, force: true });
|
|
67
|
-
delete process.env['CRTR_HOME'];
|
|
68
|
-
for (const pid of livePids) {
|
|
69
|
-
try {
|
|
70
|
-
process.kill(pid, 'SIGKILL');
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
/* already reaped by the daemon's zombie sweep — fine */
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
// superviseTick + a REAL live tmux window (gated on tmux availability)
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
/** Run `fn` with a real, live tmux window held open for its whole duration, then
|
|
81
|
-
* tear the session down. Gives superviseTick a genuinely-alive window to judge. */
|
|
82
|
-
async function withLiveWindow(tag, fn) {
|
|
83
|
-
const session = `crtr-livetest-${process.pid}-${tag}`;
|
|
84
|
-
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
|
|
85
|
-
try {
|
|
86
|
-
const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
|
|
87
|
-
const window = (r.stdout ?? '').trim().split('\n')[0];
|
|
88
|
-
await fn(session, window);
|
|
89
|
-
}
|
|
90
|
-
finally {
|
|
91
|
-
spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
/** Like withLiveWindow but also resolves the window's live `%pane_id` — the v3
|
|
95
|
-
* pane-anchored handle the daemon now keys liveness on. */
|
|
96
|
-
async function withLivePane(tag, fn) {
|
|
97
|
-
await withLiveWindow(tag, async (session, window) => {
|
|
98
|
-
const r = spawnSync('tmux', ['display-message', '-p', '-t', `${session}:${window}`, '#{pane_id}'], { encoding: 'utf8' });
|
|
99
|
-
const pane = (r.stdout ?? '').trim();
|
|
100
|
-
await fn(session, window, pane);
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
test('window alive + ALIVE pi pid → healthy, untouched', { skip: !hasTmux() }, async () => {
|
|
104
|
-
await withLiveWindow('a', async (session, window) => {
|
|
105
|
-
// pi_pid = this test process: definitely alive.
|
|
106
|
-
createNode(node('A', { tmux_session: session, window, pi_pid: process.pid, pi_session_id: 'booted' }));
|
|
107
|
-
await superviseTick();
|
|
108
|
-
assert.equal(getNode('A').status, 'active', 'a node with a live pi is left active');
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
test('window alive + dead pi pid → pending on first observation (not reaped, not revived)', { skip: !hasTmux() }, async () => {
|
|
112
|
-
await withLiveWindow('b', async (session, window) => {
|
|
113
|
-
createNode(node('D', {
|
|
114
|
-
tmux_session: session,
|
|
115
|
-
window,
|
|
116
|
-
pi_pid: deadPid(),
|
|
117
|
-
pi_session_id: 'booted-once',
|
|
118
|
-
intent: null,
|
|
119
|
-
status: 'active',
|
|
120
|
-
}));
|
|
121
|
-
await superviseTick();
|
|
122
|
-
// First observation: inside the grace window → pending. The node must NOT be
|
|
123
|
-
// reaped to 'dead' (its window is alive) nor revived yet (no new pi spawned).
|
|
124
|
-
assert.equal(getNode('D').status, 'active', 'pending node stays active on first tick');
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
// Step 3 (Q6): PANE-existence drives the daemon verdict, not window-existence.
|
|
129
|
-
// A manual move-pane/join-pane/break-pane must never read as a death; and a
|
|
130
|
-
// pane that is genuinely gone fires the existing gone-branch even when the
|
|
131
|
-
// window it used to live in is still alive.
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
test('pane alive but window/session cache STALE → LIVE (reconciled, not revived/reaped)', { skip: !hasTmux() }, async () => {
|
|
134
|
-
await withLivePane('p1', async (session, window, pane) => {
|
|
135
|
-
// The pane is live, the pi is alive (this process) — but the row's window
|
|
136
|
-
// cache is bogus (as if a manual move desynced it). Pane-existence must win:
|
|
137
|
-
// the node stays active and reconcile FOLLOWS the pane back to its real window.
|
|
138
|
-
createNode(node('M', {
|
|
139
|
-
pane,
|
|
140
|
-
tmux_session: session,
|
|
141
|
-
window: '@99999', // stale/bogus — windowAlive() would call this gone
|
|
142
|
-
pi_pid: process.pid,
|
|
143
|
-
pi_session_id: 'booted',
|
|
144
|
-
}));
|
|
145
|
-
await superviseTick();
|
|
146
|
-
const m = getNode('M');
|
|
147
|
-
assert.equal(m.status, 'active', 'a live pane keeps the node active despite a stale window cache');
|
|
148
|
-
assert.equal(m.window, window, 'reconcile FOLLOWED the live pane back to its real window');
|
|
149
|
-
assert.equal(m.pane, pane, 'the durable pane id is unchanged');
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
test('pane GONE while its old window is still alive → the gone-branch fires (crash → dead)', { skip: !hasTmux() }, async () => {
|
|
153
|
-
await withLivePane('p2', async (session, window) => {
|
|
154
|
-
// Make a guaranteed-DEAD pane id inside the still-live window: split a fresh
|
|
155
|
-
// pane, then kill it (the window survives via its original pane).
|
|
156
|
-
const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
|
|
157
|
-
const dead = (sp.stdout ?? '').trim();
|
|
158
|
-
spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
|
|
159
|
-
// The window is alive, but the node is anchored on that dead pane. Under the
|
|
160
|
-
// old window-keyed liveness this node would read healthy (window alive + live
|
|
161
|
-
// pid). Pane-keyed: the pane is gone → the gone-branch fires. The node is
|
|
162
|
-
// MID-GENERATION (busy marker present — agent_start touched it, agent_end
|
|
163
|
-
// never cleared it), so the gone-branch routes to crash → 'dead'.
|
|
164
|
-
createNode(node('G', {
|
|
165
|
-
pane: dead,
|
|
166
|
-
tmux_session: session,
|
|
167
|
-
window,
|
|
168
|
-
pi_pid: disposableLivePid(), // alive zombie — the gone-pane sweep SIGKILLs it
|
|
169
|
-
pi_session_id: 'booted',
|
|
170
|
-
intent: null,
|
|
171
|
-
}));
|
|
172
|
-
markBusy('G'); // pane killed inside a turn → genuine mid-run crash
|
|
173
|
-
await superviseTick();
|
|
174
|
-
assert.equal(getNode('G').status, 'dead', 'a gone pane mid-generation fires the gone-branch → crash (dead)');
|
|
175
|
-
clearBusy('G');
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
// ---------------------------------------------------------------------------
|
|
179
|
-
// Gone-pane routing: a pane-gone node in the crash branch is no longer an
|
|
180
|
-
// unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
|
|
181
|
-
// • mid-generation (busy marker PRESENT) → crash ('dead')
|
|
182
|
-
// • finished its turn, awaiting nothing live → finalize ('done')
|
|
183
|
-
// • finished its turn, still awaiting a LIVE child → release ('idle' + idle-release), revivable
|
|
184
|
-
// (The mid-generation → dead leg is covered by node 'G' above.)
|
|
185
|
-
// ---------------------------------------------------------------------------
|
|
186
|
-
test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
|
|
187
|
-
await withLivePane('fin1', async (session, window) => {
|
|
188
|
-
const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
|
|
189
|
-
const dead = (sp.stdout ?? '').trim();
|
|
190
|
-
spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
|
|
191
|
-
// Booted (pi_session_id set), no busy marker (agent_end cleared it → the turn
|
|
192
|
-
// finished), and no subscription to any live node. Closing the pane was a
|
|
193
|
-
// dismissal of a node that already did its own work → finalize to 'done'.
|
|
194
|
-
createNode(node('FIN', {
|
|
195
|
-
pane: dead,
|
|
196
|
-
tmux_session: session,
|
|
197
|
-
window,
|
|
198
|
-
pi_pid: disposableLivePid(), // alive zombie — swept before finalize routing
|
|
199
|
-
pi_session_id: 'booted',
|
|
200
|
-
intent: null,
|
|
201
|
-
}));
|
|
202
|
-
// no markBusy: the turn ended cleanly.
|
|
203
|
-
await superviseTick();
|
|
204
|
-
assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → idle-release (revivable, NOT dead)', { skip: !hasTmux() }, async () => {
|
|
208
|
-
await withLivePane('fin2', async (session, window) => {
|
|
209
|
-
const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
|
|
210
|
-
const dead = (sp.stdout ?? '').trim();
|
|
211
|
-
spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
|
|
212
|
-
// A live child the parent is subscribed to: hasActiveLiveSubscription === true.
|
|
213
|
-
createNode(node('CHILD', { status: 'active', pi_session_id: 'booted' }));
|
|
214
|
-
createNode(node('PARENT', {
|
|
215
|
-
pane: dead,
|
|
216
|
-
tmux_session: session,
|
|
217
|
-
window,
|
|
218
|
-
pi_pid: deadPid(), // the user closed the pane → pi is dead
|
|
219
|
-
pi_session_id: 'booted',
|
|
220
|
-
intent: null,
|
|
221
|
-
}));
|
|
222
|
-
subscribe('PARENT', 'CHILD', true); // active subscription to a LIVE node
|
|
223
|
-
// REGRESSION: closing the pane of a waiting orchestrator must NOT kill it.
|
|
224
|
-
// PARENT finished its turn (no busy marker) but still awaits a live child, so
|
|
225
|
-
// a hard 'dead' would orphan the child's report. It routes to idle-release
|
|
226
|
-
// (revivable); the second pass revives it on the next inbox push. No inbox
|
|
227
|
-
// entry here, so it stays dormant (idle + idle-release) — revivable, not dead.
|
|
228
|
-
await superviseTick();
|
|
229
|
-
const p = getNode('PARENT');
|
|
230
|
-
assert.equal(p.status, 'idle', 'a finished node still awaiting a live child is RELEASED, not killed');
|
|
231
|
-
assert.equal(p.intent, 'idle-release', 'routed to idle-release so a child push revives it');
|
|
232
|
-
assert.equal(p.window ?? null, null, 'the stale window ref is dropped');
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
// ---------------------------------------------------------------------------
|
|
236
|
-
// Step 7 (§5.3, F3): a focused-dormant node frozen via remain-on-exit is
|
|
237
|
-
// PANE-alive but pi-DEAD with intent='idle-release'. The daemon must NOT
|
|
238
|
-
// grace-revive it on liveness (handleLiveWindow early-returns for idle-release);
|
|
239
|
-
// it revives ONLY when an unseen inbox entry arrives (second pass, gated on pi
|
|
240
|
-
// liveness — NOT pane existence, which would skip a frozen pane forever).
|
|
241
|
-
// ---------------------------------------------------------------------------
|
|
242
|
-
test('idle-release + live (frozen) pane + DEAD pi → handleLiveWindow does NOT grace-revive it', { skip: !hasTmux() }, async () => {
|
|
243
|
-
await withLivePane('idle1', async (session, window, pane) => {
|
|
244
|
-
createNode(node('F', {
|
|
245
|
-
pane,
|
|
246
|
-
tmux_session: session,
|
|
247
|
-
window,
|
|
248
|
-
pi_pid: deadPid(), // pi is DEAD; the pane is frozen (remain-on-exit stand-in)
|
|
249
|
-
pi_session_id: 'booted',
|
|
250
|
-
intent: 'idle-release',
|
|
251
|
-
status: 'idle',
|
|
252
|
-
home_session: session,
|
|
253
|
-
}));
|
|
254
|
-
// Two ticks: the SECOND is far past REVIVE_GRACE_MS (20s). Without the
|
|
255
|
-
// idle-release early-return in handleLiveWindow, tick 1 would mark the dead
|
|
256
|
-
// pi 'pending' and tick 2 (now past grace) would grace-revive it, bumping
|
|
257
|
-
// cycles. The early-return means it is never even marked pending. No inbox
|
|
258
|
-
// entry, so the second pass never revives it either.
|
|
259
|
-
await superviseTick(1_000_000);
|
|
260
|
-
await superviseTick(1_000_000 + 60_000);
|
|
261
|
-
const m = getNode('F');
|
|
262
|
-
assert.equal(m.status, 'idle', 'still idle — the frozen focused-dormant node is left alone');
|
|
263
|
-
assert.equal(m.cycles ?? 0, 0, 'cycles NOT bumped → reviveNode never fired');
|
|
264
|
-
assert.equal(m.pane, pane, 'pane unchanged');
|
|
265
|
-
assert.equal(m.window, window, 'window unchanged');
|
|
266
|
-
// Non-vacuous: drop the `intent==='idle-release'` early-return and tick 2
|
|
267
|
-
// (deadFor 60s > 20s grace) revives → cycles===1, status flips → both fail.
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
test('idle-release + live (frozen) pane + DEAD pi + UNSEEN inbox → REVIVED on the second pass', { skip: !hasTmux() }, async () => {
|
|
271
|
-
await withLivePane('idle2', async (session, window, pane) => {
|
|
272
|
-
createNode(node('W', {
|
|
273
|
-
pane,
|
|
274
|
-
tmux_session: session,
|
|
275
|
-
window,
|
|
276
|
-
pi_pid: deadPid(),
|
|
277
|
-
pi_session_id: 'booted',
|
|
278
|
-
intent: 'idle-release',
|
|
279
|
-
status: 'idle',
|
|
280
|
-
home_session: session, // the revive window lands here (torn down with the session)
|
|
281
|
-
}));
|
|
282
|
-
appendInbox('W', { from: 'child', tier: 'normal', kind: 'update', label: 'work for you' });
|
|
283
|
-
await superviseTick();
|
|
284
|
-
// The OLD second-pass guard `if (isNodePaneAlive(r)) continue;` would SKIP a
|
|
285
|
-
// pane-alive node forever; the new `if (r.pi_pid != null && isPidAlive(...))`
|
|
286
|
-
// gate sees the DEAD pi and revives. reviveNode bumps cycles BEFORE placing
|
|
287
|
-
// the window, so the bump is the observable even though the launch is a real
|
|
288
|
-
// (short-lived, torn-down) window. Non-vacuous: the old pane-existence gate
|
|
289
|
-
// leaves cycles unbumped.
|
|
290
|
-
assert.equal(getNode('W').cycles, 1, 'reviveNode fired on the inbox push despite a live (frozen) pane');
|
|
291
|
-
});
|
|
292
|
-
});
|
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
// Run with: node --import tsx/esm --test src/core/__tests__/placement-focus.test.ts
|
|
2
|
-
//
|
|
3
|
-
// STEP 6 of the placement/focus migration: retargetFocus / openFocus / focus +
|
|
4
|
-
// remain-on-exit + root-boot focus #1.
|
|
5
|
-
//
|
|
6
|
-
// Two proof tiers (mirrors placement-revive.test.ts):
|
|
7
|
-
// 1. PURE (no tmux): outgoingDisposition (backstage-vs-kill). Provably
|
|
8
|
-
// non-vacuous (a wrong impl fails).
|
|
9
|
-
// 2. Gated real-tmux: the hot-swap itself — screen position invariant (ZERO new
|
|
10
|
-
// user windows), the two post-swap LOCATIONs, outgoing backstaged (still
|
|
11
|
-
// generating) vs reaped (dormant), the Q5 vacate-old-focus path, openFocus
|
|
12
|
-
// splitting a holder viewport, and the front-door round-trip with UNIQUE
|
|
13
|
-
// node_id upheld. The swap is a real tmux op, so a faithful assertion needs
|
|
14
|
-
// a live server; gated {skip:!hasTmux()} like §5.2.
|
|
15
|
-
import { test, before, after, beforeEach } from 'node:test';
|
|
16
|
-
import assert from 'node:assert/strict';
|
|
17
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
-
import { tmpdir } from 'node:os';
|
|
19
|
-
import { join } from 'node:path';
|
|
20
|
-
import { spawnSync } from 'node:child_process';
|
|
21
|
-
import { createNode, getNode, setPresence } from '../../canvas/canvas.js';
|
|
22
|
-
import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../../canvas/focuses.js';
|
|
23
|
-
import { closeDb } from '../../canvas/db.js';
|
|
24
|
-
import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, detachToBackground, } from '../../runtime/placement.js';
|
|
25
|
-
import { markBusy, isBusy } from '../../runtime/busy.js';
|
|
26
|
-
let home;
|
|
27
|
-
let savedTmux;
|
|
28
|
-
function node(id, over = {}) {
|
|
29
|
-
return {
|
|
30
|
-
node_id: id,
|
|
31
|
-
name: id,
|
|
32
|
-
created: new Date().toISOString(),
|
|
33
|
-
cwd: '/tmp/work',
|
|
34
|
-
kind: 'developer',
|
|
35
|
-
mode: 'base',
|
|
36
|
-
lifecycle: 'terminal',
|
|
37
|
-
status: 'active',
|
|
38
|
-
...over,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
function hasTmux() {
|
|
42
|
-
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
43
|
-
}
|
|
44
|
-
before(() => {
|
|
45
|
-
home = mkdtempSync(join(tmpdir(), 'crtr-placement-focus-'));
|
|
46
|
-
process.env['CRTR_HOME'] = home;
|
|
47
|
-
savedTmux = process.env['TMUX'];
|
|
48
|
-
delete process.env['TMUX']; // pure tests exercise the deterministic no-tmux path
|
|
49
|
-
});
|
|
50
|
-
beforeEach(() => {
|
|
51
|
-
closeDb();
|
|
52
|
-
rmSync(home, { recursive: true, force: true });
|
|
53
|
-
});
|
|
54
|
-
after(() => {
|
|
55
|
-
closeDb();
|
|
56
|
-
rmSync(home, { recursive: true, force: true });
|
|
57
|
-
delete process.env['CRTR_HOME'];
|
|
58
|
-
if (savedTmux !== undefined)
|
|
59
|
-
process.env['TMUX'] = savedTmux;
|
|
60
|
-
});
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
// 1a. PURE: outgoingDisposition — the 4-field truth table (kill / backstage /
|
|
63
|
-
// release). Each branch is provably non-vacuous (a wrong impl fails).
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
test('outgoingDisposition: a HOLDER / vanished node (no row) → KILL (never backstaged)', () => {
|
|
66
|
-
// A wrong impl that backstaged a holder would leak a sleep pane into crtr.
|
|
67
|
-
assert.deepEqual(outgoingDisposition({ exists: false, live: false, resident: false, generating: false }), { kind: 'kill' });
|
|
68
|
-
assert.deepEqual(outgoingDisposition({ exists: false, live: true, resident: true, generating: true }), { kind: 'kill' });
|
|
69
|
-
});
|
|
70
|
-
test('outgoingDisposition: a done/dead node (not live) → KILL (Invariant P: not-focused + not-live ⇒ no pane)', () => {
|
|
71
|
-
assert.deepEqual(outgoingDisposition({ exists: true, live: false, resident: false, generating: false }), { kind: 'kill' });
|
|
72
|
-
});
|
|
73
|
-
test('outgoingDisposition: a RESIDENT node (editor/root) → BACKSTAGE (human-driven, NEVER despawned on focus-away)', () => {
|
|
74
|
-
// Resident wins even when NOT mid-turn — a wrong impl here KILLS the user\'s editor.
|
|
75
|
-
assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: true, generating: false }), { kind: 'backstage' });
|
|
76
|
-
assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: true, generating: true }), { kind: 'backstage' });
|
|
77
|
-
});
|
|
78
|
-
test('outgoingDisposition: a still-generating terminal worker → BACKSTAGE (F2: keeps running off-screen)', () => {
|
|
79
|
-
assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: false, generating: true }), { kind: 'backstage' });
|
|
80
|
-
});
|
|
81
|
-
test('outgoingDisposition: a PARKED terminal viewer (live, not mid-turn) → RELEASE (despawn to dormant — the stuck-green fix)', () => {
|
|
82
|
-
assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: false, generating: false }), { kind: 'release' });
|
|
83
|
-
});
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// 2. Gated real-tmux: the hot-swap. Two isolated sessions: `user` (the user's
|
|
86
|
-
// terminal) + `back` (stand-in for the backstage `crtr`). Panes run `sleep`,
|
|
87
|
-
// never a real pi; node pi_pid is set explicitly to control "generating".
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
function tmuxOut(args) {
|
|
90
|
-
return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
|
|
91
|
-
}
|
|
92
|
-
function windowIds(session) {
|
|
93
|
-
return tmuxOut(['list-windows', '-t', session, '-F', '#{window_id}']).split('\n').filter((s) => s !== '');
|
|
94
|
-
}
|
|
95
|
-
function paneExistsReal(pane) {
|
|
96
|
-
return tmuxOut(['display-message', '-p', '-t', pane, '#{pane_id}']) === pane;
|
|
97
|
-
}
|
|
98
|
-
function paneSession(pane) {
|
|
99
|
-
return tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}']);
|
|
100
|
-
}
|
|
101
|
-
/** A live extra pane in `session:window` running sleep; returns its `%pane_id`. */
|
|
102
|
-
function livePane(session, window) {
|
|
103
|
-
return tmuxOut(['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600']);
|
|
104
|
-
}
|
|
105
|
-
const NOREVIVE = () => {
|
|
106
|
-
throw new Error('reviver should not be called when the node has a live pin pane');
|
|
107
|
-
};
|
|
108
|
-
/** A reviver that opens a real backstage `sleep` window for the node and points
|
|
109
|
-
* its presence at it — the stand-in for reviveIntoPlacement's backstage branch. */
|
|
110
|
-
function backstageReviver(back) {
|
|
111
|
-
return (id) => {
|
|
112
|
-
const out = tmuxOut(['new-window', '-d', '-a', '-P', '-F', '#{window_id}\t#{pane_id}', '-t', `${back}:`, 'sleep 600']);
|
|
113
|
-
const [w, p] = out.split('\t');
|
|
114
|
-
setPresence(id, { pane: p, tmux_session: back, window: w });
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
async function withSessions(tag, fn) {
|
|
118
|
-
const user = `crtr-pf-user-${process.pid}-${tag}`;
|
|
119
|
-
const back = `crtr-pf-back-${process.pid}-${tag}`;
|
|
120
|
-
spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600']);
|
|
121
|
-
spawnSync('tmux', ['new-session', '-d', '-s', back, '-c', '/tmp', 'sleep 600']);
|
|
122
|
-
try {
|
|
123
|
-
await fn({ user, back, userWindow: windowIds(user)[0], backWindow: windowIds(back)[0] });
|
|
124
|
-
}
|
|
125
|
-
finally {
|
|
126
|
-
spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
|
|
127
|
-
spawnSync('tmux', ['kill-session', '-t', back], { stdio: 'ignore' });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put (ZERO new user windows)', { skip: !hasTmux() }, async () => {
|
|
131
|
-
await withSessions('gen', async ({ user, back, userWindow, backWindow }) => {
|
|
132
|
-
const focusPane = livePane(user, userWindow); // R's focus pane (the viewport)
|
|
133
|
-
const backPane = livePane(back, backWindow); // A's live backstage pane
|
|
134
|
-
// R is the outgoing occupant, generating: a terminal worker MID-TURN (live
|
|
135
|
-
// pi_pid AND the busy marker set). A is incoming.
|
|
136
|
-
createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', lifecycle: 'terminal', pi_pid: process.pid, home_session: back }));
|
|
137
|
-
createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
138
|
-
markBusy('R'); // R is genuinely mid-turn (isGenerating = busy && pidAlive)
|
|
139
|
-
openFocusRow('f1', focusPane, user, 'R');
|
|
140
|
-
const userBefore = windowIds(user).length;
|
|
141
|
-
const res = retargetFocus('f1', 'A', NOREVIVE);
|
|
142
|
-
assert.equal(res.focused, true);
|
|
143
|
-
assert.equal(res.revived, false, 'A had a live pin pane — no revive');
|
|
144
|
-
assert.equal(windowIds(user).length, userBefore, 'ZERO new windows in the user session (screen position invariant)');
|
|
145
|
-
// A took over the viewport: it keeps its pane id, now in the user session.
|
|
146
|
-
assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A now occupies the focus');
|
|
147
|
-
assert.equal(getFocusByNode('A')?.pane, backPane, 'the focus row anchors A\'s (now-in-viewport) pane');
|
|
148
|
-
assert.equal(getNode('A').pane, backPane, 'A\'s LOCATION pane id is unchanged (swap preserves %id)');
|
|
149
|
-
assert.equal(paneSession(backPane), user, 'A\'s pane physically moved to the user viewport');
|
|
150
|
-
assert.equal(getNode('A').tmux_session, user, 'A\'s LOCATION session is the viewport');
|
|
151
|
-
// R was backstaged (still generating), keeping its pane id, now in `back`.
|
|
152
|
-
assert.equal(getFocusByNode('R'), null, 'R no longer occupies any focus');
|
|
153
|
-
assert.equal(getNode('R').pane, focusPane, 'R kept its pane id');
|
|
154
|
-
assert.equal(paneExistsReal(focusPane), true, 'R\'s pane is alive (NOT reaped — it is still generating)');
|
|
155
|
-
assert.equal(paneSession(focusPane), back, 'R\'s pane physically moved to the backstage');
|
|
156
|
-
assert.equal(getNode('R').tmux_session, back, 'R\'s LOCATION session is the backstage');
|
|
157
|
-
assert.equal(focusByPane(backPane)?.node_id, 'A', 'the focus row tracks A after the retarget');
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
test('retargetFocus: outgoing DONE/dead (not live) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
|
|
161
|
-
await withSessions('kill', async ({ user, back, userWindow, backWindow }) => {
|
|
162
|
-
const focusPane = livePane(user, userWindow);
|
|
163
|
-
const backPane = livePane(back, backWindow);
|
|
164
|
-
// R is NOT live (status done) → the KILL path: its pane must be reaped after
|
|
165
|
-
// the swap, with NO lifecycle transition.
|
|
166
|
-
createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done', pi_pid: null, home_session: back }));
|
|
167
|
-
createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
168
|
-
openFocusRow('f1', focusPane, user, 'R');
|
|
169
|
-
retargetFocus('f1', 'A', NOREVIVE);
|
|
170
|
-
assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
|
|
171
|
-
assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
|
|
172
|
-
assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was KILLED (not live ⇒ no pane)');
|
|
173
|
-
assert.equal(getNode('R').status, 'done', 'R stays done (KILL does not transition status)');
|
|
174
|
-
assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
|
|
175
|
-
assert.equal(getNode('R').tmux_session, null);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
test('retargetFocus: outgoing PARKED terminal viewer (live pi, NOT mid-turn) → RELEASED to dormant (status idle, pane reaped) — the stuck-green fix', { skip: !hasTmux() }, async () => {
|
|
179
|
-
await withSessions('park', async ({ user, back, userWindow, backWindow }) => {
|
|
180
|
-
const focusPane = livePane(user, userWindow);
|
|
181
|
-
const backPane = livePane(back, backWindow);
|
|
182
|
-
// R is a terminal node revived only for VIEWING: live pi (pidAlive) but NOT
|
|
183
|
-
// mid-turn (no busy marker). It was the stuck-green bug: misread as
|
|
184
|
-
// generating, backstaged, and left status='active' forever. It must instead
|
|
185
|
-
// be RELEASED to dormant.
|
|
186
|
-
createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', lifecycle: 'terminal', pi_pid: process.pid, home_session: back }));
|
|
187
|
-
createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
188
|
-
assert.equal(isBusy('R'), false, 'precondition: R is parked between turns (not mid-turn)');
|
|
189
|
-
openFocusRow('f1', focusPane, user, 'R');
|
|
190
|
-
retargetFocus('f1', 'A', NOREVIVE);
|
|
191
|
-
assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
|
|
192
|
-
assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
|
|
193
|
-
// The fix: focus-away flips R back to dormant (idle + idle-release) and reaps
|
|
194
|
-
// its pane — so the editor stops drawing it green/active.
|
|
195
|
-
assert.equal(getNode('R').status, 'idle', 'R was RELEASED back to dormant (no longer stuck active/green)');
|
|
196
|
-
assert.equal(getNode('R').intent, 'idle-release', 'R carries idle-release so the daemon revives it on its inbox');
|
|
197
|
-
assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was REAPED (parked viewer ⇒ no pane)');
|
|
198
|
-
assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
|
|
199
|
-
assert.equal(getNode('R').tmux_session, null);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
test('retargetFocus Q5: focusing a node already focused ELSEWHERE vacates its old focus (it MOVES, no dup)', { skip: !hasTmux() }, async () => {
|
|
203
|
-
await withSessions('q5', async ({ user, back, userWindow, backWindow }) => {
|
|
204
|
-
// R sits in focus f1 (the user viewport). M is focused in f2 (a second pane).
|
|
205
|
-
const focusPane = livePane(user, userWindow);
|
|
206
|
-
const mPane = livePane(back, backWindow); // M's current focus pane (live)
|
|
207
|
-
createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
208
|
-
createNode(node('M', { pane: mPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
209
|
-
openFocusRow('f1', focusPane, user, 'R');
|
|
210
|
-
openFocusRow('f2', mPane, back, 'M');
|
|
211
|
-
// Focus M into f1 (the user viewport): M occupies f2 ≠ f1 → vacate f2.
|
|
212
|
-
const res = retargetFocus('f1', 'M', backstageReviver(back));
|
|
213
|
-
assert.equal(res.focused, true);
|
|
214
|
-
assert.equal(getFocusById('f2'), null, 'the OLD focus f2 was vacated (closed)');
|
|
215
|
-
assert.equal(paneExistsReal(mPane), false, 'M\'s old focus pane was killed (the node moved)');
|
|
216
|
-
const mFocus = getFocusByNode('M');
|
|
217
|
-
assert.equal(mFocus?.focus_id, 'f1', 'M now occupies ONLY f1 (UNIQUE node_id upheld)');
|
|
218
|
-
assert.equal(listFocuses().filter((f) => f.node_id === 'M').length, 1, 'exactly one focus shows M');
|
|
219
|
-
assert.equal(res.revived, true, 'M was revived into the backstage after its old pane was killed');
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
test('openFocus: splits a NEW viewport pane beside the caller (a holder row), NOT a new window', { skip: !hasTmux() }, async () => {
|
|
223
|
-
await withSessions('open', async ({ user, userWindow }) => {
|
|
224
|
-
const callerPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']);
|
|
225
|
-
const userWinBefore = windowIds(user).length;
|
|
226
|
-
const f = openFocus(callerPane, {});
|
|
227
|
-
assert.ok(f, 'openFocus returned a focus row');
|
|
228
|
-
assert.equal(windowIds(user).length, userWinBefore, 'a SPLIT, not a new window (Q3 side-by-side)');
|
|
229
|
-
assert.ok(f.pane?.startsWith('%'), 'the row anchors the new split pane');
|
|
230
|
-
assert.equal(paneExistsReal(f.pane), true, 'the holder pane is live');
|
|
231
|
-
assert.ok(f.node_id.startsWith('__hold_'), 'occupied by a holder until a node is retargeted in');
|
|
232
|
-
// remain-on-exit is armed on the viewport window (F3).
|
|
233
|
-
const win = tmuxOut(['display-message', '-p', '-t', f.pane, '#{window_id}']);
|
|
234
|
-
assert.equal(tmuxOut(['show-window-options', '-t', win, 'remain-on-exit']), 'remain-on-exit on', 'remain-on-exit armed (F3)');
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
test('focus front-door: round-trip open(register #1) → retarget in place → the focus follows the viewport', { skip: !hasTmux() }, async () => {
|
|
238
|
-
await withSessions('frontdoor', async ({ user, back, userWindow, backWindow }) => {
|
|
239
|
-
const rootPane = tmuxOut(['display-message', '-p', '-t', `${user}:${userWindow}`, '#{pane_id}']);
|
|
240
|
-
const aPane = livePane(back, backWindow);
|
|
241
|
-
createNode(node('R', { pane: rootPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: user }));
|
|
242
|
-
createNode(node('A', { pane: aPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
243
|
-
// Root boot registers focus #1 on the root's own pane.
|
|
244
|
-
const f1 = registerRootFocus('R', rootPane, user, userWindow);
|
|
245
|
-
assert.ok(f1, 'focus #1 registered');
|
|
246
|
-
assert.equal(focusByPane(rootPane)?.node_id, 'R');
|
|
247
|
-
// node focus A from the root's pane → retarget IN PLACE (same focus row).
|
|
248
|
-
const res = placementFocus('A', { pane: rootPane, callerNode: 'R', revive: NOREVIVE });
|
|
249
|
-
assert.equal(res.inPlace, true);
|
|
250
|
-
assert.equal(getFocusByNode('A')?.focus_id, f1.focus_id, 'the SAME focus row now shows A (no new focus)');
|
|
251
|
-
assert.equal(listFocuses().length, 1, 'still exactly one focus (retarget, not open)');
|
|
252
|
-
assert.equal(getFocusByNode('R'), null, 'R yielded the viewport');
|
|
253
|
-
// The focus followed the viewport: focusByPane on A's (now-in-viewport) pane resolves it.
|
|
254
|
-
assert.equal(focusByPane(aPane)?.node_id, 'A', 'the focus row tracks A\'s pane');
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
// Regression guards (review findings on HEAD=ccc3ee2): a detach/failed-open must
|
|
259
|
-
// not leave a dangling focus row or an orphan viewport pane (Invariant P / F4).
|
|
260
|
-
// ---------------------------------------------------------------------------
|
|
261
|
-
test('detachToBackground: a FOCUSED node sent to the backstage CLOSES its focus row (Invariant P — no phantom viewport)', { skip: !hasTmux() }, async () => {
|
|
262
|
-
await withSessions('detach', async ({ user, back, userWindow }) => {
|
|
263
|
-
const prev = process.env['CRTR_NODE_SESSION'];
|
|
264
|
-
process.env['CRTR_NODE_SESSION'] = back; // detach relocates into THIS backstage session
|
|
265
|
-
try {
|
|
266
|
-
const focusPane = livePane(user, userWindow); // the node's foreground viewport pane
|
|
267
|
-
createNode(node('N', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
268
|
-
openFocusRow('f1', focusPane, user, 'N'); // N is the focus occupant
|
|
269
|
-
assert.equal(getFocusByNode('N')?.focus_id, 'f1', 'precondition: N is focused');
|
|
270
|
-
// N must read as GENERATING for the relocate-to-backstage path: detach now
|
|
271
|
-
// gates the break-pane (Bug 1 / Invariant P) on isGenerating = busy &&
|
|
272
|
-
// pidAlive. pi_pid is this (alive) process; set the busy marker so N is
|
|
273
|
-
// genuinely mid-turn (otherwise the gate would RELEASE + reap it instead).
|
|
274
|
-
markBusy('N');
|
|
275
|
-
const ok = detachToBackground('N', focusPane);
|
|
276
|
-
assert.equal(ok, true, 'the break to the backstage succeeded');
|
|
277
|
-
// The fix: N is now generating-but-UNFOCUSED, so its focus row is CLOSED.
|
|
278
|
-
assert.equal(getFocusByNode('N'), null, 'N no longer occupies any focus (row CLOSED — Invariant P)');
|
|
279
|
-
assert.equal(focusByPane(focusPane), null, 'NO phantom focus resolves on the relocated pane (%id survives the break)');
|
|
280
|
-
assert.equal(listFocuses().length, 0, 'no dangling focus rows remain');
|
|
281
|
-
// The pi keeps running — the pane is alive, just moved off-screen.
|
|
282
|
-
assert.equal(paneExistsReal(focusPane), true, 'N\'s pi keeps generating (pane alive, relocated not killed)');
|
|
283
|
-
assert.equal(paneSession(focusPane), back, 'N\'s pane physically moved to the backstage session');
|
|
284
|
-
}
|
|
285
|
-
finally {
|
|
286
|
-
if (prev === undefined)
|
|
287
|
-
delete process.env['CRTR_NODE_SESSION'];
|
|
288
|
-
else
|
|
289
|
-
process.env['CRTR_NODE_SESSION'] = prev;
|
|
290
|
-
}
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
test('focus --new-pane: a FAILED retarget reaps the holder pane + focus row (no orphan viewport — F4/Invariant P)', { skip: !hasTmux() }, async () => {
|
|
294
|
-
await withSessions('newpane-fail', async ({ user, userWindow }) => {
|
|
295
|
-
const callerPane = livePane(user, userWindow); // the caller's pane we split beside
|
|
296
|
-
createNode(node('D', { status: 'active', pi_pid: null })); // D is DORMANT — no live pane
|
|
297
|
-
const winBefore = windowIds(user).length;
|
|
298
|
-
const panesIn = (s, w) => tmuxOut(['list-panes', '-t', `${s}:${w}`, '-F', '#{pane_id}']).split('\n').filter((x) => x !== '').length;
|
|
299
|
-
const panesBefore = panesIn(user, userWindow);
|
|
300
|
-
// A reviver that does NOTHING → D stays dormant, retargetFocus finds no live
|
|
301
|
-
// pin and returns focused:false. The just-opened holder must be reaped.
|
|
302
|
-
const noopRevive = () => { };
|
|
303
|
-
const res = placementFocus('D', { pane: callerPane, newPane: true, revive: noopRevive });
|
|
304
|
-
assert.equal(res.focused, false, 'D could not be placed → the --new-pane focus failed');
|
|
305
|
-
assert.equal(listFocuses().length, 0, 'the just-opened focus row was REAPED (no phantom)');
|
|
306
|
-
assert.equal(panesIn(user, userWindow), panesBefore, 'the holder split pane was REAPED (no leaked sleep pane)');
|
|
307
|
-
assert.equal(windowIds(user).length, winBefore, 'no window leaked');
|
|
308
|
-
});
|
|
309
|
-
});
|