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
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Request dispatch for the control socket — PURE given its handler map.
3
+ *
4
+ * Takes one raw frame line, parses + validates the versioned envelope,
5
+ * routes to the verb's handler, and shapes the response envelope — including
6
+ * every failure mode (unparseable JSON, bad envelope, unknown verb, invalid
7
+ * params, handler error). Handlers are injected, so this whole layer unit-
8
+ * tests without a socket or a tmux server.
9
+ *
10
+ * Error codes on the wire:
11
+ * bad-request unparseable frame / envelope / params (message says which)
12
+ * unknown-verb the verb isn't in this server's handler map
13
+ * not-found the named pane/session/target doesn't exist
14
+ * timeout a `wait` ran out of time
15
+ * internal the handler threw something unexpected
16
+ */
17
+ import { CONTROL_PROTOCOL_VERSION, controlRequestSchema, } from "@tmux-ide/contracts";
18
+ import { IdeError } from "../lib/errors.js";
19
+ /** Thrown by handlers to reach the wire with a specific code. */
20
+ export class ControlVerbError extends Error {
21
+ code;
22
+ constructor(code, message) {
23
+ super(message);
24
+ this.code = code;
25
+ }
26
+ }
27
+ const ok = (id, data) => ({
28
+ v: CONTROL_PROTOCOL_VERSION,
29
+ id,
30
+ ok: true,
31
+ data,
32
+ });
33
+ const fail = (id, code, message) => ({
34
+ v: CONTROL_PROTOCOL_VERSION,
35
+ id,
36
+ ok: false,
37
+ error: { code, message },
38
+ });
39
+ /** Best-effort id recovery from a frame that failed envelope validation. */
40
+ function extractId(value) {
41
+ if (typeof value === "object" && value !== null && "id" in value) {
42
+ const id = value.id;
43
+ if (typeof id === "string" || typeof id === "number")
44
+ return id;
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Dispatch one raw line to `handlers`. ALWAYS resolves to a response frame —
50
+ * a protocol error is an answer, never a dropped request or a thrown error
51
+ * (only the transport decides to drop connections).
52
+ */
53
+ export async function dispatchLine(line, handlers, ctx) {
54
+ let raw;
55
+ try {
56
+ raw = JSON.parse(line);
57
+ }
58
+ catch {
59
+ return fail(null, "bad-request", "frame is not valid JSON");
60
+ }
61
+ const parsed = controlRequestSchema.safeParse(raw);
62
+ if (!parsed.success) {
63
+ return fail(extractId(raw), "bad-request", `invalid request envelope (need {v:${CONTROL_PROTOCOL_VERSION}, id, verb})`);
64
+ }
65
+ const { id, verb, params } = parsed.data;
66
+ const handler = handlers[verb];
67
+ if (!handler) {
68
+ return fail(id, "unknown-verb", `unknown verb "${verb}"`);
69
+ }
70
+ try {
71
+ return ok(id, await handler(params ?? {}, ctx));
72
+ }
73
+ catch (err) {
74
+ if (err instanceof ControlVerbError)
75
+ return fail(id, err.code, err.message);
76
+ if (err instanceof IdeError) {
77
+ // Data-layer errors carry honest codes already (SESSION_NOT_FOUND, …).
78
+ const code = err.code === "USAGE" ? "bad-request" : "not-found";
79
+ return fail(id, code, err.message);
80
+ }
81
+ return fail(id, "internal", err?.message ?? "internal error");
82
+ }
83
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Subscriber fan-out bookkeeping for the control server — PURE.
3
+ *
4
+ * Tracks the set of live subscribers and delivers each emitted event to all
5
+ * of them. The `onFirst`/`onLast` edges let the server run its detection
6
+ * tick ONLY while someone is listening (0→1 starts it, 1→0 stops it), so an
7
+ * idle `tmux-ide serve` costs nothing between requests.
8
+ */
9
+ export interface Fanout<T> {
10
+ /** Register a sink. Returns its unsubscribe (idempotent). */
11
+ add(sink: (event: T) => void): () => void;
12
+ /** Deliver `event` to every sink. A throwing sink is dropped, not fatal. */
13
+ emit(event: T): void;
14
+ size(): number;
15
+ }
16
+ export declare function createFanout<T>(edges?: {
17
+ onFirst?: () => void;
18
+ onLast?: () => void;
19
+ }): Fanout<T>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Subscriber fan-out bookkeeping for the control server — PURE.
3
+ *
4
+ * Tracks the set of live subscribers and delivers each emitted event to all
5
+ * of them. The `onFirst`/`onLast` edges let the server run its detection
6
+ * tick ONLY while someone is listening (0→1 starts it, 1→0 stops it), so an
7
+ * idle `tmux-ide serve` costs nothing between requests.
8
+ */
9
+ export function createFanout(edges = {}) {
10
+ const sinks = new Set();
11
+ const remove = (sink) => {
12
+ if (!sinks.delete(sink))
13
+ return;
14
+ if (sinks.size === 0)
15
+ edges.onLast?.();
16
+ };
17
+ return {
18
+ add(sink) {
19
+ sinks.add(sink);
20
+ if (sinks.size === 1)
21
+ edges.onFirst?.();
22
+ return () => remove(sink);
23
+ },
24
+ emit(event) {
25
+ for (const sink of [...sinks]) {
26
+ try {
27
+ sink(event);
28
+ }
29
+ catch {
30
+ // A sink that throws (a torn-down connection) removes itself.
31
+ remove(sink);
32
+ }
33
+ }
34
+ },
35
+ size: () => sinks.size,
36
+ };
37
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * NDJSON framing for the control socket — PURE.
3
+ *
4
+ * One frame = one JSON object on one `\n`-terminated line. TCP-style streams
5
+ * deliver arbitrary chunk boundaries, so the splitter buffers a partial
6
+ * trailing line across feeds and hands back only COMPLETE lines. Encoding is
7
+ * the trivial inverse; it lives here so every writer produces identical
8
+ * frames (a `JSON.stringify` can never contain a raw newline, so one write
9
+ * call per frame is atomic on the wire).
10
+ */
11
+ /** Refuse to buffer a partial line beyond this — a client streaming an
12
+ * unterminated megabyte is broken or hostile, not slow. */
13
+ export declare const MAX_FRAME_BYTES: number;
14
+ /** Serialize one frame: the JSON line plus its terminator. */
15
+ export declare function encodeFrame(message: unknown): string;
16
+ /**
17
+ * A stateful chunk → complete-lines splitter. Feed it raw socket data; it
18
+ * returns every COMPLETE line received so far (blank lines are dropped) and
19
+ * keeps the trailing partial line buffered for the next feed. Throws when the
20
+ * partial line outgrows {@link MAX_FRAME_BYTES} — the caller should drop the
21
+ * connection.
22
+ */
23
+ export declare function createFrameSplitter(): (chunk: string) => string[];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * NDJSON framing for the control socket — PURE.
3
+ *
4
+ * One frame = one JSON object on one `\n`-terminated line. TCP-style streams
5
+ * deliver arbitrary chunk boundaries, so the splitter buffers a partial
6
+ * trailing line across feeds and hands back only COMPLETE lines. Encoding is
7
+ * the trivial inverse; it lives here so every writer produces identical
8
+ * frames (a `JSON.stringify` can never contain a raw newline, so one write
9
+ * call per frame is atomic on the wire).
10
+ */
11
+ /** Refuse to buffer a partial line beyond this — a client streaming an
12
+ * unterminated megabyte is broken or hostile, not slow. */
13
+ export const MAX_FRAME_BYTES = 4 * 1024 * 1024;
14
+ /** Serialize one frame: the JSON line plus its terminator. */
15
+ export function encodeFrame(message) {
16
+ return `${JSON.stringify(message)}\n`;
17
+ }
18
+ /**
19
+ * A stateful chunk → complete-lines splitter. Feed it raw socket data; it
20
+ * returns every COMPLETE line received so far (blank lines are dropped) and
21
+ * keeps the trailing partial line buffered for the next feed. Throws when the
22
+ * partial line outgrows {@link MAX_FRAME_BYTES} — the caller should drop the
23
+ * connection.
24
+ */
25
+ export function createFrameSplitter() {
26
+ let buffer = "";
27
+ return (chunk) => {
28
+ buffer += chunk;
29
+ const parts = buffer.split("\n");
30
+ buffer = parts.pop() ?? "";
31
+ if (buffer.length > MAX_FRAME_BYTES) {
32
+ buffer = "";
33
+ throw new Error(`frame exceeds ${MAX_FRAME_BYTES} bytes without a newline`);
34
+ }
35
+ return parts.filter((line) => line.trim().length > 0);
36
+ };
37
+ }
@@ -0,0 +1,45 @@
1
+ import { type SpawnPlacement } from "../tui/mirror/agent-lifecycle.ts";
2
+ /** Resolve `kind`/`command` params to the command that actually launches. */
3
+ export declare function resolveLaunchCommand(params: {
4
+ kind?: string;
5
+ command?: string;
6
+ }): string;
7
+ export interface SpawnOutcome {
8
+ paneId: string;
9
+ session: string;
10
+ command: string;
11
+ placement: SpawnPlacement | "new-session";
12
+ }
13
+ /**
14
+ * Spawn an agent. With `session` the shared placement argv is used (window /
15
+ * split); without it a fresh detached session named `sessionName` starts in
16
+ * `dir`. `-P -F #{pane_id}` is threaded right after the tmux subcommand so
17
+ * the caller learns WHICH pane the agent got (the argv builders stay
18
+ * untouched — the app's flows don't want the print).
19
+ */
20
+ export declare function spawnAgent(params: {
21
+ command: string;
22
+ session?: string;
23
+ sessionName?: string;
24
+ dir?: string;
25
+ placement?: SpawnPlacement;
26
+ paneId?: string;
27
+ }): Promise<SpawnOutcome>;
28
+ /** Stop the agent in `paneId`: interrupt + authority cleanup. The pane (and
29
+ * its shell, if any) stays open — `kill-pane` is deliberately NOT offered
30
+ * over the socket; that is a human, confirmed-destructive verb. */
31
+ export declare function stopAgent(paneId: string): Promise<{
32
+ paneId: string;
33
+ stopped: true;
34
+ }>;
35
+ /**
36
+ * Restart the agent in `paneId` running `command`, using the app's two
37
+ * strategies: a SHELL-hosted agent is interrupted and relaunched via
38
+ * send-keys (the shell survives to type into); an agent that IS the pane's
39
+ * own process is respawned in place (ctrl-c would end the pane).
40
+ */
41
+ export declare function restartAgent(paneId: string, command: string): Promise<{
42
+ paneId: string;
43
+ command: string;
44
+ strategy: "relaunch" | "respawn";
45
+ }>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Agent lifecycle io for the control socket — spawn / restart / stop.
3
+ *
4
+ * The MODEL (kind → launch command, exact tmux argv, the shell-vs-own-process
5
+ * restart decision, interrupt timing) is the pure `tui/mirror/agent-lifecycle`
6
+ * module the unified app already runs on; this file is only the async tmux
7
+ * plumbing around it, so the socket drives the SAME lifecycle path as the app.
8
+ */
9
+ import { execFile } from "node:child_process";
10
+ import { INTERRUPT_TAP_GAP_MS, RESTART_GRACE_MS, clearAuthorityArgs, interruptArgs, launchCommandFor, paneHostsShell, relaunchArgs, respawnArgs, spawnAgentArgs, spawnSessionArgs, } from "../tui/mirror/agent-lifecycle.js";
11
+ import { getManifests } from "../tui/detect/manifest-loader.js";
12
+ import { ControlVerbError } from "./dispatch.js";
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+ /** One tmux call; resolves stdout, rejects on a tmux error. */
15
+ function tmuxRun(args) {
16
+ return new Promise((resolve, reject) => {
17
+ execFile("tmux", args, (err, stdout) => (err ? reject(err) : resolve(stdout.trimEnd())));
18
+ });
19
+ }
20
+ /** Like {@link tmuxRun} but errors are swallowed — for best-effort steps
21
+ * (a dead pane target is a normal race, the fleet shows the truth later). */
22
+ async function tmuxTry(args) {
23
+ await tmuxRun(args).catch(() => { });
24
+ }
25
+ /** Resolve `kind`/`command` params to the command that actually launches. */
26
+ export function resolveLaunchCommand(params) {
27
+ if (params.command)
28
+ return params.command;
29
+ return launchCommandFor(params.kind, getManifests());
30
+ }
31
+ /**
32
+ * Spawn an agent. With `session` the shared placement argv is used (window /
33
+ * split); without it a fresh detached session named `sessionName` starts in
34
+ * `dir`. `-P -F #{pane_id}` is threaded right after the tmux subcommand so
35
+ * the caller learns WHICH pane the agent got (the argv builders stay
36
+ * untouched — the app's flows don't want the print).
37
+ */
38
+ export async function spawnAgent(params) {
39
+ const dir = params.dir ?? null;
40
+ const argv = params.session
41
+ ? spawnAgentArgs(params.placement ?? "window", { session: params.session, paneId: params.paneId }, dir, params.command)
42
+ : spawnSessionArgs(params.sessionName, dir, params.command);
43
+ const [subcommand, ...rest] = argv;
44
+ let paneId;
45
+ try {
46
+ paneId = await tmuxRun([subcommand, "-P", "-F", "#{pane_id}", ...rest]);
47
+ }
48
+ catch (err) {
49
+ throw new ControlVerbError("not-found", `tmux refused to spawn: ${err.message}`);
50
+ }
51
+ const session = params.session ?? params.sessionName;
52
+ // Mark a fresh session as ours (mirrors the app's spawn flow).
53
+ if (!params.session)
54
+ await tmuxTry(["set-environment", "-t", session, "TMUX_IDE", "1"]);
55
+ return {
56
+ paneId,
57
+ session,
58
+ command: params.command,
59
+ placement: params.session ? (params.placement ?? "window") : "new-session",
60
+ };
61
+ }
62
+ /** The double ctrl-c (see agent-lifecycle: one taps, the quick second exits). */
63
+ async function interruptAgent(paneId) {
64
+ await tmuxTry(interruptArgs(paneId));
65
+ await sleep(INTERRUPT_TAP_GAP_MS);
66
+ await tmuxTry(interruptArgs(paneId));
67
+ }
68
+ /** Out-of-band stop hygiene: no hook fires, so unset the authority stamps. */
69
+ async function clearAgentAuthority(paneId) {
70
+ for (const args of clearAuthorityArgs(paneId))
71
+ await tmuxTry(args);
72
+ }
73
+ /** The pane's root command + cwd, or null when the pane is gone. */
74
+ function paneStartAndPath(paneId) {
75
+ return tmuxRun(["display", "-p", "-t", paneId, "#{pane_start_command}\t#{pane_current_path}"])
76
+ .then((out) => {
77
+ const [start = "", path = ""] = out.split("\t");
78
+ return { start, path };
79
+ })
80
+ .catch(() => null);
81
+ }
82
+ /** Stop the agent in `paneId`: interrupt + authority cleanup. The pane (and
83
+ * its shell, if any) stays open — `kill-pane` is deliberately NOT offered
84
+ * over the socket; that is a human, confirmed-destructive verb. */
85
+ export async function stopAgent(paneId) {
86
+ const live = await paneStartAndPath(paneId);
87
+ if (!live)
88
+ throw new ControlVerbError("not-found", `no pane "${paneId}"`);
89
+ await interruptAgent(paneId);
90
+ await clearAgentAuthority(paneId);
91
+ return { paneId, stopped: true };
92
+ }
93
+ /**
94
+ * Restart the agent in `paneId` running `command`, using the app's two
95
+ * strategies: a SHELL-hosted agent is interrupted and relaunched via
96
+ * send-keys (the shell survives to type into); an agent that IS the pane's
97
+ * own process is respawned in place (ctrl-c would end the pane).
98
+ */
99
+ export async function restartAgent(paneId, command) {
100
+ const live = await paneStartAndPath(paneId);
101
+ if (!live)
102
+ throw new ControlVerbError("not-found", `no pane "${paneId}"`);
103
+ if (paneHostsShell(live.start, getManifests())) {
104
+ await interruptAgent(paneId);
105
+ await clearAgentAuthority(paneId);
106
+ await sleep(RESTART_GRACE_MS);
107
+ for (const args of relaunchArgs(paneId, command))
108
+ await tmuxTry(args);
109
+ return { paneId, command, strategy: "relaunch" };
110
+ }
111
+ await clearAgentAuthority(paneId);
112
+ await tmuxTry(respawnArgs(paneId, command, live.path || null));
113
+ return { paneId, command, strategy: "respawn" };
114
+ }
@@ -0,0 +1,16 @@
1
+ /** The default socket path — under the state home so `TMUX_IDE_HOME` scopes
2
+ * tests away from the real user socket. */
3
+ export declare function defaultControlSocketPath(): string;
4
+ export interface ControlServerOptions {
5
+ socketPath?: string;
6
+ /** Diagnostics sink (the CLI passes stderr). Default: silent. */
7
+ log?: (message: string) => void;
8
+ /** Event-tick cadence override (tests); defaults to the updater's TICK_MS. */
9
+ tickMs?: number;
10
+ }
11
+ export interface ControlServer {
12
+ socketPath: string;
13
+ close(): Promise<void>;
14
+ }
15
+ /** Start the server. Resolves once the socket is listening (mode 0600). */
16
+ export declare function startControlServer(opts?: ControlServerOptions): Promise<ControlServer>;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The control-socket server — `tmux-ide serve` (M23.3).
3
+ *
4
+ * NDJSON frames over a local Unix socket (default `~/.tmux-ide/control.sock`,
5
+ * mode 0600). Agent loops connect once and drive the fleet — request/response
6
+ * verbs plus PUSHED agent-status events after `subscribe` — instead of
7
+ * spawning a CLI process per call. Protocol schemas live in
8
+ * `@tmux-ide/contracts` (control.ts); verb handlers in `./verbs.ts` call the
9
+ * SAME data layer the CLI cases do.
10
+ *
11
+ * HOST DECISION (the tradeoff, considered before building):
12
+ * (a) `tmux-ide serve` — an EXPLICIT foreground process. Costs the user a
13
+ * deliberate start, dies with them, trivially restartable after a code
14
+ * change, and its lifetime states its purpose.
15
+ * (b) piggyback on the `_tmux-ide-chrome` updater tick — always running for
16
+ * adopted fleets, but chrome-lifecycle-coupled: unadopting the last
17
+ * session would kill the API mid-conversation, `adopt` would grow a
18
+ * network-ish responsibility, and the updater's contract ("never let a
19
+ * bad tick break the bars") is the wrong place for a protocol surface.
20
+ * (c) socket-activated per-connection handlers — zero resident cost, but
21
+ * every connection pays a full node boot (the latency the socket exists
22
+ * to remove) and no resident process means no push events and no
23
+ * persistent status tracker (the cross-tick `done` needs history).
24
+ * CHOSEN: (a), deliberately WITHOUT auto-start. The old command-center HTTP
25
+ * server is the cautionary tale: an implicitly-running daemon accretes
26
+ * scope. This surface stays minimal — session-control verbs only, no feed,
27
+ * no chat, no network listener — and the CLI only uses the socket
28
+ * OPPORTUNISTICALLY (`--socket` fast-paths fall back to polling when no
29
+ * server is up). An agent that wants the socket runs `tmux-ide serve`
30
+ * itself; the process it spawned is the process it owns.
31
+ *
32
+ * SECURITY: local user only. The socket is chmod 0600, there is no network
33
+ * transport, no tokens (filesystem permissions ARE the auth). The #90 bridge
34
+ * that exposes this to a native app layers a WS server ON TOP later — each
35
+ * NDJSON frame maps 1:1 to a WS text message, so that bridge is mechanical
36
+ * and this file never grows remote scope.
37
+ *
38
+ * EVENTS: while at least one connection is subscribed, a detection tick
39
+ * (same cadence and diff as the chrome updater, one persistent tracker)
40
+ * computes the fleet and pushes session-level status transitions. No
41
+ * subscribers → no tick → an idle server does nothing. The tick does NOT
42
+ * write events.jsonl — the chrome updater owns the log; this stream is
43
+ * transport, not history.
44
+ */
45
+ import { chmodSync, existsSync, mkdirSync, statSync, unlinkSync } from "node:fs";
46
+ import { createServer, connect } from "node:net";
47
+ import { dirname, join } from "node:path";
48
+ import { CONTROL_PROTOCOL_VERSION } from "@tmux-ide/contracts";
49
+ import { IdeError } from "../lib/errors.js";
50
+ import { tuiStateHome } from "../lib/tui-binary.js";
51
+ import { createStatusTracker } from "../tui/detect/classify.js";
52
+ import { diffFleet } from "../tui/chrome/events.js";
53
+ import { fleetStatuses, TICK_MS } from "../tui/chrome/updater.js";
54
+ import { listTeamProjects } from "../tui/team/projects.js";
55
+ import { dispatchLine } from "./dispatch.js";
56
+ import { createFanout } from "./fanout.js";
57
+ import { createFrameSplitter, encodeFrame } from "./frames.js";
58
+ import { createVerbHandlers } from "./verbs.js";
59
+ /** The default socket path — under the state home so `TMUX_IDE_HOME` scopes
60
+ * tests away from the real user socket. */
61
+ export function defaultControlSocketPath() {
62
+ return join(tuiStateHome(), "control.sock");
63
+ }
64
+ /**
65
+ * Claim `path`: refuse anything that exists and is not a socket (NEVER
66
+ * unlink a foreign file), refuse a socket another live server answers on,
67
+ * and unlink a stale socket left by a dead server.
68
+ */
69
+ async function claimSocketPath(path) {
70
+ if (!existsSync(path))
71
+ return;
72
+ if (!statSync(path).isSocket()) {
73
+ throw new IdeError(`${path} exists and is not a socket — refusing to remove it. ` +
74
+ `Pass a different --socket path.`, { code: "USAGE", exitCode: 1 });
75
+ }
76
+ const alive = await new Promise((resolve) => {
77
+ const probe = connect(path);
78
+ const done = (result) => {
79
+ probe.destroy();
80
+ resolve(result);
81
+ };
82
+ probe.once("connect", () => done(true));
83
+ probe.once("error", () => done(false));
84
+ probe.setTimeout(500, () => done(false));
85
+ });
86
+ if (alive) {
87
+ throw new IdeError(`another server is already listening on ${path}`, {
88
+ code: "USAGE",
89
+ exitCode: 1,
90
+ });
91
+ }
92
+ unlinkSync(path); // stale socket from a dead server — safe to rebind
93
+ }
94
+ /** Start the server. Resolves once the socket is listening (mode 0600). */
95
+ export async function startControlServer(opts = {}) {
96
+ const socketPath = opts.socketPath ?? defaultControlSocketPath();
97
+ const log = opts.log ?? (() => { });
98
+ const tickMs = opts.tickMs ?? TICK_MS;
99
+ mkdirSync(dirname(socketPath), { recursive: true });
100
+ await claimSocketPath(socketPath);
101
+ // ONE tracker for the server's lifetime, shared by the verbs and the event
102
+ // tick — cross-tick `done` (working→idle) is only observable with history.
103
+ const tracker = createStatusTracker();
104
+ const handlers = createVerbHandlers({ tracker });
105
+ // The event tick: runs ONLY while subscribers exist (fanout edges), diffs
106
+ // the fleet exactly like the chrome updater, pushes transitions.
107
+ const prevState = new Map();
108
+ let timer = null;
109
+ const tick = () => {
110
+ try {
111
+ const { events, state } = diffFleet(prevState, fleetStatuses(listTeamProjects(tracker)));
112
+ prevState.clear();
113
+ for (const [name, status] of state)
114
+ prevState.set(name, status);
115
+ const ts = new Date().toISOString();
116
+ for (const ev of events)
117
+ fanout.emit({ ts, ...ev });
118
+ }
119
+ catch (err) {
120
+ log(`event tick failed: ${err.message}`);
121
+ }
122
+ };
123
+ const fanout = createFanout({
124
+ onFirst: () => {
125
+ tick(); // seed immediately — the first events carry the current fleet (from: null)
126
+ timer = setInterval(tick, tickMs);
127
+ },
128
+ onLast: () => {
129
+ if (timer)
130
+ clearInterval(timer);
131
+ timer = null;
132
+ prevState.clear();
133
+ },
134
+ });
135
+ const connections = new Set();
136
+ const server = createServer((conn) => {
137
+ connections.add(conn);
138
+ conn.setEncoding("utf8");
139
+ const split = createFrameSplitter();
140
+ let unsubscribe = null;
141
+ const push = (ev) => {
142
+ // One write per frame — JSON.stringify never contains a raw newline,
143
+ // so frames from concurrent requests can never interleave mid-message.
144
+ conn.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, event: "agent-status", data: ev }));
145
+ };
146
+ const ctx = {
147
+ subscribe: () => {
148
+ unsubscribe ??= fanout.add(push);
149
+ },
150
+ };
151
+ conn.on("data", (chunk) => {
152
+ let lines;
153
+ try {
154
+ lines = split(chunk);
155
+ }
156
+ catch {
157
+ conn.destroy(); // frame overflow — a broken client, not a slow one
158
+ return;
159
+ }
160
+ for (const line of lines) {
161
+ // Handled CONCURRENTLY: a long `wait` must not block this
162
+ // connection's other requests (responses correlate by id).
163
+ void dispatchLine(line, handlers, ctx).then((response) => {
164
+ if (!conn.destroyed)
165
+ conn.write(encodeFrame(response));
166
+ });
167
+ }
168
+ });
169
+ conn.on("close", () => {
170
+ unsubscribe?.();
171
+ connections.delete(conn);
172
+ });
173
+ conn.on("error", () => {
174
+ // close follows; nothing to do — never let a client error kill serve
175
+ });
176
+ });
177
+ await new Promise((resolve, reject) => {
178
+ server.once("error", (err) => {
179
+ // Unix sockets cap the path at ~104 bytes (macOS sun_path) — surfaced
180
+ // as a bare EINVAL. Say what actually went wrong and how to fix it.
181
+ if ((err.code === "EINVAL" || err.code === "ENAMETOOLONG") && socketPath.length > 100) {
182
+ reject(new IdeError(`socket path is too long for a Unix socket (${socketPath.length} chars; the OS caps it around 104): ${socketPath}\n` +
183
+ `Pass a shorter path: tmux-ide serve --socket /tmp/tmux-ide-control.sock`, { code: "USAGE", exitCode: 1 }));
184
+ return;
185
+ }
186
+ reject(err);
187
+ });
188
+ server.listen(socketPath, () => {
189
+ server.removeAllListeners("error");
190
+ resolve();
191
+ });
192
+ });
193
+ chmodSync(socketPath, 0o600);
194
+ log(`listening on ${socketPath}`);
195
+ return {
196
+ socketPath,
197
+ close: () => new Promise((resolve) => {
198
+ if (timer)
199
+ clearInterval(timer);
200
+ timer = null;
201
+ for (const conn of connections)
202
+ conn.destroy(); // clients see EOF
203
+ server.close(() => {
204
+ try {
205
+ unlinkSync(socketPath);
206
+ }
207
+ catch {
208
+ // already gone
209
+ }
210
+ resolve();
211
+ });
212
+ }),
213
+ };
214
+ }
@@ -0,0 +1,11 @@
1
+ import type { StatusTracker } from "../tui/detect/classify.ts";
2
+ import { type VerbHandler } from "./dispatch.ts";
3
+ /**
4
+ * Build the handler map. `tracker` is the server's ONE persistent status
5
+ * tracker (shared with the event tick) so `fleet`/`agents` see the
6
+ * cross-tick `done` transition exactly like the chrome updater does —
7
+ * a fresh tracker per call could never observe working→idle.
8
+ */
9
+ export declare function createVerbHandlers(ctx: {
10
+ tracker: StatusTracker;
11
+ }): Record<string, VerbHandler>;