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.
- package/README.md +36 -14
- package/bin/cli.js +4174 -1235
- package/bin/cli.ts +426 -72
- package/package.json +10 -6
- 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/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- 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/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -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/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +34 -6
- 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 +194 -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/tui-binary.ts +165 -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 +737 -52
- package/packages/daemon/src/tui/chrome/updater.ts +318 -30
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +49 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
- package/packages/daemon/src/tui/detect/manifest.ts +39 -3
- package/packages/daemon/src/tui/detect/manifests.ts +395 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +33 -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/main.ts +13 -1
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
- package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- 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/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
- package/packages/daemon/src/tui/mirror/palette.ts +564 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +376 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +184 -17
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -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 +110 -2
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
|
@@ -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
|
+
}
|