@pellux/goodvibes-terminal-shell 1.7.0

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/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @pellux/goodvibes-terminal-shell
2
+
3
+ Shared terminal-shell plumbing for GoodVibes daemon front-ends. Two front-ends drive a full-screen terminal UI over the same daemon runtime, and they must keep a specific slice of that runtime wiring **identical**. When copies of it drift, real defects ship. This package is the single home for that slice, so each front-end consumes one implementation instead of maintaining a parallel copy.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @pellux/goodvibes-terminal-shell
9
+ ```
10
+
11
+ ## What belongs here
12
+
13
+ Plumbing that must not drift between front-ends:
14
+
15
+ - **Gateway verb-group composition** — `attachWsOnlyGatewayVerbHandlers(catalog, deps)` binds the ws-only verb DESCRIPTORS (`fleet.*`, `checkpoints.*`, `sessions.search`, `push.*`) to their HANDLERS together, so a verb can never be descriptor-present but handler-absent. A descriptor with no handler answers `501 "Gateway method is not invokable"` over both websocket and HTTP invoke; registering the two together is what prevents that. `createArchivableFleetRegistry(deps)` builds the one shared, archive-aware process registry the `fleet.*` verbs query.
16
+ - **Terminal enter/restore sequencing** — `createTerminalLifecycle(deps)` owns the alt-screen enter, the idempotent synchronous restore (leave the alt screen, or clear the primary viewport **without** `ESC[3J` so scrollback survives; show the cursor on the screen the shell prompt lands on), and the restored-state gate `isTerminalRestored()`. The canonical escape sequences live in `TERMINAL_ESCAPES`.
17
+ - **Render-tick coalescing** — `createRenderScheduler(renderNow, scheduleFlush?, isReleased?)` collapses a within-tick burst of render requests into exactly one composite. Wire its third `isReleased` argument to the lifecycle's `isTerminalRestored()` so a late frame after teardown cannot paint over the restored shell.
18
+ - **The descriptor/handler conformance gate** — see below.
19
+
20
+ Every capability is a thin, dependency-injected wrapper: the front-end passes its concrete managers (process registry, checkpoint manager, session broker, secrets manager, approval broker, shell paths, terminal I/O) in, and this package owns the wiring.
21
+
22
+ ## What does NOT belong here
23
+
24
+ Surface that front-ends legitimately diverge on, and which must stay in each app:
25
+
26
+ - Panels, views, and read-models
27
+ - Rendering, layout, and theming
28
+ - Keybindings and input handling
29
+ - Command surfaces and slash commands
30
+ - Application shutdown policy (draining services, persisting sessions, exit codes) — each app calls this package's `restoreTerminal()` for the terminal hand-back, but owns its own teardown.
31
+
32
+ ## The conformance gate
33
+
34
+ The exact regression this package exists to prevent — a registered descriptor with no handler — is catchable in your own CI. Compose your daemon/gateway catalog exactly as production does, then assert every descriptor is invokable:
35
+
36
+ ```ts
37
+ import { assertEveryDescriptorHasHandler } from '@pellux/goodvibes-terminal-shell/conformance';
38
+
39
+ test('every registered gateway descriptor has a handler', () => {
40
+ const catalog = composeMyDaemonCatalog();
41
+ assertEveryDescriptorHasHandler(catalog); // throws with the offending ids
42
+ });
43
+ ```
44
+
45
+ `findMethodsMissingHandlers(catalog, options)` returns the offending ids instead of throwing. Both accept `onlyIds` / `ignoreIds` for catalogs whose builtin descriptors get handlers from a different layer. The catalog is read through a narrow structural view, so any `GatewayMethodCatalog`-shaped object works.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * conformance.ts — the descriptor/handler drift gate.
3
+ *
4
+ * The gate's implementation now lives once in the contracts package
5
+ * (@pellux/goodvibes-contracts/testing) so every consuming front-end runs the
6
+ * SAME kit rather than a divergent local copy. This module re-exports it so
7
+ * @pellux/goodvibes-terminal-shell's public surface is unchanged — the single
8
+ * source moved, the consumer contract did not.
9
+ */
10
+ export { findMethodsMissingHandlers, assertEveryDescriptorHasHandler, type GatewayCatalogConformanceView, type ConformanceOptions, } from '@pellux/goodvibes-contracts/testing';
11
+ //# sourceMappingURL=conformance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conformance.d.ts","sourceRoot":"","sources":["../src/conformance.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EACL,0BAA0B,EAC1B,+BAA+B,EAC/B,KAAK,6BAA6B,EAClC,KAAK,kBAAkB,GACxB,MAAM,qCAAqC,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * conformance.ts — the descriptor/handler drift gate.
3
+ *
4
+ * The gate's implementation now lives once in the contracts package
5
+ * (@pellux/goodvibes-contracts/testing) so every consuming front-end runs the
6
+ * SAME kit rather than a divergent local copy. This module re-exports it so
7
+ * @pellux/goodvibes-terminal-shell's public surface is unchanged — the single
8
+ * source moved, the consumer contract did not.
9
+ */
10
+ export { findMethodsMissingHandlers, assertEveryDescriptorHasHandler, } from '@pellux/goodvibes-contracts/testing';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * gateway-verbs.ts — attach handlers for every ws-only gateway verb group, and
3
+ * build the archive-aware fleet registry, in one shared place both daemon
4
+ * front-ends consume.
5
+ *
6
+ * The GatewayMethodCatalog's builtin DESCRIPTORS make fleet.* (including the
7
+ * archive verbs), checkpoints.*, sessions.search, and push.* appear in the
8
+ * contract, but a descriptor without an attached handler answers
9
+ * 501 "Gateway method is not invokable" over both websocket and HTTP invoke.
10
+ * A composition root that registers those descriptors but never calls the SDK's
11
+ * registration entry point ships exactly that 501 for the whole ws-only family
12
+ * — the regression this package exists to make impossible to reintroduce
13
+ * independently in each front-end. `attachWsOnlyGatewayVerbHandlers` binds the
14
+ * descriptors and their handlers together, and the conformance helper in
15
+ * ./conformance.ts gates that no descriptor is ever left handler-less.
16
+ *
17
+ * This module is a thin, dependency-injected wrapper over the SDK's own
18
+ * registration entry points. Front-ends pass their concrete managers in; the
19
+ * package owns the wiring so it cannot drift between them.
20
+ */
21
+ import { type GatewayMethodCatalog, type GatewayVerbGroupDeps } from '@pellux/goodvibes-sdk/platform/control-plane';
22
+ import { type ArchivableProcessRegistry, type ProcessRegistryDeps } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
23
+ /**
24
+ * Attach handlers for the ws-only gateway verb groups (fleet.* / checkpoints.* /
25
+ * sessions.search / push.*) onto an existing catalog. Descriptors and handlers
26
+ * are registered together, so a verb can never be descriptor-present but
27
+ * handler-absent (the 501 regression class).
28
+ *
29
+ * Call once, at composition time, after the injected managers all exist. The
30
+ * dependency shape (`GatewayVerbGroupDeps`) is owned by the SDK; this wrapper
31
+ * exists so each front-end has a single named call site instead of reaching
32
+ * into the SDK's registration internals directly and diverging.
33
+ */
34
+ export declare function attachWsOnlyGatewayVerbHandlers(gatewayMethods: GatewayMethodCatalog, deps: GatewayVerbGroupDeps): void;
35
+ /**
36
+ * Build the one shared, archive-aware process registry that aggregates a
37
+ * front-end's runtime managers (agents, WRFC chains, workflows, watchers,
38
+ * background processes). Constructed once per composition — not per consumer —
39
+ * so the coalesced tick and the agent-activity side-table are shared, not
40
+ * duplicated. Archive-aware: finished agent/swarm subtrees can be moved out of
41
+ * the live fleet view into a session-scoped archive (see the SDK's
42
+ * fleet/archive.ts).
43
+ *
44
+ * This is the registry the fleet.* gateway verbs above query, so building it
45
+ * here keeps the registry and its verb handlers on the same shared seam.
46
+ */
47
+ export declare function createArchivableFleetRegistry(deps: ProcessRegistryDeps): ArchivableProcessRegistry;
48
+ export type { GatewayVerbGroupDeps, ProcessRegistryDeps, ArchivableProcessRegistry };
49
+ //# sourceMappingURL=gateway-verbs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateway-verbs.d.ts","sourceRoot":"","sources":["../src/gateway-verbs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAEL,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EAC1B,MAAM,8CAA8C,CAAC;AACtD,OAAO,EAGL,KAAK,yBAAyB,EAC9B,KAAK,mBAAmB,EACzB,MAAM,8CAA8C,CAAC;AAEtD;;;;;;;;;;GAUG;AACH,wBAAgB,+BAA+B,CAC7C,cAAc,EAAE,oBAAoB,EACpC,IAAI,EAAE,oBAAoB,GACzB,IAAI,CAEN;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,mBAAmB,GAAG,yBAAyB,CAElG;AAED,YAAY,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,yBAAyB,EAAE,CAAC"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * gateway-verbs.ts — attach handlers for every ws-only gateway verb group, and
3
+ * build the archive-aware fleet registry, in one shared place both daemon
4
+ * front-ends consume.
5
+ *
6
+ * The GatewayMethodCatalog's builtin DESCRIPTORS make fleet.* (including the
7
+ * archive verbs), checkpoints.*, sessions.search, and push.* appear in the
8
+ * contract, but a descriptor without an attached handler answers
9
+ * 501 "Gateway method is not invokable" over both websocket and HTTP invoke.
10
+ * A composition root that registers those descriptors but never calls the SDK's
11
+ * registration entry point ships exactly that 501 for the whole ws-only family
12
+ * — the regression this package exists to make impossible to reintroduce
13
+ * independently in each front-end. `attachWsOnlyGatewayVerbHandlers` binds the
14
+ * descriptors and their handlers together, and the conformance helper in
15
+ * ./conformance.ts gates that no descriptor is ever left handler-less.
16
+ *
17
+ * This module is a thin, dependency-injected wrapper over the SDK's own
18
+ * registration entry points. Front-ends pass their concrete managers in; the
19
+ * package owns the wiring so it cannot drift between them.
20
+ */
21
+ import { registerGatewayVerbGroups, } from '@pellux/goodvibes-sdk/platform/control-plane';
22
+ import { createProcessRegistry, withFleetArchive, } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
23
+ /**
24
+ * Attach handlers for the ws-only gateway verb groups (fleet.* / checkpoints.* /
25
+ * sessions.search / push.*) onto an existing catalog. Descriptors and handlers
26
+ * are registered together, so a verb can never be descriptor-present but
27
+ * handler-absent (the 501 regression class).
28
+ *
29
+ * Call once, at composition time, after the injected managers all exist. The
30
+ * dependency shape (`GatewayVerbGroupDeps`) is owned by the SDK; this wrapper
31
+ * exists so each front-end has a single named call site instead of reaching
32
+ * into the SDK's registration internals directly and diverging.
33
+ */
34
+ export function attachWsOnlyGatewayVerbHandlers(gatewayMethods, deps) {
35
+ registerGatewayVerbGroups(gatewayMethods, deps);
36
+ }
37
+ /**
38
+ * Build the one shared, archive-aware process registry that aggregates a
39
+ * front-end's runtime managers (agents, WRFC chains, workflows, watchers,
40
+ * background processes). Constructed once per composition — not per consumer —
41
+ * so the coalesced tick and the agent-activity side-table are shared, not
42
+ * duplicated. Archive-aware: finished agent/swarm subtrees can be moved out of
43
+ * the live fleet view into a session-scoped archive (see the SDK's
44
+ * fleet/archive.ts).
45
+ *
46
+ * This is the registry the fleet.* gateway verbs above query, so building it
47
+ * here keeps the registry and its verb handlers on the same shared seam.
48
+ */
49
+ export function createArchivableFleetRegistry(deps) {
50
+ return withFleetArchive(createProcessRegistry(deps));
51
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @pellux/goodvibes-terminal-shell
3
+ *
4
+ * Shared terminal-shell plumbing for GoodVibes daemon front-ends. This is the
5
+ * single home for the runtime wiring that two front-ends must keep identical:
6
+ * gateway verb-group composition, terminal enter/restore sequencing, and
7
+ * render-tick coalescing. Each capability is a thin, dependency-injected wrapper
8
+ * so a front-end's composition root becomes a few named calls into this package
9
+ * instead of a hand-maintained copy that drifts.
10
+ *
11
+ * See ./conformance for the descriptor/handler gate a consumer runs against its
12
+ * own composition in CI.
13
+ */
14
+ export { attachWsOnlyGatewayVerbHandlers, createArchivableFleetRegistry, type GatewayVerbGroupDeps, type ProcessRegistryDeps, type ArchivableProcessRegistry, } from './gateway-verbs.js';
15
+ export { TERMINAL_ESCAPES, createTerminalLifecycle, type TerminalEscapes, type TerminalSequenceSet, type TerminalLifecycleDeps, type TerminalLifecycle, } from './terminal-lifecycle.js';
16
+ export { createRenderScheduler, type RenderScheduler, } from './render-scheduler.js';
17
+ export { findMethodsMissingHandlers, assertEveryDescriptorHasHandler, type GatewayCatalogConformanceView, type ConformanceOptions, } from './conformance.js';
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EACL,+BAA+B,EAC/B,6BAA6B,EAC7B,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACxB,KAAK,yBAAyB,GAC/B,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,GACvB,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EACL,qBAAqB,EACrB,KAAK,eAAe,GACrB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,0BAA0B,EAC1B,+BAA+B,EAC/B,KAAK,6BAA6B,EAClC,KAAK,kBAAkB,GACxB,MAAM,kBAAkB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @pellux/goodvibes-terminal-shell
3
+ *
4
+ * Shared terminal-shell plumbing for GoodVibes daemon front-ends. This is the
5
+ * single home for the runtime wiring that two front-ends must keep identical:
6
+ * gateway verb-group composition, terminal enter/restore sequencing, and
7
+ * render-tick coalescing. Each capability is a thin, dependency-injected wrapper
8
+ * so a front-end's composition root becomes a few named calls into this package
9
+ * instead of a hand-maintained copy that drifts.
10
+ *
11
+ * See ./conformance for the descriptor/handler gate a consumer runs against its
12
+ * own composition in CI.
13
+ */
14
+ export { attachWsOnlyGatewayVerbHandlers, createArchivableFleetRegistry, } from './gateway-verbs.js';
15
+ export { TERMINAL_ESCAPES, createTerminalLifecycle, } from './terminal-lifecycle.js';
16
+ export { createRenderScheduler, } from './render-scheduler.js';
17
+ export { findMethodsMissingHandlers, assertEveryDescriptorHasHandler, } from './conformance.js';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * render-scheduler.ts — same-tick render coalescing for the terminal shell.
3
+ *
4
+ * A front-end fans out its own direct render() calls across turn/stream/input
5
+ * wiring (many invocation contexts). Each one previously ran a full synchronous
6
+ * composite the instant it was called. When several fire within a single
7
+ * event-loop tick — a streaming burst is the canonical case — only the LAST
8
+ * frame is ever visible; the earlier composites are immediately overwritten.
9
+ * This scheduler collapses every schedule() call made within one tick into a
10
+ * single composite flushed on the microtask queue, so the tick produces exactly
11
+ * one frame. That frame is byte-identical to what the last synchronous render()
12
+ * would have produced, because the coalesced flush runs after all of the tick's
13
+ * synchronous state mutations, reading the same final state the last direct
14
+ * render() would have read.
15
+ *
16
+ * This is deliberately SEPARATE from any cross-tick, throttled (~60fps)
17
+ * coalescer a front-end may run for its panel/input/runtime fan-out — that kind
18
+ * caps repaint RATE across ticks. This one collapses the WITHIN-tick burst with
19
+ * no added latency: a microtask flushes at the tail of the current tick, before
20
+ * the event loop yields to I/O, so the frame still lands in the same turn it was
21
+ * requested in.
22
+ *
23
+ * flushNow() preserves a synchronous immediate path for callers that genuinely
24
+ * need the frame composited before they return. Terminal resize is the known
25
+ * case: the resize handler resets the compositor diff and must repaint against
26
+ * the new dimensions synchronously rather than waiting for the microtask.
27
+ * flushNow() also clears any pending coalesced flush, so a resize (or any
28
+ * immediate paint) followed by an already-queued microtask never double-composites.
29
+ */
30
+ export interface RenderScheduler {
31
+ /**
32
+ * Coalesced request: schedule a composite for the current tick. N calls within
33
+ * one tick collapse into exactly one composite, flushed on the microtask queue.
34
+ */
35
+ readonly schedule: () => void;
36
+ /**
37
+ * Immediate request: run the composite synchronously right now, and cancel any
38
+ * pending coalesced flush so the tick still composites only once. Use only where
39
+ * a caller genuinely requires synchronous output (terminal resize).
40
+ */
41
+ readonly flushNow: () => void;
42
+ }
43
+ /**
44
+ * Build a render scheduler around `renderNow` (the synchronous composite closure).
45
+ *
46
+ * `scheduleFlush` is the deferral primitive; it defaults to `queueMicrotask` so
47
+ * bursts coalesce within the current tick. Tests and benchmarks inject a manual
48
+ * queue to flush deterministically.
49
+ */
50
+ export declare function createRenderScheduler(renderNow: () => void, scheduleFlush?: (flush: () => void) => void,
51
+ /**
52
+ * When provided and returning true, every composite path becomes a no-op.
53
+ * Wire this to the terminal lifecycle's isTerminalRestored(): once the exit
54
+ * teardown has handed the terminal back to the shell, a late frame (async
55
+ * shutdown races, stray timers) would paint cursor-positioned content over
56
+ * the user's primary screen and strand the next prompt mid-screen.
57
+ */
58
+ isReleased?: () => boolean): RenderScheduler;
59
+ //# sourceMappingURL=render-scheduler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-scheduler.d.ts","sourceRoot":"","sources":["../src/render-scheduler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,IAAI,EACrB,aAAa,GAAE,CAAC,KAAK,EAAE,MAAM,IAAI,KAAK,IAAqB;AAC3D;;;;;;GAMG;AACH,UAAU,GAAE,MAAM,OAAqB,GACtC,eAAe,CA2BjB"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * render-scheduler.ts — same-tick render coalescing for the terminal shell.
3
+ *
4
+ * A front-end fans out its own direct render() calls across turn/stream/input
5
+ * wiring (many invocation contexts). Each one previously ran a full synchronous
6
+ * composite the instant it was called. When several fire within a single
7
+ * event-loop tick — a streaming burst is the canonical case — only the LAST
8
+ * frame is ever visible; the earlier composites are immediately overwritten.
9
+ * This scheduler collapses every schedule() call made within one tick into a
10
+ * single composite flushed on the microtask queue, so the tick produces exactly
11
+ * one frame. That frame is byte-identical to what the last synchronous render()
12
+ * would have produced, because the coalesced flush runs after all of the tick's
13
+ * synchronous state mutations, reading the same final state the last direct
14
+ * render() would have read.
15
+ *
16
+ * This is deliberately SEPARATE from any cross-tick, throttled (~60fps)
17
+ * coalescer a front-end may run for its panel/input/runtime fan-out — that kind
18
+ * caps repaint RATE across ticks. This one collapses the WITHIN-tick burst with
19
+ * no added latency: a microtask flushes at the tail of the current tick, before
20
+ * the event loop yields to I/O, so the frame still lands in the same turn it was
21
+ * requested in.
22
+ *
23
+ * flushNow() preserves a synchronous immediate path for callers that genuinely
24
+ * need the frame composited before they return. Terminal resize is the known
25
+ * case: the resize handler resets the compositor diff and must repaint against
26
+ * the new dimensions synchronously rather than waiting for the microtask.
27
+ * flushNow() also clears any pending coalesced flush, so a resize (or any
28
+ * immediate paint) followed by an already-queued microtask never double-composites.
29
+ */
30
+ /**
31
+ * Build a render scheduler around `renderNow` (the synchronous composite closure).
32
+ *
33
+ * `scheduleFlush` is the deferral primitive; it defaults to `queueMicrotask` so
34
+ * bursts coalesce within the current tick. Tests and benchmarks inject a manual
35
+ * queue to flush deterministically.
36
+ */
37
+ export function createRenderScheduler(renderNow, scheduleFlush = queueMicrotask,
38
+ /**
39
+ * When provided and returning true, every composite path becomes a no-op.
40
+ * Wire this to the terminal lifecycle's isTerminalRestored(): once the exit
41
+ * teardown has handed the terminal back to the shell, a late frame (async
42
+ * shutdown races, stray timers) would paint cursor-positioned content over
43
+ * the user's primary screen and strand the next prompt mid-screen.
44
+ */
45
+ isReleased = () => false) {
46
+ let scheduled = false;
47
+ const flush = () => {
48
+ // A synchronous flushNow() (or a superseding immediate paint) before this
49
+ // microtask ran clears `scheduled`, turning this into a no-op so a single
50
+ // tick never composites twice.
51
+ if (!scheduled)
52
+ return;
53
+ scheduled = false;
54
+ if (isReleased())
55
+ return;
56
+ renderNow();
57
+ };
58
+ const schedule = () => {
59
+ if (scheduled)
60
+ return;
61
+ scheduled = true;
62
+ scheduleFlush(flush);
63
+ };
64
+ const flushNow = () => {
65
+ // Satisfy any pending coalesced flush, then composite synchronously.
66
+ scheduled = false;
67
+ if (isReleased())
68
+ return;
69
+ renderNow();
70
+ };
71
+ return { schedule, flushNow };
72
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * terminal-lifecycle.ts — shared terminal enter/restore sequencing for GoodVibes
3
+ * daemon front-ends.
4
+ *
5
+ * Both front-ends drive a full-screen terminal UI and must enter and later
6
+ * restore the terminal identically. When these sequences diverge between them,
7
+ * exit teardown leaves the wrong screen active or wipes the user's scrollback —
8
+ * a defect class that recurs whenever the two copies drift. This module is the
9
+ * single home for the escape sequences and the enter/restore ordering, so both
10
+ * front-ends share one implementation.
11
+ *
12
+ * What lives here is only the terminal-state sequencing and the restored-state
13
+ * gate. The graceful application shutdown (draining services, persisting
14
+ * sessions, exit codes) is front-end-specific and stays in each app's own
15
+ * process-lifecycle wiring; that wiring calls `restoreTerminal()` from this
16
+ * module for the synchronous terminal hand-back.
17
+ */
18
+ /**
19
+ * The canonical control sequences the shell writes to enter and leave terminal
20
+ * mode (alt screen, mouse, cursor, keyboard-extension, paste, and focus
21
+ * reporting). Shared verbatim so the two front-ends cannot drift on the bytes.
22
+ */
23
+ export declare const TERMINAL_ESCAPES: {
24
+ readonly ALT_SCREEN_ENTER: "\u001B[?1049h";
25
+ readonly ALT_SCREEN_EXIT: "\u001B[?1049l";
26
+ readonly MOUSE_ENABLE: "\u001B[?1000h\u001B[?1002h\u001B[?1006h";
27
+ readonly MOUSE_DISABLE: "\u001B[?1006l\u001B[?1002l\u001B[?1000l";
28
+ readonly CURSOR_HIDE: "\u001B[?25l";
29
+ readonly CURSOR_SHOW: "\u001B[?25h";
30
+ readonly KEYBOARD_EXT_ENABLE: "\u001B[>4;2m\u001B[?1u";
31
+ readonly KEYBOARD_EXT_DISABLE: "\u001B[>4;0m\u001B[?1l";
32
+ readonly PASTE_ENABLE: "\u001B[?2004h";
33
+ readonly PASTE_DISABLE: "\u001B[?2004l";
34
+ readonly FOCUS_ENABLE: "\u001B[?1004h";
35
+ readonly FOCUS_DISABLE: "\u001B[?1004l";
36
+ /**
37
+ * Clear viewport + home the cursor, WITHOUT ESC[3J. The 3J form wipes the
38
+ * user's PRIMARY-screen scrollback on several emulators; the restore path
39
+ * must never use it. Used by the no-alt-screen restore path.
40
+ */
41
+ readonly CLEAR_VIEWPORT_HOME: "\u001B[2J\u001B[H";
42
+ };
43
+ export type TerminalEscapes = typeof TERMINAL_ESCAPES;
44
+ /**
45
+ * The subset of escape sequences the enter/restore paths compose. Defaults to
46
+ * TERMINAL_ESCAPES; overridable only for tests that assert exact byte output.
47
+ */
48
+ export interface TerminalSequenceSet {
49
+ readonly ALT_SCREEN_ENTER: string;
50
+ readonly ALT_SCREEN_EXIT: string;
51
+ readonly MOUSE_ENABLE: string;
52
+ readonly MOUSE_DISABLE: string;
53
+ readonly CURSOR_HIDE: string;
54
+ readonly CURSOR_SHOW: string;
55
+ readonly KEYBOARD_EXT_ENABLE: string;
56
+ readonly KEYBOARD_EXT_DISABLE: string;
57
+ readonly PASTE_ENABLE: string;
58
+ readonly PASTE_DISABLE: string;
59
+ readonly FOCUS_ENABLE: string;
60
+ readonly FOCUS_DISABLE: string;
61
+ readonly CLEAR_VIEWPORT_HOME: string;
62
+ }
63
+ export interface TerminalLifecycleDeps {
64
+ /** Raw write to the terminal output stream (typically stdout.write bound). */
65
+ readonly write: (data: string) => void;
66
+ /**
67
+ * Whether the alt screen is skipped. Mirrors the front-end's --no-alt-screen
68
+ * flag: when true, enter never switches to the alt screen and restore clears
69
+ * the primary viewport (without 3J) instead of leaving the alt screen.
70
+ */
71
+ readonly noAltScreen: boolean;
72
+ /**
73
+ * Wrap the restore write so it is not suppressed by an output guard. When a
74
+ * front-end installs a stdout guard, restore must still reach the real
75
+ * terminal. Defaults to calling the write directly.
76
+ */
77
+ readonly guardedWrite?: (write: () => void) => void;
78
+ /**
79
+ * Dispose the front-end's terminal output guard, if any. Called AFTER the
80
+ * restore write so a crash stack reaches the real stderr instead of being
81
+ * suppressed by the guard. No-op by default.
82
+ */
83
+ readonly disposeOutputGuard?: () => void;
84
+ /**
85
+ * Set raw mode on the input stream. Called last during restore; wrapped in a
86
+ * try/catch because stdin may not be a TTY. No-op by default.
87
+ */
88
+ readonly setRawMode?: (enabled: boolean) => void;
89
+ /** Escape sequences; defaults to the shared TERMINAL_ESCAPES. */
90
+ readonly escapes?: TerminalSequenceSet;
91
+ }
92
+ export interface TerminalLifecycle {
93
+ /**
94
+ * Compose and write the enter sequence: alt-screen (unless noAltScreen),
95
+ * clear + home, hide cursor, then enable mouse / keyboard-extension / paste /
96
+ * focus reporting. Idempotent-safe to call once at startup.
97
+ */
98
+ readonly enterTerminal: () => void;
99
+ /**
100
+ * Idempotent, synchronous-only terminal restore. Safe to call from
101
+ * process 'exit', signal handlers, uncaughtException, and the graceful exit
102
+ * path. Disposes the output guard AFTER the restore write so a crash stack
103
+ * still reaches the real stderr.
104
+ */
105
+ readonly restoreTerminal: () => void;
106
+ /**
107
+ * True once restoreTerminal has run. The compositor must never write another
108
+ * frame after this: the terminal is back on the user's primary screen, and a
109
+ * late cursor-positioned frame (async shutdown races, stray timers) would
110
+ * paint over shell content and strand the prompt mid-screen. Wire the render
111
+ * scheduler's `isReleased` to this.
112
+ */
113
+ readonly isTerminalRestored: () => boolean;
114
+ }
115
+ /**
116
+ * Build the shared terminal lifecycle around a front-end's injected terminal
117
+ * I/O. Behavior mirrors the reference front-end exactly: enter switches to the
118
+ * alt screen (unless disabled) and enables the input modes; restore leaves the
119
+ * alt screen (or clears the primary viewport without 3J), shows the cursor on
120
+ * the screen the shell prompt lands on, then hands input back.
121
+ */
122
+ export declare function createTerminalLifecycle(deps: TerminalLifecycleDeps): TerminalLifecycle;
123
+ //# sourceMappingURL=terminal-lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminal-lifecycle.d.ts","sourceRoot":"","sources":["../src/terminal-lifecycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;GAIG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;IAa3B;;;;OAIG;;CAEK,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,OAAO,gBAAgB,CAAC;AAEtD;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;CACtC;AAED,MAAM,WAAW,qBAAqB;IACpC,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IACpD;;;;OAIG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,IAAI,CAAC;IACzC;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD,iEAAiE;IACjE,QAAQ,CAAC,OAAO,CAAC,EAAE,mBAAmB,CAAC;CACxC;AAED,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,QAAQ,CAAC,aAAa,EAAE,MAAM,IAAI,CAAC;IACnC;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,IAAI,CAAC;IACrC;;;;;;OAMG;IACH,QAAQ,CAAC,kBAAkB,EAAE,MAAM,OAAO,CAAC;CAC5C;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,qBAAqB,GAAG,iBAAiB,CAmDtF"}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * terminal-lifecycle.ts — shared terminal enter/restore sequencing for GoodVibes
3
+ * daemon front-ends.
4
+ *
5
+ * Both front-ends drive a full-screen terminal UI and must enter and later
6
+ * restore the terminal identically. When these sequences diverge between them,
7
+ * exit teardown leaves the wrong screen active or wipes the user's scrollback —
8
+ * a defect class that recurs whenever the two copies drift. This module is the
9
+ * single home for the escape sequences and the enter/restore ordering, so both
10
+ * front-ends share one implementation.
11
+ *
12
+ * What lives here is only the terminal-state sequencing and the restored-state
13
+ * gate. The graceful application shutdown (draining services, persisting
14
+ * sessions, exit codes) is front-end-specific and stays in each app's own
15
+ * process-lifecycle wiring; that wiring calls `restoreTerminal()` from this
16
+ * module for the synchronous terminal hand-back.
17
+ */
18
+ /**
19
+ * The canonical control sequences the shell writes to enter and leave terminal
20
+ * mode (alt screen, mouse, cursor, keyboard-extension, paste, and focus
21
+ * reporting). Shared verbatim so the two front-ends cannot drift on the bytes.
22
+ */
23
+ export const TERMINAL_ESCAPES = {
24
+ ALT_SCREEN_ENTER: '\x1b[?1049h',
25
+ ALT_SCREEN_EXIT: '\x1b[?1049l',
26
+ MOUSE_ENABLE: '\x1b[?1000h\x1b[?1002h\x1b[?1006h',
27
+ MOUSE_DISABLE: '\x1b[?1006l\x1b[?1002l\x1b[?1000l',
28
+ CURSOR_HIDE: '\x1b[?25l',
29
+ CURSOR_SHOW: '\x1b[?25h',
30
+ KEYBOARD_EXT_ENABLE: '\x1b[>4;2m\x1b[?1u',
31
+ KEYBOARD_EXT_DISABLE: '\x1b[>4;0m\x1b[?1l',
32
+ PASTE_ENABLE: '\x1b[?2004h',
33
+ PASTE_DISABLE: '\x1b[?2004l',
34
+ FOCUS_ENABLE: '\x1b[?1004h',
35
+ FOCUS_DISABLE: '\x1b[?1004l',
36
+ /**
37
+ * Clear viewport + home the cursor, WITHOUT ESC[3J. The 3J form wipes the
38
+ * user's PRIMARY-screen scrollback on several emulators; the restore path
39
+ * must never use it. Used by the no-alt-screen restore path.
40
+ */
41
+ CLEAR_VIEWPORT_HOME: '\x1b[2J\x1b[H',
42
+ };
43
+ /**
44
+ * Build the shared terminal lifecycle around a front-end's injected terminal
45
+ * I/O. Behavior mirrors the reference front-end exactly: enter switches to the
46
+ * alt screen (unless disabled) and enables the input modes; restore leaves the
47
+ * alt screen (or clears the primary viewport without 3J), shows the cursor on
48
+ * the screen the shell prompt lands on, then hands input back.
49
+ */
50
+ export function createTerminalLifecycle(deps) {
51
+ const escapes = deps.escapes ?? TERMINAL_ESCAPES;
52
+ const guardedWrite = deps.guardedWrite ?? ((write) => write());
53
+ const disposeOutputGuard = deps.disposeOutputGuard ?? (() => { });
54
+ const setRawMode = deps.setRawMode ?? (() => { });
55
+ const enterTerminal = () => {
56
+ guardedWrite(() => deps.write((deps.noAltScreen ? '' : escapes.ALT_SCREEN_ENTER)
57
+ + escapes.CLEAR_VIEWPORT_HOME
58
+ + escapes.CURSOR_HIDE
59
+ + escapes.MOUSE_ENABLE
60
+ + escapes.KEYBOARD_EXT_ENABLE
61
+ + escapes.PASTE_ENABLE
62
+ + escapes.FOCUS_ENABLE));
63
+ };
64
+ let terminalRestored = false;
65
+ const restoreTerminal = () => {
66
+ if (terminalRestored)
67
+ return;
68
+ terminalRestored = true;
69
+ // Alt-screen path: just leave the alt screen — 1049l restores the primary
70
+ // screen and cursor exactly as they were at launch. Clearing first is
71
+ // pointless (the alt screen is discarded) and actively harmful: a clear
72
+ // with ESC[3J wipes the PRIMARY scrollback on several emulators even when
73
+ // issued from the alt screen.
74
+ // No-alt path: the compositor painted over the primary screen, so clear
75
+ // the viewport and home the cursor — but WITHOUT 3J, the user's scrollback
76
+ // is theirs. CURSOR_SHOW goes AFTER the screen switch so visibility
77
+ // applies to the screen the shell prompt lands on.
78
+ const exitScreen = deps.noAltScreen ? escapes.CLEAR_VIEWPORT_HOME : escapes.ALT_SCREEN_EXIT;
79
+ guardedWrite(() => deps.write(escapes.PASTE_DISABLE + escapes.KEYBOARD_EXT_DISABLE + escapes.MOUSE_DISABLE + escapes.FOCUS_DISABLE
80
+ + exitScreen + escapes.CURSOR_SHOW));
81
+ // Dispose the guard AFTER the restore write so a crash stack reaches the
82
+ // real stderr instead of being suppressed by the guard.
83
+ disposeOutputGuard();
84
+ try {
85
+ setRawMode(false);
86
+ }
87
+ catch { /* input stream may not be a TTY */ }
88
+ };
89
+ return {
90
+ enterTerminal,
91
+ restoreTerminal,
92
+ isTerminalRestored: () => terminalRestored,
93
+ };
94
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@pellux/goodvibes-terminal-shell",
3
+ "version": "1.7.0",
4
+ "engines": {
5
+ "bun": "1.3.10",
6
+ "node": ">=22.0.0"
7
+ },
8
+ "description": "Shared terminal-shell plumbing for GoodVibes daemon front-ends: gateway verb-group composition, terminal enter/restore sequencing, render-tick coalescing, and a descriptor/handler conformance gate.",
9
+ "type": "module",
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./conformance": {
18
+ "types": "./dist/conformance.d.ts",
19
+ "import": "./dist/conformance.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "sideEffects": false,
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/mgd34msu/goodvibes-sdk.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/mgd34msu/goodvibes-sdk/issues"
34
+ },
35
+ "homepage": "https://github.com/mgd34msu/goodvibes-sdk",
36
+ "keywords": [
37
+ "goodvibes",
38
+ "sdk",
39
+ "terminal",
40
+ "daemon",
41
+ "gateway"
42
+ ],
43
+ "dependencies": {
44
+ "@pellux/goodvibes-contracts": "1.7.0",
45
+ "@pellux/goodvibes-sdk": "1.7.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }