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
@@ -64,21 +64,16 @@ function prepareMessage(message, busyStatus) {
64
64
  }
65
65
  return message;
66
66
  }
67
- export async function send(targetDir, opts) {
68
- const dir = resolve(targetDir ?? ".");
69
- const { name: session } = getSessionName(dir);
70
- const { json, to: target, message: rawMessage, noEnter } = opts;
71
- if (!target) {
72
- throw new IdeError("Missing target. Usage: tmux-ide send <target> <message>", {
73
- code: "USAGE",
74
- });
75
- }
76
- if (!rawMessage) {
77
- throw new IdeError("Missing message. Usage: tmux-ide send <target> <message>", {
78
- code: "USAGE",
79
- });
80
- }
81
- // Verify session is running
67
+ /**
68
+ * Deliver `message` to a pane of `session` — the SHARED core behind the
69
+ * `tmux-ide send` CLI case and the control socket's `send` verb. Resolves
70
+ * `target` (pane id / @ide_name / title / role / partial title), adapts to
71
+ * the pane's busy status, and routes long messages through a dispatch file
72
+ * under `dir` when one is given (without a `dir` the text is sent directly).
73
+ * Throws `IdeError` (SESSION_NOT_FOUND / PANE_NOT_FOUND) — no printing here.
74
+ */
75
+ export function deliverMessage(opts) {
76
+ const { session, target, noEnter, dir } = opts;
82
77
  const state = getSessionState(session);
83
78
  if (!state.running) {
84
79
  throw new IdeError(`Session "${session}" is not running`, {
@@ -99,13 +94,13 @@ export async function send(targetDir, opts) {
99
94
  });
100
95
  }
101
96
  const busyStatus = getPaneBusyStatus(session, pane.id);
102
- const message = prepareMessage(rawMessage, busyStatus);
97
+ const message = prepareMessage(opts.message, busyStatus);
103
98
  let sentViaFile = false;
104
99
  if (noEnter) {
105
100
  sendText(session, pane.id, message);
106
101
  }
107
102
  else {
108
- const dispatch = writeDispatchFile(dir, pane.id, message);
103
+ const dispatch = dir ? writeDispatchFile(dir, pane.id, message) : null;
109
104
  if (dispatch) {
110
105
  sendCommand(session, pane.id, dispatch.triggerCmd);
111
106
  sentViaFile = true;
@@ -114,7 +109,7 @@ export async function send(targetDir, opts) {
114
109
  sendCommand(session, pane.id, message);
115
110
  }
116
111
  }
117
- const result = {
112
+ return {
118
113
  ok: true,
119
114
  session,
120
115
  target: {
@@ -128,13 +123,31 @@ export async function send(targetDir, opts) {
128
123
  sentViaFile,
129
124
  ...(busyStatus === "agent" ? { warning: "agent_busy" } : {}),
130
125
  };
126
+ }
127
+ export async function send(targetDir, opts) {
128
+ const dir = resolve(targetDir ?? ".");
129
+ const { name: session } = getSessionName(dir);
130
+ const { json, to: target, message: rawMessage, noEnter } = opts;
131
+ if (!target) {
132
+ throw new IdeError("Missing target. Usage: tmux-ide send <target> <message>", {
133
+ code: "USAGE",
134
+ });
135
+ }
136
+ if (!rawMessage) {
137
+ throw new IdeError("Missing message. Usage: tmux-ide send <target> <message>", {
138
+ code: "USAGE",
139
+ });
140
+ }
141
+ const result = deliverMessage({ session, target, message: rawMessage, noEnter, dir });
142
+ const { message, busyStatus } = result;
143
+ const pane = result.target;
131
144
  if (json) {
132
145
  console.log(JSON.stringify(result, null, 2));
133
146
  return;
134
147
  }
135
148
  const label = pane.name ?? pane.title;
136
149
  const preview = message.length > 60 ? message.slice(0, 60) + "..." : message;
137
- console.log(`Sent to "${label}" (${pane.id}): ${preview}`);
150
+ console.log(`Sent to "${label}" (${pane.paneId}): ${preview}`);
138
151
  if (busyStatus === "agent") {
139
152
  console.log("Warning: agent appears busy. Message sent anyway.");
140
153
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * A minimal control-socket client — what the CLI's `--socket` fast-paths
3
+ * (`events --follow --socket`, `wait … --socket`) ride on. Connect, send
4
+ * id-correlated requests, optionally receive pushed event frames. Kept
5
+ * dependency-light on purpose: this is also the reference for "how would an
6
+ * agent drive the socket from node" (see skill/SKILL.md).
7
+ */
8
+ import { connect, type Socket } from "node:net";
9
+ import {
10
+ CONTROL_PROTOCOL_VERSION,
11
+ controlEventSchema,
12
+ controlResponseSchema,
13
+ type ControlEventFrame,
14
+ } from "@tmux-ide/contracts";
15
+ import { createFrameSplitter, encodeFrame } from "./frames.ts";
16
+ import { defaultControlSocketPath } from "./server.ts";
17
+
18
+ /** A failed verb, carrying the server's machine-readable error code. */
19
+ export class ControlRequestError extends Error {
20
+ readonly code: string;
21
+ constructor(code: string, message: string) {
22
+ super(message);
23
+ this.code = code;
24
+ }
25
+ }
26
+
27
+ export interface ControlClient {
28
+ /** Send one verb; resolves with the response `data`, rejects with
29
+ * {@link ControlRequestError} on an error response or a dropped socket. */
30
+ request(verb: string, params?: Record<string, unknown>): Promise<unknown>;
31
+ /** Receive pushed event frames (also sends the `subscribe` verb). */
32
+ subscribe(onEvent: (event: ControlEventFrame) => void): Promise<void>;
33
+ close(): void;
34
+ /** Resolves when the connection ends (server shutdown → EOF). */
35
+ done: Promise<void>;
36
+ }
37
+
38
+ /**
39
+ * Connect to a control server. Rejects (quickly) when nothing is listening —
40
+ * callers treat that as "no server, fall back to polling".
41
+ */
42
+ export function connectControl(opts: { socketPath?: string } = {}): Promise<ControlClient> {
43
+ const path = opts.socketPath ?? defaultControlSocketPath();
44
+ return new Promise((resolve, reject) => {
45
+ const socket: Socket = connect(path);
46
+ socket.once("error", reject);
47
+ socket.once("connect", () => {
48
+ socket.removeListener("error", reject);
49
+ resolve(wrap(socket));
50
+ });
51
+ });
52
+ }
53
+
54
+ function wrap(socket: Socket): ControlClient {
55
+ socket.setEncoding("utf8");
56
+ const split = createFrameSplitter();
57
+ const pending = new Map<
58
+ string | number,
59
+ { resolve: (data: unknown) => void; reject: (err: Error) => void }
60
+ >();
61
+ const eventSinks: Array<(event: ControlEventFrame) => void> = [];
62
+ let nextId = 1;
63
+
64
+ let markDone: () => void;
65
+ const done = new Promise<void>((r) => {
66
+ markDone = r;
67
+ });
68
+
69
+ socket.on("data", (chunk: string) => {
70
+ for (const line of split(chunk)) {
71
+ let raw: unknown;
72
+ try {
73
+ raw = JSON.parse(line);
74
+ } catch {
75
+ continue; // a malformed server frame — skip, ids keep us honest
76
+ }
77
+ const event = controlEventSchema.safeParse(raw);
78
+ if (event.success) {
79
+ for (const sink of eventSinks) sink(event.data);
80
+ continue;
81
+ }
82
+ const response = controlResponseSchema.safeParse(raw);
83
+ if (!response.success || response.data.id === null) continue;
84
+ const waiter = pending.get(response.data.id);
85
+ if (!waiter) continue;
86
+ pending.delete(response.data.id);
87
+ if (response.data.ok) waiter.resolve(response.data.data);
88
+ else {
89
+ waiter.reject(
90
+ new ControlRequestError(response.data.error.code, response.data.error.message),
91
+ );
92
+ }
93
+ }
94
+ });
95
+ const teardown = (): void => {
96
+ for (const { reject } of pending.values()) {
97
+ reject(new ControlRequestError("disconnected", "control socket closed"));
98
+ }
99
+ pending.clear();
100
+ markDone();
101
+ };
102
+ socket.on("close", teardown);
103
+ socket.on("error", () => {
104
+ // 'close' follows and runs the teardown
105
+ });
106
+
107
+ const request = (verb: string, params?: Record<string, unknown>): Promise<unknown> => {
108
+ const id = nextId++;
109
+ return new Promise((resolve, reject) => {
110
+ if (socket.destroyed) {
111
+ reject(new ControlRequestError("disconnected", "control socket closed"));
112
+ return;
113
+ }
114
+ pending.set(id, { resolve, reject });
115
+ socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, id, verb, params }));
116
+ });
117
+ };
118
+
119
+ return {
120
+ request,
121
+ subscribe: async (onEvent) => {
122
+ eventSinks.push(onEvent);
123
+ await request("subscribe");
124
+ },
125
+ close: () => socket.destroy(),
126
+ done,
127
+ };
128
+ }
@@ -0,0 +1,107 @@
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 {
18
+ CONTROL_PROTOCOL_VERSION,
19
+ controlRequestSchema,
20
+ type ControlResponse,
21
+ } from "@tmux-ide/contracts";
22
+ import { IdeError } from "../lib/errors.ts";
23
+
24
+ /** Thrown by handlers to reach the wire with a specific code. */
25
+ export class ControlVerbError extends Error {
26
+ readonly code: string;
27
+ constructor(code: string, message: string) {
28
+ super(message);
29
+ this.code = code;
30
+ }
31
+ }
32
+
33
+ /** What a handler gets besides its (already unknown-typed) params. */
34
+ export interface VerbContext {
35
+ /** Flip this connection into receiving event frames (the `subscribe` verb). */
36
+ subscribe: () => void;
37
+ }
38
+
39
+ export type VerbHandler = (params: unknown, ctx: VerbContext) => Promise<unknown> | unknown;
40
+
41
+ const ok = (id: string | number, data: unknown): ControlResponse => ({
42
+ v: CONTROL_PROTOCOL_VERSION,
43
+ id,
44
+ ok: true,
45
+ data,
46
+ });
47
+
48
+ const fail = (id: string | number | null, code: string, message: string): ControlResponse => ({
49
+ v: CONTROL_PROTOCOL_VERSION,
50
+ id,
51
+ ok: false,
52
+ error: { code, message },
53
+ });
54
+
55
+ /** Best-effort id recovery from a frame that failed envelope validation. */
56
+ function extractId(value: unknown): string | number | null {
57
+ if (typeof value === "object" && value !== null && "id" in value) {
58
+ const id = (value as { id: unknown }).id;
59
+ if (typeof id === "string" || typeof id === "number") return id;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Dispatch one raw line to `handlers`. ALWAYS resolves to a response frame —
66
+ * a protocol error is an answer, never a dropped request or a thrown error
67
+ * (only the transport decides to drop connections).
68
+ */
69
+ export async function dispatchLine(
70
+ line: string,
71
+ handlers: Record<string, VerbHandler>,
72
+ ctx: VerbContext,
73
+ ): Promise<ControlResponse> {
74
+ let raw: unknown;
75
+ try {
76
+ raw = JSON.parse(line);
77
+ } catch {
78
+ return fail(null, "bad-request", "frame is not valid JSON");
79
+ }
80
+
81
+ const parsed = controlRequestSchema.safeParse(raw);
82
+ if (!parsed.success) {
83
+ return fail(
84
+ extractId(raw),
85
+ "bad-request",
86
+ `invalid request envelope (need {v:${CONTROL_PROTOCOL_VERSION}, id, verb})`,
87
+ );
88
+ }
89
+
90
+ const { id, verb, params } = parsed.data;
91
+ const handler = handlers[verb];
92
+ if (!handler) {
93
+ return fail(id, "unknown-verb", `unknown verb "${verb}"`);
94
+ }
95
+
96
+ try {
97
+ return ok(id, await handler(params ?? {}, ctx));
98
+ } catch (err) {
99
+ if (err instanceof ControlVerbError) return fail(id, err.code, err.message);
100
+ if (err instanceof IdeError) {
101
+ // Data-layer errors carry honest codes already (SESSION_NOT_FOUND, …).
102
+ const code = err.code === "USAGE" ? "bad-request" : "not-found";
103
+ return fail(id, code, err.message);
104
+ }
105
+ return fail(id, "internal", (err as Error)?.message ?? "internal error");
106
+ }
107
+ }
@@ -0,0 +1,44 @@
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
+
10
+ export interface Fanout<T> {
11
+ /** Register a sink. Returns its unsubscribe (idempotent). */
12
+ add(sink: (event: T) => void): () => void;
13
+ /** Deliver `event` to every sink. A throwing sink is dropped, not fatal. */
14
+ emit(event: T): void;
15
+ size(): number;
16
+ }
17
+
18
+ export function createFanout<T>(
19
+ edges: { onFirst?: () => void; onLast?: () => void } = {},
20
+ ): Fanout<T> {
21
+ const sinks = new Set<(event: T) => void>();
22
+ const remove = (sink: (event: T) => void): void => {
23
+ if (!sinks.delete(sink)) return;
24
+ if (sinks.size === 0) edges.onLast?.();
25
+ };
26
+ return {
27
+ add(sink) {
28
+ sinks.add(sink);
29
+ if (sinks.size === 1) edges.onFirst?.();
30
+ return () => remove(sink);
31
+ },
32
+ emit(event) {
33
+ for (const sink of [...sinks]) {
34
+ try {
35
+ sink(event);
36
+ } catch {
37
+ // A sink that throws (a torn-down connection) removes itself.
38
+ remove(sink);
39
+ }
40
+ }
41
+ },
42
+ size: () => sinks.size,
43
+ };
44
+ }
@@ -0,0 +1,40 @@
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
+
12
+ /** Refuse to buffer a partial line beyond this — a client streaming an
13
+ * unterminated megabyte is broken or hostile, not slow. */
14
+ export const MAX_FRAME_BYTES = 4 * 1024 * 1024;
15
+
16
+ /** Serialize one frame: the JSON line plus its terminator. */
17
+ export function encodeFrame(message: unknown): string {
18
+ return `${JSON.stringify(message)}\n`;
19
+ }
20
+
21
+ /**
22
+ * A stateful chunk → complete-lines splitter. Feed it raw socket data; it
23
+ * returns every COMPLETE line received so far (blank lines are dropped) and
24
+ * keeps the trailing partial line buffered for the next feed. Throws when the
25
+ * partial line outgrows {@link MAX_FRAME_BYTES} — the caller should drop the
26
+ * connection.
27
+ */
28
+ export function createFrameSplitter(): (chunk: string) => string[] {
29
+ let buffer = "";
30
+ return (chunk: string): string[] => {
31
+ buffer += chunk;
32
+ const parts = buffer.split("\n");
33
+ buffer = parts.pop() ?? "";
34
+ if (buffer.length > MAX_FRAME_BYTES) {
35
+ buffer = "";
36
+ throw new Error(`frame exceeds ${MAX_FRAME_BYTES} bytes without a newline`);
37
+ }
38
+ return parts.filter((line) => line.trim().length > 0);
39
+ };
40
+ }
@@ -0,0 +1,151 @@
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 {
11
+ INTERRUPT_TAP_GAP_MS,
12
+ RESTART_GRACE_MS,
13
+ clearAuthorityArgs,
14
+ interruptArgs,
15
+ launchCommandFor,
16
+ paneHostsShell,
17
+ relaunchArgs,
18
+ respawnArgs,
19
+ spawnAgentArgs,
20
+ spawnSessionArgs,
21
+ type SpawnPlacement,
22
+ } from "../tui/mirror/agent-lifecycle.ts";
23
+ import { getManifests } from "../tui/detect/manifest-loader.ts";
24
+ import { ControlVerbError } from "./dispatch.ts";
25
+
26
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
27
+
28
+ /** One tmux call; resolves stdout, rejects on a tmux error. */
29
+ function tmuxRun(args: string[]): Promise<string> {
30
+ return new Promise((resolve, reject) => {
31
+ execFile("tmux", args, (err, stdout) => (err ? reject(err) : resolve(stdout.trimEnd())));
32
+ });
33
+ }
34
+
35
+ /** Like {@link tmuxRun} but errors are swallowed — for best-effort steps
36
+ * (a dead pane target is a normal race, the fleet shows the truth later). */
37
+ async function tmuxTry(args: string[]): Promise<void> {
38
+ await tmuxRun(args).catch(() => {});
39
+ }
40
+
41
+ /** Resolve `kind`/`command` params to the command that actually launches. */
42
+ export function resolveLaunchCommand(params: { kind?: string; command?: string }): string {
43
+ if (params.command) return params.command;
44
+ return launchCommandFor(params.kind!, getManifests());
45
+ }
46
+
47
+ export interface SpawnOutcome {
48
+ paneId: string;
49
+ session: string;
50
+ command: string;
51
+ placement: SpawnPlacement | "new-session";
52
+ }
53
+
54
+ /**
55
+ * Spawn an agent. With `session` the shared placement argv is used (window /
56
+ * split); without it a fresh detached session named `sessionName` starts in
57
+ * `dir`. `-P -F #{pane_id}` is threaded right after the tmux subcommand so
58
+ * the caller learns WHICH pane the agent got (the argv builders stay
59
+ * untouched — the app's flows don't want the print).
60
+ */
61
+ export async function spawnAgent(params: {
62
+ command: string;
63
+ session?: string;
64
+ sessionName?: string;
65
+ dir?: string;
66
+ placement?: SpawnPlacement;
67
+ paneId?: string;
68
+ }): Promise<SpawnOutcome> {
69
+ const dir = params.dir ?? null;
70
+ const argv = params.session
71
+ ? spawnAgentArgs(
72
+ params.placement ?? "window",
73
+ { session: params.session, paneId: params.paneId },
74
+ dir,
75
+ params.command,
76
+ )
77
+ : spawnSessionArgs(params.sessionName!, dir, params.command);
78
+ const [subcommand, ...rest] = argv;
79
+ let paneId: string;
80
+ try {
81
+ paneId = await tmuxRun([subcommand!, "-P", "-F", "#{pane_id}", ...rest]);
82
+ } catch (err) {
83
+ throw new ControlVerbError("not-found", `tmux refused to spawn: ${(err as Error).message}`);
84
+ }
85
+ const session = params.session ?? params.sessionName!;
86
+ // Mark a fresh session as ours (mirrors the app's spawn flow).
87
+ if (!params.session) await tmuxTry(["set-environment", "-t", session, "TMUX_IDE", "1"]);
88
+ return {
89
+ paneId,
90
+ session,
91
+ command: params.command,
92
+ placement: params.session ? (params.placement ?? "window") : "new-session",
93
+ };
94
+ }
95
+
96
+ /** The double ctrl-c (see agent-lifecycle: one taps, the quick second exits). */
97
+ async function interruptAgent(paneId: string): Promise<void> {
98
+ await tmuxTry(interruptArgs(paneId));
99
+ await sleep(INTERRUPT_TAP_GAP_MS);
100
+ await tmuxTry(interruptArgs(paneId));
101
+ }
102
+
103
+ /** Out-of-band stop hygiene: no hook fires, so unset the authority stamps. */
104
+ async function clearAgentAuthority(paneId: string): Promise<void> {
105
+ for (const args of clearAuthorityArgs(paneId)) await tmuxTry(args);
106
+ }
107
+
108
+ /** The pane's root command + cwd, or null when the pane is gone. */
109
+ function paneStartAndPath(paneId: string): Promise<{ start: string; path: string } | null> {
110
+ return tmuxRun(["display", "-p", "-t", paneId, "#{pane_start_command}\t#{pane_current_path}"])
111
+ .then((out) => {
112
+ const [start = "", path = ""] = out.split("\t");
113
+ return { start, path };
114
+ })
115
+ .catch(() => null);
116
+ }
117
+
118
+ /** Stop the agent in `paneId`: interrupt + authority cleanup. The pane (and
119
+ * its shell, if any) stays open — `kill-pane` is deliberately NOT offered
120
+ * over the socket; that is a human, confirmed-destructive verb. */
121
+ export async function stopAgent(paneId: string): Promise<{ paneId: string; stopped: true }> {
122
+ const live = await paneStartAndPath(paneId);
123
+ if (!live) throw new ControlVerbError("not-found", `no pane "${paneId}"`);
124
+ await interruptAgent(paneId);
125
+ await clearAgentAuthority(paneId);
126
+ return { paneId, stopped: true };
127
+ }
128
+
129
+ /**
130
+ * Restart the agent in `paneId` running `command`, using the app's two
131
+ * strategies: a SHELL-hosted agent is interrupted and relaunched via
132
+ * send-keys (the shell survives to type into); an agent that IS the pane's
133
+ * own process is respawned in place (ctrl-c would end the pane).
134
+ */
135
+ export async function restartAgent(
136
+ paneId: string,
137
+ command: string,
138
+ ): Promise<{ paneId: string; command: string; strategy: "relaunch" | "respawn" }> {
139
+ const live = await paneStartAndPath(paneId);
140
+ if (!live) throw new ControlVerbError("not-found", `no pane "${paneId}"`);
141
+ if (paneHostsShell(live.start, getManifests())) {
142
+ await interruptAgent(paneId);
143
+ await clearAgentAuthority(paneId);
144
+ await sleep(RESTART_GRACE_MS);
145
+ for (const args of relaunchArgs(paneId, command)) await tmuxTry(args);
146
+ return { paneId, command, strategy: "relaunch" };
147
+ }
148
+ await clearAgentAuthority(paneId);
149
+ await tmuxTry(respawnArgs(paneId, command, live.path || null));
150
+ return { paneId, command, strategy: "respawn" };
151
+ }