tmux-ide 2.6.0 → 2.6.1
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/bin/cli.js +13 -5
- package/bin/cli.ts +1 -1
- package/bunfig.toml +4 -0
- package/package.json +11 -3
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/src/agent-explain.ts +270 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +336 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +195 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +428 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +113 -0
- package/packages/daemon/src/tui/detect/classify.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
- package/packages/daemon/src/tui/detect/manifest.ts +184 -0
- package/packages/daemon/src/tui/detect/manifests.ts +226 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +70 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +11 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +73 -0
- package/packages/daemon/src/tui/team/sessions.ts +344 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/postinstall.js +26 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tmux-ide SIDEBAR — the app's persistent nav column.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the widget PANELS (floating popups) this is a real, always-visible
|
|
5
|
+
* tmux pane: a narrow full-height left column that lists the fleet
|
|
6
|
+
* (project → session → window) with live status glyphs and jumps the viewing
|
|
7
|
+
* client anywhere on enter. It's summoned per-session with a toggle key
|
|
8
|
+
* (`keys.sidebar`, default `M-b`) that runs `tmux-ide sidebar-toggle`, so the
|
|
9
|
+
* app column appears wherever you call for it — in ANY adopted session.
|
|
10
|
+
*
|
|
11
|
+
* This module owns the tmux mechanics: the pure toggle-key bind builders (bound
|
|
12
|
+
* server-wide by `adoptSession`, like the other chrome keys) and the io that
|
|
13
|
+
* opens/finds/closes the column pane. The pane is marked with
|
|
14
|
+
* {@link SIDEBAR_PANE_OPTION} so the data layer excludes it from status rollups
|
|
15
|
+
* (see `../team/sessions.ts`) and the chip updater never labels it an agent.
|
|
16
|
+
*
|
|
17
|
+
* The command builders are PURE (tested); the io wrappers are thin.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { runTmux } from "@tmux-ide/tmux-bridge";
|
|
23
|
+
import type { ThemeConfig } from "../../types.ts";
|
|
24
|
+
import { shellEscape } from "../../lib/shell.ts";
|
|
25
|
+
import { SIDEBAR_PANE_OPTION } from "../team/sessions.ts";
|
|
26
|
+
import { resolveTuiLaunch, findCompiledTui, isBunAvailable } from "../compiled.ts";
|
|
27
|
+
|
|
28
|
+
export { SIDEBAR_PANE_OPTION };
|
|
29
|
+
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the sidebar widget's `.tsx` entry, robust across BOTH run modes. The
|
|
34
|
+
* widget sources ship uncompiled and are spawned by `bun`, so a bundled caller
|
|
35
|
+
* (this module inlined into `bin/cli.js`) must reach them via a bin-anchored
|
|
36
|
+
* path, while a dev caller (this module at `tui/chrome/`) reaches them relatively
|
|
37
|
+
* — the two differ, so we probe candidates and take the first that exists. Falls
|
|
38
|
+
* back to the dev path so the returned command is at least well-formed.
|
|
39
|
+
*/
|
|
40
|
+
export function sidebarWidgetScript(): string {
|
|
41
|
+
const candidates = [
|
|
42
|
+
resolve(__dirname, "../../widgets/sidebar/index.tsx"),
|
|
43
|
+
resolve(__dirname, "../packages/daemon/src/widgets/sidebar/index.tsx"),
|
|
44
|
+
];
|
|
45
|
+
return candidates.find((p) => existsSync(p)) ?? candidates[0]!;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* PURE — the shell command the sidebar pane runs. `cd`s into the project dir
|
|
50
|
+
* first so bun finds `bunfig.toml` (the @opentui/solid JSX preload), then boots
|
|
51
|
+
* the widget with the current session, dir, and (optional) theme. Mirrors
|
|
52
|
+
* `resolveWidgetCommand`'s shape but self-locates the script so it survives the
|
|
53
|
+
* bundle (see {@link sidebarWidgetScript}).
|
|
54
|
+
*/
|
|
55
|
+
export function sidebarWidgetCommand(
|
|
56
|
+
scriptPath: string,
|
|
57
|
+
session: string,
|
|
58
|
+
dir: string,
|
|
59
|
+
theme: ThemeConfig | null,
|
|
60
|
+
): string {
|
|
61
|
+
const args = [`--session=${session}`, `--dir=${dir}`];
|
|
62
|
+
if (theme) args.push(`--theme=${JSON.stringify(theme)}`);
|
|
63
|
+
|
|
64
|
+
const launch = resolveTuiLaunch({
|
|
65
|
+
surface: "sidebar",
|
|
66
|
+
scriptPath,
|
|
67
|
+
args,
|
|
68
|
+
checkoutExists: existsSync(scriptPath),
|
|
69
|
+
bunAvailable: isBunAvailable(),
|
|
70
|
+
compiledBinary: findCompiledTui(),
|
|
71
|
+
});
|
|
72
|
+
if (launch.mode === "unavailable") {
|
|
73
|
+
// Keep the pane command well-formed; the pane shows bun's own error. This
|
|
74
|
+
// path only trips on a broken install (no checkout, no bun, no binary).
|
|
75
|
+
return `cd ${shellEscape(dir)} && bun ${shellEscape(scriptPath)} ${args.map(shellEscape).join(" ")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const escaped = launch.argv.map(shellEscape).join(" ");
|
|
79
|
+
// Both modes cd into the project dir first: bun needs a nearby bunfig only in
|
|
80
|
+
// a checkout (which sits above dir); the binary is self-contained.
|
|
81
|
+
return `cd ${shellEscape(dir)} && ${shellEscape(launch.bin)} ${escaped}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Root-table key that toggles the sidebar: `M-b` (Alt+b). Like the other chrome
|
|
86
|
+
* keys it lives in the ROOT table so it fires without the prefix from any
|
|
87
|
+
* adopted session; Alt-letter keys are low-collision in terminal apps. Default
|
|
88
|
+
* for {@link sidebarToggleBindCommand}; overridable via `keys.sidebar`.
|
|
89
|
+
*/
|
|
90
|
+
export const SIDEBAR_KEY = "M-b";
|
|
91
|
+
|
|
92
|
+
/** Default sidebar column width in tmux columns — narrow enough to leave the
|
|
93
|
+
* work area room, wide enough for a glyph + a truncated name. */
|
|
94
|
+
export const DEFAULT_SIDEBAR_WIDTH = 30;
|
|
95
|
+
|
|
96
|
+
/** Normalized sidebar settings. */
|
|
97
|
+
export interface ResolvedSidebar {
|
|
98
|
+
enabled: boolean;
|
|
99
|
+
width: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* PURE — normalize the ide.yml `sidebar` sugar (`true` | `{ width }` | `false` |
|
|
104
|
+
* undefined) into `{ enabled, width }`. A missing/invalid width falls back to
|
|
105
|
+
* {@link DEFAULT_SIDEBAR_WIDTH}; width is clamped to a sane floor so a typo can't
|
|
106
|
+
* produce a zero-width column.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveSidebarConfig(raw: unknown): ResolvedSidebar {
|
|
109
|
+
if (raw === true) return { enabled: true, width: DEFAULT_SIDEBAR_WIDTH };
|
|
110
|
+
if (!raw || typeof raw !== "object") return { enabled: false, width: DEFAULT_SIDEBAR_WIDTH };
|
|
111
|
+
const width = parseSidebarWidth((raw as { width?: unknown }).width);
|
|
112
|
+
return { enabled: true, width };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** PURE — parse a width value (string or number) to a positive int column count,
|
|
116
|
+
* falling back to the default and flooring at 10. */
|
|
117
|
+
export function parseSidebarWidth(value: unknown): number {
|
|
118
|
+
const n =
|
|
119
|
+
typeof value === "number" ? value : typeof value === "string" ? parseInt(value, 10) : NaN;
|
|
120
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_SIDEBAR_WIDTH;
|
|
121
|
+
return Math.max(10, Math.floor(n));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* PURE — the tmux argv that binds the sidebar toggle key. `run-shell`
|
|
126
|
+
* format-expands `#{session_name}` into `--session` (bind args themselves do NOT
|
|
127
|
+
* expand; run-shell DOES — the same trick the menu bind uses), so the CLI toggles
|
|
128
|
+
* the column in whichever session the client is viewing. Server-wide root-table
|
|
129
|
+
* bind, mirroring the switcher's M-p — see the note on `unadoptSession`.
|
|
130
|
+
*/
|
|
131
|
+
export function sidebarToggleBindCommand(
|
|
132
|
+
cli = "tmux-ide sidebar-toggle",
|
|
133
|
+
key = SIDEBAR_KEY,
|
|
134
|
+
): string[] {
|
|
135
|
+
return ["bind-key", "-n", key, "run-shell", `${cli} --session '#{session_name}'`];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** PURE — the tmux argv that removes the sidebar toggle binding. */
|
|
139
|
+
export function sidebarToggleUnbindCommand(key = SIDEBAR_KEY): string[] {
|
|
140
|
+
return ["unbind-key", "-n", key];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* PURE — the `split-window` argv that opens the sidebar column. `-h -b -f`
|
|
145
|
+
* creates a horizontal split placed BEFORE (left of) the target that spans the
|
|
146
|
+
* FULL window height (`-f`) regardless of which pane is active, `-l <width>`
|
|
147
|
+
* fixes its column width, and `-P -F '#{pane_id}'` prints the new pane id. The
|
|
148
|
+
* trailing `widgetCmd` runs the sidebar widget in the pane.
|
|
149
|
+
*/
|
|
150
|
+
export function sidebarSplitCommand(
|
|
151
|
+
session: string,
|
|
152
|
+
dir: string,
|
|
153
|
+
width: number,
|
|
154
|
+
widgetCmd: string,
|
|
155
|
+
): string[] {
|
|
156
|
+
return [
|
|
157
|
+
"split-window",
|
|
158
|
+
"-P",
|
|
159
|
+
"-F",
|
|
160
|
+
"#{pane_id}",
|
|
161
|
+
"-t",
|
|
162
|
+
session,
|
|
163
|
+
"-h",
|
|
164
|
+
"-b",
|
|
165
|
+
"-f",
|
|
166
|
+
"-l",
|
|
167
|
+
String(width),
|
|
168
|
+
"-c",
|
|
169
|
+
dir,
|
|
170
|
+
widgetCmd,
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* io — the sidebar pane id already open in `session`, or null. Reads the
|
|
176
|
+
* {@link SIDEBAR_PANE_OPTION} marker off each pane so a toggle can find and close
|
|
177
|
+
* an existing column.
|
|
178
|
+
*/
|
|
179
|
+
export function findSidebarPane(session: string): string | null {
|
|
180
|
+
try {
|
|
181
|
+
const raw = runTmux(
|
|
182
|
+
["list-panes", "-t", session, "-F", `#{pane_id}\t#{${SIDEBAR_PANE_OPTION}}`],
|
|
183
|
+
{ encoding: "utf-8" },
|
|
184
|
+
)
|
|
185
|
+
.toString()
|
|
186
|
+
.trim();
|
|
187
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
188
|
+
const [id = "", flag = ""] = line.split("\t");
|
|
189
|
+
if (flag === "1" && id) return id;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// no such session / tmux unavailable — treat as "no sidebar"
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* io — open the sidebar column in `session` on `dir`, mark the pane so the data
|
|
199
|
+
* layer excludes it, title it "sidebar" (the pane-border chip falls back to the
|
|
200
|
+
* title for non-agent panes), and return the new pane id.
|
|
201
|
+
*/
|
|
202
|
+
export function openSidebarPane(
|
|
203
|
+
session: string,
|
|
204
|
+
dir: string,
|
|
205
|
+
width: number,
|
|
206
|
+
theme: ThemeConfig | null,
|
|
207
|
+
): string {
|
|
208
|
+
const widgetCmd = sidebarWidgetCommand(sidebarWidgetScript(), session, dir, theme);
|
|
209
|
+
const paneId = runTmux(sidebarSplitCommand(session, dir, width, widgetCmd), {
|
|
210
|
+
encoding: "utf-8",
|
|
211
|
+
})
|
|
212
|
+
.toString()
|
|
213
|
+
.trim();
|
|
214
|
+
runTmux(["set-option", "-pqt", paneId, SIDEBAR_PANE_OPTION, "1"]);
|
|
215
|
+
runTmux(["select-pane", "-t", paneId, "-T", "sidebar"]);
|
|
216
|
+
return paneId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** io — close the sidebar column pane. */
|
|
220
|
+
export function closeSidebarPane(paneId: string): void {
|
|
221
|
+
runTmux(["kill-pane", "-t", paneId]);
|
|
222
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet snapshot — the fleet's DISASTER-RECOVERY layer.
|
|
3
|
+
*
|
|
4
|
+
* The chrome {@link ./updater.ts updater} sees the whole tmux server every
|
|
5
|
+
* tick. This module turns that view into a durable, structural picture of the
|
|
6
|
+
* fleet — every session's windows, panes, cwds, layouts, titles, and the agent
|
|
7
|
+
* that was running in each pane — and persists it to `~/.tmux-ide/snapshot.json`.
|
|
8
|
+
* When the tmux server dies for real, `tmux-ide restore` ({@link ../../restore.ts})
|
|
9
|
+
* rebuilds the fleet from this file.
|
|
10
|
+
*
|
|
11
|
+
* Split as usual: {@link buildSnapshot} + {@link snapshotFingerprint} are PURE
|
|
12
|
+
* (assembled from raw tmux lines + a process table, unit-tested without a live
|
|
13
|
+
* tmux), while {@link collectFleetSnapshot} / {@link writeSnapshot} /
|
|
14
|
+
* {@link readSnapshot} are the thin io wrappers. {@link createSnapshotter}
|
|
15
|
+
* carries the throttle + change-detection the updater loop drives each tick.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { runTmux } from "@tmux-ide/tmux-bridge";
|
|
22
|
+
import { readProcessTable, resolveAgentCommand, type ProcEntry } from "../detect/process-tree.ts";
|
|
23
|
+
import { isListableSession } from "../team/sessions.ts";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Model
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** A single pane, frozen for rebuild. */
|
|
30
|
+
export interface PaneSnapshot {
|
|
31
|
+
/** `pane_index` within its window. */
|
|
32
|
+
index: number;
|
|
33
|
+
/** `pane_current_path` — the pane's working directory. */
|
|
34
|
+
cwd: string;
|
|
35
|
+
/**
|
|
36
|
+
* The command to re-run under `--run-commands`, or `null` for a bare shell
|
|
37
|
+
* (which restores as a plain pane). An agent pane records the RESOLVED agent
|
|
38
|
+
* id (e.g. `claude`), not the generic `node`/`bun` shim; a non-shell,
|
|
39
|
+
* non-agent pane records its `pane_current_command` verbatim.
|
|
40
|
+
*/
|
|
41
|
+
command: string | null;
|
|
42
|
+
/** Resolved agent id (e.g. `claude`), or `null` for a non-agent pane. */
|
|
43
|
+
agent: string | null;
|
|
44
|
+
/** `@agent_session_id` — the agent's own session id (0037 resumes from it). */
|
|
45
|
+
agentSessionId: string | null;
|
|
46
|
+
/** `@agent_state` — the authority status line. Excluded from the fingerprint (it churns every tick). */
|
|
47
|
+
agentState: string | null;
|
|
48
|
+
/** `pane_title`. */
|
|
49
|
+
title: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A single window (tab), with the layout string that restores its geometry. */
|
|
53
|
+
export interface WindowSnapshot {
|
|
54
|
+
/** `window_index`. */
|
|
55
|
+
index: number;
|
|
56
|
+
/** `window_name`. */
|
|
57
|
+
name: string;
|
|
58
|
+
/** Whether this is the session's active window. */
|
|
59
|
+
active: boolean;
|
|
60
|
+
/** `window_layout` — the checksummed layout string tmux's `select-layout` accepts verbatim. */
|
|
61
|
+
layout: string;
|
|
62
|
+
panes: PaneSnapshot[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A single session, with its adopted flag so restore re-adopts it. */
|
|
66
|
+
export interface SessionSnapshot {
|
|
67
|
+
name: string;
|
|
68
|
+
/** Session cwd — the first window's first pane's cwd. */
|
|
69
|
+
cwd: string;
|
|
70
|
+
/** Whether the session was adopted into the chrome. */
|
|
71
|
+
adopted: boolean;
|
|
72
|
+
windows: WindowSnapshot[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The whole fleet at one instant. */
|
|
76
|
+
export interface FleetSnapshot {
|
|
77
|
+
version: 1;
|
|
78
|
+
savedAt: string;
|
|
79
|
+
sessions: SessionSnapshot[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Read validation — a snapshot on disk is untrusted input
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const PaneSnapshotSchemaZ = z.object({
|
|
87
|
+
index: z.number(),
|
|
88
|
+
cwd: z.string(),
|
|
89
|
+
command: z.string().nullable(),
|
|
90
|
+
agent: z.string().nullable(),
|
|
91
|
+
agentSessionId: z.string().nullable(),
|
|
92
|
+
agentState: z.string().nullable(),
|
|
93
|
+
title: z.string(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const WindowSnapshotSchemaZ = z.object({
|
|
97
|
+
index: z.number(),
|
|
98
|
+
name: z.string(),
|
|
99
|
+
active: z.boolean(),
|
|
100
|
+
layout: z.string(),
|
|
101
|
+
panes: z.array(PaneSnapshotSchemaZ),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const SessionSnapshotSchemaZ = z.object({
|
|
105
|
+
name: z.string(),
|
|
106
|
+
cwd: z.string(),
|
|
107
|
+
adopted: z.boolean(),
|
|
108
|
+
windows: z.array(WindowSnapshotSchemaZ),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const FleetSnapshotSchemaZ = z.object({
|
|
112
|
+
version: z.literal(1),
|
|
113
|
+
savedAt: z.string(),
|
|
114
|
+
sessions: z.array(SessionSnapshotSchemaZ),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Pure assembly
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** The tab-separated `list-panes -a` fields {@link buildSnapshot} parses, in order. */
|
|
122
|
+
export const SNAPSHOT_PANE_FORMAT = [
|
|
123
|
+
"#{session_name}",
|
|
124
|
+
"#{window_index}",
|
|
125
|
+
"#{window_name}",
|
|
126
|
+
"#{window_active}",
|
|
127
|
+
"#{window_layout}",
|
|
128
|
+
"#{pane_index}",
|
|
129
|
+
"#{pane_current_path}",
|
|
130
|
+
"#{pane_current_command}",
|
|
131
|
+
"#{pane_pid}",
|
|
132
|
+
"#{@agent_session_id}",
|
|
133
|
+
"#{@agent_state}",
|
|
134
|
+
"#{@agent_hint}",
|
|
135
|
+
"#{pane_title}",
|
|
136
|
+
].join("\t");
|
|
137
|
+
|
|
138
|
+
/** The `list-sessions` fields the adopted-flag pass parses. */
|
|
139
|
+
export const SNAPSHOT_SESSION_FORMAT = ["#{session_name}", "#{@tmux_ide_adopted}"].join("\t");
|
|
140
|
+
|
|
141
|
+
/** A bare interactive shell — restores as a plain pane, records no command. */
|
|
142
|
+
function isBareShell(cmd: string): boolean {
|
|
143
|
+
return /^-?(zsh|bash|sh|fish|dash|ksh|tcsh|csh|nu)$/.test(cmd.trim());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Decide a pane's `agent` + `command` from its command line + process tree.
|
|
148
|
+
*
|
|
149
|
+
* A real agent (a manifest that isn't the `shell` catch-all) records its id as
|
|
150
|
+
* BOTH the agent and the restore command; a bare shell records neither; any
|
|
151
|
+
* other foreground process records its `pane_current_command` as the command.
|
|
152
|
+
*/
|
|
153
|
+
function resolvePaneCommand(
|
|
154
|
+
cmd: string,
|
|
155
|
+
pid: number,
|
|
156
|
+
hint: string,
|
|
157
|
+
table: ProcEntry[],
|
|
158
|
+
): { agent: string | null; command: string | null } {
|
|
159
|
+
const { manifest } = resolveAgentCommand(cmd, pid, table, { hint: hint || undefined });
|
|
160
|
+
if (manifest && manifest.id !== "shell") {
|
|
161
|
+
return { agent: manifest.id, command: manifest.id };
|
|
162
|
+
}
|
|
163
|
+
if (isBareShell(cmd)) return { agent: null, command: null };
|
|
164
|
+
return { agent: null, command: cmd };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `""` → null, so absent user-options round-trip as null rather than empty string. */
|
|
168
|
+
function nullable(value: string): string | null {
|
|
169
|
+
return value.length > 0 ? value : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* PURE — assemble a {@link FleetSnapshot} from raw `list-panes -a` lines, raw
|
|
174
|
+
* `list-sessions` lines (for the adopted flag), and a process table (for agent
|
|
175
|
+
* resolution). Internal `_`-prefixed sessions are skipped. Windows and panes
|
|
176
|
+
* come out in ascending index order; a session's cwd is its first window's
|
|
177
|
+
* first pane's cwd.
|
|
178
|
+
*/
|
|
179
|
+
export function buildSnapshot(
|
|
180
|
+
rawPanes: string[],
|
|
181
|
+
rawSessions: string[],
|
|
182
|
+
table: ProcEntry[],
|
|
183
|
+
savedAt: string = new Date().toISOString(),
|
|
184
|
+
): FleetSnapshot {
|
|
185
|
+
// Adopted flag per session (marker field exactly "1").
|
|
186
|
+
const adopted = new Set<string>();
|
|
187
|
+
for (const line of rawSessions) {
|
|
188
|
+
const [name = "", flag = ""] = line.split("\t");
|
|
189
|
+
if (name && flag === "1") adopted.add(name);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Group panes → session → window, preserving insertion but sorting at the end.
|
|
193
|
+
interface WinAccum {
|
|
194
|
+
index: number;
|
|
195
|
+
name: string;
|
|
196
|
+
active: boolean;
|
|
197
|
+
layout: string;
|
|
198
|
+
panes: PaneSnapshot[];
|
|
199
|
+
}
|
|
200
|
+
const sessions = new Map<string, Map<number, WinAccum>>();
|
|
201
|
+
|
|
202
|
+
for (const line of rawPanes) {
|
|
203
|
+
if (line.length === 0) continue;
|
|
204
|
+
const [
|
|
205
|
+
session = "",
|
|
206
|
+
windowIndex = "0",
|
|
207
|
+
windowName = "",
|
|
208
|
+
windowActive = "0",
|
|
209
|
+
layout = "",
|
|
210
|
+
paneIndex = "0",
|
|
211
|
+
cwd = "",
|
|
212
|
+
cmd = "",
|
|
213
|
+
pid = "0",
|
|
214
|
+
agentSessionId = "",
|
|
215
|
+
agentState = "",
|
|
216
|
+
hint = "",
|
|
217
|
+
...titleParts
|
|
218
|
+
] = line.split("\t");
|
|
219
|
+
if (!session || !isListableSession(session)) continue;
|
|
220
|
+
|
|
221
|
+
let windows = sessions.get(session);
|
|
222
|
+
if (!windows) {
|
|
223
|
+
windows = new Map();
|
|
224
|
+
sessions.set(session, windows);
|
|
225
|
+
}
|
|
226
|
+
const wIndex = Number(windowIndex) || 0;
|
|
227
|
+
let win = windows.get(wIndex);
|
|
228
|
+
if (!win) {
|
|
229
|
+
win = {
|
|
230
|
+
index: wIndex,
|
|
231
|
+
name: windowName,
|
|
232
|
+
active: windowActive === "1",
|
|
233
|
+
layout,
|
|
234
|
+
panes: [],
|
|
235
|
+
};
|
|
236
|
+
windows.set(wIndex, win);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { agent, command } = resolvePaneCommand(cmd, Number(pid) || 0, hint, table);
|
|
240
|
+
win.panes.push({
|
|
241
|
+
index: Number(paneIndex) || 0,
|
|
242
|
+
cwd,
|
|
243
|
+
command,
|
|
244
|
+
agent,
|
|
245
|
+
agentSessionId: nullable(agentSessionId),
|
|
246
|
+
agentState: nullable(agentState),
|
|
247
|
+
title: titleParts.join("\t"),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const out: SessionSnapshot[] = [];
|
|
252
|
+
for (const [name, windows] of sessions) {
|
|
253
|
+
const windowList: WindowSnapshot[] = [...windows.values()]
|
|
254
|
+
.sort((a, b) => a.index - b.index)
|
|
255
|
+
.map((w) => ({
|
|
256
|
+
index: w.index,
|
|
257
|
+
name: w.name,
|
|
258
|
+
active: w.active,
|
|
259
|
+
layout: w.layout,
|
|
260
|
+
panes: w.panes.slice().sort((a, b) => a.index - b.index),
|
|
261
|
+
}));
|
|
262
|
+
const cwd = windowList[0]?.panes[0]?.cwd ?? "";
|
|
263
|
+
out.push({ name, cwd, adopted: adopted.has(name), windows: windowList });
|
|
264
|
+
}
|
|
265
|
+
// Stable session order so the fingerprint doesn't churn on Map iteration.
|
|
266
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
267
|
+
|
|
268
|
+
return { version: 1, savedAt, sessions: out };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* PURE — a stable fingerprint of the fleet's STRUCTURE. Everything but
|
|
273
|
+
* `savedAt` and each pane's `agentState` is included, so the fingerprint is
|
|
274
|
+
* unchanged as agents churn working→idle every tick (agentState is a timestamped
|
|
275
|
+
* status line) but DOES change when a pane/window/session, a cwd, a layout, a
|
|
276
|
+
* title, or an agent-session-id changes. The updater rewrites the file only when
|
|
277
|
+
* this value moves — a steady fleet issues zero snapshot writes.
|
|
278
|
+
*/
|
|
279
|
+
export function snapshotFingerprint(snapshot: FleetSnapshot): string {
|
|
280
|
+
const structural = {
|
|
281
|
+
sessions: snapshot.sessions.map((s) => ({
|
|
282
|
+
name: s.name,
|
|
283
|
+
cwd: s.cwd,
|
|
284
|
+
adopted: s.adopted,
|
|
285
|
+
windows: s.windows.map((w) => ({
|
|
286
|
+
index: w.index,
|
|
287
|
+
name: w.name,
|
|
288
|
+
active: w.active,
|
|
289
|
+
layout: w.layout,
|
|
290
|
+
panes: w.panes.map((p) => ({
|
|
291
|
+
index: p.index,
|
|
292
|
+
cwd: p.cwd,
|
|
293
|
+
command: p.command,
|
|
294
|
+
agent: p.agent,
|
|
295
|
+
agentSessionId: p.agentSessionId,
|
|
296
|
+
title: p.title,
|
|
297
|
+
// agentState deliberately omitted — it churns every tick.
|
|
298
|
+
})),
|
|
299
|
+
})),
|
|
300
|
+
})),
|
|
301
|
+
};
|
|
302
|
+
return JSON.stringify(structural);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// io
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/** Injectable io for {@link collectFleetSnapshot} — real tmux by default. */
|
|
310
|
+
export interface SnapshotIo {
|
|
311
|
+
listPanes: () => string;
|
|
312
|
+
listSessions: () => string;
|
|
313
|
+
processTable: () => ProcEntry[];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const defaultIo: SnapshotIo = {
|
|
317
|
+
listPanes: () => runTmux(["list-panes", "-a", "-F", SNAPSHOT_PANE_FORMAT]).toString(),
|
|
318
|
+
listSessions: () => runTmux(["list-sessions", "-F", SNAPSHOT_SESSION_FORMAT]).toString(),
|
|
319
|
+
processTable: () => readProcessTable(),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** Collect the live fleet into a {@link FleetSnapshot}. Thin io over {@link buildSnapshot}. */
|
|
323
|
+
export function collectFleetSnapshot(io: SnapshotIo = defaultIo): FleetSnapshot {
|
|
324
|
+
const rawPanes = io.listPanes().split("\n").filter(Boolean);
|
|
325
|
+
const rawSessions = io.listSessions().split("\n").filter(Boolean);
|
|
326
|
+
return buildSnapshot(rawPanes, rawSessions, io.processTable());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Absolute path to the fleet snapshot. */
|
|
330
|
+
export function snapshotPath(): string {
|
|
331
|
+
return join(homedir(), ".tmux-ide", "snapshot.json");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Write the snapshot atomically (temp + rename), keeping ONE previous
|
|
336
|
+
* generation as `snapshot.json.1`. Best-effort: a filesystem failure must never
|
|
337
|
+
* break the updater loop.
|
|
338
|
+
*/
|
|
339
|
+
export function writeSnapshot(snapshot: FleetSnapshot): void {
|
|
340
|
+
const path = snapshotPath();
|
|
341
|
+
try {
|
|
342
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
343
|
+
const tmp = `${path}.tmp`;
|
|
344
|
+
writeFileSync(tmp, JSON.stringify(snapshot, null, 2) + "\n");
|
|
345
|
+
// Rotate the current generation out of the way, then swap the new one in.
|
|
346
|
+
if (existsSync(path)) {
|
|
347
|
+
try {
|
|
348
|
+
renameSync(path, `${path}.1`);
|
|
349
|
+
} catch {
|
|
350
|
+
// one-generation rotation is best-effort
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
renameSync(tmp, path);
|
|
354
|
+
} catch {
|
|
355
|
+
// a failed write just means a staler snapshot — never fatal
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Read + validate the snapshot on disk. Returns null when missing or malformed. */
|
|
360
|
+
export function readSnapshot(): FleetSnapshot | null {
|
|
361
|
+
const path = snapshotPath();
|
|
362
|
+
try {
|
|
363
|
+
if (!existsSync(path)) return null;
|
|
364
|
+
const raw = readFileSync(path, "utf-8");
|
|
365
|
+
if (raw.trim().length === 0) return null;
|
|
366
|
+
const result = FleetSnapshotSchemaZ.safeParse(JSON.parse(raw));
|
|
367
|
+
return result.success ? result.data : null;
|
|
368
|
+
} catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Throttle + change detection — driven by the updater loop
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
/** Snapshot every N ticks. At {@link ./updater.ts TICK_MS} = 2s, 15 ticks ≈ 30s. */
|
|
378
|
+
export const SNAPSHOT_EVERY = 15;
|
|
379
|
+
|
|
380
|
+
/** Resolve the tick throttle, honoring `TMUX_IDE_SNAPSHOT_EVERY` (tests/tuning). */
|
|
381
|
+
export function snapshotEvery(): number {
|
|
382
|
+
const raw = process.env.TMUX_IDE_SNAPSHOT_EVERY;
|
|
383
|
+
const n = raw ? Number(raw) : NaN;
|
|
384
|
+
return Number.isInteger(n) && n > 0 ? n : SNAPSHOT_EVERY;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Deps for {@link createSnapshotter} — injectable so the throttle is unit-tested. */
|
|
388
|
+
export interface SnapshotterDeps {
|
|
389
|
+
collect: () => FleetSnapshot;
|
|
390
|
+
read: () => FleetSnapshot | null;
|
|
391
|
+
write: (snapshot: FleetSnapshot) => void;
|
|
392
|
+
every: number;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A stateful snapshotter the updater loop pulses once per tick via
|
|
397
|
+
* {@link Snapshotter.onTick}. It fires only every `every` ticks, and even then
|
|
398
|
+
* writes only when the STRUCTURE changed since the last write (seeded from the
|
|
399
|
+
* existing file on first fire, so a restart doesn't rewrite an unchanged fleet).
|
|
400
|
+
*/
|
|
401
|
+
export interface Snapshotter {
|
|
402
|
+
onTick: () => void;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function createSnapshotter(deps: SnapshotterDeps): Snapshotter {
|
|
406
|
+
let ticks = 0;
|
|
407
|
+
let seeded = false;
|
|
408
|
+
let lastFingerprint: string | null = null;
|
|
409
|
+
return {
|
|
410
|
+
onTick(): void {
|
|
411
|
+
ticks++;
|
|
412
|
+
if (deps.every <= 0 || ticks % deps.every !== 0) return;
|
|
413
|
+
if (!seeded) {
|
|
414
|
+
const existing = deps.read();
|
|
415
|
+
lastFingerprint = existing ? snapshotFingerprint(existing) : null;
|
|
416
|
+
seeded = true;
|
|
417
|
+
}
|
|
418
|
+
const snapshot = deps.collect();
|
|
419
|
+
const fingerprint = snapshotFingerprint(snapshot);
|
|
420
|
+
if (fingerprint === lastFingerprint) return;
|
|
421
|
+
lastFingerprint = fingerprint;
|
|
422
|
+
deps.write(snapshot);
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|