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.
- package/README.md +36 -14
- package/bin/cli.js +4174 -1235
- package/bin/cli.ts +426 -72
- package/package.json +10 -6
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +34 -6
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +194 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +737 -52
- package/packages/daemon/src/tui/chrome/updater.ts +318 -30
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +49 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
- package/packages/daemon/src/tui/detect/manifest.ts +39 -3
- package/packages/daemon/src/tui/detect/manifests.ts +395 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/main.ts +13 -1
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
- package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
- package/packages/daemon/src/tui/mirror/palette.ts +564 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +376 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +184 -17
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- package/skill/SKILL.md +110 -2
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
- 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
|
import type { AgentStatus } from "../tui/detect/classify.ts";
|
|
27
27
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
@@ -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. */
|
|
@@ -133,6 +161,57 @@ export interface AppIntegrations {
|
|
|
133
161
|
offer: boolean;
|
|
134
162
|
}
|
|
135
163
|
|
|
164
|
+
/** The unified-app front-door decision (M22.6). */
|
|
165
|
+
export interface AppApp {
|
|
166
|
+
/**
|
|
167
|
+
* Whether bare `tmux-ide` (no ide.yml here, no `--team`) launches the unified
|
|
168
|
+
* app (`tmux-ide app`) instead of the classic team cockpit. Default false —
|
|
169
|
+
* the flip is opt-in until the default-entry decision is made. `tmux-ide team`
|
|
170
|
+
* (the explicit cockpit) and a project's `ide.yml` auto-launch are unaffected.
|
|
171
|
+
*/
|
|
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;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The two Terminal-spawn cwd policies (see {@link AppApp.newAgentCwd}). */
|
|
213
|
+
export type NewAgentCwd = "pane" | "session";
|
|
214
|
+
|
|
136
215
|
/** Worktree flow config (`tmux-ide worktree`). */
|
|
137
216
|
export interface AppWorktrees {
|
|
138
217
|
/**
|
|
@@ -154,6 +233,7 @@ export interface AppConfig {
|
|
|
154
233
|
welcome: AppWelcome;
|
|
155
234
|
integrations: AppIntegrations;
|
|
156
235
|
worktrees: AppWorktrees;
|
|
236
|
+
app: AppApp;
|
|
157
237
|
}
|
|
158
238
|
|
|
159
239
|
// ---------------------------------------------------------------------------
|
|
@@ -184,12 +264,19 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|
|
184
264
|
glyphs: { active: "●", inactive: "○" },
|
|
185
265
|
},
|
|
186
266
|
updater: { tickMs: 2000, snapshotEvery: 15 },
|
|
187
|
-
notifications: { toast: true, macos: false },
|
|
267
|
+
notifications: { toast: true, macos: false, terminal: true, delaySeconds: 2, sound: "blocked" },
|
|
188
268
|
restore: { resumeAgents: false },
|
|
189
|
-
updates: { check: true },
|
|
269
|
+
updates: { check: true, manifests: false },
|
|
190
270
|
welcome: { show: true },
|
|
191
271
|
integrations: { offer: true },
|
|
192
272
|
worktrees: { dir: "" },
|
|
273
|
+
app: {
|
|
274
|
+
frontDoor: false,
|
|
275
|
+
detachable: false,
|
|
276
|
+
dragSelect: "agents",
|
|
277
|
+
newAgentCwd: "pane",
|
|
278
|
+
kittyKeys: true,
|
|
279
|
+
},
|
|
193
280
|
};
|
|
194
281
|
|
|
195
282
|
/** The default theme tokens — the fallback threaded into the pure builders. */
|
|
@@ -224,6 +311,18 @@ function pickPosInt(value: unknown, fallback: number): number {
|
|
|
224
311
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
225
312
|
}
|
|
226
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
|
+
|
|
227
326
|
/**
|
|
228
327
|
* PURE — merge an unknown (typically parsed JSON) over {@link DEFAULT_APP_CONFIG}.
|
|
229
328
|
* Every block/leaf is validated independently: a missing or mistyped field falls
|
|
@@ -245,6 +344,7 @@ export function parseAppConfig(input: unknown): AppConfig {
|
|
|
245
344
|
const welcome = asObject(root.welcome);
|
|
246
345
|
const integrations = asObject(root.integrations);
|
|
247
346
|
const worktrees = asObject(root.worktrees);
|
|
347
|
+
const app = asObject(root.app);
|
|
248
348
|
return {
|
|
249
349
|
keys: {
|
|
250
350
|
popup: pickString(keys.popup, D.keys.popup),
|
|
@@ -281,12 +381,25 @@ export function parseAppConfig(input: unknown): AppConfig {
|
|
|
281
381
|
notifications: {
|
|
282
382
|
toast: pickBool(notifications.toast, D.notifications.toast),
|
|
283
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),
|
|
284
387
|
},
|
|
285
388
|
restore: { resumeAgents: pickBool(restore.resumeAgents, D.restore.resumeAgents) },
|
|
286
|
-
updates: {
|
|
389
|
+
updates: {
|
|
390
|
+
check: pickBool(updates.check, D.updates.check),
|
|
391
|
+
manifests: pickBool(updates.manifests, D.updates.manifests),
|
|
392
|
+
},
|
|
287
393
|
welcome: { show: pickBool(welcome.show, D.welcome.show) },
|
|
288
394
|
integrations: { offer: pickBool(integrations.offer, D.integrations.offer) },
|
|
289
395
|
worktrees: { dir: pickString(worktrees.dir, D.worktrees.dir) },
|
|
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
|
+
},
|
|
290
403
|
};
|
|
291
404
|
}
|
|
292
405
|
|
|
@@ -334,3 +447,79 @@ export function getAppConfig(): AppConfig {
|
|
|
334
447
|
export function _resetForTests(): void {
|
|
335
448
|
cached = null;
|
|
336
449
|
}
|
|
450
|
+
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
// Writes (M22.4 — the settings surface persists through here)
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* io — the RAW parsed config file as the user wrote it (`{}` when missing or
|
|
457
|
+
* malformed). Writers merge over THIS, not over {@link parseAppConfig}'s output:
|
|
458
|
+
* round-tripping through the parser would materialize every default into the
|
|
459
|
+
* hand-editable file and silently DROP fields the typed shape doesn't model yet
|
|
460
|
+
* (e.g. the notification polish fields `notifications.enabled` /
|
|
461
|
+
* `notifications.quietHours` that {@link ../tui/chrome/notify.ts} reads raw).
|
|
462
|
+
*/
|
|
463
|
+
export function loadRawAppConfig(): Record<string, unknown> {
|
|
464
|
+
const path = appConfigPath();
|
|
465
|
+
if (!existsSync(path)) return {};
|
|
466
|
+
try {
|
|
467
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
468
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
469
|
+
? (parsed as Record<string, unknown>)
|
|
470
|
+
: {};
|
|
471
|
+
} catch {
|
|
472
|
+
return {};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** A partial config write: plain objects merge recursively, scalars/arrays
|
|
477
|
+
* replace, and an explicit `undefined` DELETES the key (how "reset to
|
|
478
|
+
* defaults" removes a block so the parser's defaults take over). */
|
|
479
|
+
export type AppConfigPatch = { [key: string]: unknown };
|
|
480
|
+
|
|
481
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
482
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* PURE — merge `patch` into `raw` without touching either input. Objects merge
|
|
487
|
+
* key-by-key (recursively), anything else replaces, and a key whose patch value
|
|
488
|
+
* is `undefined` is REMOVED from the result. Unknown user fields survive — the
|
|
489
|
+
* merge only visits keys the patch names.
|
|
490
|
+
*/
|
|
491
|
+
export function mergeConfigPatch(
|
|
492
|
+
raw: Record<string, unknown>,
|
|
493
|
+
patch: AppConfigPatch,
|
|
494
|
+
): Record<string, unknown> {
|
|
495
|
+
const out: Record<string, unknown> = { ...raw };
|
|
496
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
497
|
+
if (value === undefined) {
|
|
498
|
+
delete out[key];
|
|
499
|
+
} else if (isPlainObject(value) && isPlainObject(out[key])) {
|
|
500
|
+
out[key] = mergeConfigPatch(out[key] as Record<string, unknown>, value as AppConfigPatch);
|
|
501
|
+
} else if (isPlainObject(value)) {
|
|
502
|
+
out[key] = mergeConfigPatch({}, value as AppConfigPatch);
|
|
503
|
+
} else {
|
|
504
|
+
out[key] = value;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* io — apply a patch to the config file ATOMICALLY (temp + rename, the same
|
|
512
|
+
* discipline as the editor save): read raw → {@link mergeConfigPatch} → write.
|
|
513
|
+
* Busts the {@link getAppConfig} process cache and returns the new parsed
|
|
514
|
+
* config. Honors `TMUX_IDE_CONFIG`, so tests never touch the real file.
|
|
515
|
+
*/
|
|
516
|
+
export function updateAppConfig(patch: AppConfigPatch): AppConfig {
|
|
517
|
+
const path = appConfigPath();
|
|
518
|
+
const merged = mergeConfigPatch(loadRawAppConfig(), patch);
|
|
519
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
520
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
521
|
+
writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
522
|
+
renameSync(tmp, path);
|
|
523
|
+
cached = null;
|
|
524
|
+
return parseAppConfig(merged);
|
|
525
|
+
}
|
|
@@ -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
|
+
}
|