tmux-ide 2.6.0 → 2.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 +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- 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/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -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 +427 -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/tui-binary.ts +165 -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 +382 -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 +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -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 +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -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 +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -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/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AckWriter — ack-paced, coalescing writes into an async sink (M21.5).
|
|
3
|
+
*
|
|
4
|
+
* xterm-headless's `write()` is asynchronous: bytes queue in its WriteBuffer
|
|
5
|
+
* and parse on its own schedule, with a completion callback per write. Feeding
|
|
6
|
+
* every control-mode `%output` chunk straight in piles up unbounded entries in
|
|
7
|
+
* that internal queue during a flood. This writer paces instead: chunks
|
|
8
|
+
* arriving while a write is in flight buffer HERE, and the completion callback
|
|
9
|
+
* triggers exactly one follow-up write of everything buffered, joined. At most
|
|
10
|
+
* ONE sink write is ever outstanding, order and content are preserved
|
|
11
|
+
* byte-for-byte, and the caller (the control-channel reader loop) never waits.
|
|
12
|
+
*
|
|
13
|
+
* Pure core: the sink is injected (`term.write(data, done)` in production, a
|
|
14
|
+
* recording stub in tests); no timers, no io.
|
|
15
|
+
*/
|
|
16
|
+
export class AckWriter {
|
|
17
|
+
private queue: Uint8Array[] = [];
|
|
18
|
+
private inFlight = false;
|
|
19
|
+
|
|
20
|
+
/** `onAck` fires after EACH sink write completes — i.e. when those bytes are
|
|
21
|
+
* actually visible in the sink (parsed, for xterm). Consumers that gate
|
|
22
|
+
* rendering on "content changed" must re-arm on this, not on enqueue: with
|
|
23
|
+
* pacing, an enqueue-time signal can be consumed before the parse lands and
|
|
24
|
+
* the final frame would never render. */
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly sink: (data: Uint8Array, done: () => void) => void,
|
|
27
|
+
private readonly onAck?: () => void,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
/** Enqueue bytes; writes through immediately when the sink is idle. */
|
|
31
|
+
write(data: Uint8Array): void {
|
|
32
|
+
if (data.length === 0) return;
|
|
33
|
+
this.queue.push(data);
|
|
34
|
+
this.pump();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bytes buffered here awaiting the in-flight write's ack (tests only). */
|
|
38
|
+
pendingBytes(): number {
|
|
39
|
+
let n = 0;
|
|
40
|
+
for (const c of this.queue) n += c.length;
|
|
41
|
+
return n;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A sink write is currently outstanding (tests only). */
|
|
45
|
+
busy(): boolean {
|
|
46
|
+
return this.inFlight;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private pump(): void {
|
|
50
|
+
if (this.inFlight || this.queue.length === 0) return;
|
|
51
|
+
const chunks = this.queue;
|
|
52
|
+
this.queue = [];
|
|
53
|
+
const data = chunks.length === 1 ? chunks[0]! : concatBytes(chunks);
|
|
54
|
+
this.inFlight = true;
|
|
55
|
+
let acked = false;
|
|
56
|
+
this.sink(data, () => {
|
|
57
|
+
if (acked) return; // a double-fired callback must not unleash overlap
|
|
58
|
+
acked = true;
|
|
59
|
+
this.inFlight = false;
|
|
60
|
+
this.onAck?.();
|
|
61
|
+
this.pump();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Join byte chunks into one contiguous array (exported for tests). */
|
|
67
|
+
export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
|
|
68
|
+
let total = 0;
|
|
69
|
+
for (const c of chunks) total += c.length;
|
|
70
|
+
const out = new Uint8Array(total);
|
|
71
|
+
let off = 0;
|
|
72
|
+
for (const c of chunks) {
|
|
73
|
+
out.set(c, off);
|
|
74
|
+
off += c.length;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-pane agent chips for the unified app (M22.3) — PURE.
|
|
3
|
+
*
|
|
4
|
+
* The fleet payload (`tmux-ide team --json`) carries per-pane agent entries
|
|
5
|
+
* (`agents: PaneAgentEntry[]`, M22.1). This module is the io-free half of the
|
|
6
|
+
* Terminal surface's pane chips: the paneId→entry join, the label builder, and
|
|
7
|
+
* the width-degrading truncation. The render side (app.tsx) styles the label
|
|
8
|
+
* with the app's own STATUS_COLOR/STATUS_GLYPH grammar — the OpenTUI-side
|
|
9
|
+
* mirror of the dock chip grammar in tui/chrome/chip.ts — so a pane chip in
|
|
10
|
+
* the app reads the same as its sidebar/home glyphs. The glyph travels IN as a
|
|
11
|
+
* parameter: this module carries no grammar constants of its own (they live
|
|
12
|
+
* with the app; see the status-grammar coordination note in the M22 board).
|
|
13
|
+
*
|
|
14
|
+
* Staleness is NOT re-implemented here: the report already applies the
|
|
15
|
+
* authority staleness window and falls back to scraping, so `state` is always
|
|
16
|
+
* the final answer — this module only joins and formats.
|
|
17
|
+
*/
|
|
18
|
+
import type { AgentStatus } from "../detect/classify.ts";
|
|
19
|
+
|
|
20
|
+
/** The slice of a fleet `PaneAgentEntry` the chip needs (structural — the app
|
|
21
|
+
* declares its fleet shapes locally instead of importing the data layer). */
|
|
22
|
+
export interface ChipAgent {
|
|
23
|
+
/** tmux pane id (`%N`) — the mirror's pane ids are the same ids, so the join
|
|
24
|
+
* is a straight map lookup. */
|
|
25
|
+
paneId: string;
|
|
26
|
+
/** Resolved agent kind (manifest id: `claude`, `codex`, …). */
|
|
27
|
+
kind: string;
|
|
28
|
+
/** Final per-pane status (authority when fresh, else scraped/tracked). */
|
|
29
|
+
state: AgentStatus;
|
|
30
|
+
/** Authority epoch (SECONDS) when the authority layer supplied the state;
|
|
31
|
+
* null for scraped panes. Feeds the "blocked 4m" age suffix. */
|
|
32
|
+
since: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Flatten a fleet payload into a paneId→entry map. Tolerates sessions without
|
|
37
|
+
* an `agents` field (older payloads degrade to no chips, never a crash).
|
|
38
|
+
* First entry wins on a duplicate paneId (a session listed under two projects
|
|
39
|
+
* repeats its panes; pane ids are server-global so the entries are identical).
|
|
40
|
+
*/
|
|
41
|
+
export function agentsByPane(
|
|
42
|
+
projects: ReadonlyArray<{
|
|
43
|
+
sessions: ReadonlyArray<{ agents?: ReadonlyArray<ChipAgent> }>;
|
|
44
|
+
}>,
|
|
45
|
+
): Map<string, ChipAgent> {
|
|
46
|
+
const map = new Map<string, ChipAgent>();
|
|
47
|
+
for (const p of projects)
|
|
48
|
+
for (const s of p.sessions)
|
|
49
|
+
for (const a of s.agents ?? []) if (!map.has(a.paneId)) map.set(a.paneId, a);
|
|
50
|
+
return map;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compact age of a state stamp — "32s" / "4m" / "2h" / "3d" — or null when
|
|
55
|
+
* there is no authoritative timestamp (scraped pane). Negative skew clamps to
|
|
56
|
+
* "0s" rather than inventing time travel.
|
|
57
|
+
*/
|
|
58
|
+
export function stateAge(since: number | null, nowMs: number): string | null {
|
|
59
|
+
if (since === null) return null;
|
|
60
|
+
const s = Math.max(0, Math.floor(nowMs / 1000 - since));
|
|
61
|
+
if (s < 60) return `${s}s`;
|
|
62
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
63
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
64
|
+
return `${Math.floor(s / 86400)}d`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a chip label that fits `budget` cells (the printable text, WITHOUT the
|
|
69
|
+
* one-cell padding the renderer adds each side). Degrades in steps rather than
|
|
70
|
+
* mid-word ellipsis so a narrow pane still reads:
|
|
71
|
+
*
|
|
72
|
+
* `● claude · blocked 4m` blocked, authority age known
|
|
73
|
+
* `● claude · blocked` blocked, scraped (no stamp)
|
|
74
|
+
* `● claude` any other state (color carries the state)
|
|
75
|
+
* `●` narrow pane
|
|
76
|
+
* null no room at all (or a hidden-worthy budget < 1)
|
|
77
|
+
*
|
|
78
|
+
* Only `blocked` spells its state out — it is the attention state; everything
|
|
79
|
+
* else leans on the glyph color, matching the dock chips' quiet default.
|
|
80
|
+
*/
|
|
81
|
+
export function chipLabel(
|
|
82
|
+
entry: ChipAgent,
|
|
83
|
+
glyph: string,
|
|
84
|
+
nowMs: number,
|
|
85
|
+
budget: number,
|
|
86
|
+
): string | null {
|
|
87
|
+
const base = `${glyph} ${entry.kind}`;
|
|
88
|
+
const candidates: string[] = [];
|
|
89
|
+
if (entry.state === "blocked") {
|
|
90
|
+
const age = stateAge(entry.since, nowMs);
|
|
91
|
+
if (age) candidates.push(`${base} · blocked ${age}`);
|
|
92
|
+
candidates.push(`${base} · blocked`);
|
|
93
|
+
}
|
|
94
|
+
candidates.push(base, glyph);
|
|
95
|
+
for (const c of candidates) if (c.length <= budget) return c;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar AGENTS section's pure model (M22.2) — PURE so it unit-tests
|
|
3
|
+
* without OpenTUI. app.tsx flattens the fleet payload's per-session
|
|
4
|
+
* `agents: PaneAgentEntry[]` (M22.1) into one fleet-wide list, sorts it
|
|
5
|
+
* attention-first, renders one row per agent (reusing app.tsx's existing
|
|
6
|
+
* STATUS_GLYPH / STATUS_COLOR grammar keyed by state — NOT reinvented here), and
|
|
7
|
+
* lets a click JUMP to the exact session/window/pane. Ordering, row-label
|
|
8
|
+
* truncation, state-age formatting, and the sidebar row hit-test all live here;
|
|
9
|
+
* the render/router just read them.
|
|
10
|
+
*
|
|
11
|
+
* The sort order mirrors {@link ../team/home.ts}'s `ROLLUP_ORDER` so the sidebar
|
|
12
|
+
* list and the header rollup chips agree on what "attention-first" means.
|
|
13
|
+
*/
|
|
14
|
+
import type { AgentStatus } from "../detect/classify.ts";
|
|
15
|
+
import { ROLLUP_ORDER } from "../team/home.ts";
|
|
16
|
+
|
|
17
|
+
/** The per-agent fields the sidebar needs — a structural subset of the fleet
|
|
18
|
+
* payload's `PaneAgentEntry` (app.tsx keeps its fleet types local + io-free, so
|
|
19
|
+
* this narrower shape is what it flattens into). */
|
|
20
|
+
export interface AgentRowInput {
|
|
21
|
+
/** tmux pane id, e.g. `%5` — the exact pane a click focuses. */
|
|
22
|
+
paneId: string;
|
|
23
|
+
/** `window_index` of the tab this pane lives in — the window a click selects. */
|
|
24
|
+
windowIndex: number;
|
|
25
|
+
/** Owning session name — the workspace a click switches to. */
|
|
26
|
+
session: string;
|
|
27
|
+
/** Resolved agent kind (manifest id: `claude`, `codex`, …). */
|
|
28
|
+
kind: string;
|
|
29
|
+
/** Final per-pane status (drives the glyph + color, reusing the app grammar). */
|
|
30
|
+
state: AgentStatus;
|
|
31
|
+
/** Authority state's epoch-seconds stamp, or null for a scraped pane. */
|
|
32
|
+
since: number | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Attention-first rank: blocked, working, done, idle (from `ROLLUP_ORDER`), then
|
|
36
|
+
* `unknown` last. Lower sorts earlier. */
|
|
37
|
+
const STATE_RANK: Record<AgentStatus, number> = (() => {
|
|
38
|
+
const rank = {} as Record<AgentStatus, number>;
|
|
39
|
+
ROLLUP_ORDER.forEach((s, i) => (rank[s] = i));
|
|
40
|
+
rank.unknown = ROLLUP_ORDER.length;
|
|
41
|
+
return rank;
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* PURE — order agents attention-first (blocked → working → done → idle →
|
|
46
|
+
* unknown), STABLE within a group so rows don't jitter between polls when states
|
|
47
|
+
* are unchanged (`Array.prototype.sort` is stable). Returns a new array; the
|
|
48
|
+
* input is untouched.
|
|
49
|
+
*/
|
|
50
|
+
export function sortAgentRows<T extends { state: AgentStatus }>(agents: readonly T[]): T[] {
|
|
51
|
+
return [...agents].sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* PURE — the label shown on a sidebar agent row: `"<kind> · <session>"`,
|
|
56
|
+
* truncated with a trailing ellipsis to at most `maxChars` columns (the sidebar
|
|
57
|
+
* is width-constrained and drag-resizable). `maxChars <= 0` yields "".
|
|
58
|
+
*/
|
|
59
|
+
export function agentRowLabel(kind: string, session: string, maxChars: number): string {
|
|
60
|
+
const full = `${kind} · ${session}`;
|
|
61
|
+
if (maxChars <= 0) return "";
|
|
62
|
+
if (full.length <= maxChars) return full;
|
|
63
|
+
if (maxChars === 1) return "…";
|
|
64
|
+
return full.slice(0, maxChars - 1) + "…";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* PURE — the AGENTS section header, `"agents · <count>"`, truncated to
|
|
69
|
+
* `maxChars`. The count is always shown so the header doubles as the tally the
|
|
70
|
+
* card asks for.
|
|
71
|
+
*/
|
|
72
|
+
export function agentsHeaderLabel(count: number, maxChars: number): string {
|
|
73
|
+
const full = `agents · ${count}`;
|
|
74
|
+
if (maxChars <= 0) return "";
|
|
75
|
+
return full.length <= maxChars ? full : full.slice(0, Math.max(1, maxChars - 1)) + "…";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The quiet empty-state line shown under the header when no agents are running. */
|
|
79
|
+
export const AGENTS_EMPTY_LINE = "no agents running — panes running claude or codex show up here";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* PURE — a compact state-age like `"blocked 4m"` from a `since` epoch-seconds
|
|
83
|
+
* stamp, for the hovered row. Returns null when there is no timestamp (a scraped
|
|
84
|
+
* pane) or the dwell rounds to zero — nothing worth showing. Granularity:
|
|
85
|
+
* seconds under a minute, then minutes, then hours.
|
|
86
|
+
*/
|
|
87
|
+
export function agentAgeLabel(
|
|
88
|
+
state: AgentStatus,
|
|
89
|
+
since: number | null,
|
|
90
|
+
nowSec: number,
|
|
91
|
+
): string | null {
|
|
92
|
+
if (since == null) return null;
|
|
93
|
+
const secs = Math.max(0, Math.floor(nowSec - since));
|
|
94
|
+
let span: string;
|
|
95
|
+
if (secs < 60) span = `${secs}s`;
|
|
96
|
+
else if (secs < 3600) span = `${Math.floor(secs / 60)}m`;
|
|
97
|
+
else span = `${Math.floor(secs / 3600)}h`;
|
|
98
|
+
return `${state} ${span}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Blank rows the render leaves between the session list and the agents header
|
|
102
|
+
* (the agents section's `marginTop`). Kept here so `sidebarHit` and the render
|
|
103
|
+
* agree on the row the header sits on — a mismatch lands clicks one row off. */
|
|
104
|
+
export const AGENTS_GAP_ROWS = 1;
|
|
105
|
+
|
|
106
|
+
/** What a sidebar content row (a tab-bar-adjusted `gy`) targets. `agents-header`,
|
|
107
|
+
* the gap row(s), and the empty-state row are inert (the router does nothing). */
|
|
108
|
+
export type SidebarHit =
|
|
109
|
+
| { kind: "session"; index: number }
|
|
110
|
+
| { kind: "agent"; index: number }
|
|
111
|
+
| { kind: "agents-header" }
|
|
112
|
+
| null;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* PURE — map a sidebar content row `gy` (already `y - TABBAR_H`) to what it
|
|
116
|
+
* targets, shared by the click router and the hover resolver so a click can
|
|
117
|
+
* never land where a row isn't drawn. Layout, top to bottom: `gy 0` title,
|
|
118
|
+
* `gy 1` rule, then `sessionCount` session rows from `gy 2`, then
|
|
119
|
+
* `AGENTS_GAP_ROWS` blank row(s), then the agents section — one header row, then
|
|
120
|
+
* `agentCount` agent rows (or a single inert empty-state row when
|
|
121
|
+
* `agentCount === 0`).
|
|
122
|
+
*/
|
|
123
|
+
export function sidebarHit(gy: number, sessionCount: number, agentCount: number): SidebarHit {
|
|
124
|
+
if (gy < 2) return null;
|
|
125
|
+
const si = gy - 2;
|
|
126
|
+
if (si < sessionCount) return { kind: "session", index: si };
|
|
127
|
+
const headerGy = 2 + sessionCount + AGENTS_GAP_ROWS;
|
|
128
|
+
if (gy < headerGy) return null; // the gap row(s) between sessions and agents
|
|
129
|
+
if (gy === headerGy) return { kind: "agents-header" };
|
|
130
|
+
const ai = gy - headerGy - 1;
|
|
131
|
+
if (agentCount === 0 || ai < 0 || ai >= agentCount) return null;
|
|
132
|
+
return { kind: "agent", index: ai };
|
|
133
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted app state for the unified IDE (M18.4) — the one small JSON that
|
|
3
|
+
* makes the app remember where you were: the last surface TAB, the workspace
|
|
4
|
+
* CONTEXT session, and the file that was open in the editor / selected in the
|
|
5
|
+
* diff panel.
|
|
6
|
+
*
|
|
7
|
+
* PURE parse/serialize live here so they unit-test as tables (like
|
|
8
|
+
* {@link ./editor-buffer.ts} / {@link ./diff-model.ts}); the io wrappers
|
|
9
|
+
* ({@link loadAppState}/{@link saveAppState}) are thin. The file lives at
|
|
10
|
+
* `~/.tmux-ide/app-state.json`, overridable via `TMUX_IDE_HOME` (tests /
|
|
11
|
+
* per-run isolation point the whole home elsewhere).
|
|
12
|
+
*
|
|
13
|
+
* {@link parseAppState} is TOLERANT: a missing file, malformed JSON, or a
|
|
14
|
+
* mistyped field each falls back to its default and it never throws — the same
|
|
15
|
+
* discipline as {@link ../../lib/app-config.ts}. Restoring stale state must
|
|
16
|
+
* never crash the launch.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { writeFile } from "node:fs/promises";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
|
|
23
|
+
/** The four top-level surfaces. `terminal` is the SessionMirror, `files` the
|
|
24
|
+
* editor + file list, `diff` the git panel, `home` the fleet cockpit. */
|
|
25
|
+
export type Tab = "home" | "terminal" | "files" | "diff";
|
|
26
|
+
|
|
27
|
+
const TABS: readonly Tab[] = ["home", "terminal", "files", "diff"];
|
|
28
|
+
|
|
29
|
+
/** PURE — is `x` one of the four tab keys? */
|
|
30
|
+
export function isTab(x: unknown): x is Tab {
|
|
31
|
+
return typeof x === "string" && (TABS as readonly string[]).includes(x);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Sidebar-width bounds (M19.3). The user drags the sidebar/main boundary; the
|
|
35
|
+
* resulting width is clamped to this range and persisted. `DEFAULT` is the
|
|
36
|
+
* historical fixed width. */
|
|
37
|
+
export const SIDEBAR_W_MIN = 16;
|
|
38
|
+
export const SIDEBAR_W_MAX = 48;
|
|
39
|
+
export const SIDEBAR_W_DEFAULT = 24;
|
|
40
|
+
|
|
41
|
+
/** PURE — clamp a candidate sidebar width to `[SIDEBAR_W_MIN, SIDEBAR_W_MAX]`,
|
|
42
|
+
* falling back to the default for a non-finite value. */
|
|
43
|
+
export function clampSidebarWidth(w: number): number {
|
|
44
|
+
if (!Number.isFinite(w)) return SIDEBAR_W_DEFAULT;
|
|
45
|
+
return Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, Math.round(w)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How many recently-opened folders home remembers (M22.5). Oldest fall off. */
|
|
49
|
+
export const RECENTS_CAP = 8;
|
|
50
|
+
|
|
51
|
+
/** The persisted shape. `null` means "nothing remembered" for that slot. */
|
|
52
|
+
export interface AppState {
|
|
53
|
+
/** The tab the app was showing when it last saved. */
|
|
54
|
+
lastTab: Tab;
|
|
55
|
+
/** The workspace-context session name (drives terminal target + files/diff dir). */
|
|
56
|
+
contextSession: string | null;
|
|
57
|
+
/** Absolute path of the file open in the editor. */
|
|
58
|
+
openFile: string | null;
|
|
59
|
+
/** Repo-relative path of the file selected in the diff panel. */
|
|
60
|
+
diffFile: string | null;
|
|
61
|
+
/** The sidebar column width (M19.3), clamped to the bounds above. */
|
|
62
|
+
sidebarW: number;
|
|
63
|
+
/** Recently-opened folder paths (M22.5), most-recent first, deduped and
|
|
64
|
+
* capped at {@link RECENTS_CAP}. Home renders these under a "recent" header. */
|
|
65
|
+
recentFolders: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_APP_STATE: AppState = {
|
|
69
|
+
lastTab: "home",
|
|
70
|
+
contextSession: null,
|
|
71
|
+
openFile: null,
|
|
72
|
+
diffFile: null,
|
|
73
|
+
sidebarW: SIDEBAR_W_DEFAULT,
|
|
74
|
+
recentFolders: [],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** PURE — the recents list after opening `dir`: it moves to the front,
|
|
78
|
+
* any earlier occurrence is removed (dedupe), and the tail past `cap` drops.
|
|
79
|
+
* Blank paths are ignored (return the list unchanged). */
|
|
80
|
+
export function addRecentFolder(
|
|
81
|
+
list: readonly string[],
|
|
82
|
+
dir: string,
|
|
83
|
+
cap: number = RECENTS_CAP,
|
|
84
|
+
): string[] {
|
|
85
|
+
if (dir.length === 0) return [...list];
|
|
86
|
+
return [dir, ...list.filter((d) => d !== dir)].slice(0, cap);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** PURE — a persisted recents value coerced to clean strings: non-empty,
|
|
90
|
+
* deduped (first wins), capped. Anything not a string[] yields []. */
|
|
91
|
+
function sanitizeRecents(v: unknown): string[] {
|
|
92
|
+
if (!Array.isArray(v)) return [];
|
|
93
|
+
const out: string[] = [];
|
|
94
|
+
for (const item of v) {
|
|
95
|
+
if (typeof item === "string" && item.length > 0 && !out.includes(item)) out.push(item);
|
|
96
|
+
if (out.length >= RECENTS_CAP) break;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The tmux-ide home dir: `TMUX_IDE_HOME` when set, else `~/.tmux-ide`. */
|
|
102
|
+
export function appStateHome(): string {
|
|
103
|
+
return process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Absolute path to `app-state.json` under {@link appStateHome}. */
|
|
107
|
+
export function appStatePath(): string {
|
|
108
|
+
return join(appStateHome(), "app-state.json");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A string field that must be non-empty, else `null`. */
|
|
112
|
+
function optString(v: unknown): string | null {
|
|
113
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* PURE — parse a raw JSON string into a fully-populated {@link AppState}. Any
|
|
118
|
+
* missing/mistyped field falls back to its default; invalid JSON yields the
|
|
119
|
+
* defaults. Never throws.
|
|
120
|
+
*/
|
|
121
|
+
export function parseAppState(raw: string): AppState {
|
|
122
|
+
let obj: Record<string, unknown>;
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(raw);
|
|
125
|
+
if (!parsed || typeof parsed !== "object") return { ...DEFAULT_APP_STATE };
|
|
126
|
+
obj = parsed as Record<string, unknown>;
|
|
127
|
+
} catch {
|
|
128
|
+
return { ...DEFAULT_APP_STATE };
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
lastTab: isTab(obj.lastTab) ? obj.lastTab : DEFAULT_APP_STATE.lastTab,
|
|
132
|
+
contextSession: optString(obj.contextSession),
|
|
133
|
+
openFile: optString(obj.openFile),
|
|
134
|
+
diffFile: optString(obj.diffFile),
|
|
135
|
+
sidebarW:
|
|
136
|
+
typeof obj.sidebarW === "number"
|
|
137
|
+
? clampSidebarWidth(obj.sidebarW)
|
|
138
|
+
: DEFAULT_APP_STATE.sidebarW,
|
|
139
|
+
recentFolders: sanitizeRecents(obj.recentFolders),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** PURE — serialize an {@link AppState} to the exact four-key JSON we persist
|
|
144
|
+
* (extra runtime keys are dropped; stable key order for tidy diffs). */
|
|
145
|
+
export function serializeAppState(state: AppState): string {
|
|
146
|
+
const clean: AppState = {
|
|
147
|
+
lastTab: isTab(state.lastTab) ? state.lastTab : "home",
|
|
148
|
+
contextSession: optString(state.contextSession),
|
|
149
|
+
openFile: optString(state.openFile),
|
|
150
|
+
diffFile: optString(state.diffFile),
|
|
151
|
+
sidebarW: clampSidebarWidth(state.sidebarW),
|
|
152
|
+
recentFolders: sanitizeRecents(state.recentFolders),
|
|
153
|
+
};
|
|
154
|
+
return JSON.stringify(clean, null, 2);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Read the persisted state (one-shot at launch — NOT on the render loop).
|
|
158
|
+
* Missing/unreadable file → defaults. */
|
|
159
|
+
export function loadAppState(): AppState {
|
|
160
|
+
const path = appStatePath();
|
|
161
|
+
if (!existsSync(path)) return { ...DEFAULT_APP_STATE };
|
|
162
|
+
try {
|
|
163
|
+
return parseAppState(readFileSync(path, "utf8"));
|
|
164
|
+
} catch {
|
|
165
|
+
return { ...DEFAULT_APP_STATE };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Write the state asynchronously (debounced by the caller), creating the home
|
|
170
|
+
* dir if needed. Swallows io errors — persistence is best-effort. */
|
|
171
|
+
export async function saveAppState(state: AppState): Promise<void> {
|
|
172
|
+
const path = appStatePath();
|
|
173
|
+
try {
|
|
174
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
175
|
+
await writeFile(path, serializeAppState(state));
|
|
176
|
+
} catch {
|
|
177
|
+
// best-effort: a failed save must never disturb the running app
|
|
178
|
+
}
|
|
179
|
+
}
|