tmux-ide 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -5
- package/bin/cli.js +3532 -1090
- package/bin/cli.ts +368 -71
- package/package.json +2 -1
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- package/packages/daemon/dist/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +103 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +582 -84
- package/packages/daemon/src/tui/chrome/updater.ts +268 -62
- package/packages/daemon/src/tui/detect/classify.ts +34 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
- package/packages/daemon/src/tui/detect/manifest.ts +24 -3
- package/packages/daemon/src/tui/detect/manifests.ts +240 -6
- package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
- package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
- package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
- package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
- package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
- package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
- package/packages/daemon/src/tui/mirror/palette.ts +299 -9
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
- package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
- package/packages/daemon/src/tui/mirror/selection.ts +122 -8
- package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
- package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/sessions.ts +85 -7
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- package/skill/SKILL.md +88 -2
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema tests for the control-socket protocol (M23.3): the versioned
|
|
3
|
+
* envelope and the per-verb params. These pin the WIRE contract — a change
|
|
4
|
+
* that breaks one of these breaks every connected agent loop.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
CONTROL_PROTOCOL_VERSION,
|
|
9
|
+
CONTROL_WAIT_MAX_TIMEOUT_MS,
|
|
10
|
+
agentStatusEventSchema,
|
|
11
|
+
controlEventSchema,
|
|
12
|
+
controlRequestSchema,
|
|
13
|
+
controlResponseSchema,
|
|
14
|
+
restartAgentParamsSchema,
|
|
15
|
+
sendParamsSchema,
|
|
16
|
+
spawnParamsSchema,
|
|
17
|
+
waitParamsSchema,
|
|
18
|
+
} from "../control";
|
|
19
|
+
|
|
20
|
+
describe("controlRequestSchema", () => {
|
|
21
|
+
it("accepts a minimal request (params optional)", () => {
|
|
22
|
+
const r = controlRequestSchema.parse({ v: 1, id: 1, verb: "fleet" });
|
|
23
|
+
expect(r.verb).toBe("fleet");
|
|
24
|
+
expect(r.params).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("accepts string ids and object params", () => {
|
|
28
|
+
const r = controlRequestSchema.parse({
|
|
29
|
+
v: 1,
|
|
30
|
+
id: "req-7",
|
|
31
|
+
verb: "send",
|
|
32
|
+
params: { session: "s", target: "%1", message: "hi" },
|
|
33
|
+
});
|
|
34
|
+
expect(r.id).toBe("req-7");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("keeps verb an OPEN string — unknown verbs parse (dispatch answers them)", () => {
|
|
38
|
+
expect(controlRequestSchema.parse({ v: 1, id: 1, verb: "verb-from-the-future" }).verb).toBe(
|
|
39
|
+
"verb-from-the-future",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects a wrong or missing version", () => {
|
|
44
|
+
expect(() => controlRequestSchema.parse({ v: 2, id: 1, verb: "fleet" })).toThrow();
|
|
45
|
+
expect(() => controlRequestSchema.parse({ id: 1, verb: "fleet" })).toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects a missing id or empty verb", () => {
|
|
49
|
+
expect(() => controlRequestSchema.parse({ v: 1, verb: "fleet" })).toThrow();
|
|
50
|
+
expect(() => controlRequestSchema.parse({ v: 1, id: 1, verb: "" })).toThrow();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("controlResponseSchema", () => {
|
|
55
|
+
it("accepts an ok response with arbitrary data", () => {
|
|
56
|
+
const r = controlResponseSchema.parse({ v: 1, id: 3, ok: true, data: { projects: [] } });
|
|
57
|
+
expect(r.ok).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("accepts an error response with a null id (uncorrelatable frame)", () => {
|
|
61
|
+
const r = controlResponseSchema.parse({
|
|
62
|
+
v: 1,
|
|
63
|
+
id: null,
|
|
64
|
+
ok: false,
|
|
65
|
+
error: { code: "bad-request", message: "unparseable frame" },
|
|
66
|
+
});
|
|
67
|
+
expect(r.ok).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("rejects an error response without the error object", () => {
|
|
71
|
+
expect(() => controlResponseSchema.parse({ v: 1, id: 1, ok: false })).toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("controlEventSchema + agentStatusEventSchema", () => {
|
|
76
|
+
it("accepts an event frame and its agent-status payload", () => {
|
|
77
|
+
const frame = controlEventSchema.parse({
|
|
78
|
+
v: CONTROL_PROTOCOL_VERSION,
|
|
79
|
+
event: "agent-status",
|
|
80
|
+
data: { ts: "2026-07-10T12:00:00Z", session: "zz-x", from: null, to: "working" },
|
|
81
|
+
});
|
|
82
|
+
const ev = agentStatusEventSchema.parse(frame.data);
|
|
83
|
+
expect(ev.from).toBeNull();
|
|
84
|
+
expect(ev.to).toBe("working");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("rejects an unknown status in the payload", () => {
|
|
88
|
+
expect(() =>
|
|
89
|
+
agentStatusEventSchema.parse({ ts: "t", session: "s", from: null, to: "sleeping" }),
|
|
90
|
+
).toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("waitParamsSchema", () => {
|
|
95
|
+
it("accepts both kinds", () => {
|
|
96
|
+
expect(
|
|
97
|
+
waitParamsSchema.parse({ kind: "agent-status", session: "s", status: "done" }).kind,
|
|
98
|
+
).toBe("agent-status");
|
|
99
|
+
expect(waitParamsSchema.parse({ kind: "output", target: "%1", match: "ok" }).kind).toBe(
|
|
100
|
+
"output",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("caps timeoutMs", () => {
|
|
105
|
+
expect(() =>
|
|
106
|
+
waitParamsSchema.parse({
|
|
107
|
+
kind: "output",
|
|
108
|
+
target: "%1",
|
|
109
|
+
match: "ok",
|
|
110
|
+
timeoutMs: CONTROL_WAIT_MAX_TIMEOUT_MS + 1,
|
|
111
|
+
}),
|
|
112
|
+
).toThrow();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("spawnParamsSchema", () => {
|
|
117
|
+
it("requires exactly one of kind/command", () => {
|
|
118
|
+
expect(() => spawnParamsSchema.parse({ session: "s" })).toThrow();
|
|
119
|
+
expect(() => spawnParamsSchema.parse({ session: "s", kind: "claude", command: "x" })).toThrow();
|
|
120
|
+
expect(spawnParamsSchema.parse({ session: "s", kind: "claude" }).kind).toBe("claude");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("requires a session or a sessionName", () => {
|
|
124
|
+
expect(() => spawnParamsSchema.parse({ kind: "claude" })).toThrow();
|
|
125
|
+
expect(spawnParamsSchema.parse({ sessionName: "zz-new", kind: "claude" }).sessionName).toBe(
|
|
126
|
+
"zz-new",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("requires paneId for split placements only", () => {
|
|
131
|
+
expect(() =>
|
|
132
|
+
spawnParamsSchema.parse({ session: "s", kind: "claude", placement: "split-h" }),
|
|
133
|
+
).toThrow();
|
|
134
|
+
expect(
|
|
135
|
+
spawnParamsSchema.parse({ session: "s", kind: "claude", placement: "window" }).placement,
|
|
136
|
+
).toBe("window");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("sendParamsSchema / restartAgentParamsSchema", () => {
|
|
141
|
+
it("send requires session, target and a non-empty message", () => {
|
|
142
|
+
expect(() => sendParamsSchema.parse({ session: "s", target: "%1", message: "" })).toThrow();
|
|
143
|
+
expect(sendParamsSchema.parse({ session: "s", target: "%1", message: "go" }).noEnter).toBe(
|
|
144
|
+
undefined,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("restart requires kind or command", () => {
|
|
149
|
+
expect(() => restartAgentParamsSchema.parse({ paneId: "%1" })).toThrow();
|
|
150
|
+
expect(restartAgentParamsSchema.parse({ paneId: "%1", command: "claude" }).command).toBe(
|
|
151
|
+
"claude",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local control-socket protocol (M23.3) — newline-delimited JSON frames
|
|
3
|
+
* over a Unix socket (`~/.tmux-ide/control.sock` by default). This is the
|
|
4
|
+
* scriptable surface for agent loops: connect once, drive the fleet, get
|
|
5
|
+
* events PUSHED instead of spawning a CLI per poll.
|
|
6
|
+
*
|
|
7
|
+
* Every frame is one JSON object on one line. Three frame shapes:
|
|
8
|
+
*
|
|
9
|
+
* request {v:1, id, verb, params?} client → server
|
|
10
|
+
* response {v:1, id, ok, data|error} server → client (id-correlated)
|
|
11
|
+
* event {v:1, event, data} server → client (after `subscribe`)
|
|
12
|
+
*
|
|
13
|
+
* The envelope is VERSIONED from day one (`v: 1`) and `verb` is an open
|
|
14
|
+
* string in the envelope (unknown verbs are answered with an `unknown-verb`
|
|
15
|
+
* error, not a parse failure) so a v2 can add verbs without breaking v1
|
|
16
|
+
* clients. The future native-app bridge (#90) layers on mechanically: each
|
|
17
|
+
* NDJSON frame maps 1:1 to a WebSocket text message — id correlation and the
|
|
18
|
+
* unsolicited event channel already match WS semantics, so the bridge is a
|
|
19
|
+
* transport swap, not a protocol change.
|
|
20
|
+
*
|
|
21
|
+
* Params schemas are per-verb and STRICT about types but tolerant of absence
|
|
22
|
+
* (`params` may be omitted for verbs that need none).
|
|
23
|
+
*/
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
|
|
26
|
+
/** The protocol version every frame carries. Bump only on breaking changes. */
|
|
27
|
+
export const CONTROL_PROTOCOL_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
/** Request ids are client-chosen; responses echo them verbatim. */
|
|
30
|
+
export const controlIdSchema = z.union([z.string(), z.number()]);
|
|
31
|
+
export type ControlId = z.infer<typeof controlIdSchema>;
|
|
32
|
+
|
|
33
|
+
/** The agent statuses the detection layer produces (mirrors the daemon's `AgentStatus`). */
|
|
34
|
+
export const agentStatusSchema = z.enum(["blocked", "working", "done", "idle", "unknown"]);
|
|
35
|
+
export type ControlAgentStatus = z.infer<typeof agentStatusSchema>;
|
|
36
|
+
|
|
37
|
+
/** The verbs a v1 server understands (dispatch also answers unknown strings honestly). */
|
|
38
|
+
export const CONTROL_VERBS = [
|
|
39
|
+
"fleet",
|
|
40
|
+
"agents",
|
|
41
|
+
"send",
|
|
42
|
+
"wait",
|
|
43
|
+
"spawn",
|
|
44
|
+
"restart-agent",
|
|
45
|
+
"stop-agent",
|
|
46
|
+
"explain",
|
|
47
|
+
"subscribe",
|
|
48
|
+
] as const;
|
|
49
|
+
export type ControlVerb = (typeof CONTROL_VERBS)[number];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The request envelope. `verb` stays an open string here — verb existence is
|
|
53
|
+
* a DISPATCH concern (answered with `unknown-verb`), not a parse failure, so
|
|
54
|
+
* newer clients get an honest error from an older server.
|
|
55
|
+
*/
|
|
56
|
+
export const controlRequestSchema = z.object({
|
|
57
|
+
v: z.literal(CONTROL_PROTOCOL_VERSION),
|
|
58
|
+
id: controlIdSchema,
|
|
59
|
+
verb: z.string().min(1),
|
|
60
|
+
params: z.record(z.string(), z.unknown()).optional(),
|
|
61
|
+
});
|
|
62
|
+
export type ControlRequest = z.infer<typeof controlRequestSchema>;
|
|
63
|
+
|
|
64
|
+
/** Machine-readable error codes a response can carry. */
|
|
65
|
+
export const controlErrorSchema = z.object({
|
|
66
|
+
code: z.string(),
|
|
67
|
+
message: z.string(),
|
|
68
|
+
});
|
|
69
|
+
export type ControlError = z.infer<typeof controlErrorSchema>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The response envelope. `id` is null only for frames the server could not
|
|
73
|
+
* correlate (unparseable JSON / envelope) — everything else echoes the
|
|
74
|
+
* request id.
|
|
75
|
+
*/
|
|
76
|
+
export const controlResponseSchema = z.discriminatedUnion("ok", [
|
|
77
|
+
z.object({
|
|
78
|
+
v: z.literal(CONTROL_PROTOCOL_VERSION),
|
|
79
|
+
id: controlIdSchema.nullable(),
|
|
80
|
+
ok: z.literal(true),
|
|
81
|
+
data: z.unknown(),
|
|
82
|
+
}),
|
|
83
|
+
z.object({
|
|
84
|
+
v: z.literal(CONTROL_PROTOCOL_VERSION),
|
|
85
|
+
id: controlIdSchema.nullable(),
|
|
86
|
+
ok: z.literal(false),
|
|
87
|
+
error: controlErrorSchema,
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
export type ControlResponse = z.infer<typeof controlResponseSchema>;
|
|
91
|
+
|
|
92
|
+
/** An unsolicited push frame (only sent to connections that ran `subscribe`). */
|
|
93
|
+
export const controlEventSchema = z.object({
|
|
94
|
+
v: z.literal(CONTROL_PROTOCOL_VERSION),
|
|
95
|
+
event: z.string().min(1),
|
|
96
|
+
data: z.unknown(),
|
|
97
|
+
});
|
|
98
|
+
export type ControlEventFrame = z.infer<typeof controlEventSchema>;
|
|
99
|
+
|
|
100
|
+
/** The one v1 event stream: session-level agent-status transitions
|
|
101
|
+
* (`from` is null the first time the subscriber's server sees a session). */
|
|
102
|
+
export const agentStatusEventSchema = z.object({
|
|
103
|
+
ts: z.string(),
|
|
104
|
+
session: z.string(),
|
|
105
|
+
from: agentStatusSchema.nullable(),
|
|
106
|
+
to: agentStatusSchema,
|
|
107
|
+
});
|
|
108
|
+
export type AgentStatusEvent = z.infer<typeof agentStatusEventSchema>;
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Per-verb params
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/** `agents` — flat per-pane agent entries, optionally scoped to one session. */
|
|
115
|
+
export const agentsParamsSchema = z.object({
|
|
116
|
+
session: z.string().optional(),
|
|
117
|
+
});
|
|
118
|
+
export type AgentsParams = z.infer<typeof agentsParamsSchema>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `send` — deliver text to a pane in `session`. `target` accepts the same
|
|
122
|
+
* forms as `tmux-ide send`: a pane id (%N), an @ide_name, a title, a role, or
|
|
123
|
+
* a partial title. `dir` (optional) is the project dir long messages are
|
|
124
|
+
* dispatched through as a file; without it long text is sent directly.
|
|
125
|
+
*/
|
|
126
|
+
export const sendParamsSchema = z.object({
|
|
127
|
+
session: z.string().min(1),
|
|
128
|
+
target: z.string().min(1),
|
|
129
|
+
message: z.string().min(1),
|
|
130
|
+
noEnter: z.boolean().optional(),
|
|
131
|
+
dir: z.string().optional(),
|
|
132
|
+
});
|
|
133
|
+
export type SendParams = z.infer<typeof sendParamsSchema>;
|
|
134
|
+
|
|
135
|
+
/** Server-side cap on a single `wait` — protects the resident process. */
|
|
136
|
+
export const CONTROL_WAIT_MAX_TIMEOUT_MS = 600_000;
|
|
137
|
+
|
|
138
|
+
const waitTimeoutSchema = z.number().int().positive().max(CONTROL_WAIT_MAX_TIMEOUT_MS).optional();
|
|
139
|
+
|
|
140
|
+
/** `wait` — block (server-side) until a condition holds. Two kinds, mirroring
|
|
141
|
+
* `tmux-ide wait agent-status` and `tmux-ide wait output`. */
|
|
142
|
+
export const waitParamsSchema = z.discriminatedUnion("kind", [
|
|
143
|
+
z.object({
|
|
144
|
+
kind: z.literal("agent-status"),
|
|
145
|
+
session: z.string().min(1),
|
|
146
|
+
status: agentStatusSchema,
|
|
147
|
+
timeoutMs: waitTimeoutSchema,
|
|
148
|
+
}),
|
|
149
|
+
z.object({
|
|
150
|
+
kind: z.literal("output"),
|
|
151
|
+
target: z.string().min(1),
|
|
152
|
+
match: z.string().min(1),
|
|
153
|
+
timeoutMs: waitTimeoutSchema,
|
|
154
|
+
}),
|
|
155
|
+
]);
|
|
156
|
+
export type WaitParams = z.infer<typeof waitParamsSchema>;
|
|
157
|
+
|
|
158
|
+
/** Where a spawned agent lands (mirrors the app's lifecycle placements). */
|
|
159
|
+
export const spawnPlacementSchema = z.enum(["window", "split-h", "split-v"]);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `spawn` — start an agent via the SAME lifecycle argv builders the app uses.
|
|
163
|
+
* Exactly one of `kind` (a detection-manifest id, resolved to its launch
|
|
164
|
+
* command) or `command` (verbatim) must be given. With `session` the agent
|
|
165
|
+
* lands in that live session (`placement`, default a new window; splits need
|
|
166
|
+
* `paneId`); without it a fresh detached session named `sessionName` is
|
|
167
|
+
* created in `dir`.
|
|
168
|
+
*/
|
|
169
|
+
export const spawnParamsSchema = z
|
|
170
|
+
.object({
|
|
171
|
+
kind: z.string().min(1).optional(),
|
|
172
|
+
command: z.string().min(1).optional(),
|
|
173
|
+
session: z.string().min(1).optional(),
|
|
174
|
+
sessionName: z.string().min(1).optional(),
|
|
175
|
+
dir: z.string().optional(),
|
|
176
|
+
placement: spawnPlacementSchema.optional(),
|
|
177
|
+
paneId: z.string().optional(),
|
|
178
|
+
})
|
|
179
|
+
.refine((p) => Boolean(p.kind) !== Boolean(p.command), {
|
|
180
|
+
message: "exactly one of `kind` or `command` is required",
|
|
181
|
+
})
|
|
182
|
+
.refine((p) => Boolean(p.session) || Boolean(p.sessionName), {
|
|
183
|
+
message: "`session` (spawn into it) or `sessionName` (create it) is required",
|
|
184
|
+
})
|
|
185
|
+
.refine((p) => !(p.placement && p.placement !== "window") || Boolean(p.paneId), {
|
|
186
|
+
message: "split placements need `paneId`",
|
|
187
|
+
});
|
|
188
|
+
export type SpawnParams = z.infer<typeof spawnParamsSchema>;
|
|
189
|
+
|
|
190
|
+
/** `restart-agent` — restart the agent in `paneId`, relaunching `command`
|
|
191
|
+
* (or `kind`'s launch command). One of the two is required. */
|
|
192
|
+
export const restartAgentParamsSchema = z
|
|
193
|
+
.object({
|
|
194
|
+
paneId: z.string().min(1),
|
|
195
|
+
kind: z.string().min(1).optional(),
|
|
196
|
+
command: z.string().min(1).optional(),
|
|
197
|
+
})
|
|
198
|
+
.refine((p) => Boolean(p.kind) || Boolean(p.command), {
|
|
199
|
+
message: "`kind` or `command` is required",
|
|
200
|
+
});
|
|
201
|
+
export type RestartAgentParams = z.infer<typeof restartAgentParamsSchema>;
|
|
202
|
+
|
|
203
|
+
/** `stop-agent` — interrupt the agent in `paneId` (the pane stays open). */
|
|
204
|
+
export const stopAgentParamsSchema = z.object({
|
|
205
|
+
paneId: z.string().min(1),
|
|
206
|
+
});
|
|
207
|
+
export type StopAgentParams = z.infer<typeof stopAgentParamsSchema>;
|
|
208
|
+
|
|
209
|
+
/** `explain` — the detection debugger for one pane (or a session's active pane). */
|
|
210
|
+
export const explainParamsSchema = z.object({
|
|
211
|
+
target: z.string().min(1),
|
|
212
|
+
});
|
|
213
|
+
export type ExplainParams = z.infer<typeof explainParamsSchema>;
|
|
214
|
+
|
|
215
|
+
/** `subscribe` — flip this connection into receiving event frames. */
|
|
216
|
+
export const subscribeParamsSchema = z.object({}).loose();
|
|
217
|
+
export type SubscribeParams = z.infer<typeof subscribeParamsSchema>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ControlEventFrame } from "@tmux-ide/contracts";
|
|
2
|
+
/** A failed verb, carrying the server's machine-readable error code. */
|
|
3
|
+
export declare class ControlRequestError extends Error {
|
|
4
|
+
readonly code: string;
|
|
5
|
+
constructor(code: string, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface ControlClient {
|
|
8
|
+
/** Send one verb; resolves with the response `data`, rejects with
|
|
9
|
+
* {@link ControlRequestError} on an error response or a dropped socket. */
|
|
10
|
+
request(verb: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
11
|
+
/** Receive pushed event frames (also sends the `subscribe` verb). */
|
|
12
|
+
subscribe(onEvent: (event: ControlEventFrame) => void): Promise<void>;
|
|
13
|
+
close(): void;
|
|
14
|
+
/** Resolves when the connection ends (server shutdown → EOF). */
|
|
15
|
+
done: Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Connect to a control server. Rejects (quickly) when nothing is listening —
|
|
19
|
+
* callers treat that as "no server, fall back to polling".
|
|
20
|
+
*/
|
|
21
|
+
export declare function connectControl(opts?: {
|
|
22
|
+
socketPath?: string;
|
|
23
|
+
}): Promise<ControlClient>;
|
|
@@ -0,0 +1,105 @@
|
|
|
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 } from "node:net";
|
|
9
|
+
import { CONTROL_PROTOCOL_VERSION, controlEventSchema, controlResponseSchema, } from "@tmux-ide/contracts";
|
|
10
|
+
import { createFrameSplitter, encodeFrame } from "./frames.js";
|
|
11
|
+
import { defaultControlSocketPath } from "./server.js";
|
|
12
|
+
/** A failed verb, carrying the server's machine-readable error code. */
|
|
13
|
+
export class ControlRequestError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Connect to a control server. Rejects (quickly) when nothing is listening —
|
|
22
|
+
* callers treat that as "no server, fall back to polling".
|
|
23
|
+
*/
|
|
24
|
+
export function connectControl(opts = {}) {
|
|
25
|
+
const path = opts.socketPath ?? defaultControlSocketPath();
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const socket = connect(path);
|
|
28
|
+
socket.once("error", reject);
|
|
29
|
+
socket.once("connect", () => {
|
|
30
|
+
socket.removeListener("error", reject);
|
|
31
|
+
resolve(wrap(socket));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function wrap(socket) {
|
|
36
|
+
socket.setEncoding("utf8");
|
|
37
|
+
const split = createFrameSplitter();
|
|
38
|
+
const pending = new Map();
|
|
39
|
+
const eventSinks = [];
|
|
40
|
+
let nextId = 1;
|
|
41
|
+
let markDone;
|
|
42
|
+
const done = new Promise((r) => {
|
|
43
|
+
markDone = r;
|
|
44
|
+
});
|
|
45
|
+
socket.on("data", (chunk) => {
|
|
46
|
+
for (const line of split(chunk)) {
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = JSON.parse(line);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
continue; // a malformed server frame — skip, ids keep us honest
|
|
53
|
+
}
|
|
54
|
+
const event = controlEventSchema.safeParse(raw);
|
|
55
|
+
if (event.success) {
|
|
56
|
+
for (const sink of eventSinks)
|
|
57
|
+
sink(event.data);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const response = controlResponseSchema.safeParse(raw);
|
|
61
|
+
if (!response.success || response.data.id === null)
|
|
62
|
+
continue;
|
|
63
|
+
const waiter = pending.get(response.data.id);
|
|
64
|
+
if (!waiter)
|
|
65
|
+
continue;
|
|
66
|
+
pending.delete(response.data.id);
|
|
67
|
+
if (response.data.ok)
|
|
68
|
+
waiter.resolve(response.data.data);
|
|
69
|
+
else {
|
|
70
|
+
waiter.reject(new ControlRequestError(response.data.error.code, response.data.error.message));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const teardown = () => {
|
|
75
|
+
for (const { reject } of pending.values()) {
|
|
76
|
+
reject(new ControlRequestError("disconnected", "control socket closed"));
|
|
77
|
+
}
|
|
78
|
+
pending.clear();
|
|
79
|
+
markDone();
|
|
80
|
+
};
|
|
81
|
+
socket.on("close", teardown);
|
|
82
|
+
socket.on("error", () => {
|
|
83
|
+
// 'close' follows and runs the teardown
|
|
84
|
+
});
|
|
85
|
+
const request = (verb, params) => {
|
|
86
|
+
const id = nextId++;
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
if (socket.destroyed) {
|
|
89
|
+
reject(new ControlRequestError("disconnected", "control socket closed"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
pending.set(id, { resolve, reject });
|
|
93
|
+
socket.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, id, verb, params }));
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
request,
|
|
98
|
+
subscribe: async (onEvent) => {
|
|
99
|
+
eventSinks.push(onEvent);
|
|
100
|
+
await request("subscribe");
|
|
101
|
+
},
|
|
102
|
+
close: () => socket.destroy(),
|
|
103
|
+
done,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { type ControlResponse } from "@tmux-ide/contracts";
|
|
18
|
+
/** Thrown by handlers to reach the wire with a specific code. */
|
|
19
|
+
export declare class ControlVerbError extends Error {
|
|
20
|
+
readonly code: string;
|
|
21
|
+
constructor(code: string, message: string);
|
|
22
|
+
}
|
|
23
|
+
/** What a handler gets besides its (already unknown-typed) params. */
|
|
24
|
+
export interface VerbContext {
|
|
25
|
+
/** Flip this connection into receiving event frames (the `subscribe` verb). */
|
|
26
|
+
subscribe: () => void;
|
|
27
|
+
}
|
|
28
|
+
export type VerbHandler = (params: unknown, ctx: VerbContext) => Promise<unknown> | unknown;
|
|
29
|
+
/**
|
|
30
|
+
* Dispatch one raw line to `handlers`. ALWAYS resolves to a response frame —
|
|
31
|
+
* a protocol error is an answer, never a dropped request or a thrown error
|
|
32
|
+
* (only the transport decides to drop connections).
|
|
33
|
+
*/
|
|
34
|
+
export declare function dispatchLine(line: string, handlers: Record<string, VerbHandler>, ctx: VerbContext): Promise<ControlResponse>;
|
|
@@ -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
|
+
}
|