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.
- package/README.md +22 -5
- package/bin/cli.js +3532 -1090
- package/bin/cli.ts +368 -71
- package/package.json +2 -1
- 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/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/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/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 +103 -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/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 +582 -84
- package/packages/daemon/src/tui/chrome/updater.ts +268 -62
- package/packages/daemon/src/tui/detect/classify.ts +34 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
- package/packages/daemon/src/tui/detect/manifest.ts +24 -3
- package/packages/daemon/src/tui/detect/manifests.ts +240 -6
- package/packages/daemon/src/tui/detect/process-tree.ts +13 -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/mirror/agent-chip.ts +40 -11
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
- package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
- package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
- package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
- package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
- 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/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
- package/packages/daemon/src/tui/mirror/palette.ts +299 -9
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
- package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
- package/packages/daemon/src/tui/mirror/selection.ts +122 -8
- package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
- package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/sessions.ts +85 -7
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -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 +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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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}"
|
|
759
|
+
`Timed out after ${timeout}ms waiting for ${sessionName} to reach status "${want}"`,
|
|
643
760
|
);
|
|
644
761
|
process.exit(1);
|
|
645
762
|
}
|
|
646
|
-
|
|
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
|
|
817
|
-
|
|
818
|
-
|
|
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
|
|
822
|
-
"
|
|
823
|
-
"
|
|
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
|
-
|
|
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.
|
|
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\"",
|