tmux-ide 2.7.0 → 2.8.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.
Files changed (101) hide show
  1. package/README.md +22 -5
  2. package/bin/cli.js +3532 -1090
  3. package/bin/cli.ts +368 -71
  4. package/package.json +2 -1
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/control/client.d.ts +23 -0
  9. package/packages/daemon/dist/control/client.js +105 -0
  10. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  11. package/packages/daemon/dist/control/dispatch.js +83 -0
  12. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  13. package/packages/daemon/dist/control/fanout.js +37 -0
  14. package/packages/daemon/dist/control/frames.d.ts +23 -0
  15. package/packages/daemon/dist/control/frames.js +37 -0
  16. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  17. package/packages/daemon/dist/control/lifecycle.js +114 -0
  18. package/packages/daemon/dist/control/server.d.ts +16 -0
  19. package/packages/daemon/dist/control/server.js +214 -0
  20. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  21. package/packages/daemon/dist/control/verbs.js +91 -0
  22. package/packages/daemon/dist/doctor.d.ts +18 -0
  23. package/packages/daemon/dist/doctor.js +105 -15
  24. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  25. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  26. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  27. package/packages/daemon/dist/lib/app-config.js +104 -5
  28. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  29. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  30. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  31. package/packages/daemon/dist/lib/state-home.js +12 -0
  32. package/packages/daemon/dist/lib/update-check.js +5 -0
  33. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  34. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  35. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  36. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  39. package/packages/daemon/dist/restore.d.ts +35 -8
  40. package/packages/daemon/dist/restore.js +52 -15
  41. package/packages/daemon/dist/send.d.ts +33 -1
  42. package/packages/daemon/dist/send.js +32 -19
  43. package/packages/daemon/src/control/client.ts +128 -0
  44. package/packages/daemon/src/control/dispatch.ts +107 -0
  45. package/packages/daemon/src/control/fanout.ts +44 -0
  46. package/packages/daemon/src/control/frames.ts +40 -0
  47. package/packages/daemon/src/control/lifecycle.ts +151 -0
  48. package/packages/daemon/src/control/server.ts +237 -0
  49. package/packages/daemon/src/control/verbs.ts +118 -0
  50. package/packages/daemon/src/doctor.ts +113 -28
  51. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  52. package/packages/daemon/src/lib/app-config.ts +103 -5
  53. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  54. package/packages/daemon/src/lib/state-home.ts +13 -0
  55. package/packages/daemon/src/lib/update-check.ts +5 -0
  56. package/packages/daemon/src/restore.ts +53 -15
  57. package/packages/daemon/src/send.ts +55 -21
  58. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  59. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  60. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  61. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  62. package/packages/daemon/src/tui/chrome/notify.ts +582 -84
  63. package/packages/daemon/src/tui/chrome/updater.ts +268 -62
  64. package/packages/daemon/src/tui/detect/classify.ts +34 -0
  65. package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
  66. package/packages/daemon/src/tui/detect/manifest.ts +24 -3
  67. package/packages/daemon/src/tui/detect/manifests.ts +240 -6
  68. package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
  69. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  70. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  71. package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
  72. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  73. package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
  74. package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
  75. package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
  76. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  77. package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
  78. package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
  79. package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
  80. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  81. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  82. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  83. package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
  84. package/packages/daemon/src/tui/mirror/palette.ts +299 -9
  85. package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
  86. package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
  87. package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
  88. package/packages/daemon/src/tui/mirror/selection.ts +122 -8
  89. package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
  90. package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
  91. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  92. package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
  93. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  94. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  95. package/packages/daemon/src/tui/team/sessions.ts +85 -7
  96. package/packages/daemon/src/tui/team/wait.ts +144 -0
  97. package/scripts/build-macos-notifier.mjs +160 -0
  98. package/scripts/postinstall.js +8 -1
  99. package/scripts/prepublish-check.mjs +37 -1
  100. package/scripts/publish-tap.sh +55 -0
  101. package/skill/SKILL.md +88 -2
@@ -1,11 +1,13 @@
1
1
  import { execSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { accessSync, constants, existsSync } from "node:fs";
3
3
  import { resolve, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { getCurrentVersion, getUpdateStatus } from "./lib/update-check.js";
6
6
  import { installedSkillVersion } from "./lib/skill-sync.js";
7
7
  import { discoverAgents, presentAgents } from "./lib/agent-discovery.js";
8
8
  import { findCompiledTui, isBunAvailable } from "./tui/compiled.js";
9
+ import { claudeSettingsPath } from "./tui/integrations/claude.js";
10
+ import { readNotificationPrefs, resolveNativeMacosNotifierPath } from "./tui/chrome/notify.js";
9
11
  /**
10
12
  * PURE — the "agent integrations" doctor rows, one per DISCOVERED agent (absent
11
13
  * agents produce nothing — no noise). All rows are optional (informational, they
@@ -19,12 +21,18 @@ export function agentIntegrationRows(agents) {
19
21
  return presentAgents(agents).map((agent) => {
20
22
  const label = `agent: ${agent.id}`;
21
23
  if (agent.integration) {
24
+ // What installing actually buys, honestly per mechanism: claude's hooks
25
+ // give ground-truth status (and record resume ids); opencode's plugin
26
+ // records resume ids only.
27
+ const benefit = agent.capture === "hooks"
28
+ ? "for ground-truth status"
29
+ : "to record session ids for restore --resume-agents";
22
30
  return agent.installed
23
31
  ? { label, pass: true, detail: "integration installed ✓", optional: true }
24
32
  : {
25
33
  label,
26
34
  pass: false,
27
- detail: `found on PATH — run \`tmux-ide integration install ${agent.id}\` for ground-truth status`,
35
+ detail: `found on PATH — run \`tmux-ide integration install ${agent.id}\` ${benefit}`,
28
36
  optional: true,
29
37
  };
30
38
  }
@@ -36,6 +44,52 @@ export function agentIntegrationRows(agents) {
36
44
  };
37
45
  });
38
46
  }
47
+ /**
48
+ * PURE — the "Claude hooks target writable" row from observed facts. Both
49
+ * `integration install claude` and the npm postinstall write the settings
50
+ * file; surface a permissions problem BEFORE an install fails halfway.
51
+ * Optional: a machine without Claude Code shouldn't fail doctor over this.
52
+ */
53
+ export function hooksTargetRow(facts) {
54
+ const label = "Claude hooks target writable";
55
+ if (facts.writable) {
56
+ return {
57
+ label,
58
+ pass: true,
59
+ detail: facts.fileExists ? facts.settingsPath : `${facts.settingsPath} (will be created)`,
60
+ optional: true,
61
+ };
62
+ }
63
+ return {
64
+ label,
65
+ pass: false,
66
+ detail: `cannot write ${facts.settingsPath} — fix its permissions (chown/chmod), or point TMUX_IDE_CLAUDE_SETTINGS at a writable path`,
67
+ optional: true,
68
+ };
69
+ }
70
+ /**
71
+ * PURE — native notifier health, shown only when macOS notifications are ON.
72
+ * Release packages always include the helper; absence means an incomplete or
73
+ * old installation, which can still fall back to the unbranded AppleScript path.
74
+ * Optional — informational, never fails doctor.
75
+ */
76
+ export function notifierRow(present) {
77
+ const label = "native macOS notifications";
78
+ if (present) {
79
+ return {
80
+ label,
81
+ pass: true,
82
+ detail: "bundled — branded banners and click-to-jump are ready",
83
+ optional: true,
84
+ };
85
+ }
86
+ return {
87
+ label,
88
+ pass: false,
89
+ detail: "native helper missing — reinstall tmux-ide; unbranded AppleScript banners remain available",
90
+ optional: true,
91
+ };
92
+ }
39
93
  function check(label, fn, { optional = false } = {}) {
40
94
  try {
41
95
  const result = fn();
@@ -48,7 +102,12 @@ function check(label, fn, { optional = false } = {}) {
48
102
  export async function doctor({ json, } = {}) {
49
103
  const checks = [];
50
104
  checks.push(check("tmux installed", () => {
51
- execSync("which tmux", { stdio: "ignore" });
105
+ try {
106
+ execSync("which tmux", { stdio: "ignore" });
107
+ }
108
+ catch {
109
+ throw new Error("not found on PATH — install it (macOS: `brew install tmux`; Debian/Ubuntu: `sudo apt install tmux`)");
110
+ }
52
111
  return "found";
53
112
  }));
54
113
  checks.push(check("tmux version ≥ 3.0", () => {
@@ -103,18 +162,45 @@ export async function doctor({ json, } = {}) {
103
162
  }
104
163
  return "enabled";
105
164
  }, { optional: true }));
106
- checks.push(check("tailscale CLI", () => {
107
- const version = execSync("tailscale version", { encoding: "utf-8" }).trim().split("\n")[0];
108
- return version;
109
- }, { optional: true }));
110
- checks.push(check("ngrok CLI", () => {
111
- const version = execSync("ngrok version", { encoding: "utf-8" }).trim();
112
- return version;
113
- }, { optional: true }));
114
- checks.push(check("cloudflared CLI", () => {
115
- const version = execSync("cloudflared --version", { encoding: "utf-8" }).trim();
116
- return version;
117
- }, { optional: true }));
165
+ // Optional tunnel CLIs: probe quietly (a missing binary must not leak
166
+ // "command not found" stderr into doctor's own output) and fail with a
167
+ // plain hint rather than execSync's raw error.
168
+ const tunnelCli = (label, cmd) => check(label, () => {
169
+ try {
170
+ return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] })
171
+ .trim()
172
+ .split("\n")[0];
173
+ }
174
+ catch {
175
+ throw new Error("not found (optional — used for remote access tunnels)");
176
+ }
177
+ }, { optional: true });
178
+ checks.push(tunnelCli("tailscale CLI", "tailscale version"));
179
+ checks.push(tunnelCli("ngrok CLI", "ngrok version"));
180
+ checks.push(tunnelCli("cloudflared CLI", "cloudflared --version"));
181
+ checks.push((() => {
182
+ // Claude hooks target: the settings file `integration install claude`
183
+ // (and the npm postinstall) will write. Writability of the file itself
184
+ // when it exists, else of its nearest existing ancestor directory.
185
+ const settingsPath = claudeSettingsPath();
186
+ const fileExists = existsSync(settingsPath);
187
+ let probe = fileExists ? settingsPath : dirname(settingsPath);
188
+ while (!existsSync(probe)) {
189
+ const parent = dirname(probe);
190
+ if (parent === probe)
191
+ break;
192
+ probe = parent;
193
+ }
194
+ let writable = false;
195
+ try {
196
+ accessSync(probe, constants.W_OK);
197
+ writable = true;
198
+ }
199
+ catch {
200
+ // leave writable=false — the row explains the fix
201
+ }
202
+ return hooksTargetRow({ settingsPath, fileExists, writable });
203
+ })());
118
204
  checks.push(check("tmux-ide up to date", () => {
119
205
  const current = getCurrentVersion();
120
206
  const { latest, updateAvailable } = getUpdateStatus({ currentVersion: current });
@@ -137,6 +223,10 @@ export async function doctor({ json, } = {}) {
137
223
  }
138
224
  return `in sync (v${installed})`;
139
225
  }, { optional: true }));
226
+ // Native branded sender: only when the macOS channel is actually on.
227
+ if (process.platform === "darwin" && readNotificationPrefs().macos) {
228
+ checks.push(notifierRow(resolveNativeMacosNotifierPath() !== null));
229
+ }
140
230
  // Agent integrations: one row per agent discovered on PATH (absent → no row).
141
231
  checks.push(...agentIntegrationRows(discoverAgents()));
142
232
  const allPass = checks.every((c) => c.pass || c.optional);
@@ -1,3 +1,16 @@
1
+ /**
2
+ * How a kind's `@agent_session_id` (the `restore --resume-agents` key) gets
3
+ * captured:
4
+ * - `"hooks"` — the agent's own lifecycle hooks stamp it (claude; needs
5
+ * `integration install`).
6
+ * - `"plugin"` — an in-process plugin stamps it (opencode; needs
7
+ * `integration install`).
8
+ * - `"probe"` — the chrome updater discovers it from the agent's own on-disk
9
+ * session state (codex, cursor; automatic, nothing to install).
10
+ * - `null` — no defensible capture surface (see
11
+ * {@link ../tui/detect/session-id.ts} for the per-kind evidence).
12
+ */
13
+ export type CaptureMechanism = "hooks" | "plugin" | "probe" | null;
1
14
  /** A coding agent tmux-ide knows how to detect. */
2
15
  export interface KnownAgent {
3
16
  /** Stable id — also the `tmux-ide integration <install|status> <id>` selector. */
@@ -6,11 +19,14 @@ export interface KnownAgent {
6
19
  bin: string;
7
20
  /** True → tmux-ide ships a lifecycle-integration installer for this agent. */
8
21
  integration: boolean;
22
+ /** How this kind's session id is captured for `restore --resume-agents`. */
23
+ capture: CaptureMechanism;
9
24
  }
10
25
  /**
11
26
  * The agents tmux-ide recognizes. `integration: true` means we HAVE an installer
12
- * (a real lifecycle hook → ground-truth pane state); the rest are detected via
13
- * screen-manifest scraping only, with no lifecycle hook yet.
27
+ * (claude's lifecycle hooks, opencode's plugin); the rest are detected via
28
+ * screen-manifest scraping only. `capture` records each kind's session-id
29
+ * story ({@link CaptureMechanism}).
14
30
  */
15
31
  export declare const KNOWN_AGENTS: readonly KnownAgent[];
16
32
  /** One probed agent: its registry facts plus what the PATH/integration probe found. */
@@ -27,6 +43,15 @@ export interface DiscoveredAgent {
27
43
  * integrate (there's nothing to install) and for any agent absent from PATH.
28
44
  */
29
45
  installed: boolean;
46
+ /** How this kind's session id is captured (copied from the registry). */
47
+ capture: CaptureMechanism;
48
+ /**
49
+ * Whether session-id capture is LIVE for this kind on this machine:
50
+ * `"probe"` capture is automatic whenever the binary is present; hook/plugin
51
+ * capture requires the integration to be installed; `null` capture is never
52
+ * active.
53
+ */
54
+ captureActive: boolean;
30
55
  }
31
56
  /** Resolve a binary to its absolute path, or null. Must never throw. */
32
57
  export type WhichRunner = (bin: string) => string | null;
@@ -17,17 +17,21 @@
17
17
  */
18
18
  import { execFileSync } from "node:child_process";
19
19
  import { claudeIntegrationStatus } from "../tui/integrations/claude.js";
20
+ import { opencodeIntegrationStatus } from "../tui/integrations/opencode.js";
20
21
  /**
21
22
  * The agents tmux-ide recognizes. `integration: true` means we HAVE an installer
22
- * (a real lifecycle hook → ground-truth pane state); the rest are detected via
23
- * screen-manifest scraping only, with no lifecycle hook yet.
23
+ * (claude's lifecycle hooks, opencode's plugin); the rest are detected via
24
+ * screen-manifest scraping only. `capture` records each kind's session-id
25
+ * story ({@link CaptureMechanism}).
24
26
  */
25
27
  export const KNOWN_AGENTS = [
26
- { id: "claude", bin: "claude", integration: true },
27
- { id: "codex", bin: "codex", integration: false },
28
- { id: "opencode", bin: "opencode", integration: false },
29
- { id: "gemini", bin: "gemini", integration: false },
30
- { id: "aider", bin: "aider", integration: false },
28
+ { id: "claude", bin: "claude", integration: true, capture: "hooks" },
29
+ { id: "codex", bin: "codex", integration: false, capture: "probe" },
30
+ { id: "opencode", bin: "opencode", integration: true, capture: "plugin" },
31
+ { id: "gemini", bin: "gemini", integration: false, capture: null },
32
+ { id: "aider", bin: "aider", integration: false, capture: null },
33
+ { id: "cursor", bin: "cursor-agent", integration: false, capture: "probe" },
34
+ { id: "copilot", bin: "copilot", integration: false, capture: null },
31
35
  ];
32
36
  /**
33
37
  * Default which-runner: `which <bin>`, hard-capped at 2s, swallowing every
@@ -51,15 +55,17 @@ const defaultWhich = (bin) => {
51
55
  }
52
56
  };
53
57
  /**
54
- * Default integration probe: only `claude` has an installer, so only it can be
55
- * "installed". Reads the real Claude settings; any failure degrades to false so
56
- * discovery never throws.
58
+ * Default integration probe: claude's hooks and opencode's plugin are the two
59
+ * shipped installers. Reads the real settings/plugin file; any failure degrades
60
+ * to false so discovery never throws.
57
61
  */
58
62
  const defaultIntegrationProbe = (agentId) => {
59
- if (agentId !== "claude")
60
- return false;
61
63
  try {
62
- return claudeIntegrationStatus().installed;
64
+ if (agentId === "claude")
65
+ return claudeIntegrationStatus().installed;
66
+ if (agentId === "opencode")
67
+ return opencodeIntegrationStatus().installed;
68
+ return false;
63
69
  }
64
70
  catch {
65
71
  return false;
@@ -76,7 +82,16 @@ export function discoverAgents(which = defaultWhich, isInstalled = defaultIntegr
76
82
  const path = which(agent.bin);
77
83
  const present = path !== null;
78
84
  const installed = present && agent.integration ? isInstalled(agent.id) : false;
79
- return { id: agent.id, bin: agent.bin, integration: agent.integration, path, installed };
85
+ const captureActive = agent.capture === "probe" ? present : agent.capture !== null ? installed : false;
86
+ return {
87
+ id: agent.id,
88
+ bin: agent.bin,
89
+ integration: agent.integration,
90
+ path,
91
+ installed,
92
+ capture: agent.capture,
93
+ captureActive,
94
+ };
80
95
  });
81
96
  }
82
97
  /** The subset of discovered agents actually present on PATH (path resolved). */
@@ -60,10 +60,29 @@ export interface AppUpdater {
60
60
  /** Write a disaster-recovery snapshot every N ticks (default 15). */
61
61
  snapshotEvery: number;
62
62
  }
63
+ /** The sound channel's states: ping sound on blocked only, on every ping, or never. */
64
+ export type NotificationSound = "blocked" | "all" | "none";
63
65
  /** Notification channel toggles. */
64
66
  export interface AppNotifications {
65
67
  toast: boolean;
66
68
  macos: boolean;
69
+ /**
70
+ * Terminal-native banners (M25.2): an OSC 9 (kitty: OSC 99) escape written to
71
+ * each eligible attached client's tty, so terminals that surface them (iTerm2,
72
+ * Ghostty, WezTerm, kitty) show a native notification even over ssh. Default
73
+ * true — terminals without support ignore the sequence entirely.
74
+ */
75
+ terminal: boolean;
76
+ /**
77
+ * Seconds the OS-LEVEL channels (banner / terminal escape / sound / BEL) wait
78
+ * before firing, re-verifying the pane is STILL in the notified state — a
79
+ * blocked→working flap inside the window fires nothing. 0 fires immediately.
80
+ * The in-app toast never waits. Tick-native: the delay rounds up to the
81
+ * updater's next tick at or after `delaySeconds`.
82
+ */
83
+ delaySeconds: number;
84
+ /** Sound channel (M25.2) — `afplay`/`paplay` + a BEL to eligible client ttys. */
85
+ sound: NotificationSound;
67
86
  }
68
87
  /** Restore behaviour toggles. */
69
88
  export interface AppRestore {
@@ -72,6 +91,14 @@ export interface AppRestore {
72
91
  /** Update-check toggles (consumed later by the update-flow card). */
73
92
  export interface AppUpdates {
74
93
  check: boolean;
94
+ /**
95
+ * Whether the daily update check ALSO refreshes the remote agent-detection
96
+ * manifest pack into `~/.tmux-ide/agent-detection/pack/` (M25.4). Default
97
+ * FALSE — auto-installing detection rules is opt-in; `tmux-ide update
98
+ * --manifests` always works explicitly. Requires `check: true` (the refresh
99
+ * rides the same daily throttle — no timer of its own).
100
+ */
101
+ manifests: boolean;
75
102
  }
76
103
  /** First-run welcome toggles. */
77
104
  export interface AppWelcome {
@@ -92,6 +119,55 @@ export interface AppIntegrations {
92
119
  */
93
120
  offer: boolean;
94
121
  }
122
+ /** The unified-app front-door decision (M22.6). */
123
+ export interface AppApp {
124
+ /**
125
+ * Whether bare `tmux-ide` (no ide.yml here, no `--team`) launches the unified
126
+ * app (`tmux-ide app`) instead of the classic team cockpit. Default false —
127
+ * the flip is opt-in until the default-entry decision is made. `tmux-ide team`
128
+ * (the explicit cockpit) and a project's `ide.yml` auto-launch are unaffected.
129
+ */
130
+ frontDoor: boolean;
131
+ /**
132
+ * Whether `tmux-ide app` runs HOSTED by default (M23.2): the app lives in an
133
+ * internal `_tmux-ide-app` tmux session and the invoking terminal attaches to
134
+ * it, so the cockpit survives the terminal and reattaches from anywhere (^q
135
+ * detaches instead of quitting). Default false — the same behavior as the
136
+ * explicit `--detachable` flag; `--detachable`/`--hosted` still force it on a
137
+ * single run.
138
+ */
139
+ detachable: boolean;
140
+ /**
141
+ * When a plain left drag on an app-mouse pane SELECTS locally instead of
142
+ * forwarding to the pane's app (M24.2). "agents" (default): panes our own
143
+ * detection matches to a fleet agent entry select; other app-mouse panes
144
+ * (vim/htop) forward. "always": every app-mouse pane selects on drag.
145
+ * "never": every app-mouse pane forwards (the pre-M24.2 behavior). A genuine
146
+ * click (press+release in one cell) is still forwarded on select-default
147
+ * panes; shift inverts a pane's default; the right-click pane menu overrides
148
+ * per pane for the session.
149
+ */
150
+ dragSelect: "agents" | "always" | "never";
151
+ /**
152
+ * Where a Terminal-surface "New agent" spawn starts (M24.1): `"pane"`
153
+ * (default) inherits the FOCUSED pane's current working directory —
154
+ * `#{pane_current_path}`, so the agent lands where you are — while
155
+ * `"session"` keeps the session/project directory. Home/sidebar spawns
156
+ * always use the session/project dir (no focused pane exists there).
157
+ */
158
+ newAgentCwd: NewAgentCwd;
159
+ /**
160
+ * Whether the unified app enables the kitty keyboard protocol on its host
161
+ * terminal (M24.4). On (default), kitty-capable terminals deliver ⌘-modified
162
+ * keys — ⌘K opens the command palette — and disambiguated escapes; the app
163
+ * re-encodes every key for the mirrored panes either way, and terminals
164
+ * without the protocol ignore the request entirely. Off restores the legacy
165
+ * key encoding for hosts where the protocol misbehaves.
166
+ */
167
+ kittyKeys: boolean;
168
+ }
169
+ /** The two Terminal-spawn cwd policies (see {@link AppApp.newAgentCwd}). */
170
+ export type NewAgentCwd = "pane" | "session";
95
171
  /** Worktree flow config (`tmux-ide worktree`). */
96
172
  export interface AppWorktrees {
97
173
  /**
@@ -112,6 +188,7 @@ export interface AppConfig {
112
188
  welcome: AppWelcome;
113
189
  integrations: AppIntegrations;
114
190
  worktrees: AppWorktrees;
191
+ app: AppApp;
115
192
  }
116
193
  /** The built-in config — every field's fallback. */
117
194
  export declare const DEFAULT_APP_CONFIG: AppConfig;
@@ -145,3 +222,32 @@ export declare function loadAppConfig(): AppConfig;
145
222
  export declare function getAppConfig(): AppConfig;
146
223
  /** Drop the cache so the next {@link getAppConfig} re-reads. Test-only. */
147
224
  export declare function _resetForTests(): void;
225
+ /**
226
+ * io — the RAW parsed config file as the user wrote it (`{}` when missing or
227
+ * malformed). Writers merge over THIS, not over {@link parseAppConfig}'s output:
228
+ * round-tripping through the parser would materialize every default into the
229
+ * hand-editable file and silently DROP fields the typed shape doesn't model yet
230
+ * (e.g. the notification polish fields `notifications.enabled` /
231
+ * `notifications.quietHours` that {@link ../tui/chrome/notify.ts} reads raw).
232
+ */
233
+ export declare function loadRawAppConfig(): Record<string, unknown>;
234
+ /** A partial config write: plain objects merge recursively, scalars/arrays
235
+ * replace, and an explicit `undefined` DELETES the key (how "reset to
236
+ * defaults" removes a block so the parser's defaults take over). */
237
+ export type AppConfigPatch = {
238
+ [key: string]: unknown;
239
+ };
240
+ /**
241
+ * PURE — merge `patch` into `raw` without touching either input. Objects merge
242
+ * key-by-key (recursively), anything else replaces, and a key whose patch value
243
+ * is `undefined` is REMOVED from the result. Unknown user fields survive — the
244
+ * merge only visits keys the patch names.
245
+ */
246
+ export declare function mergeConfigPatch(raw: Record<string, unknown>, patch: AppConfigPatch): Record<string, unknown>;
247
+ /**
248
+ * io — apply a patch to the config file ATOMICALLY (temp + rename, the same
249
+ * discipline as the editor save): read raw → {@link mergeConfigPatch} → write.
250
+ * Busts the {@link getAppConfig} process cache and returns the new parsed
251
+ * config. Honors `TMUX_IDE_CONFIG`, so tests never touch the real file.
252
+ */
253
+ export declare function updateAppConfig(patch: AppConfigPatch): AppConfig;
@@ -20,9 +20,9 @@
20
20
  * per-concern readers ({@link ../tui/chrome/notify.ts}, {@link ../restore.ts})
21
21
  * now delegate here.
22
22
  */
23
- import { existsSync, readFileSync } from "node:fs";
23
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
- import { join } from "node:path";
25
+ import { dirname, join } from "node:path";
26
26
  // ---------------------------------------------------------------------------
27
27
  // Defaults
28
28
  // ---------------------------------------------------------------------------
@@ -50,12 +50,19 @@ export const DEFAULT_APP_CONFIG = {
50
50
  glyphs: { active: "●", inactive: "○" },
51
51
  },
52
52
  updater: { tickMs: 2000, snapshotEvery: 15 },
53
- notifications: { toast: true, macos: false },
53
+ notifications: { toast: true, macos: false, terminal: true, delaySeconds: 2, sound: "blocked" },
54
54
  restore: { resumeAgents: false },
55
- updates: { check: true },
55
+ updates: { check: true, manifests: false },
56
56
  welcome: { show: true },
57
57
  integrations: { offer: true },
58
58
  worktrees: { dir: "" },
59
+ app: {
60
+ frontDoor: false,
61
+ detachable: false,
62
+ dragSelect: "agents",
63
+ newAgentCwd: "pane",
64
+ kittyKeys: true,
65
+ },
59
66
  };
60
67
  /** The default theme tokens — the fallback threaded into the pure builders. */
61
68
  export const DEFAULT_THEME = DEFAULT_APP_CONFIG.theme;
@@ -82,6 +89,16 @@ function pickBool(value, fallback) {
82
89
  function pickPosInt(value, fallback) {
83
90
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
84
91
  }
92
+ /** A non-negative integer (0 allowed — e.g. "no delay"), else the default. */
93
+ function pickNonNegInt(value, fallback) {
94
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
95
+ }
96
+ /** One of the allowed literals, else the default. */
97
+ function pickChoice(value, allowed, fallback) {
98
+ return typeof value === "string" && allowed.includes(value)
99
+ ? value
100
+ : fallback;
101
+ }
85
102
  /**
86
103
  * PURE — merge an unknown (typically parsed JSON) over {@link DEFAULT_APP_CONFIG}.
87
104
  * Every block/leaf is validated independently: a missing or mistyped field falls
@@ -103,6 +120,7 @@ export function parseAppConfig(input) {
103
120
  const welcome = asObject(root.welcome);
104
121
  const integrations = asObject(root.integrations);
105
122
  const worktrees = asObject(root.worktrees);
123
+ const app = asObject(root.app);
106
124
  return {
107
125
  keys: {
108
126
  popup: pickString(keys.popup, D.keys.popup),
@@ -139,12 +157,25 @@ export function parseAppConfig(input) {
139
157
  notifications: {
140
158
  toast: pickBool(notifications.toast, D.notifications.toast),
141
159
  macos: pickBool(notifications.macos, D.notifications.macos),
160
+ terminal: pickBool(notifications.terminal, D.notifications.terminal),
161
+ delaySeconds: pickNonNegInt(notifications.delaySeconds, D.notifications.delaySeconds),
162
+ sound: pickChoice(notifications.sound, ["blocked", "all", "none"], D.notifications.sound),
142
163
  },
143
164
  restore: { resumeAgents: pickBool(restore.resumeAgents, D.restore.resumeAgents) },
144
- updates: { check: pickBool(updates.check, D.updates.check) },
165
+ updates: {
166
+ check: pickBool(updates.check, D.updates.check),
167
+ manifests: pickBool(updates.manifests, D.updates.manifests),
168
+ },
145
169
  welcome: { show: pickBool(welcome.show, D.welcome.show) },
146
170
  integrations: { offer: pickBool(integrations.offer, D.integrations.offer) },
147
171
  worktrees: { dir: pickString(worktrees.dir, D.worktrees.dir) },
172
+ app: {
173
+ frontDoor: pickBool(app.frontDoor, D.app.frontDoor),
174
+ detachable: pickBool(app.detachable, D.app.detachable),
175
+ dragSelect: pickChoice(app.dragSelect, ["agents", "always", "never"], D.app.dragSelect),
176
+ newAgentCwd: pickChoice(app.newAgentCwd, ["pane", "session"], D.app.newAgentCwd),
177
+ kittyKeys: pickBool(app.kittyKeys, D.app.kittyKeys),
178
+ },
148
179
  };
149
180
  }
150
181
  // ---------------------------------------------------------------------------
@@ -189,3 +220,71 @@ export function getAppConfig() {
189
220
  export function _resetForTests() {
190
221
  cached = null;
191
222
  }
223
+ // ---------------------------------------------------------------------------
224
+ // Writes (M22.4 — the settings surface persists through here)
225
+ // ---------------------------------------------------------------------------
226
+ /**
227
+ * io — the RAW parsed config file as the user wrote it (`{}` when missing or
228
+ * malformed). Writers merge over THIS, not over {@link parseAppConfig}'s output:
229
+ * round-tripping through the parser would materialize every default into the
230
+ * hand-editable file and silently DROP fields the typed shape doesn't model yet
231
+ * (e.g. the notification polish fields `notifications.enabled` /
232
+ * `notifications.quietHours` that {@link ../tui/chrome/notify.ts} reads raw).
233
+ */
234
+ export function loadRawAppConfig() {
235
+ const path = appConfigPath();
236
+ if (!existsSync(path))
237
+ return {};
238
+ try {
239
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
240
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
241
+ ? parsed
242
+ : {};
243
+ }
244
+ catch {
245
+ return {};
246
+ }
247
+ }
248
+ function isPlainObject(value) {
249
+ return !!value && typeof value === "object" && !Array.isArray(value);
250
+ }
251
+ /**
252
+ * PURE — merge `patch` into `raw` without touching either input. Objects merge
253
+ * key-by-key (recursively), anything else replaces, and a key whose patch value
254
+ * is `undefined` is REMOVED from the result. Unknown user fields survive — the
255
+ * merge only visits keys the patch names.
256
+ */
257
+ export function mergeConfigPatch(raw, patch) {
258
+ const out = { ...raw };
259
+ for (const [key, value] of Object.entries(patch)) {
260
+ if (value === undefined) {
261
+ delete out[key];
262
+ }
263
+ else if (isPlainObject(value) && isPlainObject(out[key])) {
264
+ out[key] = mergeConfigPatch(out[key], value);
265
+ }
266
+ else if (isPlainObject(value)) {
267
+ out[key] = mergeConfigPatch({}, value);
268
+ }
269
+ else {
270
+ out[key] = value;
271
+ }
272
+ }
273
+ return out;
274
+ }
275
+ /**
276
+ * io — apply a patch to the config file ATOMICALLY (temp + rename, the same
277
+ * discipline as the editor save): read raw → {@link mergeConfigPatch} → write.
278
+ * Busts the {@link getAppConfig} process cache and returns the new parsed
279
+ * config. Honors `TMUX_IDE_CONFIG`, so tests never touch the real file.
280
+ */
281
+ export function updateAppConfig(patch) {
282
+ const path = appConfigPath();
283
+ const merged = mergeConfigPatch(loadRawAppConfig(), patch);
284
+ mkdirSync(dirname(path), { recursive: true });
285
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
286
+ writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
287
+ renameSync(tmp, path);
288
+ cached = null;
289
+ return parseAppConfig(merged);
290
+ }
@@ -0,0 +1,79 @@
1
+ import type { AgentManifest } from "../tui/detect/manifest.ts";
2
+ /** The pack-format version this build understands. */
3
+ export declare const MANIFEST_PACK_SCHEMA = 1;
4
+ /** The release asset name a pack is published under. */
5
+ export declare const MANIFEST_PACK_ASSET = "agent-manifests.json";
6
+ /** Env override for the pack URL (tests/dev; may be `file://` or loopback http). */
7
+ export declare const MANIFEST_PACK_URL_ENV = "TMUX_IDE_MANIFEST_PACK_URL";
8
+ /** A fetched/validated manifest pack. */
9
+ export interface ManifestPack {
10
+ schema: number;
11
+ /** Pack version — informational (surfaced in logs), any non-empty string. */
12
+ pack: string;
13
+ manifests: AgentManifest[];
14
+ }
15
+ /**
16
+ * PURE — the default pack URL for a version:
17
+ * `https://github.com/<repo>/releases/download/v<version>/agent-manifests.json`
18
+ * (the same asset-URL construction as the TUI binary download).
19
+ */
20
+ export declare function manifestPackUrl(version?: string): string;
21
+ /**
22
+ * PURE — is `url` an acceptable pack source? `https:` always; `file:` and
23
+ * LOOPBACK `http:` (127.0.0.1 / localhost / [::1]) only so tests and local
24
+ * fixtures can drive the flow. Any other scheme/host is rejected.
25
+ */
26
+ export declare function isAllowedPackUrl(url: string): boolean;
27
+ /**
28
+ * PURE — validate an untrusted pack document. Returns the typed pack or a
29
+ * human-readable reason. Every manifest entry must pass the SAME structural
30
+ * validation user override files do ({@link validateManifestShape}) — one bad
31
+ * entry rejects the whole pack (a partial install would be harder to reason
32
+ * about than a loud failure). Never throws.
33
+ */
34
+ export declare function validateManifestPack(value: unknown): {
35
+ ok: true;
36
+ pack: ManifestPack;
37
+ } | {
38
+ ok: false;
39
+ reason: string;
40
+ };
41
+ /** The installed pack's directory: `<agent-detection>/pack/`. */
42
+ export declare function packDir(): string;
43
+ /** The installed pack's path: `<agent-detection>/pack/manifest-pack.json`. */
44
+ export declare function packPath(): string;
45
+ /**
46
+ * io — fetch and validate a pack. `file://` URLs are read from disk (Node's
47
+ * fetch does not speak file:); everything else goes through fetch with a
48
+ * timeout. THROWS with an actionable message on a disallowed URL, a failed
49
+ * fetch, malformed JSON, or a schema-invalid pack — the caller decides whether
50
+ * that is loud (CLI) or swallowed (the background refresh).
51
+ */
52
+ export declare function fetchManifestPack(url: string, timeoutMs?: number): Promise<ManifestPack>;
53
+ /**
54
+ * io — write a validated pack into place atomically (temp-in-same-dir →
55
+ * rename, so a crash never leaves a half-written pack the loader would then
56
+ * warn about every run). Returns the installed path.
57
+ */
58
+ export declare function installManifestPack(pack: ManifestPack, dest?: string): string;
59
+ /**
60
+ * io — the `tmux-ide update --manifests` flow: resolve the URL (env override →
61
+ * the release-asset default), fetch, validate, install. Throws loudly on any
62
+ * failure (the CLI surfaces it); returns what was installed for reporting.
63
+ */
64
+ export declare function updateManifestPack(opts?: {
65
+ url?: string;
66
+ log?: (msg: string) => void;
67
+ }): Promise<{
68
+ path: string;
69
+ packVersion: string;
70
+ count: number;
71
+ }>;
72
+ /**
73
+ * io — the GATED background refresh the daily update check invokes (see
74
+ * `runUpdateCheck` in {@link ./update-check.ts}). Opt-in via
75
+ * `updates.manifests: true` (default false); NEVER throws — a failed refresh
76
+ * just keeps the previous pack, exactly like a failed version check keeps the
77
+ * previous `latest`.
78
+ */
79
+ export declare function maybeRefreshManifestPack(): Promise<void>;