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
@@ -96,10 +96,30 @@ export interface AppUpdater {
96
96
  snapshotEvery: number;
97
97
  }
98
98
 
99
+ /** The sound channel's states: ping sound on blocked only, on every ping, or never. */
100
+ export type NotificationSound = "blocked" | "all" | "none";
101
+
99
102
  /** Notification channel toggles. */
100
103
  export interface AppNotifications {
101
104
  toast: boolean;
102
105
  macos: boolean;
106
+ /**
107
+ * Terminal-native banners (M25.2): an OSC 9 (kitty: OSC 99) escape written to
108
+ * each eligible attached client's tty, so terminals that surface them (iTerm2,
109
+ * Ghostty, WezTerm, kitty) show a native notification even over ssh. Default
110
+ * true — terminals without support ignore the sequence entirely.
111
+ */
112
+ terminal: boolean;
113
+ /**
114
+ * Seconds the OS-LEVEL channels (banner / terminal escape / sound / BEL) wait
115
+ * before firing, re-verifying the pane is STILL in the notified state — a
116
+ * blocked→working flap inside the window fires nothing. 0 fires immediately.
117
+ * The in-app toast never waits. Tick-native: the delay rounds up to the
118
+ * updater's next tick at or after `delaySeconds`.
119
+ */
120
+ delaySeconds: number;
121
+ /** Sound channel (M25.2) — `afplay`/`paplay` + a BEL to eligible client ttys. */
122
+ sound: NotificationSound;
103
123
  }
104
124
 
105
125
  /** Restore behaviour toggles. */
@@ -110,6 +130,14 @@ export interface AppRestore {
110
130
  /** Update-check toggles (consumed later by the update-flow card). */
111
131
  export interface AppUpdates {
112
132
  check: boolean;
133
+ /**
134
+ * Whether the daily update check ALSO refreshes the remote agent-detection
135
+ * manifest pack into `~/.tmux-ide/agent-detection/pack/` (M25.4). Default
136
+ * FALSE — auto-installing detection rules is opt-in; `tmux-ide update
137
+ * --manifests` always works explicitly. Requires `check: true` (the refresh
138
+ * rides the same daily throttle — no timer of its own).
139
+ */
140
+ manifests: boolean;
113
141
  }
114
142
 
115
143
  /** First-run welcome toggles. */
@@ -142,8 +170,48 @@ export interface AppApp {
142
170
  * (the explicit cockpit) and a project's `ide.yml` auto-launch are unaffected.
143
171
  */
144
172
  frontDoor: boolean;
173
+ /**
174
+ * Whether `tmux-ide app` runs HOSTED by default (M23.2): the app lives in an
175
+ * internal `_tmux-ide-app` tmux session and the invoking terminal attaches to
176
+ * it, so the cockpit survives the terminal and reattaches from anywhere (^q
177
+ * detaches instead of quitting). Default false — the same behavior as the
178
+ * explicit `--detachable` flag; `--detachable`/`--hosted` still force it on a
179
+ * single run.
180
+ */
181
+ detachable: boolean;
182
+ /**
183
+ * When a plain left drag on an app-mouse pane SELECTS locally instead of
184
+ * forwarding to the pane's app (M24.2). "agents" (default): panes our own
185
+ * detection matches to a fleet agent entry select; other app-mouse panes
186
+ * (vim/htop) forward. "always": every app-mouse pane selects on drag.
187
+ * "never": every app-mouse pane forwards (the pre-M24.2 behavior). A genuine
188
+ * click (press+release in one cell) is still forwarded on select-default
189
+ * panes; shift inverts a pane's default; the right-click pane menu overrides
190
+ * per pane for the session.
191
+ */
192
+ dragSelect: "agents" | "always" | "never";
193
+ /**
194
+ * Where a Terminal-surface "New agent" spawn starts (M24.1): `"pane"`
195
+ * (default) inherits the FOCUSED pane's current working directory —
196
+ * `#{pane_current_path}`, so the agent lands where you are — while
197
+ * `"session"` keeps the session/project directory. Home/sidebar spawns
198
+ * always use the session/project dir (no focused pane exists there).
199
+ */
200
+ newAgentCwd: NewAgentCwd;
201
+ /**
202
+ * Whether the unified app enables the kitty keyboard protocol on its host
203
+ * terminal (M24.4). On (default), kitty-capable terminals deliver ⌘-modified
204
+ * keys — ⌘K opens the command palette — and disambiguated escapes; the app
205
+ * re-encodes every key for the mirrored panes either way, and terminals
206
+ * without the protocol ignore the request entirely. Off restores the legacy
207
+ * key encoding for hosts where the protocol misbehaves.
208
+ */
209
+ kittyKeys: boolean;
145
210
  }
146
211
 
212
+ /** The two Terminal-spawn cwd policies (see {@link AppApp.newAgentCwd}). */
213
+ export type NewAgentCwd = "pane" | "session";
214
+
147
215
  /** Worktree flow config (`tmux-ide worktree`). */
148
216
  export interface AppWorktrees {
149
217
  /**
@@ -196,13 +264,19 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
196
264
  glyphs: { active: "●", inactive: "○" },
197
265
  },
198
266
  updater: { tickMs: 2000, snapshotEvery: 15 },
199
- notifications: { toast: true, macos: false },
267
+ notifications: { toast: true, macos: false, terminal: true, delaySeconds: 2, sound: "blocked" },
200
268
  restore: { resumeAgents: false },
201
- updates: { check: true },
269
+ updates: { check: true, manifests: false },
202
270
  welcome: { show: true },
203
271
  integrations: { offer: true },
204
272
  worktrees: { dir: "" },
205
- app: { frontDoor: false },
273
+ app: {
274
+ frontDoor: false,
275
+ detachable: false,
276
+ dragSelect: "agents",
277
+ newAgentCwd: "pane",
278
+ kittyKeys: true,
279
+ },
206
280
  };
207
281
 
208
282
  /** The default theme tokens — the fallback threaded into the pure builders. */
@@ -237,6 +311,18 @@ function pickPosInt(value: unknown, fallback: number): number {
237
311
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
238
312
  }
239
313
 
314
+ /** A non-negative integer (0 allowed — e.g. "no delay"), else the default. */
315
+ function pickNonNegInt(value: unknown, fallback: number): number {
316
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
317
+ }
318
+
319
+ /** One of the allowed literals, else the default. */
320
+ function pickChoice<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
321
+ return typeof value === "string" && (allowed as readonly string[]).includes(value)
322
+ ? (value as T)
323
+ : fallback;
324
+ }
325
+
240
326
  /**
241
327
  * PURE — merge an unknown (typically parsed JSON) over {@link DEFAULT_APP_CONFIG}.
242
328
  * Every block/leaf is validated independently: a missing or mistyped field falls
@@ -295,13 +381,25 @@ export function parseAppConfig(input: unknown): AppConfig {
295
381
  notifications: {
296
382
  toast: pickBool(notifications.toast, D.notifications.toast),
297
383
  macos: pickBool(notifications.macos, D.notifications.macos),
384
+ terminal: pickBool(notifications.terminal, D.notifications.terminal),
385
+ delaySeconds: pickNonNegInt(notifications.delaySeconds, D.notifications.delaySeconds),
386
+ sound: pickChoice(notifications.sound, ["blocked", "all", "none"], D.notifications.sound),
298
387
  },
299
388
  restore: { resumeAgents: pickBool(restore.resumeAgents, D.restore.resumeAgents) },
300
- updates: { check: pickBool(updates.check, D.updates.check) },
389
+ updates: {
390
+ check: pickBool(updates.check, D.updates.check),
391
+ manifests: pickBool(updates.manifests, D.updates.manifests),
392
+ },
301
393
  welcome: { show: pickBool(welcome.show, D.welcome.show) },
302
394
  integrations: { offer: pickBool(integrations.offer, D.integrations.offer) },
303
395
  worktrees: { dir: pickString(worktrees.dir, D.worktrees.dir) },
304
- app: { frontDoor: pickBool(app.frontDoor, D.app.frontDoor) },
396
+ app: {
397
+ frontDoor: pickBool(app.frontDoor, D.app.frontDoor),
398
+ detachable: pickBool(app.detachable, D.app.detachable),
399
+ dragSelect: pickChoice(app.dragSelect, ["agents", "always", "never"], D.app.dragSelect),
400
+ newAgentCwd: pickChoice(app.newAgentCwd, ["pane", "session"], D.app.newAgentCwd),
401
+ kittyKeys: pickBool(app.kittyKeys, D.app.kittyKeys),
402
+ },
305
403
  };
306
404
  }
307
405
 
@@ -0,0 +1,255 @@
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 type { AgentManifest } from "../tui/detect/manifest.ts";
59
+ import { overrideDir, validateManifestShape } from "../tui/detect/manifest-loader.ts";
60
+ import { getAppConfig } from "./app-config.ts";
61
+ import { getCurrentVersion } from "./update-check.ts";
62
+ import { RELEASE_REPO } from "./tui-binary.ts";
63
+
64
+ /** The pack-format version this build understands. */
65
+ export const MANIFEST_PACK_SCHEMA = 1;
66
+
67
+ /** The release asset name a pack is published under. */
68
+ export const MANIFEST_PACK_ASSET = "agent-manifests.json";
69
+
70
+ /** Env override for the pack URL (tests/dev; may be `file://` or loopback http). */
71
+ export const MANIFEST_PACK_URL_ENV = "TMUX_IDE_MANIFEST_PACK_URL";
72
+
73
+ /** A fetched/validated manifest pack. */
74
+ export interface ManifestPack {
75
+ schema: number;
76
+ /** Pack version — informational (surfaced in logs), any non-empty string. */
77
+ pack: string;
78
+ manifests: AgentManifest[];
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Pure
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * PURE — the default pack URL for a version:
87
+ * `https://github.com/<repo>/releases/download/v<version>/agent-manifests.json`
88
+ * (the same asset-URL construction as the TUI binary download).
89
+ */
90
+ export function manifestPackUrl(version: string = getCurrentVersion()): string {
91
+ const v = version.startsWith("v") ? version.slice(1) : version;
92
+ return `https://github.com/${RELEASE_REPO}/releases/download/v${v}/${MANIFEST_PACK_ASSET}`;
93
+ }
94
+
95
+ /**
96
+ * PURE — is `url` an acceptable pack source? `https:` always; `file:` and
97
+ * LOOPBACK `http:` (127.0.0.1 / localhost / [::1]) only so tests and local
98
+ * fixtures can drive the flow. Any other scheme/host is rejected.
99
+ */
100
+ export function isAllowedPackUrl(url: string): boolean {
101
+ let parsed: URL;
102
+ try {
103
+ parsed = new URL(url);
104
+ } catch {
105
+ return false;
106
+ }
107
+ if (parsed.protocol === "https:" || parsed.protocol === "file:") return true;
108
+ if (parsed.protocol === "http:") {
109
+ return ["127.0.0.1", "localhost", "[::1]", "::1"].includes(parsed.hostname);
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /**
115
+ * PURE — validate an untrusted pack document. Returns the typed pack or a
116
+ * human-readable reason. Every manifest entry must pass the SAME structural
117
+ * validation user override files do ({@link validateManifestShape}) — one bad
118
+ * entry rejects the whole pack (a partial install would be harder to reason
119
+ * about than a loud failure). Never throws.
120
+ */
121
+ export function validateManifestPack(
122
+ value: unknown,
123
+ ): { ok: true; pack: ManifestPack } | { ok: false; reason: string } {
124
+ if (typeof value !== "object" || value === null) return { ok: false, reason: "not an object" };
125
+ const v = value as Record<string, unknown>;
126
+ if (v.schema !== MANIFEST_PACK_SCHEMA) {
127
+ return {
128
+ ok: false,
129
+ reason: `unsupported schema ${JSON.stringify(v.schema)} (want ${MANIFEST_PACK_SCHEMA})`,
130
+ };
131
+ }
132
+ if (typeof v.pack !== "string" || v.pack.trim().length === 0) {
133
+ return { ok: false, reason: "missing pack version string" };
134
+ }
135
+ if (!Array.isArray(v.manifests) || v.manifests.length === 0) {
136
+ return { ok: false, reason: "manifests must be a non-empty array" };
137
+ }
138
+ for (let i = 0; i < v.manifests.length; i++) {
139
+ if (!validateManifestShape(v.manifests[i])) {
140
+ return {
141
+ ok: false,
142
+ reason: `manifests[${i}] is not a valid AgentManifest (need id, commands[], states)`,
143
+ };
144
+ }
145
+ }
146
+ return {
147
+ ok: true,
148
+ pack: { schema: MANIFEST_PACK_SCHEMA, pack: v.pack, manifests: v.manifests as AgentManifest[] },
149
+ };
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // io
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** The installed pack's directory: `<agent-detection>/pack/`. */
157
+ export function packDir(): string {
158
+ return join(overrideDir(), "pack");
159
+ }
160
+
161
+ /** The installed pack's path: `<agent-detection>/pack/manifest-pack.json`. */
162
+ export function packPath(): string {
163
+ return join(packDir(), "manifest-pack.json");
164
+ }
165
+
166
+ /**
167
+ * io — fetch and validate a pack. `file://` URLs are read from disk (Node's
168
+ * fetch does not speak file:); everything else goes through fetch with a
169
+ * timeout. THROWS with an actionable message on a disallowed URL, a failed
170
+ * fetch, malformed JSON, or a schema-invalid pack — the caller decides whether
171
+ * that is loud (CLI) or swallowed (the background refresh).
172
+ */
173
+ export async function fetchManifestPack(url: string, timeoutMs = 5000): Promise<ManifestPack> {
174
+ if (!isAllowedPackUrl(url)) {
175
+ throw new Error(
176
+ `refusing manifest-pack URL ${url} — https only (file:// and loopback http are allowed for local testing)`,
177
+ );
178
+ }
179
+ let body: string;
180
+ if (url.startsWith("file:")) {
181
+ body = readFileSync(fileURLToPath(url), "utf8");
182
+ } else {
183
+ const controller = new AbortController();
184
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
185
+ try {
186
+ const res = await fetch(url, { signal: controller.signal });
187
+ if (!res.ok) {
188
+ throw new Error(
189
+ `manifest-pack download failed (${url} → HTTP ${res.status} ${res.statusText})`,
190
+ );
191
+ }
192
+ body = await res.text();
193
+ } finally {
194
+ clearTimeout(timer);
195
+ }
196
+ }
197
+ let parsed: unknown;
198
+ try {
199
+ parsed = JSON.parse(body);
200
+ } catch {
201
+ throw new Error(`manifest pack at ${url} is not valid JSON`);
202
+ }
203
+ const verdict = validateManifestPack(parsed);
204
+ if (!verdict.ok) {
205
+ throw new Error(`manifest pack at ${url} rejected: ${verdict.reason}`);
206
+ }
207
+ return verdict.pack;
208
+ }
209
+
210
+ /**
211
+ * io — write a validated pack into place atomically (temp-in-same-dir →
212
+ * rename, so a crash never leaves a half-written pack the loader would then
213
+ * warn about every run). Returns the installed path.
214
+ */
215
+ export function installManifestPack(pack: ManifestPack, dest: string = packPath()): string {
216
+ mkdirSync(dirname(dest), { recursive: true });
217
+ const tmp = `${dest}.${process.pid}.tmp`;
218
+ writeFileSync(tmp, JSON.stringify(pack, null, 2));
219
+ renameSync(tmp, dest);
220
+ return dest;
221
+ }
222
+
223
+ /**
224
+ * io — the `tmux-ide update --manifests` flow: resolve the URL (env override →
225
+ * the release-asset default), fetch, validate, install. Throws loudly on any
226
+ * failure (the CLI surfaces it); returns what was installed for reporting.
227
+ */
228
+ export async function updateManifestPack(
229
+ opts: { url?: string; log?: (msg: string) => void } = {},
230
+ ): Promise<{ path: string; packVersion: string; count: number }> {
231
+ const log = opts.log ?? (() => {});
232
+ const url = opts.url ?? process.env[MANIFEST_PACK_URL_ENV] ?? manifestPackUrl();
233
+ log(`fetching manifest pack from ${url}`);
234
+ const pack = await fetchManifestPack(url);
235
+ const path = installManifestPack(pack);
236
+ log(`installed pack ${pack.pack} (${pack.manifests.length} manifests) → ${path}`);
237
+ return { path, packVersion: pack.pack, count: pack.manifests.length };
238
+ }
239
+
240
+ /**
241
+ * io — the GATED background refresh the daily update check invokes (see
242
+ * `runUpdateCheck` in {@link ./update-check.ts}). Opt-in via
243
+ * `updates.manifests: true` (default false); NEVER throws — a failed refresh
244
+ * just keeps the previous pack, exactly like a failed version check keeps the
245
+ * previous `latest`.
246
+ */
247
+ export async function maybeRefreshManifestPack(): Promise<void> {
248
+ try {
249
+ if (!getAppConfig().updates.manifests) return;
250
+ await updateManifestPack();
251
+ } catch {
252
+ // Offline, a missing release asset, or an invalid pack — all degrade to
253
+ // "keep what we have"; the explicit CLI path reports errors loudly instead.
254
+ }
255
+ }
@@ -0,0 +1,13 @@
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
+
10
+ /** Absolute path to the state home directory (not created here). */
11
+ export function stateHome(): string {
12
+ return process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
13
+ }
@@ -252,6 +252,11 @@ export function getUpdateStatus({
252
252
  export async function runUpdateCheck({ now = Date.now() }: { now?: number } = {}): Promise<void> {
253
253
  const cache = readUpdateCache();
254
254
  if (!shouldCheck(cache?.lastCheckedAt ?? null, now)) return;
255
+ // The agent-detection manifest-pack refresh RIDES this same daily throttle
256
+ // (M25.4) — no timer of its own, gated again inside on `updates.manifests`
257
+ // (default false) and never throwing. Dynamic import: manifest-pack imports
258
+ // this module for getCurrentVersion, so a static import would be a cycle.
259
+ void import("./manifest-pack.ts").then((m) => m.maybeRefreshManifestPack()).catch(() => {});
255
260
  const fetched = await fetchLatestVersion();
256
261
  writeUpdateCache({
257
262
  lastCheckedAt: now,
@@ -13,9 +13,11 @@
13
13
  *
14
14
  * `--resume-agents` (or `{ "restore": { "resumeAgents": true } }` in
15
15
  * `~/.tmux-ide/config.json`) layers native agent-resume on top: a rebuilt
16
- * claude pane that carries a recorded `@agent_session_id` relaunches as
17
- * `claude --resume <id>`, reviving the actual conversation rather than opening
18
- * a fresh shell. See {@link paneResumeCommand} for the exact decision table.
16
+ * agent pane that carries a recorded `@agent_session_id` relaunches with its
17
+ * kind's VERIFIED resume invocation (`claude --resume <id>`, `codex resume
18
+ * <id>`, … — {@link AGENT_RESUME_COMMANDS}), reviving the actual conversation
19
+ * rather than opening a fresh shell. See {@link paneResumeCommand} for the
20
+ * exact decision table.
19
21
  *
20
22
  * {@link buildRestorePlan} + {@link paneResumeCommand} + {@link restorePrefs}
21
23
  * are PURE (unit-tested); {@link restore} is the thin io wrapper that reads the
@@ -92,22 +94,57 @@ export function buildRestorePlan(
92
94
  // Pure — agent resume decision
93
95
  // ---------------------------------------------------------------------------
94
96
 
95
- /** A session id is trusted only if it's the uuid alphabet — nothing shell-active. */
96
- const SAFE_SESSION_ID = /^[A-Za-z0-9-]+$/;
97
+ /** A session id is trusted only if it's uuid-ish (`[A-Za-z0-9_-]` — underscore
98
+ * covers opencode's `ses_…` ids) — nothing shell-active. */
99
+ const SAFE_SESSION_ID = /^[A-Za-z0-9_-]+$/;
100
+
101
+ /**
102
+ * The native session-resume invocation per agent kind (M24.1 — the table was
103
+ * claude-only until then). ONLY VERIFIED entries ship; each spelling's source:
104
+ * - `claude --resume <id>`: the shipped integration (its hooks record the id).
105
+ * - `codex resume <id>`: `codex resume --help` — "Usage: codex resume
106
+ * [OPTIONS] [SESSION_ID] … Session id (UUID) or session name".
107
+ * - `opencode --session <id>`: `opencode --help` — "-s, --session session id
108
+ * to continue".
109
+ * - `cursor-agent --resume <id>`: `cursor-agent --help` — "--resume [chatId]
110
+ * Select a session to resume".
111
+ * - `copilot --resume=<id>`: GitHub Copilot CLI command reference —
112
+ * "--resume[=VALUE] Resume a previous interactive session … Optionally
113
+ * specify a session ID" (the `=` form, since the value is optional).
114
+ * Unverified (no entry): gemini, aider, goose, amp — no confirmed native
115
+ * per-session resume invocation at the time of writing.
116
+ *
117
+ * How `@agent_session_id` gets recorded per kind (M25.3):
118
+ * - claude — the hooks integration (`integration install claude`).
119
+ * - opencode — the plugin integration (`integration install opencode`).
120
+ * - codex, cursor — automatic: the chrome updater probes the agent's own
121
+ * on-disk session state ({@link ./tui/detect/session-id.ts}).
122
+ * - copilot — no automatic capture (skipped with evidence — see the
123
+ * session-id module header); fires only for panes whose agent self-reported
124
+ * the id per the agent contract
125
+ * (`tmux set-option -p @agent_session_id <id>`), which any kind may do.
126
+ */
127
+ export const AGENT_RESUME_COMMANDS: Record<string, (id: string) => string> = {
128
+ claude: (id) => `claude --resume ${id}`,
129
+ codex: (id) => `codex resume ${id}`,
130
+ opencode: (id) => `opencode --session ${id}`,
131
+ cursor: (id) => `cursor-agent --resume ${id}`,
132
+ copilot: (id) => `copilot --resume=${id}`,
133
+ };
97
134
 
98
135
  /**
99
136
  * PURE — the command to relaunch a pane so its agent conversation is revived,
100
137
  * or `null` for "no resume — leave it a plain shell (or replay `command`)".
101
138
  *
102
139
  * Decision table (only fires when `resumeAgents` is on):
103
- * - claude + a recorded `@agent_session_id` → `claude --resume <id>`. The id is
104
- * a uuid, but we don't trust the snapshot: unless it matches
105
- * {@link SAFE_SESSION_ID} (`[A-Za-z0-9-]`) we bail to `null` rather than risk
106
- * feeding shell metacharacters into send-keys.
107
- * - claude with NO session id → `null`. `claude --continue` would be too
108
- * magical here (it resumes the most-recent conversation in the cwd, which may
109
- * belong to a different pane); a fresh shell is the safe, predictable default.
110
- * - any other agent → `null`. No native resume story yet.
140
+ * - a kind in {@link AGENT_RESUME_COMMANDS} + a recorded `@agent_session_id` →
141
+ * that kind's resume invocation. The id is uuid-ish, but we don't trust the
142
+ * snapshot: unless it matches {@link SAFE_SESSION_ID} we bail to `null`
143
+ * rather than risk feeding shell metacharacters into send-keys.
144
+ * - a known kind with NO session id → `null`. A "continue most recent" flag
145
+ * would be too magical here (it resumes the cwd's most-recent conversation,
146
+ * which may belong to a different pane); a fresh shell is the safe default.
147
+ * - any other agent → `null`. No VERIFIED resume story.
111
148
  * - `resumeAgents` off → always `null`.
112
149
  */
113
150
  export function paneResumeCommand(
@@ -115,10 +152,11 @@ export function paneResumeCommand(
115
152
  opts: { resumeAgents: boolean },
116
153
  ): string | null {
117
154
  if (!opts.resumeAgents) return null;
118
- if (pane.agent !== "claude") return null;
155
+ const resume = pane.agent ? AGENT_RESUME_COMMANDS[pane.agent] : undefined;
156
+ if (!resume) return null;
119
157
  const id = pane.agentSessionId;
120
158
  if (!id || !SAFE_SESSION_ID.test(id)) return null;
121
- return `claude --resume ${id}`;
159
+ return resume(id);
122
160
  }
123
161
 
124
162
  /** PURE — how many of a session's panes would resume under `resumeAgents`. */
@@ -86,24 +86,36 @@ function prepareMessage(message: string, busyStatus: PaneBusyStatus): string {
86
86
  return message;
87
87
  }
88
88
 
89
- export async function send(targetDir: string | undefined, opts: SendOptions): Promise<void> {
90
- const dir = resolve(targetDir ?? ".");
91
- const { name: session } = getSessionName(dir);
92
- const { json, to: target, message: rawMessage, noEnter } = opts;
93
-
94
- if (!target) {
95
- throw new IdeError("Missing target. Usage: tmux-ide send <target> <message>", {
96
- code: "USAGE",
97
- });
98
- }
89
+ /** What a delivery reports back (the `send --json` payload, and the control
90
+ * socket's `send` verb data). */
91
+ export interface DeliverResult {
92
+ ok: true;
93
+ session: string;
94
+ target: { paneId: string; name: string | null; title: string; role: string | null };
95
+ message: string;
96
+ busyStatus: PaneBusyStatus;
97
+ sentViaFile: boolean;
98
+ warning?: "agent_busy";
99
+ }
99
100
 
100
- if (!rawMessage) {
101
- throw new IdeError("Missing message. Usage: tmux-ide send <target> <message>", {
102
- code: "USAGE",
103
- });
104
- }
101
+ /**
102
+ * Deliver `message` to a pane of `session` — the SHARED core behind the
103
+ * `tmux-ide send` CLI case and the control socket's `send` verb. Resolves
104
+ * `target` (pane id / @ide_name / title / role / partial title), adapts to
105
+ * the pane's busy status, and routes long messages through a dispatch file
106
+ * under `dir` when one is given (without a `dir` the text is sent directly).
107
+ * Throws `IdeError` (SESSION_NOT_FOUND / PANE_NOT_FOUND) — no printing here.
108
+ */
109
+ export function deliverMessage(opts: {
110
+ session: string;
111
+ target: string;
112
+ message: string;
113
+ noEnter?: boolean;
114
+ /** The project dir long messages are dispatched through as a file. */
115
+ dir?: string;
116
+ }): DeliverResult {
117
+ const { session, target, noEnter, dir } = opts;
105
118
 
106
- // Verify session is running
107
119
  const state = getSessionState(session);
108
120
  if (!state.running) {
109
121
  throw new IdeError(`Session "${session}" is not running`, {
@@ -126,13 +138,13 @@ export async function send(targetDir: string | undefined, opts: SendOptions): Pr
126
138
  }
127
139
 
128
140
  const busyStatus = getPaneBusyStatus(session, pane.id);
129
- const message = prepareMessage(rawMessage, busyStatus);
141
+ const message = prepareMessage(opts.message, busyStatus);
130
142
 
131
143
  let sentViaFile = false;
132
144
  if (noEnter) {
133
145
  sendText(session, pane.id, message);
134
146
  } else {
135
- const dispatch = writeDispatchFile(dir, pane.id, message);
147
+ const dispatch = dir ? writeDispatchFile(dir, pane.id, message) : null;
136
148
  if (dispatch) {
137
149
  sendCommand(session, pane.id, dispatch.triggerCmd);
138
150
  sentViaFile = true;
@@ -141,7 +153,7 @@ export async function send(targetDir: string | undefined, opts: SendOptions): Pr
141
153
  }
142
154
  }
143
155
 
144
- const result = {
156
+ return {
145
157
  ok: true,
146
158
  session,
147
159
  target: {
@@ -153,8 +165,30 @@ export async function send(targetDir: string | undefined, opts: SendOptions): Pr
153
165
  message,
154
166
  busyStatus,
155
167
  sentViaFile,
156
- ...(busyStatus === "agent" ? { warning: "agent_busy" } : {}),
168
+ ...(busyStatus === "agent" ? { warning: "agent_busy" as const } : {}),
157
169
  };
170
+ }
171
+
172
+ export async function send(targetDir: string | undefined, opts: SendOptions): Promise<void> {
173
+ const dir = resolve(targetDir ?? ".");
174
+ const { name: session } = getSessionName(dir);
175
+ const { json, to: target, message: rawMessage, noEnter } = opts;
176
+
177
+ if (!target) {
178
+ throw new IdeError("Missing target. Usage: tmux-ide send <target> <message>", {
179
+ code: "USAGE",
180
+ });
181
+ }
182
+
183
+ if (!rawMessage) {
184
+ throw new IdeError("Missing message. Usage: tmux-ide send <target> <message>", {
185
+ code: "USAGE",
186
+ });
187
+ }
188
+
189
+ const result = deliverMessage({ session, target, message: rawMessage, noEnter, dir });
190
+ const { message, busyStatus } = result;
191
+ const pane = result.target;
158
192
 
159
193
  if (json) {
160
194
  console.log(JSON.stringify(result, null, 2));
@@ -163,7 +197,7 @@ export async function send(targetDir: string | undefined, opts: SendOptions): Pr
163
197
 
164
198
  const label = pane.name ?? pane.title;
165
199
  const preview = message.length > 60 ? message.slice(0, 60) + "..." : message;
166
- console.log(`Sent to "${label}" (${pane.id}): ${preview}`);
200
+ console.log(`Sent to "${label}" (${pane.paneId}): ${preview}`);
167
201
 
168
202
  if (busyStatus === "agent") {
169
203
  console.log("Warning: agent appears busy. Message sent anyway.");