tmux-ide 2.6.1 → 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 (154) hide show
  1. package/README.md +36 -14
  2. package/bin/cli.js +4174 -1235
  3. package/bin/cli.ts +426 -72
  4. package/package.json +10 -6
  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/agent-explain.d.ts +8 -1
  9. package/packages/daemon/dist/agent-explain.js +19 -3
  10. package/packages/daemon/dist/control/client.d.ts +23 -0
  11. package/packages/daemon/dist/control/client.js +105 -0
  12. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  13. package/packages/daemon/dist/control/dispatch.js +83 -0
  14. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  15. package/packages/daemon/dist/control/fanout.js +37 -0
  16. package/packages/daemon/dist/control/frames.d.ts +23 -0
  17. package/packages/daemon/dist/control/frames.js +37 -0
  18. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  19. package/packages/daemon/dist/control/lifecycle.js +114 -0
  20. package/packages/daemon/dist/control/server.d.ts +16 -0
  21. package/packages/daemon/dist/control/server.js +214 -0
  22. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  23. package/packages/daemon/dist/control/verbs.js +91 -0
  24. package/packages/daemon/dist/doctor.d.ts +18 -0
  25. package/packages/daemon/dist/doctor.js +105 -15
  26. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  27. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  28. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  29. package/packages/daemon/dist/lib/app-config.js +104 -5
  30. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  31. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  32. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  33. package/packages/daemon/dist/lib/state-home.js +12 -0
  34. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  35. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  36. package/packages/daemon/dist/lib/update-check.js +5 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  43. package/packages/daemon/dist/restore.d.ts +35 -8
  44. package/packages/daemon/dist/restore.js +52 -15
  45. package/packages/daemon/dist/send.d.ts +33 -1
  46. package/packages/daemon/dist/send.js +32 -19
  47. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  48. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  49. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  50. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  51. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  52. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  53. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  54. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  55. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  56. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  57. package/packages/daemon/src/agent-explain.ts +34 -6
  58. package/packages/daemon/src/control/client.ts +128 -0
  59. package/packages/daemon/src/control/dispatch.ts +107 -0
  60. package/packages/daemon/src/control/fanout.ts +44 -0
  61. package/packages/daemon/src/control/frames.ts +40 -0
  62. package/packages/daemon/src/control/lifecycle.ts +151 -0
  63. package/packages/daemon/src/control/server.ts +237 -0
  64. package/packages/daemon/src/control/verbs.ts +118 -0
  65. package/packages/daemon/src/doctor.ts +113 -28
  66. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  67. package/packages/daemon/src/lib/app-config.ts +194 -5
  68. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  69. package/packages/daemon/src/lib/state-home.ts +13 -0
  70. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  71. package/packages/daemon/src/lib/update-check.ts +5 -0
  72. package/packages/daemon/src/restore.ts +53 -15
  73. package/packages/daemon/src/send.ts +55 -21
  74. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  75. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  76. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  77. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  78. package/packages/daemon/src/tui/chrome/notify.ts +737 -52
  79. package/packages/daemon/src/tui/chrome/updater.ts +318 -30
  80. package/packages/daemon/src/tui/compiled.ts +11 -3
  81. package/packages/daemon/src/tui/detect/classify.ts +49 -0
  82. package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
  83. package/packages/daemon/src/tui/detect/manifest.ts +39 -3
  84. package/packages/daemon/src/tui/detect/manifests.ts +395 -33
  85. package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
  86. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  87. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  88. package/packages/daemon/src/tui/main.ts +13 -1
  89. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  90. package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
  91. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  92. package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
  93. package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
  94. package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
  95. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  96. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  97. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  98. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  99. package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
  100. package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
  101. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  102. package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
  103. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  104. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  105. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  106. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  107. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  108. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  109. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  110. package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
  111. package/packages/daemon/src/tui/mirror/palette.ts +564 -0
  112. package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
  113. package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
  114. package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
  115. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  116. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  117. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  118. package/packages/daemon/src/tui/mirror/selection.ts +376 -0
  119. package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
  120. package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
  121. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  122. package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
  123. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  124. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  125. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  126. package/packages/daemon/src/tui/team/entry.ts +34 -7
  127. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  128. package/packages/daemon/src/tui/team/report.ts +11 -1
  129. package/packages/daemon/src/tui/team/sessions.ts +184 -17
  130. package/packages/daemon/src/tui/team/wait.ts +144 -0
  131. package/scripts/build-macos-notifier.mjs +160 -0
  132. package/scripts/build-tui.mjs +11 -4
  133. package/scripts/perf-mirror.mjs +313 -0
  134. package/scripts/postinstall.js +8 -1
  135. package/scripts/prepublish-check.mjs +37 -1
  136. package/scripts/publish-tap.sh +55 -0
  137. package/skill/SKILL.md +110 -2
  138. package/templates/AGENTS.md +14 -7
  139. package/templates/agent-team-monorepo.yml +8 -0
  140. package/templates/agent-team-nextjs.yml +8 -0
  141. package/templates/agent-team.yml +10 -0
  142. package/templates/convex.yml +2 -0
  143. package/templates/default.yml +11 -5
  144. package/templates/go.yml +4 -0
  145. package/templates/missions.yml +6 -0
  146. package/templates/nextjs.yml +4 -0
  147. package/templates/python.yml +4 -0
  148. package/templates/skills/backend.md +5 -12
  149. package/templates/skills/frontend.md +5 -12
  150. package/templates/skills/general-worker.md +5 -12
  151. package/templates/skills/researcher.md +7 -12
  152. package/templates/skills/reviewer.md +7 -16
  153. package/templates/vite.yml +4 -0
  154. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -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>;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Remote agent-detection manifest packs — `tmux-ide update --manifests` (M25.4).
3
+ *
4
+ * Detection breadth shouldn't wait for an npm release: agent TUIs change their
5
+ * chrome between our versions, and a re-tuned manifest is pure data. This
6
+ * module fetches a versioned MANIFEST PACK from a static URL and installs it
7
+ * where the manifest loader already hot-merges from.
8
+ *
9
+ * ## The pack format (publish contract)
10
+ *
11
+ * A single JSON document:
12
+ *
13
+ * ```json
14
+ * {
15
+ * "schema": 1, // this format's version — reject others
16
+ * "pack": "2026.07.12", // pack version (informational, any string)
17
+ * "manifests": [ // one AgentManifest per entry, exactly the
18
+ * { // override-file shape manifest-loader.ts
19
+ * "id": "claude", // documents (id, commands[], states,
20
+ * "commands": ["claude"], // optional confidence)
21
+ * "confidence": "tuned",
22
+ * "states": { "working": { "any": [{ "contains": "esc to interrupt" }] } }
23
+ * }
24
+ * ]
25
+ * }
26
+ * ```
27
+ *
28
+ * Publishing: attach the document as the `agent-manifests.json` asset on a
29
+ * GitHub release — {@link manifestPackUrl} constructs the same
30
+ * `releases/download/v<version>/<asset>` URL the TUI-binary download uses, so
31
+ * a pack rides every release and a NEWER pack can be re-attached to the
32
+ * CURRENT release tag at any time (the fetch always re-downloads; the pane
33
+ * option cache never pins it).
34
+ *
35
+ * ## Precedence (verified by tests)
36
+ *
37
+ * The pack installs into `<agent-detection>/pack/manifest-pack.json` — a
38
+ * SUBDIRECTORY of the user-override dir, so `readOverrideManifests` (which
39
+ * lists only `*.json` FILES in the top level) never sees it. The loader merges
40
+ * `bundled → pack → user`: a pack manifest replaces a bundled id or appends a
41
+ * new one, and a user's own `<agent-detection>/*.json` file ALWAYS beats both.
42
+ *
43
+ * ## Safety
44
+ *
45
+ * - Https only for remote URLs. `file://` and loopback `http://` are accepted
46
+ * ONLY so tests/dev can exercise the flow without the network — anything
47
+ * else is rejected before a byte is fetched.
48
+ * - The pack is schema-validated ({@link validateManifestPack}) BEFORE being
49
+ * written; an invalid pack fails loudly and leaves the previous one intact.
50
+ * - The gated periodic refresh ({@link maybeRefreshManifestPack}) rides the
51
+ * existing daily update check (see {@link ./update-check.ts}) — no timers or
52
+ * daemons of its own — and is double-gated on `updates.check` (the caller)
53
+ * and `updates.manifests` (read here, default false).
54
+ */
55
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
56
+ import { dirname, join } from "node:path";
57
+ import { fileURLToPath } from "node:url";
58
+ import { overrideDir, validateManifestShape } from "../tui/detect/manifest-loader.js";
59
+ import { getAppConfig } from "./app-config.js";
60
+ import { getCurrentVersion } from "./update-check.js";
61
+ import { RELEASE_REPO } from "./tui-binary.js";
62
+ /** The pack-format version this build understands. */
63
+ export const MANIFEST_PACK_SCHEMA = 1;
64
+ /** The release asset name a pack is published under. */
65
+ export const MANIFEST_PACK_ASSET = "agent-manifests.json";
66
+ /** Env override for the pack URL (tests/dev; may be `file://` or loopback http). */
67
+ export const MANIFEST_PACK_URL_ENV = "TMUX_IDE_MANIFEST_PACK_URL";
68
+ // ---------------------------------------------------------------------------
69
+ // Pure
70
+ // ---------------------------------------------------------------------------
71
+ /**
72
+ * PURE — the default pack URL for a version:
73
+ * `https://github.com/<repo>/releases/download/v<version>/agent-manifests.json`
74
+ * (the same asset-URL construction as the TUI binary download).
75
+ */
76
+ export function manifestPackUrl(version = getCurrentVersion()) {
77
+ const v = version.startsWith("v") ? version.slice(1) : version;
78
+ return `https://github.com/${RELEASE_REPO}/releases/download/v${v}/${MANIFEST_PACK_ASSET}`;
79
+ }
80
+ /**
81
+ * PURE — is `url` an acceptable pack source? `https:` always; `file:` and
82
+ * LOOPBACK `http:` (127.0.0.1 / localhost / [::1]) only so tests and local
83
+ * fixtures can drive the flow. Any other scheme/host is rejected.
84
+ */
85
+ export function isAllowedPackUrl(url) {
86
+ let parsed;
87
+ try {
88
+ parsed = new URL(url);
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ if (parsed.protocol === "https:" || parsed.protocol === "file:")
94
+ return true;
95
+ if (parsed.protocol === "http:") {
96
+ return ["127.0.0.1", "localhost", "[::1]", "::1"].includes(parsed.hostname);
97
+ }
98
+ return false;
99
+ }
100
+ /**
101
+ * PURE — validate an untrusted pack document. Returns the typed pack or a
102
+ * human-readable reason. Every manifest entry must pass the SAME structural
103
+ * validation user override files do ({@link validateManifestShape}) — one bad
104
+ * entry rejects the whole pack (a partial install would be harder to reason
105
+ * about than a loud failure). Never throws.
106
+ */
107
+ export function validateManifestPack(value) {
108
+ if (typeof value !== "object" || value === null)
109
+ return { ok: false, reason: "not an object" };
110
+ const v = value;
111
+ if (v.schema !== MANIFEST_PACK_SCHEMA) {
112
+ return {
113
+ ok: false,
114
+ reason: `unsupported schema ${JSON.stringify(v.schema)} (want ${MANIFEST_PACK_SCHEMA})`,
115
+ };
116
+ }
117
+ if (typeof v.pack !== "string" || v.pack.trim().length === 0) {
118
+ return { ok: false, reason: "missing pack version string" };
119
+ }
120
+ if (!Array.isArray(v.manifests) || v.manifests.length === 0) {
121
+ return { ok: false, reason: "manifests must be a non-empty array" };
122
+ }
123
+ for (let i = 0; i < v.manifests.length; i++) {
124
+ if (!validateManifestShape(v.manifests[i])) {
125
+ return {
126
+ ok: false,
127
+ reason: `manifests[${i}] is not a valid AgentManifest (need id, commands[], states)`,
128
+ };
129
+ }
130
+ }
131
+ return {
132
+ ok: true,
133
+ pack: { schema: MANIFEST_PACK_SCHEMA, pack: v.pack, manifests: v.manifests },
134
+ };
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // io
138
+ // ---------------------------------------------------------------------------
139
+ /** The installed pack's directory: `<agent-detection>/pack/`. */
140
+ export function packDir() {
141
+ return join(overrideDir(), "pack");
142
+ }
143
+ /** The installed pack's path: `<agent-detection>/pack/manifest-pack.json`. */
144
+ export function packPath() {
145
+ return join(packDir(), "manifest-pack.json");
146
+ }
147
+ /**
148
+ * io — fetch and validate a pack. `file://` URLs are read from disk (Node's
149
+ * fetch does not speak file:); everything else goes through fetch with a
150
+ * timeout. THROWS with an actionable message on a disallowed URL, a failed
151
+ * fetch, malformed JSON, or a schema-invalid pack — the caller decides whether
152
+ * that is loud (CLI) or swallowed (the background refresh).
153
+ */
154
+ export async function fetchManifestPack(url, timeoutMs = 5000) {
155
+ if (!isAllowedPackUrl(url)) {
156
+ throw new Error(`refusing manifest-pack URL ${url} — https only (file:// and loopback http are allowed for local testing)`);
157
+ }
158
+ let body;
159
+ if (url.startsWith("file:")) {
160
+ body = readFileSync(fileURLToPath(url), "utf8");
161
+ }
162
+ else {
163
+ const controller = new AbortController();
164
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
165
+ try {
166
+ const res = await fetch(url, { signal: controller.signal });
167
+ if (!res.ok) {
168
+ throw new Error(`manifest-pack download failed (${url} → HTTP ${res.status} ${res.statusText})`);
169
+ }
170
+ body = await res.text();
171
+ }
172
+ finally {
173
+ clearTimeout(timer);
174
+ }
175
+ }
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(body);
179
+ }
180
+ catch {
181
+ throw new Error(`manifest pack at ${url} is not valid JSON`);
182
+ }
183
+ const verdict = validateManifestPack(parsed);
184
+ if (!verdict.ok) {
185
+ throw new Error(`manifest pack at ${url} rejected: ${verdict.reason}`);
186
+ }
187
+ return verdict.pack;
188
+ }
189
+ /**
190
+ * io — write a validated pack into place atomically (temp-in-same-dir →
191
+ * rename, so a crash never leaves a half-written pack the loader would then
192
+ * warn about every run). Returns the installed path.
193
+ */
194
+ export function installManifestPack(pack, dest = packPath()) {
195
+ mkdirSync(dirname(dest), { recursive: true });
196
+ const tmp = `${dest}.${process.pid}.tmp`;
197
+ writeFileSync(tmp, JSON.stringify(pack, null, 2));
198
+ renameSync(tmp, dest);
199
+ return dest;
200
+ }
201
+ /**
202
+ * io — the `tmux-ide update --manifests` flow: resolve the URL (env override →
203
+ * the release-asset default), fetch, validate, install. Throws loudly on any
204
+ * failure (the CLI surfaces it); returns what was installed for reporting.
205
+ */
206
+ export async function updateManifestPack(opts = {}) {
207
+ const log = opts.log ?? (() => { });
208
+ const url = opts.url ?? process.env[MANIFEST_PACK_URL_ENV] ?? manifestPackUrl();
209
+ log(`fetching manifest pack from ${url}`);
210
+ const pack = await fetchManifestPack(url);
211
+ const path = installManifestPack(pack);
212
+ log(`installed pack ${pack.pack} (${pack.manifests.length} manifests) → ${path}`);
213
+ return { path, packVersion: pack.pack, count: pack.manifests.length };
214
+ }
215
+ /**
216
+ * io — the GATED background refresh the daily update check invokes (see
217
+ * `runUpdateCheck` in {@link ./update-check.ts}). Opt-in via
218
+ * `updates.manifests: true` (default false); NEVER throws — a failed refresh
219
+ * just keeps the previous pack, exactly like a failed version check keeps the
220
+ * previous `latest`.
221
+ */
222
+ export async function maybeRefreshManifestPack() {
223
+ try {
224
+ if (!getAppConfig().updates.manifests)
225
+ return;
226
+ await updateManifestPack();
227
+ }
228
+ catch {
229
+ // Offline, a missing release asset, or an invalid pack — all degrade to
230
+ // "keep what we have"; the explicit CLI path reports errors loudly instead.
231
+ }
232
+ }
@@ -0,0 +1,2 @@
1
+ /** Absolute path to the state home directory (not created here). */
2
+ export declare function stateHome(): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * THE tmux-ide state home — `TMUX_IDE_HOME` when set (tests / per-run
3
+ * overrides), else `~/.tmux-ide`. The same resolution the welcome marker,
4
+ * update cache, and app state each grew locally; new state files should
5
+ * resolve through here so none of them can drift from the override again.
6
+ */
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ /** Absolute path to the state home directory (not created here). */
10
+ export function stateHome() {
11
+ return process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
12
+ }
@@ -0,0 +1,57 @@
1
+ /** The `<os>-<arch>` tags we publish a prebuilt TUI binary for. */
2
+ export type TuiPlatformTag = "darwin-arm64" | "darwin-x64" | "linux-x64" | "linux-arm64";
3
+ /** The GitHub repo the release assets live under. */
4
+ export declare const RELEASE_REPO = "wavyrai/tmux-ide";
5
+ /** A downloaded binary smaller than this is treated as corrupt/truncated. */
6
+ export declare const MIN_TUI_BINARY_BYTES: number;
7
+ /**
8
+ * PURE — map a Node `process.platform`/`process.arch` pair to the release tag,
9
+ * or null when we don't publish a binary for it (e.g. windows, freebsd) so the
10
+ * caller can fall back to the "install bun" message.
11
+ */
12
+ export declare function tuiPlatformTag(platform?: NodeJS.Platform, arch?: string): TuiPlatformTag | null;
13
+ /** PURE — the `bun build --compile --target` flag for a tag (`bun-<tag>`). */
14
+ export declare function bunTargetForTag(tag: TuiPlatformTag): string;
15
+ /** PURE — the release asset filename for a tag (gzip-compressed binary). */
16
+ export declare function releaseAssetName(tag: TuiPlatformTag): string;
17
+ /** PURE — strip a leading `v` from a version string; `2.6.1` and `v2.6.1` both → `2.6.1`. */
18
+ export declare function normalizeVersion(version: string): string;
19
+ /**
20
+ * PURE — the GitHub download URL for a platform's asset at a given version:
21
+ * `https://github.com/<repo>/releases/download/v<version>/tmux-ide-tui-<tag>.gz`.
22
+ */
23
+ export declare function releaseAssetUrl(version: string, tag: TuiPlatformTag): string;
24
+ /**
25
+ * PURE — where a downloaded binary lives: `<home>/bin/tmux-ide-tui-<tag>-<version>`.
26
+ * The version is stamped INTO the name so a `tmux-ide update` to a new version
27
+ * misses the old download and re-fetches (rather than launching a stale binary).
28
+ */
29
+ export declare function downloadedTuiPath(home: string, tag: TuiPlatformTag, version: string): string;
30
+ /**
31
+ * io — the tmux-ide state home (`TMUX_IDE_HOME` override, else `~/.tmux-ide`),
32
+ * the same resolution the update-check cache and welcome marker use.
33
+ */
34
+ export declare function tuiStateHome(): string;
35
+ /**
36
+ * io — locate a previously downloaded per-platform binary for THIS version and
37
+ * platform, or null. Feeds the resolution order in `tui/compiled.ts` after the
38
+ * shipped/local compiled binary and before the honest "unavailable" error.
39
+ */
40
+ export declare function findDownloadedTui(version?: string): string | null;
41
+ /**
42
+ * io — download, verify, and install the per-platform TUI binary for the running
43
+ * version. Fetches the gzip asset, inflates it, rejects anything under
44
+ * {@link MIN_TUI_BINARY_BYTES} (a truncated download or an HTML error page),
45
+ * writes it `0o755`, and atomically renames it into place (temp-in-same-dir →
46
+ * rename, so a crashed download never leaves a half-written executable).
47
+ *
48
+ * Throws with an actionable message on an unsupported platform or a failed
49
+ * fetch. Returns the installed path and byte size.
50
+ */
51
+ export declare function downloadTuiBinary(opts?: {
52
+ version?: string;
53
+ log?: (msg: string) => void;
54
+ }): Promise<{
55
+ path: string;
56
+ bytes: number;
57
+ }>;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Per-platform TUI binary: the runtime-download fallback that lets a clean
3
+ * `npm i -g tmux-ide` run the full OpenTUI/Solid cockpit WITHOUT `bun`.
4
+ *
5
+ * The dev checkout runs the `.tsx` surfaces via bun; an npm install with no bun
6
+ * needs a self-contained binary. We do NOT bundle that ~70MB blob in the npm
7
+ * tarball (a surprise on every install) — instead the release workflow
8
+ * (`.github/workflows/release-binaries.yml`) cross-compiles one per platform and
9
+ * uploads them as GitHub release assets, and this module downloads the right one
10
+ * on demand (explicit `tmux-ide update --tui-binary`, or a consented first run).
11
+ *
12
+ * The mapping/URL/path helpers are PURE (unit-tested); {@link downloadTuiBinary}
13
+ * and {@link findDownloadedTui} are the thin io that fetch and probe.
14
+ */
15
+ import { chmodSync, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { gunzipSync } from "node:zlib";
19
+ import { getCurrentVersion } from "./update-check.js";
20
+ /** The GitHub repo the release assets live under. */
21
+ export const RELEASE_REPO = "wavyrai/tmux-ide";
22
+ /** A downloaded binary smaller than this is treated as corrupt/truncated. */
23
+ export const MIN_TUI_BINARY_BYTES = 10 * 1024 * 1024;
24
+ const SUPPORTED = {
25
+ "darwin-arm64": "darwin-arm64",
26
+ "darwin-x64": "darwin-x64",
27
+ "linux-x64": "linux-x64",
28
+ "linux-arm64": "linux-arm64",
29
+ };
30
+ // ---------------------------------------------------------------------------
31
+ // Pure
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * PURE — map a Node `process.platform`/`process.arch` pair to the release tag,
35
+ * or null when we don't publish a binary for it (e.g. windows, freebsd) so the
36
+ * caller can fall back to the "install bun" message.
37
+ */
38
+ export function tuiPlatformTag(platform = process.platform, arch = process.arch) {
39
+ return SUPPORTED[`${platform}-${arch}`] ?? null;
40
+ }
41
+ /** PURE — the `bun build --compile --target` flag for a tag (`bun-<tag>`). */
42
+ export function bunTargetForTag(tag) {
43
+ return `bun-${tag}`;
44
+ }
45
+ /** PURE — the release asset filename for a tag (gzip-compressed binary). */
46
+ export function releaseAssetName(tag) {
47
+ return `tmux-ide-tui-${tag}.gz`;
48
+ }
49
+ /** PURE — strip a leading `v` from a version string; `2.6.1` and `v2.6.1` both → `2.6.1`. */
50
+ export function normalizeVersion(version) {
51
+ return version.startsWith("v") ? version.slice(1) : version;
52
+ }
53
+ /**
54
+ * PURE — the GitHub download URL for a platform's asset at a given version:
55
+ * `https://github.com/<repo>/releases/download/v<version>/tmux-ide-tui-<tag>.gz`.
56
+ */
57
+ export function releaseAssetUrl(version, tag) {
58
+ return `https://github.com/${RELEASE_REPO}/releases/download/v${normalizeVersion(version)}/${releaseAssetName(tag)}`;
59
+ }
60
+ /**
61
+ * PURE — where a downloaded binary lives: `<home>/bin/tmux-ide-tui-<tag>-<version>`.
62
+ * The version is stamped INTO the name so a `tmux-ide update` to a new version
63
+ * misses the old download and re-fetches (rather than launching a stale binary).
64
+ */
65
+ export function downloadedTuiPath(home, tag, version) {
66
+ return join(home, "bin", `tmux-ide-tui-${tag}-${normalizeVersion(version)}`);
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // io
70
+ // ---------------------------------------------------------------------------
71
+ /**
72
+ * io — the tmux-ide state home (`TMUX_IDE_HOME` override, else `~/.tmux-ide`),
73
+ * the same resolution the update-check cache and welcome marker use.
74
+ */
75
+ export function tuiStateHome() {
76
+ return process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
77
+ }
78
+ /**
79
+ * io — locate a previously downloaded per-platform binary for THIS version and
80
+ * platform, or null. Feeds the resolution order in `tui/compiled.ts` after the
81
+ * shipped/local compiled binary and before the honest "unavailable" error.
82
+ */
83
+ export function findDownloadedTui(version = getCurrentVersion()) {
84
+ const tag = tuiPlatformTag();
85
+ if (!tag)
86
+ return null;
87
+ const path = downloadedTuiPath(tuiStateHome(), tag, version);
88
+ return existsSync(path) ? path : null;
89
+ }
90
+ /**
91
+ * io — download, verify, and install the per-platform TUI binary for the running
92
+ * version. Fetches the gzip asset, inflates it, rejects anything under
93
+ * {@link MIN_TUI_BINARY_BYTES} (a truncated download or an HTML error page),
94
+ * writes it `0o755`, and atomically renames it into place (temp-in-same-dir →
95
+ * rename, so a crashed download never leaves a half-written executable).
96
+ *
97
+ * Throws with an actionable message on an unsupported platform or a failed
98
+ * fetch. Returns the installed path and byte size.
99
+ */
100
+ export async function downloadTuiBinary(opts = {}) {
101
+ const log = opts.log ?? (() => { });
102
+ const version = normalizeVersion(opts.version ?? getCurrentVersion());
103
+ const tag = tuiPlatformTag();
104
+ if (!tag) {
105
+ throw new Error(`no prebuilt TUI binary is published for ${process.platform}-${process.arch} — ` +
106
+ `install bun (https://bun.sh) to run the TUI surfaces from source instead`);
107
+ }
108
+ const url = releaseAssetUrl(version, tag);
109
+ const dest = downloadedTuiPath(tuiStateHome(), tag, version);
110
+ mkdirSync(dirname(dest), { recursive: true });
111
+ log(`downloading ${url}`);
112
+ const res = await fetch(url);
113
+ if (!res.ok) {
114
+ throw new Error(`could not download the TUI binary (${url} → HTTP ${res.status} ${res.statusText}). ` +
115
+ `Check that release v${version} exists and published its assets.`);
116
+ }
117
+ const gz = Buffer.from(await res.arrayBuffer());
118
+ const bin = gunzipSync(gz);
119
+ if (bin.byteLength < MIN_TUI_BINARY_BYTES) {
120
+ throw new Error(`the downloaded TUI binary is only ${bin.byteLength} bytes (expected >10MB) — ` +
121
+ `treating it as corrupt and leaving the previous binary (if any) in place`);
122
+ }
123
+ const tmp = `${dest}.${process.pid}.tmp`;
124
+ writeFileSync(tmp, bin, { mode: 0o755 });
125
+ chmodSync(tmp, 0o755);
126
+ renameSync(tmp, dest);
127
+ const mb = (bin.byteLength / 1024 / 1024).toFixed(1);
128
+ log(`installed ${dest} (${mb} MB)`);
129
+ return { path: dest, bytes: bin.byteLength };
130
+ }