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
package/bin/cli.ts CHANGED
@@ -9,8 +9,17 @@ import { existsSync } from "node:fs";
9
9
  import { fileURLToPath } from "node:url";
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ // The node-runnable CLI path that spawned TUI surfaces shell back to (the app's
13
+ // async fleet poll + `detect --write`). When we're the published `bin/cli.js`
14
+ // this file IS it; under dev (`bun bin/cli.ts`) it's the built sibling
15
+ // `bin/cli.js`. Forwarded to surfaces as TMUX_IDE_CLI so the COMPILED TUI binary
16
+ // — whose own `import.meta.url` is a virtual bunfs path with no real cli.js next
17
+ // to it — can still find the CLI to run its subprocesses.
18
+ const selfPath = fileURLToPath(import.meta.url);
19
+ const nodeCliPath = selfPath.endsWith(".js") ? selfPath : resolve(__dirname, "cli.js");
12
20
  import { launch } from "../packages/daemon/src/launch.ts";
13
- import { shouldOpenCockpit } from "../packages/daemon/src/tui/team/entry.ts";
21
+ import { resolveEntry } from "../packages/daemon/src/tui/team/entry.ts";
22
+ import { loadAppConfig } from "../packages/daemon/src/lib/app-config.ts";
14
23
  import {
15
24
  resolveTuiLaunch,
16
25
  findCompiledTui,
@@ -31,6 +40,16 @@ import { restore } from "../packages/daemon/src/restore.ts";
31
40
  import { send } from "../packages/daemon/src/send.ts";
32
41
  import { IdeError } from "../packages/daemon/src/lib/errors.ts";
33
42
  import { printCommandError } from "../packages/daemon/src/lib/output.ts";
43
+ import {
44
+ wantsHostedApp,
45
+ hostedEnvVars,
46
+ hostedCommandLine,
47
+ hostExistsArgv,
48
+ hostCreateArgv,
49
+ hostSetupArgvs,
50
+ hostAttachArgv,
51
+ HOSTED_ENV,
52
+ } from "../packages/daemon/src/tui/mirror/hosted.ts";
34
53
 
35
54
  const { positionals, values } = parseArgs({
36
55
  allowPositionals: true,
@@ -83,6 +102,10 @@ const { positionals, values } = parseArgs({
83
102
  // actions menu opens at the pointer instead of centered (see `menu` case)
84
103
  x: { type: "string" },
85
104
  y: { type: "string" },
105
+ // app: host the cockpit in the internal `_tmux-ide-app` session and attach
106
+ // to it (M23.2) — `--detachable` is the primary name, `--hosted` the alias
107
+ detachable: { type: "boolean" },
108
+ hosted: { type: "boolean" },
86
109
  // worktree: base ref for a new branch, the worktree checkout dir override,
87
110
  // skip creating a session, and force-remove a dirty worktree (see `worktree`)
88
111
  from: { type: "string" },
@@ -110,6 +133,7 @@ const knownCommands = new Set([
110
133
  "send",
111
134
  "settings",
112
135
  "team",
136
+ "app",
113
137
  "switcher",
114
138
  "wait",
115
139
  "events",
@@ -127,6 +151,7 @@ const knownCommands = new Set([
127
151
  "worktree",
128
152
  "update",
129
153
  "skill-sync",
154
+ "serve",
130
155
  "command-center",
131
156
  "server",
132
157
  "help",
@@ -177,12 +202,15 @@ ${bold("Usage:")}
177
202
  ${dim("(--resume-agents revives claude conversations via claude --resume)")}
178
203
  ${cyan("tmux-ide attach")} ${dim("Reattach to a running session")}
179
204
  ${cyan("tmux-ide team")} [--json] ${dim("TUI over all tmux sessions (--json prints fleet state)")}
205
+ ${cyan("tmux-ide app")} [session] ${dim("Unified app: fleet home + live session mirror (bare = home)")}
206
+ ${cyan("tmux-ide app --detachable")} ${dim("Host the app in tmux and attach — survives the terminal, ^q detaches")}
180
207
  ${cyan("tmux-ide switcher")} ${dim("Compact session picker (opens in the M-p popup on adopted sessions)")}
181
208
  ${cyan("tmux-ide wait agent-status")} <session> --status <s> [--timeout <ms>]
182
209
  ${dim("Block until a session reaches a status (exit 0 match / 1 timeout)")}
183
210
  ${cyan("tmux-ide wait output")} <pane|session> --match <regex> [--timeout <ms>]
184
211
  ${dim("Block until a pane's output matches a regex (exit 0 match / 1 timeout)")}
185
- ${cyan("tmux-ide events")} [--follow] [--json] ${dim("Stream agent-status transitions (needs an adopted session)")}
212
+ ${cyan("tmux-ide events")} [--follow] [--json] [--socket] ${dim("Stream agent-status transitions (--socket: push from a running serve)")}
213
+ ${cyan("tmux-ide serve")} [--socket <path>] ${dim("Local control socket: NDJSON verbs + pushed events (~/.tmux-ide/control.sock)")}
186
214
  ${cyan("tmux-ide adopt")} <session> ${dim("Add the live tmux-ide status bar to a session")}
187
215
  ${cyan("tmux-ide adopt --all")} ${dim("Adopt every live (non-internal) session")}
188
216
  ${cyan("tmux-ide unadopt")} <session> ${dim("Remove the status bar")}
@@ -202,6 +230,7 @@ ${bold("Usage:")}
202
230
  ${cyan("tmux-ide inspect")} [--json] ${dim("Show effective config and runtime state")}
203
231
  ${cyan("tmux-ide doctor")} ${dim("Check system requirements")}
204
232
  ${cyan("tmux-ide update")} [--dry-run] ${dim("Update tmux-ide (detects dev checkout vs npm/pnpm/bun global)")}
233
+ ${cyan("tmux-ide update --manifests")} ${dim("Fetch the latest agent-detection manifest pack (your overrides still win)")}
205
234
  ${cyan("tmux-ide skill-sync")} ${dim("Refresh the bundled Claude Code skill in ~/.claude/skills/tmux-ide")}
206
235
  ${cyan("tmux-ide validate")} [--json] ${dim("Validate ide.yml")}
207
236
  ${cyan("tmux-ide detect")} [--json] ${dim("Detect project stack")}
@@ -269,7 +298,12 @@ function execBunWidget(
269
298
  );
270
299
  }
271
300
 
272
- const env = { ...process.env, TMUX_IDE_CWD: process.cwd(), ...extraEnv };
301
+ const env = {
302
+ ...process.env,
303
+ TMUX_IDE_CWD: process.cwd(),
304
+ TMUX_IDE_CLI: nodeCliPath,
305
+ ...extraEnv,
306
+ };
273
307
  if (launch.mode === "bun") {
274
308
  // Spawn from the repo root so bun finds `bunfig.toml` (the @opentui/solid
275
309
  // JSX preload). Without this, running from any other cwd — e.g. bare
@@ -290,6 +324,63 @@ function execBunWidget(
290
324
  execFileSync(launch.bin, launch.argv, { stdio: "inherit", env });
291
325
  }
292
326
 
327
+ // The detachable cockpit (M23.2): instead of running the app in THIS terminal,
328
+ // ensure the internal `_tmux-ide-app` session exists running it full-screen
329
+ // (status off, window-size latest — see hostSetupArgvs), then attach here.
330
+ // Re-invocation from any terminal reattaches the SAME cockpit; ^q inside a
331
+ // hosted app detaches (the HOSTED_ENV marker on the pane command flips it).
332
+ // Inside tmux the client switch-clients instead of nesting an attach.
333
+ function launchHostedApp(scriptPath: string, appArgs: string[]): void {
334
+ const launch = resolveTuiLaunch({
335
+ surface: "app",
336
+ scriptPath,
337
+ args: appArgs,
338
+ checkoutExists: existsSync(scriptPath),
339
+ bunAvailable: isBunAvailable(),
340
+ compiledBinary: findCompiledTui(),
341
+ });
342
+ if (launch.mode === "unavailable") {
343
+ throw new IdeError(
344
+ `\`tmux-ide app --detachable\` is unavailable because ${launch.reasons.join(" and ")}.\n` +
345
+ `Install bun (https://bun.sh) — the TUI surfaces run on it. Sources ship with the npm package since v2.6.1.`,
346
+ { code: "USAGE", exitCode: 1 },
347
+ );
348
+ }
349
+
350
+ let exists = true;
351
+ try {
352
+ execFileSync("tmux", hostExistsArgv(), { stdio: "ignore" });
353
+ } catch {
354
+ exists = false; // also covers "no server yet" — new-session starts one
355
+ }
356
+ if (!exists) {
357
+ // Same cwd rule as execBunWidget: bun needs the repo root (bunfig preload),
358
+ // the compiled binary must NOT run from it. The app's real env travels on
359
+ // the pane command line — the tmux server's environment is not ours.
360
+ const cwd = launch.mode === "bun" ? resolve(__dirname, "..") : process.cwd();
361
+ const commandLine = hostedCommandLine(
362
+ launch.bin,
363
+ launch.argv,
364
+ hostedEnvVars({
365
+ cwd: process.cwd(),
366
+ cli: nodeCliPath,
367
+ path: process.env.PATH,
368
+ home: process.env.TMUX_IDE_HOME,
369
+ config: process.env.TMUX_IDE_CONFIG,
370
+ tuiBin: process.env.TMUX_IDE_TUI_BIN,
371
+ }),
372
+ );
373
+ execFileSync("tmux", hostCreateArgv({ cwd, commandLine }), { stdio: "ignore" });
374
+ }
375
+ // Setup runs on EVERY ensure, not just create (M25.5): the list is
376
+ // idempotent, so this upgrades a host created by an older tmux-ide (no
377
+ // resize hooks yet) and re-asserts `window-size latest` on a host a stray
378
+ // `resize-window` flipped to manual — the measured way a cockpit gets stuck
379
+ // at a departed client's size.
380
+ for (const args of hostSetupArgvs()) execFileSync("tmux", args, { stdio: "ignore" });
381
+ execFileSync("tmux", hostAttachArgv(Boolean(process.env.TMUX)), { stdio: "inherit" });
382
+ }
383
+
293
384
  // The scriptable control surface for the cockpit: print the fleet state as JSON
294
385
  // and exit without spawning the (bun/OpenTUI) TUI. Shared by `tmux-ide team
295
386
  // --json` and bare `tmux-ide --json` when there's no ide.yml to launch. Dynamic
@@ -301,7 +392,43 @@ async function printFleetJson(): Promise<void> {
301
392
  console.log(JSON.stringify(toFleetJson(listTeamProjects(createStatusTracker())), null, 2));
302
393
  }
303
394
 
395
+ // The `--socket[=path]` opt-in (undeclared in parseArgs on purpose: bare
396
+ // `--socket` parses to `true`, `--socket=/path` to the path — strict:false
397
+ // gives optional-value semantics parseArgs can't declare). `true | string |
398
+ // undefined`; string overrides the default socket path.
399
+ const socketFlag = values.socket as string | boolean | undefined;
400
+
401
+ // Run a `wait` on a live `tmux-ide serve` (connect once, the server holds the
402
+ // wait — no local polling). Returns null when the flag is off, no server
403
+ // answers, or the connection drops mid-wait — callers fall back SILENTLY to
404
+ // the local polling implementation (same shared logic, tui/team/wait.ts).
405
+ async function waitOverSocket(
406
+ params: Record<string, unknown>,
407
+ ): Promise<{ timedOut: boolean; data?: unknown } | null> {
408
+ if (!socketFlag) return null;
409
+ const { connectControl, ControlRequestError } =
410
+ await import("../packages/daemon/src/control/client.ts");
411
+ let client: Awaited<ReturnType<typeof connectControl>>;
412
+ try {
413
+ client = await connectControl({
414
+ socketPath: typeof socketFlag === "string" ? socketFlag : undefined,
415
+ });
416
+ } catch {
417
+ return null; // no server listening — poll locally instead
418
+ }
419
+ try {
420
+ const data = await client.request("wait", params);
421
+ return { timedOut: false, data };
422
+ } catch (err) {
423
+ if (err instanceof ControlRequestError && err.code === "timeout") return { timedOut: true };
424
+ return null; // dropped/errored mid-wait — fall back to polling
425
+ } finally {
426
+ client.close();
427
+ }
428
+ }
429
+
304
430
  const teamScriptPath = resolve(__dirname, "../packages/daemon/src/tui/team/index.tsx");
431
+ const appScriptPath = resolve(__dirname, "../packages/daemon/src/tui/mirror/app.tsx");
305
432
 
306
433
  // `tmux-ide team` runs the standalone full-screen cockpit (the OpenTUI app owns
307
434
  // the whole terminal). The floating switcher popup (M-p on adopted sessions)
@@ -310,6 +437,29 @@ function launchTeamCockpit(): void {
310
437
  execBunWidget("team", teamScriptPath, [], "team");
311
438
  }
312
439
 
440
+ // The one entry for the unified app: `--detachable`/`--hosted` (or
441
+ // `app.detachable` in config) route through the hosted launcher, everything
442
+ // else runs the app in this terminal as before. The HOSTED_ENV guard keeps the
443
+ // app INSIDE the host session from re-hosting itself.
444
+ function runApp(appArgs: string[]): void {
445
+ const hosted = wantsHostedApp({
446
+ flagDetachable: values.detachable === true,
447
+ flagHosted: values.hosted === true,
448
+ configDetachable: loadAppConfig().app.detachable,
449
+ hostedEnv: process.env[HOSTED_ENV] === "1",
450
+ });
451
+ if (hosted) launchHostedApp(appScriptPath, appArgs);
452
+ else execBunWidget("app", appScriptPath, appArgs, "app");
453
+ }
454
+
455
+ // The unified app as the front door (M22.6): bare `tmux-ide` opens `tmux-ide
456
+ // app`'s HOME panel when `app.frontDoor` is on and there's nothing else to
457
+ // launch. Same entry as the explicit `app` command with no session positional
458
+ // — including the hosted flip when `app.detachable` is set (M23.2).
459
+ function launchApp(): void {
460
+ runApp([]);
461
+ }
462
+
313
463
  try {
314
464
  switch (command) {
315
465
  case "start": {
@@ -332,13 +482,23 @@ try {
332
482
  }
333
483
  const targetDir = resolve(startTargetDir || ".");
334
484
  const hasIdeYml = existsSync(join(targetDir, "ide.yml"));
335
- if (shouldOpenCockpit(hasIdeYml, values.team === true)) {
336
- // No project here (or --team): the cockpit is the front door.
485
+ // M22.6 — the front-door decision. `--team` always means the classic
486
+ // cockpit; a present ide.yml still auto-launches the project; otherwise
487
+ // `app.frontDoor` flips the default no-project entry to the unified app.
488
+ const entry = resolveEntry({
489
+ hasIdeYml,
490
+ teamFlag: values.team === true,
491
+ frontDoor: loadAppConfig().app.frontDoor,
492
+ });
493
+ if (entry !== "project") {
494
+ // No project to launch here. `--json` is a scripting surface — it always
495
+ // prints the fleet, whichever interactive front door is configured.
337
496
  if (json) {
338
497
  await printFleetJson();
339
498
  break;
340
499
  }
341
- launchTeamCockpit();
500
+ if (entry === "app") launchApp();
501
+ else launchTeamCockpit();
342
502
  break;
343
503
  }
344
504
  await launch(startTargetDir, { json });
@@ -491,6 +651,20 @@ try {
491
651
  break;
492
652
  }
493
653
 
654
+ case "app": {
655
+ // The unified app (M18.1): sidebar fleet + a live tmux-session mirror.
656
+ // Bare `tmux-ide app` opens the HOME panel (fleet cards); an optional
657
+ // session positional boots straight into that session's mirror. With
658
+ // `--detachable` (alias `--hosted`, or `app.detachable` in config) the
659
+ // app runs in the internal `_tmux-ide-app` session instead and this
660
+ // terminal attaches to it (M23.2) — the positional only shapes the
661
+ // cockpit at CREATE time; a reattach finds the app exactly as left.
662
+ const session = positionals[1];
663
+ const appArgs = session ? [`--target=${session}`] : [];
664
+ runApp(appArgs);
665
+ break;
666
+ }
667
+
494
668
  case "switcher": {
495
669
  // Runs the team app in PICKER mode inside a tmux `display-popup` (bound to
496
670
  // `M-p` on adopt). Picking a session `switch-client`s the invoking client
@@ -513,7 +687,7 @@ try {
513
687
  const pattern = values.match;
514
688
  if (!target || typeof pattern !== "string" || pattern.length === 0) {
515
689
  console.error(
516
- "Usage: tmux-ide wait output <pane|session> --match <regex> [--timeout <ms>]",
690
+ "Usage: tmux-ide wait output <pane|session> --match <regex> [--timeout <ms>] [--socket[=path]]",
517
691
  );
518
692
  process.exit(1);
519
693
  }
@@ -523,46 +697,42 @@ try {
523
697
  console.error(`Invalid --match regex: ${(err as Error).message}`);
524
698
  process.exit(1);
525
699
  }
526
- const { capturePane } = await import("../packages/tmux-bridge/src/index.ts");
527
700
  const outTimeout = Number(values.timeout ?? "60000");
528
- const outStart = Date.now();
529
- const nap = (ms: number) => new Promise((r) => setTimeout(r, ms));
530
- while (true) {
531
- let text = "";
532
- try {
533
- text = capturePane(target!, { lines: 200 });
534
- } catch {
535
- // pane/session not (yet) available — keep polling until timeout
536
- }
537
- const lines = text.split("\n");
538
- // Fresh regex per test so a user-supplied /g flag can't carry lastIndex
539
- // between calls. Report the specific matching line when we can.
540
- let hit: string | null = null;
541
- for (const line of lines) {
542
- if (new RegExp(pattern!).test(line)) {
543
- hit = line;
544
- break;
545
- }
546
- }
547
- if (hit === null && new RegExp(pattern!).test(text)) hit = lines[lines.length - 1] ?? "";
548
- if (hit !== null) {
549
- if (json) console.log(JSON.stringify({ matched: hit }));
550
- else console.log(hit);
551
- process.exit(0);
552
- }
553
- if (Date.now() - outStart >= outTimeout) {
701
+
702
+ // `--socket` fast path: let a running `tmux-ide serve` hold the wait
703
+ // (one process, no spawn-per-poll). Falls back silently when no server
704
+ // is listening — the polling below is exactly the same implementation.
705
+ const viaSocket = await waitOverSocket({
706
+ kind: "output",
707
+ target,
708
+ match: pattern,
709
+ timeoutMs: outTimeout,
710
+ });
711
+ if (viaSocket) {
712
+ if (viaSocket.timedOut) {
554
713
  console.error(
555
714
  `Timed out after ${outTimeout}ms waiting for ${target} output to match /${pattern}/`,
556
715
  );
557
716
  process.exit(1);
558
717
  }
559
- await nap(500);
718
+ const hit = (viaSocket.data as { matched: string }).matched;
719
+ if (json) console.log(JSON.stringify({ matched: hit }));
720
+ else console.log(hit);
721
+ process.exit(0);
560
722
  }
561
- }
562
723
 
563
- const { createStatusTracker } = await import("../packages/daemon/src/tui/detect/classify.ts");
564
- const { listTeamSessions } = await import("../packages/daemon/src/tui/team/sessions.ts");
565
- const { findSessionStatus } = await import("../packages/daemon/src/tui/team/report.ts");
724
+ const { waitForOutputMatch } = await import("../packages/daemon/src/tui/team/wait.ts");
725
+ const result = await waitForOutputMatch(target!, pattern!, { timeoutMs: outTimeout });
726
+ if (!result.ok) {
727
+ console.error(
728
+ `Timed out after ${outTimeout}ms waiting for ${target} output to match /${pattern}/`,
729
+ );
730
+ process.exit(1);
731
+ }
732
+ if (json) console.log(JSON.stringify({ matched: result.matched }));
733
+ else console.log(result.matched);
734
+ process.exit(0);
735
+ }
566
736
 
567
737
  const VALID = new Set(["blocked", "working", "done", "idle", "unknown"]);
568
738
  const sessionName = positionals[2];
@@ -570,37 +740,50 @@ try {
570
740
 
571
741
  if (sub !== "agent-status" || !sessionName || typeof want !== "string" || !VALID.has(want)) {
572
742
  console.error(
573
- "Usage: tmux-ide wait agent-status <session> --status <blocked|working|done|idle|unknown> [--timeout <ms>]",
743
+ "Usage: tmux-ide wait agent-status <session> --status <blocked|working|done|idle|unknown> [--timeout <ms>] [--socket[=path]]",
574
744
  );
575
745
  process.exit(1);
576
746
  }
577
747
 
578
748
  const timeout = Number(values.timeout ?? "60000");
579
- const started = Date.now();
580
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
581
- // One tracker persists across polls so the working→idle `done` transition
582
- // can be observed (it's inherently cross-tick).
583
- const tracker = createStatusTracker();
584
-
585
- while (true) {
586
- const sessions = listTeamSessions(tracker);
587
- const status = findSessionStatus(sessions, sessionName!);
588
- if (status === want) {
589
- if (json) {
590
- console.log(JSON.stringify({ session: sessionName, status, ok: true }));
591
- } else {
592
- console.log(`${sessionName} reached status: ${status}`);
593
- }
594
- process.exit(0);
595
- }
596
- if (Date.now() - started >= timeout) {
749
+
750
+ const viaSocket = await waitOverSocket({
751
+ kind: "agent-status",
752
+ session: sessionName,
753
+ status: want,
754
+ timeoutMs: timeout,
755
+ });
756
+ if (viaSocket) {
757
+ if (viaSocket.timedOut) {
597
758
  console.error(
598
- `Timed out after ${timeout}ms waiting for ${sessionName} to reach status "${want}" (last: ${status ?? "absent"})`,
759
+ `Timed out after ${timeout}ms waiting for ${sessionName} to reach status "${want}"`,
599
760
  );
600
761
  process.exit(1);
601
762
  }
602
- await sleep(750);
763
+ if (json) console.log(JSON.stringify({ session: sessionName, status: want, ok: true }));
764
+ else console.log(`${sessionName} reached status: ${want}`);
765
+ process.exit(0);
603
766
  }
767
+
768
+ const { waitForAgentStatus } = await import("../packages/daemon/src/tui/team/wait.ts");
769
+ const result = await waitForAgentStatus(
770
+ sessionName!,
771
+ want as import("../packages/daemon/src/tui/detect/classify.ts").AgentStatus,
772
+ { timeoutMs: timeout },
773
+ );
774
+ if (!result.ok) {
775
+ console.error(
776
+ `Timed out after ${timeout}ms waiting for ${sessionName} to reach status "${want}" (last: ${result.status ?? "absent"})`,
777
+ );
778
+ process.exit(1);
779
+ }
780
+ if (json) {
781
+ console.log(JSON.stringify({ session: sessionName, status: result.status, ok: true }));
782
+ } else {
783
+ console.log(`${sessionName} reached status: ${result.status}`);
784
+ }
785
+ process.exit(0);
786
+ break;
604
787
  }
605
788
 
606
789
  case "events": {
@@ -613,10 +796,6 @@ try {
613
796
  type EventLike = { ts: string; session: string; from: Status | null; to: Status };
614
797
 
615
798
  const path = eventsPath();
616
- if (!existsSync(path)) {
617
- console.log("no events yet — is a session adopted? (the chrome updater writes events)");
618
- break;
619
- }
620
799
 
621
800
  // Status → ANSI color, matching the chrome bar's palette in spirit.
622
801
  const paintStatus = (status: Status | null, text: string): string => {
@@ -646,6 +825,40 @@ try {
646
825
  }
647
826
  };
648
827
 
828
+ // `--follow --socket` fast path: a running `tmux-ide serve` PUSHES
829
+ // transitions the moment its detection tick sees them — no file polling.
830
+ // The last-50 backlog still comes from the log (the server has no
831
+ // history); falls back silently to the polling path when no server is up.
832
+ if (values.follow && socketFlag) {
833
+ const { connectControl } = await import("../packages/daemon/src/control/client.ts");
834
+ const client = await connectControl({
835
+ socketPath: typeof socketFlag === "string" ? socketFlag : undefined,
836
+ }).catch(() => null);
837
+ if (client) {
838
+ if (existsSync(path)) {
839
+ const backlog = readFileSync(path, "utf8")
840
+ .split("\n")
841
+ .filter((l) => l.trim().length > 0);
842
+ for (const line of backlog.slice(-50)) printLine(line);
843
+ }
844
+ await client.subscribe((frame) => {
845
+ if (frame.event === "agent-status") printLine(JSON.stringify(frame.data));
846
+ });
847
+ process.on("SIGINT", () => {
848
+ client.close();
849
+ process.exit(0);
850
+ });
851
+ // Ends when the server shuts down (EOF) — exit cleanly then.
852
+ await client.done;
853
+ break;
854
+ }
855
+ }
856
+
857
+ if (!existsSync(path)) {
858
+ console.log("no events yet — is a session adopted? (the chrome updater writes events)");
859
+ break;
860
+ }
861
+
649
862
  const allLines = readFileSync(path, "utf8")
650
863
  .split("\n")
651
864
  .filter((l) => l.trim().length > 0);
@@ -769,18 +982,36 @@ try {
769
982
  case "integration": {
770
983
  const sub = positionals[1];
771
984
  const agent = positionals[2];
772
- // `status` and `offer` need no agent arg; install/uninstall are claude-only.
773
- const needsClaude = sub === "install" || sub === "uninstall";
774
- if (!sub || (needsClaude && agent !== "claude")) {
985
+ // `status` and `offer` need no agent arg; install/uninstall take a kind
986
+ // we ship an integration for (claude hooks, opencode plugin).
987
+ const needsAgent = sub === "install" || sub === "uninstall";
988
+ const installable = agent === "claude" || agent === "opencode";
989
+ if (!sub || (needsAgent && !installable)) {
775
990
  console.error(
776
- "Usage: tmux-ide integration <install|uninstall|status|offer> [claude]\n" +
777
- " install hook Claude Code lifecycle events into tmux pane state\n" +
778
- " uninstall remove exactly the tmux-ide hook entries\n" +
779
- " status list discovered agents + integration state\n" +
991
+ "Usage: tmux-ide integration <install|uninstall|status|offer> [claude|opencode]\n" +
992
+ " install claude: hook lifecycle events into tmux pane state\n" +
993
+ " opencode: plugin that records the session id for restore --resume-agents\n" +
994
+ " uninstall remove exactly the tmux-ide entries for that agent\n" +
995
+ " status list discovered agents + integration/capture state\n" +
780
996
  " offer one-time first-adopt install prompt (used by the popup)",
781
997
  );
782
998
  process.exit(1);
783
999
  }
1000
+ if (needsAgent && agent === "opencode") {
1001
+ const oc = await import("../packages/daemon/src/tui/integrations/opencode.ts");
1002
+ if (sub === "install") {
1003
+ const { pluginPath } = oc.installOpencodeIntegration();
1004
+ console.log(`plugin: ${pluginPath}`);
1005
+ console.log(
1006
+ "installed — NEW opencode sessions record their session id into the pane\n" +
1007
+ "(@agent_session_id), so `tmux-ide restore --resume-agents` can revive them.",
1008
+ );
1009
+ } else {
1010
+ const { wasInstalled } = oc.uninstallOpencodeIntegration();
1011
+ console.log(wasInstalled ? "uninstalled — plugin removed" : "was not installed");
1012
+ }
1013
+ break;
1014
+ }
784
1015
  const mod = await import("../packages/daemon/src/tui/integrations/claude.ts");
785
1016
  if (sub === "install") {
786
1017
  const { scriptPath, settingsPath } = mod.installClaudeIntegration();
@@ -796,6 +1027,51 @@ try {
796
1027
  "installed — NEW Claude Code sessions now report working/blocked/done " +
797
1028
  "authoritatively into the tmux-ide chrome.",
798
1029
  );
1030
+ // M25.1: this is the moment the user is wiring notifications, so offer
1031
+ // the macOS banner channel here — one plain y/N key, same shape as the
1032
+ // first-adopt offer. Skipped when already on, off-macOS, or when there
1033
+ // is no TTY to ask (TMUX_IDE_NOTIFY_KEY forces an answer for tests).
1034
+ const { getAppConfig, updateAppConfig } =
1035
+ await import("../packages/daemon/src/lib/app-config.ts");
1036
+ const forcedKey = process.env.TMUX_IDE_NOTIFY_KEY;
1037
+ const canAsk = forcedKey !== undefined || process.stdin.isTTY === true;
1038
+ if (process.platform === "darwin" && !getAppConfig().notifications.macos && canAsk) {
1039
+ const act = (key: string): void => {
1040
+ if (key === "y" || key === "Y") {
1041
+ updateAppConfig({ notifications: { macos: true } });
1042
+ console.log(
1043
+ "macOS notifications on — native branded banners will jump to the session when clicked.",
1044
+ );
1045
+ } else {
1046
+ console.log(
1047
+ "skipped — turn banners on anytime: notifications.macos in ~/.tmux-ide/config.json.",
1048
+ );
1049
+ }
1050
+ };
1051
+ process.stdout.write("\nAlso get a macOS notification when an agent needs you? [y/N] ");
1052
+ if (forcedKey !== undefined) {
1053
+ console.log(forcedKey);
1054
+ act(forcedKey);
1055
+ } else {
1056
+ const key = await new Promise<string>((resolve) => {
1057
+ try {
1058
+ process.stdin.setRawMode?.(true);
1059
+ process.stdin.resume();
1060
+ process.stdin.once("data", (data) => resolve(data.toString()));
1061
+ } catch {
1062
+ resolve(""); // no readable stdin — treat as "no"
1063
+ }
1064
+ });
1065
+ try {
1066
+ process.stdin.setRawMode?.(false);
1067
+ process.stdin.pause();
1068
+ } catch {
1069
+ // best-effort terminal restore
1070
+ }
1071
+ console.log(/^[ -~]$/.test(key) ? key : "");
1072
+ act(key);
1073
+ }
1074
+ }
799
1075
  } else if (sub === "uninstall") {
800
1076
  const { wasInstalled } = mod.uninstallClaudeIntegration();
801
1077
  console.log(wasInstalled ? "uninstalled — hook entries removed" : "was not installed");
@@ -861,7 +1137,18 @@ try {
861
1137
  else if (a.integration)
862
1138
  state = a.installed ? "integration installed ✓" : "on PATH — integration not installed";
863
1139
  else state = "detected (no integration)";
864
- console.log(` ${a.id.padEnd(10)} ${state}`);
1140
+ // The resume-key story: how @agent_session_id (what `restore
1141
+ // --resume-agents` revives from) gets captured for this kind.
1142
+ let capture = "";
1143
+ if (a.path !== null) {
1144
+ if (a.capture === "probe") capture = " · session-id capture: automatic";
1145
+ else if (a.capture !== null)
1146
+ capture = a.captureActive
1147
+ ? ` · session-id capture: ${a.capture} ✓`
1148
+ : ` · session-id capture: ${a.capture} (install to enable)`;
1149
+ else capture = " · session-id capture: none";
1150
+ }
1151
+ console.log(` ${a.id.padEnd(10)} ${state}${capture}`);
865
1152
  }
866
1153
  }
867
1154
  break;
@@ -1338,6 +1625,45 @@ try {
1338
1625
  }
1339
1626
 
1340
1627
  case "update": {
1628
+ // `--manifests`: fetch the agent-detection manifest pack (versioned JSON,
1629
+ // a GitHub release asset) into ~/.tmux-ide/agent-detection/pack/ — the
1630
+ // loader hot-merges it under bundled<pack<user precedence. Schema-invalid
1631
+ // packs are rejected loudly. See lib/manifest-pack.ts for the format.
1632
+ if (values["manifests"] === true) {
1633
+ const { updateManifestPack } = await import("../packages/daemon/src/lib/manifest-pack.ts");
1634
+ try {
1635
+ const r = await updateManifestPack({ log: (m) => console.error(m) });
1636
+ if (json) {
1637
+ console.log(JSON.stringify({ ok: true, ...r }, null, 2));
1638
+ } else {
1639
+ console.log(
1640
+ `manifest pack ${r.packVersion} installed (${r.count} manifests): ${r.path}`,
1641
+ );
1642
+ console.log(
1643
+ "your own agent-detection/*.json overrides still win — the pack merges beneath them",
1644
+ );
1645
+ }
1646
+ } catch (err) {
1647
+ const message = err instanceof Error ? err.message : String(err);
1648
+ if (json) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
1649
+ else console.error(`manifest pack NOT installed: ${message}`);
1650
+ process.exitCode = 1;
1651
+ }
1652
+ break;
1653
+ }
1654
+ // `--tui-binary`: download the per-platform TUI binary (the fallback that
1655
+ // lets an npm install with no bun run the full cockpit). Explicit opt-in —
1656
+ // never auto-fetched on install (it's ~70MB). See lib/tui-binary.ts.
1657
+ if (values["tui-binary"] === true) {
1658
+ const { downloadTuiBinary } = await import("../packages/daemon/src/lib/tui-binary.ts");
1659
+ const { path } = await downloadTuiBinary({ log: (m) => console.error(m) });
1660
+ if (json) {
1661
+ console.log(JSON.stringify({ ok: true, path }, null, 2));
1662
+ } else {
1663
+ console.log(`TUI binary ready: ${path}`);
1664
+ }
1665
+ break;
1666
+ }
1341
1667
  // Detect how tmux-ide was installed and act on the pending update: a dev
1342
1668
  // checkout prints the `git pull` hint; a global install prints (with
1343
1669
  // --dry-run) or runs its package manager's update command. `__dirname` is
@@ -1381,6 +1707,34 @@ try {
1381
1707
  break;
1382
1708
  }
1383
1709
 
1710
+ case "serve": {
1711
+ // The local control socket (M23.3): NDJSON verbs + pushed agent-status
1712
+ // events over a 0600 Unix socket. Foreground on purpose — the process
1713
+ // the user (or their agent loop) started is the process they own; see
1714
+ // the host tradeoff in control/server.ts.
1715
+ const { startControlServer, defaultControlSocketPath } =
1716
+ await import("../packages/daemon/src/control/server.ts");
1717
+ const socketPath =
1718
+ typeof socketFlag === "string"
1719
+ ? socketFlag
1720
+ : (positionals[1] ?? defaultControlSocketPath());
1721
+ const server = await startControlServer({
1722
+ socketPath,
1723
+ log: (m) => console.error(`[serve] ${m}`),
1724
+ });
1725
+ let closing = false;
1726
+ const shutdown = () => {
1727
+ if (closing) return;
1728
+ closing = true;
1729
+ // Unlinks the socket and EOFs every client before exiting.
1730
+ void server.close().then(() => process.exit(0));
1731
+ };
1732
+ process.on("SIGTERM", shutdown);
1733
+ process.on("SIGINT", shutdown);
1734
+ await new Promise(() => {}); // the server owns the process lifetime
1735
+ break;
1736
+ }
1737
+
1384
1738
  case "command-center": {
1385
1739
  const { startCommandCenter } = await import("../packages/daemon/src/command-center/index.ts");
1386
1740
  await startCommandCenter({ port: parseInt(values.port ?? "4000") });