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
package/bin/cli.ts CHANGED
@@ -40,6 +40,16 @@ import { restore } from "../packages/daemon/src/restore.ts";
40
40
  import { send } from "../packages/daemon/src/send.ts";
41
41
  import { IdeError } from "../packages/daemon/src/lib/errors.ts";
42
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";
43
53
 
44
54
  const { positionals, values } = parseArgs({
45
55
  allowPositionals: true,
@@ -92,6 +102,10 @@ const { positionals, values } = parseArgs({
92
102
  // actions menu opens at the pointer instead of centered (see `menu` case)
93
103
  x: { type: "string" },
94
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" },
95
109
  // worktree: base ref for a new branch, the worktree checkout dir override,
96
110
  // skip creating a session, and force-remove a dirty worktree (see `worktree`)
97
111
  from: { type: "string" },
@@ -137,6 +151,7 @@ const knownCommands = new Set([
137
151
  "worktree",
138
152
  "update",
139
153
  "skill-sync",
154
+ "serve",
140
155
  "command-center",
141
156
  "server",
142
157
  "help",
@@ -188,12 +203,14 @@ ${bold("Usage:")}
188
203
  ${cyan("tmux-ide attach")} ${dim("Reattach to a running session")}
189
204
  ${cyan("tmux-ide team")} [--json] ${dim("TUI over all tmux sessions (--json prints fleet state)")}
190
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")}
191
207
  ${cyan("tmux-ide switcher")} ${dim("Compact session picker (opens in the M-p popup on adopted sessions)")}
192
208
  ${cyan("tmux-ide wait agent-status")} <session> --status <s> [--timeout <ms>]
193
209
  ${dim("Block until a session reaches a status (exit 0 match / 1 timeout)")}
194
210
  ${cyan("tmux-ide wait output")} <pane|session> --match <regex> [--timeout <ms>]
195
211
  ${dim("Block until a pane's output matches a regex (exit 0 match / 1 timeout)")}
196
- ${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)")}
197
214
  ${cyan("tmux-ide adopt")} <session> ${dim("Add the live tmux-ide status bar to a session")}
198
215
  ${cyan("tmux-ide adopt --all")} ${dim("Adopt every live (non-internal) session")}
199
216
  ${cyan("tmux-ide unadopt")} <session> ${dim("Remove the status bar")}
@@ -213,6 +230,7 @@ ${bold("Usage:")}
213
230
  ${cyan("tmux-ide inspect")} [--json] ${dim("Show effective config and runtime state")}
214
231
  ${cyan("tmux-ide doctor")} ${dim("Check system requirements")}
215
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)")}
216
234
  ${cyan("tmux-ide skill-sync")} ${dim("Refresh the bundled Claude Code skill in ~/.claude/skills/tmux-ide")}
217
235
  ${cyan("tmux-ide validate")} [--json] ${dim("Validate ide.yml")}
218
236
  ${cyan("tmux-ide detect")} [--json] ${dim("Detect project stack")}
@@ -306,6 +324,63 @@ function execBunWidget(
306
324
  execFileSync(launch.bin, launch.argv, { stdio: "inherit", env });
307
325
  }
308
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
+
309
384
  // The scriptable control surface for the cockpit: print the fleet state as JSON
310
385
  // and exit without spawning the (bun/OpenTUI) TUI. Shared by `tmux-ide team
311
386
  // --json` and bare `tmux-ide --json` when there's no ide.yml to launch. Dynamic
@@ -317,6 +392,41 @@ async function printFleetJson(): Promise<void> {
317
392
  console.log(JSON.stringify(toFleetJson(listTeamProjects(createStatusTracker())), null, 2));
318
393
  }
319
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
+
320
430
  const teamScriptPath = resolve(__dirname, "../packages/daemon/src/tui/team/index.tsx");
321
431
  const appScriptPath = resolve(__dirname, "../packages/daemon/src/tui/mirror/app.tsx");
322
432
 
@@ -327,11 +437,27 @@ function launchTeamCockpit(): void {
327
437
  execBunWidget("team", teamScriptPath, [], "team");
328
438
  }
329
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
+
330
455
  // The unified app as the front door (M22.6): bare `tmux-ide` opens `tmux-ide
331
456
  // app`'s HOME panel when `app.frontDoor` is on and there's nothing else to
332
- // launch. Same entry as the explicit `app` command with no session positional.
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).
333
459
  function launchApp(): void {
334
- execBunWidget("app", appScriptPath, [], "app");
460
+ runApp([]);
335
461
  }
336
462
 
337
463
  try {
@@ -528,10 +654,14 @@ try {
528
654
  case "app": {
529
655
  // The unified app (M18.1): sidebar fleet + a live tmux-session mirror.
530
656
  // Bare `tmux-ide app` opens the HOME panel (fleet cards); an optional
531
- // session positional boots straight into that session's mirror.
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.
532
662
  const session = positionals[1];
533
663
  const appArgs = session ? [`--target=${session}`] : [];
534
- execBunWidget("app", appScriptPath, appArgs, "app");
664
+ runApp(appArgs);
535
665
  break;
536
666
  }
537
667
 
@@ -557,7 +687,7 @@ try {
557
687
  const pattern = values.match;
558
688
  if (!target || typeof pattern !== "string" || pattern.length === 0) {
559
689
  console.error(
560
- "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]]",
561
691
  );
562
692
  process.exit(1);
563
693
  }
@@ -567,46 +697,42 @@ try {
567
697
  console.error(`Invalid --match regex: ${(err as Error).message}`);
568
698
  process.exit(1);
569
699
  }
570
- const { capturePane } = await import("../packages/tmux-bridge/src/index.ts");
571
700
  const outTimeout = Number(values.timeout ?? "60000");
572
- const outStart = Date.now();
573
- const nap = (ms: number) => new Promise((r) => setTimeout(r, ms));
574
- while (true) {
575
- let text = "";
576
- try {
577
- text = capturePane(target!, { lines: 200 });
578
- } catch {
579
- // pane/session not (yet) available — keep polling until timeout
580
- }
581
- const lines = text.split("\n");
582
- // Fresh regex per test so a user-supplied /g flag can't carry lastIndex
583
- // between calls. Report the specific matching line when we can.
584
- let hit: string | null = null;
585
- for (const line of lines) {
586
- if (new RegExp(pattern!).test(line)) {
587
- hit = line;
588
- break;
589
- }
590
- }
591
- if (hit === null && new RegExp(pattern!).test(text)) hit = lines[lines.length - 1] ?? "";
592
- if (hit !== null) {
593
- if (json) console.log(JSON.stringify({ matched: hit }));
594
- else console.log(hit);
595
- process.exit(0);
596
- }
597
- 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) {
598
713
  console.error(
599
714
  `Timed out after ${outTimeout}ms waiting for ${target} output to match /${pattern}/`,
600
715
  );
601
716
  process.exit(1);
602
717
  }
603
- 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);
604
722
  }
605
- }
606
723
 
607
- const { createStatusTracker } = await import("../packages/daemon/src/tui/detect/classify.ts");
608
- const { listTeamSessions } = await import("../packages/daemon/src/tui/team/sessions.ts");
609
- 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
+ }
610
736
 
611
737
  const VALID = new Set(["blocked", "working", "done", "idle", "unknown"]);
612
738
  const sessionName = positionals[2];
@@ -614,37 +740,50 @@ try {
614
740
 
615
741
  if (sub !== "agent-status" || !sessionName || typeof want !== "string" || !VALID.has(want)) {
616
742
  console.error(
617
- "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]]",
618
744
  );
619
745
  process.exit(1);
620
746
  }
621
747
 
622
748
  const timeout = Number(values.timeout ?? "60000");
623
- const started = Date.now();
624
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
625
- // One tracker persists across polls so the working→idle `done` transition
626
- // can be observed (it's inherently cross-tick).
627
- const tracker = createStatusTracker();
628
-
629
- while (true) {
630
- const sessions = listTeamSessions(tracker);
631
- const status = findSessionStatus(sessions, sessionName!);
632
- if (status === want) {
633
- if (json) {
634
- console.log(JSON.stringify({ session: sessionName, status, ok: true }));
635
- } else {
636
- console.log(`${sessionName} reached status: ${status}`);
637
- }
638
- process.exit(0);
639
- }
640
- 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) {
641
758
  console.error(
642
- `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}"`,
643
760
  );
644
761
  process.exit(1);
645
762
  }
646
- 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);
647
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;
648
787
  }
649
788
 
650
789
  case "events": {
@@ -657,10 +796,6 @@ try {
657
796
  type EventLike = { ts: string; session: string; from: Status | null; to: Status };
658
797
 
659
798
  const path = eventsPath();
660
- if (!existsSync(path)) {
661
- console.log("no events yet — is a session adopted? (the chrome updater writes events)");
662
- break;
663
- }
664
799
 
665
800
  // Status → ANSI color, matching the chrome bar's palette in spirit.
666
801
  const paintStatus = (status: Status | null, text: string): string => {
@@ -690,6 +825,40 @@ try {
690
825
  }
691
826
  };
692
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
+
693
862
  const allLines = readFileSync(path, "utf8")
694
863
  .split("\n")
695
864
  .filter((l) => l.trim().length > 0);
@@ -813,18 +982,36 @@ try {
813
982
  case "integration": {
814
983
  const sub = positionals[1];
815
984
  const agent = positionals[2];
816
- // `status` and `offer` need no agent arg; install/uninstall are claude-only.
817
- const needsClaude = sub === "install" || sub === "uninstall";
818
- 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)) {
819
990
  console.error(
820
- "Usage: tmux-ide integration <install|uninstall|status|offer> [claude]\n" +
821
- " install hook Claude Code lifecycle events into tmux pane state\n" +
822
- " uninstall remove exactly the tmux-ide hook entries\n" +
823
- " 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" +
824
996
  " offer one-time first-adopt install prompt (used by the popup)",
825
997
  );
826
998
  process.exit(1);
827
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
+ }
828
1015
  const mod = await import("../packages/daemon/src/tui/integrations/claude.ts");
829
1016
  if (sub === "install") {
830
1017
  const { scriptPath, settingsPath } = mod.installClaudeIntegration();
@@ -840,6 +1027,51 @@ try {
840
1027
  "installed — NEW Claude Code sessions now report working/blocked/done " +
841
1028
  "authoritatively into the tmux-ide chrome.",
842
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
+ }
843
1075
  } else if (sub === "uninstall") {
844
1076
  const { wasInstalled } = mod.uninstallClaudeIntegration();
845
1077
  console.log(wasInstalled ? "uninstalled — hook entries removed" : "was not installed");
@@ -905,7 +1137,18 @@ try {
905
1137
  else if (a.integration)
906
1138
  state = a.installed ? "integration installed ✓" : "on PATH — integration not installed";
907
1139
  else state = "detected (no integration)";
908
- 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}`);
909
1152
  }
910
1153
  }
911
1154
  break;
@@ -1382,6 +1625,32 @@ try {
1382
1625
  }
1383
1626
 
1384
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
+ }
1385
1654
  // `--tui-binary`: download the per-platform TUI binary (the fallback that
1386
1655
  // lets an npm install with no bun run the full cockpit). Explicit opt-in —
1387
1656
  // never auto-fetched on install (it's ~70MB). See lib/tui-binary.ts.
@@ -1438,6 +1707,34 @@ try {
1438
1707
  break;
1439
1708
  }
1440
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
+
1441
1738
  case "command-center": {
1442
1739
  const { startCommandCenter } = await import("../packages/daemon/src/command-center/index.ts");
1443
1740
  await startCommandCenter({ port: parseInt(values.port ?? "4000") });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Turn any project into a tmux-powered terminal IDE with a simple ide.yml",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "scripts": {
24
24
  "build": "pnpm build:cli",
25
25
  "build:cli": "node scripts/build-cli.mjs",
26
+ "build:macos-notifier": "node scripts/build-macos-notifier.mjs",
26
27
  "build:tui": "bun scripts/build-tui.mjs",
27
28
  "prepublishOnly": "pnpm build:cli && pnpm check && node scripts/prepublish-check.mjs",
28
29
  "typecheck": "echo \"root typecheck deferred to per-package turbo run\"",