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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The control-socket server — `tmux-ide serve` (M23.3).
|
|
3
|
+
*
|
|
4
|
+
* NDJSON frames over a local Unix socket (default `~/.tmux-ide/control.sock`,
|
|
5
|
+
* mode 0600). Agent loops connect once and drive the fleet — request/response
|
|
6
|
+
* verbs plus PUSHED agent-status events after `subscribe` — instead of
|
|
7
|
+
* spawning a CLI process per call. Protocol schemas live in
|
|
8
|
+
* `@tmux-ide/contracts` (control.ts); verb handlers in `./verbs.ts` call the
|
|
9
|
+
* SAME data layer the CLI cases do.
|
|
10
|
+
*
|
|
11
|
+
* HOST DECISION (the tradeoff, considered before building):
|
|
12
|
+
* (a) `tmux-ide serve` — an EXPLICIT foreground process. Costs the user a
|
|
13
|
+
* deliberate start, dies with them, trivially restartable after a code
|
|
14
|
+
* change, and its lifetime states its purpose.
|
|
15
|
+
* (b) piggyback on the `_tmux-ide-chrome` updater tick — always running for
|
|
16
|
+
* adopted fleets, but chrome-lifecycle-coupled: unadopting the last
|
|
17
|
+
* session would kill the API mid-conversation, `adopt` would grow a
|
|
18
|
+
* network-ish responsibility, and the updater's contract ("never let a
|
|
19
|
+
* bad tick break the bars") is the wrong place for a protocol surface.
|
|
20
|
+
* (c) socket-activated per-connection handlers — zero resident cost, but
|
|
21
|
+
* every connection pays a full node boot (the latency the socket exists
|
|
22
|
+
* to remove) and no resident process means no push events and no
|
|
23
|
+
* persistent status tracker (the cross-tick `done` needs history).
|
|
24
|
+
* CHOSEN: (a), deliberately WITHOUT auto-start. The old command-center HTTP
|
|
25
|
+
* server is the cautionary tale: an implicitly-running daemon accretes
|
|
26
|
+
* scope. This surface stays minimal — session-control verbs only, no feed,
|
|
27
|
+
* no chat, no network listener — and the CLI only uses the socket
|
|
28
|
+
* OPPORTUNISTICALLY (`--socket` fast-paths fall back to polling when no
|
|
29
|
+
* server is up). An agent that wants the socket runs `tmux-ide serve`
|
|
30
|
+
* itself; the process it spawned is the process it owns.
|
|
31
|
+
*
|
|
32
|
+
* SECURITY: local user only. The socket is chmod 0600, there is no network
|
|
33
|
+
* transport, no tokens (filesystem permissions ARE the auth). The #90 bridge
|
|
34
|
+
* that exposes this to a native app layers a WS server ON TOP later — each
|
|
35
|
+
* NDJSON frame maps 1:1 to a WS text message, so that bridge is mechanical
|
|
36
|
+
* and this file never grows remote scope.
|
|
37
|
+
*
|
|
38
|
+
* EVENTS: while at least one connection is subscribed, a detection tick
|
|
39
|
+
* (same cadence and diff as the chrome updater, one persistent tracker)
|
|
40
|
+
* computes the fleet and pushes session-level status transitions. No
|
|
41
|
+
* subscribers → no tick → an idle server does nothing. The tick does NOT
|
|
42
|
+
* write events.jsonl — the chrome updater owns the log; this stream is
|
|
43
|
+
* transport, not history.
|
|
44
|
+
*/
|
|
45
|
+
import { chmodSync, existsSync, mkdirSync, statSync, unlinkSync } from "node:fs";
|
|
46
|
+
import { createServer, connect, type Server, type Socket } from "node:net";
|
|
47
|
+
import { dirname, join } from "node:path";
|
|
48
|
+
import { CONTROL_PROTOCOL_VERSION, type AgentStatusEvent } from "@tmux-ide/contracts";
|
|
49
|
+
import { IdeError } from "../lib/errors.ts";
|
|
50
|
+
import { tuiStateHome } from "../lib/tui-binary.ts";
|
|
51
|
+
import { createStatusTracker, type AgentStatus } from "../tui/detect/classify.ts";
|
|
52
|
+
import { diffFleet } from "../tui/chrome/events.ts";
|
|
53
|
+
import { fleetStatuses, TICK_MS } from "../tui/chrome/updater.ts";
|
|
54
|
+
import { listTeamProjects } from "../tui/team/projects.ts";
|
|
55
|
+
import { dispatchLine } from "./dispatch.ts";
|
|
56
|
+
import { createFanout } from "./fanout.ts";
|
|
57
|
+
import { createFrameSplitter, encodeFrame } from "./frames.ts";
|
|
58
|
+
import { createVerbHandlers } from "./verbs.ts";
|
|
59
|
+
|
|
60
|
+
/** The default socket path — under the state home so `TMUX_IDE_HOME` scopes
|
|
61
|
+
* tests away from the real user socket. */
|
|
62
|
+
export function defaultControlSocketPath(): string {
|
|
63
|
+
return join(tuiStateHome(), "control.sock");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ControlServerOptions {
|
|
67
|
+
socketPath?: string;
|
|
68
|
+
/** Diagnostics sink (the CLI passes stderr). Default: silent. */
|
|
69
|
+
log?: (message: string) => void;
|
|
70
|
+
/** Event-tick cadence override (tests); defaults to the updater's TICK_MS. */
|
|
71
|
+
tickMs?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ControlServer {
|
|
75
|
+
socketPath: string;
|
|
76
|
+
close(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Claim `path`: refuse anything that exists and is not a socket (NEVER
|
|
81
|
+
* unlink a foreign file), refuse a socket another live server answers on,
|
|
82
|
+
* and unlink a stale socket left by a dead server.
|
|
83
|
+
*/
|
|
84
|
+
async function claimSocketPath(path: string): Promise<void> {
|
|
85
|
+
if (!existsSync(path)) return;
|
|
86
|
+
if (!statSync(path).isSocket()) {
|
|
87
|
+
throw new IdeError(
|
|
88
|
+
`${path} exists and is not a socket — refusing to remove it. ` +
|
|
89
|
+
`Pass a different --socket path.`,
|
|
90
|
+
{ code: "USAGE", exitCode: 1 },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const alive = await new Promise<boolean>((resolve) => {
|
|
94
|
+
const probe: Socket = connect(path);
|
|
95
|
+
const done = (result: boolean) => {
|
|
96
|
+
probe.destroy();
|
|
97
|
+
resolve(result);
|
|
98
|
+
};
|
|
99
|
+
probe.once("connect", () => done(true));
|
|
100
|
+
probe.once("error", () => done(false));
|
|
101
|
+
probe.setTimeout(500, () => done(false));
|
|
102
|
+
});
|
|
103
|
+
if (alive) {
|
|
104
|
+
throw new IdeError(`another server is already listening on ${path}`, {
|
|
105
|
+
code: "USAGE",
|
|
106
|
+
exitCode: 1,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
unlinkSync(path); // stale socket from a dead server — safe to rebind
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Start the server. Resolves once the socket is listening (mode 0600). */
|
|
113
|
+
export async function startControlServer(opts: ControlServerOptions = {}): Promise<ControlServer> {
|
|
114
|
+
const socketPath = opts.socketPath ?? defaultControlSocketPath();
|
|
115
|
+
const log = opts.log ?? (() => {});
|
|
116
|
+
const tickMs = opts.tickMs ?? TICK_MS;
|
|
117
|
+
|
|
118
|
+
mkdirSync(dirname(socketPath), { recursive: true });
|
|
119
|
+
await claimSocketPath(socketPath);
|
|
120
|
+
|
|
121
|
+
// ONE tracker for the server's lifetime, shared by the verbs and the event
|
|
122
|
+
// tick — cross-tick `done` (working→idle) is only observable with history.
|
|
123
|
+
const tracker = createStatusTracker();
|
|
124
|
+
const handlers = createVerbHandlers({ tracker });
|
|
125
|
+
|
|
126
|
+
// The event tick: runs ONLY while subscribers exist (fanout edges), diffs
|
|
127
|
+
// the fleet exactly like the chrome updater, pushes transitions.
|
|
128
|
+
const prevState = new Map<string, AgentStatus>();
|
|
129
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
130
|
+
const tick = (): void => {
|
|
131
|
+
try {
|
|
132
|
+
const { events, state } = diffFleet(prevState, fleetStatuses(listTeamProjects(tracker)));
|
|
133
|
+
prevState.clear();
|
|
134
|
+
for (const [name, status] of state) prevState.set(name, status);
|
|
135
|
+
const ts = new Date().toISOString();
|
|
136
|
+
for (const ev of events) fanout.emit({ ts, ...ev });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
log(`event tick failed: ${(err as Error).message}`);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const fanout = createFanout<AgentStatusEvent>({
|
|
142
|
+
onFirst: () => {
|
|
143
|
+
tick(); // seed immediately — the first events carry the current fleet (from: null)
|
|
144
|
+
timer = setInterval(tick, tickMs);
|
|
145
|
+
},
|
|
146
|
+
onLast: () => {
|
|
147
|
+
if (timer) clearInterval(timer);
|
|
148
|
+
timer = null;
|
|
149
|
+
prevState.clear();
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const connections = new Set<Socket>();
|
|
154
|
+
const server: Server = createServer((conn) => {
|
|
155
|
+
connections.add(conn);
|
|
156
|
+
conn.setEncoding("utf8");
|
|
157
|
+
const split = createFrameSplitter();
|
|
158
|
+
let unsubscribe: (() => void) | null = null;
|
|
159
|
+
|
|
160
|
+
const push = (ev: AgentStatusEvent): void => {
|
|
161
|
+
// One write per frame — JSON.stringify never contains a raw newline,
|
|
162
|
+
// so frames from concurrent requests can never interleave mid-message.
|
|
163
|
+
conn.write(encodeFrame({ v: CONTROL_PROTOCOL_VERSION, event: "agent-status", data: ev }));
|
|
164
|
+
};
|
|
165
|
+
const ctx = {
|
|
166
|
+
subscribe: () => {
|
|
167
|
+
unsubscribe ??= fanout.add(push);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
conn.on("data", (chunk: string) => {
|
|
172
|
+
let lines: string[];
|
|
173
|
+
try {
|
|
174
|
+
lines = split(chunk);
|
|
175
|
+
} catch {
|
|
176
|
+
conn.destroy(); // frame overflow — a broken client, not a slow one
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
// Handled CONCURRENTLY: a long `wait` must not block this
|
|
181
|
+
// connection's other requests (responses correlate by id).
|
|
182
|
+
void dispatchLine(line, handlers, ctx).then((response) => {
|
|
183
|
+
if (!conn.destroyed) conn.write(encodeFrame(response));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
conn.on("close", () => {
|
|
188
|
+
unsubscribe?.();
|
|
189
|
+
connections.delete(conn);
|
|
190
|
+
});
|
|
191
|
+
conn.on("error", () => {
|
|
192
|
+
// close follows; nothing to do — never let a client error kill serve
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await new Promise<void>((resolve, reject) => {
|
|
197
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
198
|
+
// Unix sockets cap the path at ~104 bytes (macOS sun_path) — surfaced
|
|
199
|
+
// as a bare EINVAL. Say what actually went wrong and how to fix it.
|
|
200
|
+
if ((err.code === "EINVAL" || err.code === "ENAMETOOLONG") && socketPath.length > 100) {
|
|
201
|
+
reject(
|
|
202
|
+
new IdeError(
|
|
203
|
+
`socket path is too long for a Unix socket (${socketPath.length} chars; the OS caps it around 104): ${socketPath}\n` +
|
|
204
|
+
`Pass a shorter path: tmux-ide serve --socket /tmp/tmux-ide-control.sock`,
|
|
205
|
+
{ code: "USAGE", exitCode: 1 },
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
reject(err);
|
|
211
|
+
});
|
|
212
|
+
server.listen(socketPath, () => {
|
|
213
|
+
server.removeAllListeners("error");
|
|
214
|
+
resolve();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
chmodSync(socketPath, 0o600);
|
|
218
|
+
log(`listening on ${socketPath}`);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
socketPath,
|
|
222
|
+
close: () =>
|
|
223
|
+
new Promise<void>((resolve) => {
|
|
224
|
+
if (timer) clearInterval(timer);
|
|
225
|
+
timer = null;
|
|
226
|
+
for (const conn of connections) conn.destroy(); // clients see EOF
|
|
227
|
+
server.close(() => {
|
|
228
|
+
try {
|
|
229
|
+
unlinkSync(socketPath);
|
|
230
|
+
} catch {
|
|
231
|
+
// already gone
|
|
232
|
+
}
|
|
233
|
+
resolve();
|
|
234
|
+
});
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The v1 verb handlers — each one a THIN adapter from validated params to
|
|
3
|
+
* the SAME data-layer functions the CLI cases call (report/sessions/wait/
|
|
4
|
+
* send/agent-explain/lifecycle). No fleet logic lives here; if a verb needs
|
|
5
|
+
* logic a CLI case has inline, the logic moves to the data layer and both
|
|
6
|
+
* point at it (that's how `wait` and `send` got their shared cores).
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
agentsParamsSchema,
|
|
10
|
+
explainParamsSchema,
|
|
11
|
+
restartAgentParamsSchema,
|
|
12
|
+
sendParamsSchema,
|
|
13
|
+
spawnParamsSchema,
|
|
14
|
+
stopAgentParamsSchema,
|
|
15
|
+
waitParamsSchema,
|
|
16
|
+
} from "@tmux-ide/contracts";
|
|
17
|
+
import type { ZodType } from "zod";
|
|
18
|
+
import { buildReport } from "../agent-explain.ts";
|
|
19
|
+
import { deliverMessage } from "../send.ts";
|
|
20
|
+
import type { StatusTracker } from "../tui/detect/classify.ts";
|
|
21
|
+
import { toFleetJson } from "../tui/team/report.ts";
|
|
22
|
+
import { listTeamProjects } from "../tui/team/projects.ts";
|
|
23
|
+
import { listTeamSessions } from "../tui/team/sessions.ts";
|
|
24
|
+
import { waitForAgentStatus, waitForOutputMatch } from "../tui/team/wait.ts";
|
|
25
|
+
import { ControlVerbError, type VerbHandler } from "./dispatch.ts";
|
|
26
|
+
import { resolveLaunchCommand, restartAgent, spawnAgent, stopAgent } from "./lifecycle.ts";
|
|
27
|
+
|
|
28
|
+
/** Validate `params` against a verb's schema, or answer `bad-request`. */
|
|
29
|
+
function parse<T>(schema: ZodType<T>, params: unknown): T {
|
|
30
|
+
const result = schema.safeParse(params);
|
|
31
|
+
if (!result.success) {
|
|
32
|
+
const issue = result.error.issues[0];
|
|
33
|
+
const at = issue?.path?.length ? ` at ${issue.path.join(".")}` : "";
|
|
34
|
+
throw new ControlVerbError("bad-request", `invalid params${at}: ${issue?.message ?? "?"}`);
|
|
35
|
+
}
|
|
36
|
+
return result.data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the handler map. `tracker` is the server's ONE persistent status
|
|
41
|
+
* tracker (shared with the event tick) so `fleet`/`agents` see the
|
|
42
|
+
* cross-tick `done` transition exactly like the chrome updater does —
|
|
43
|
+
* a fresh tracker per call could never observe working→idle.
|
|
44
|
+
*/
|
|
45
|
+
export function createVerbHandlers(ctx: { tracker: StatusTracker }): Record<string, VerbHandler> {
|
|
46
|
+
return {
|
|
47
|
+
fleet: () => toFleetJson(listTeamProjects(ctx.tracker)),
|
|
48
|
+
|
|
49
|
+
agents: (params) => {
|
|
50
|
+
const p = parse(agentsParamsSchema, params);
|
|
51
|
+
const sessions = listTeamSessions(ctx.tracker);
|
|
52
|
+
const scoped = p.session ? sessions.filter((s) => s.name === p.session) : sessions;
|
|
53
|
+
if (p.session && scoped.length === 0) {
|
|
54
|
+
throw new ControlVerbError("not-found", `no session "${p.session}"`);
|
|
55
|
+
}
|
|
56
|
+
return { agents: scoped.flatMap((s) => s.agents ?? []) };
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
send: (params) => {
|
|
60
|
+
const p = parse(sendParamsSchema, params);
|
|
61
|
+
return deliverMessage(p);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
wait: async (params) => {
|
|
65
|
+
const p = parse(waitParamsSchema, params);
|
|
66
|
+
if (p.kind === "output") {
|
|
67
|
+
try {
|
|
68
|
+
new RegExp(p.match);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw new ControlVerbError(
|
|
71
|
+
"bad-request",
|
|
72
|
+
`invalid match regex: ${(err as Error).message}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const result =
|
|
77
|
+
p.kind === "agent-status"
|
|
78
|
+
? await waitForAgentStatus(p.session, p.status, { timeoutMs: p.timeoutMs })
|
|
79
|
+
: await waitForOutputMatch(p.target, p.match, { timeoutMs: p.timeoutMs });
|
|
80
|
+
if (!result.ok) {
|
|
81
|
+
const what =
|
|
82
|
+
p.kind === "agent-status"
|
|
83
|
+
? `"${p.session}" to reach status "${p.status}"`
|
|
84
|
+
: `${p.target} output to match /${p.match}/`;
|
|
85
|
+
throw new ControlVerbError(
|
|
86
|
+
"timeout",
|
|
87
|
+
`timed out after ${result.timedOutAfterMs}ms waiting for ${what}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
spawn: (params) => {
|
|
94
|
+
const p = parse(spawnParamsSchema, params);
|
|
95
|
+
return spawnAgent({ ...p, command: resolveLaunchCommand(p) });
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
"restart-agent": (params) => {
|
|
99
|
+
const p = parse(restartAgentParamsSchema, params);
|
|
100
|
+
return restartAgent(p.paneId, resolveLaunchCommand(p));
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
"stop-agent": (params) => {
|
|
104
|
+
const p = parse(stopAgentParamsSchema, params);
|
|
105
|
+
return stopAgent(p.paneId);
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
explain: (params) => {
|
|
109
|
+
const p = parse(explainParamsSchema, params);
|
|
110
|
+
return buildReport(p.target);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
subscribe: (_params, verbCtx) => {
|
|
114
|
+
verbCtx.subscribe();
|
|
115
|
+
return { subscribed: true, events: ["agent-status"] };
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { accessSync, constants, existsSync } from "node:fs";
|
|
3
3
|
import { resolve, dirname } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { getCurrentVersion, getUpdateStatus } from "./lib/update-check.ts";
|
|
6
6
|
import { installedSkillVersion } from "./lib/skill-sync.ts";
|
|
7
7
|
import { discoverAgents, presentAgents, type DiscoveredAgent } from "./lib/agent-discovery.ts";
|
|
8
8
|
import { findCompiledTui, isBunAvailable } from "./tui/compiled.ts";
|
|
9
|
+
import { claudeSettingsPath } from "./tui/integrations/claude.ts";
|
|
10
|
+
import { readNotificationPrefs, resolveNativeMacosNotifierPath } from "./tui/chrome/notify.ts";
|
|
9
11
|
|
|
10
12
|
interface CheckResult {
|
|
11
13
|
label: string;
|
|
@@ -27,12 +29,19 @@ export function agentIntegrationRows(agents: DiscoveredAgent[]): CheckResult[] {
|
|
|
27
29
|
return presentAgents(agents).map((agent) => {
|
|
28
30
|
const label = `agent: ${agent.id}`;
|
|
29
31
|
if (agent.integration) {
|
|
32
|
+
// What installing actually buys, honestly per mechanism: claude's hooks
|
|
33
|
+
// give ground-truth status (and record resume ids); opencode's plugin
|
|
34
|
+
// records resume ids only.
|
|
35
|
+
const benefit =
|
|
36
|
+
agent.capture === "hooks"
|
|
37
|
+
? "for ground-truth status"
|
|
38
|
+
: "to record session ids for restore --resume-agents";
|
|
30
39
|
return agent.installed
|
|
31
40
|
? { label, pass: true, detail: "integration installed ✓", optional: true }
|
|
32
41
|
: {
|
|
33
42
|
label,
|
|
34
43
|
pass: false,
|
|
35
|
-
detail: `found on PATH — run \`tmux-ide integration install ${agent.id}\`
|
|
44
|
+
detail: `found on PATH — run \`tmux-ide integration install ${agent.id}\` ${benefit}`,
|
|
36
45
|
optional: true,
|
|
37
46
|
};
|
|
38
47
|
}
|
|
@@ -45,6 +54,59 @@ export function agentIntegrationRows(agents: DiscoveredAgent[]): CheckResult[] {
|
|
|
45
54
|
});
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
/**
|
|
58
|
+
* PURE — the "Claude hooks target writable" row from observed facts. Both
|
|
59
|
+
* `integration install claude` and the npm postinstall write the settings
|
|
60
|
+
* file; surface a permissions problem BEFORE an install fails halfway.
|
|
61
|
+
* Optional: a machine without Claude Code shouldn't fail doctor over this.
|
|
62
|
+
*/
|
|
63
|
+
export function hooksTargetRow(facts: {
|
|
64
|
+
settingsPath: string;
|
|
65
|
+
fileExists: boolean;
|
|
66
|
+
writable: boolean;
|
|
67
|
+
}): CheckResult {
|
|
68
|
+
const label = "Claude hooks target writable";
|
|
69
|
+
if (facts.writable) {
|
|
70
|
+
return {
|
|
71
|
+
label,
|
|
72
|
+
pass: true,
|
|
73
|
+
detail: facts.fileExists ? facts.settingsPath : `${facts.settingsPath} (will be created)`,
|
|
74
|
+
optional: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
label,
|
|
79
|
+
pass: false,
|
|
80
|
+
detail: `cannot write ${facts.settingsPath} — fix its permissions (chown/chmod), or point TMUX_IDE_CLAUDE_SETTINGS at a writable path`,
|
|
81
|
+
optional: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* PURE — native notifier health, shown only when macOS notifications are ON.
|
|
87
|
+
* Release packages always include the helper; absence means an incomplete or
|
|
88
|
+
* old installation, which can still fall back to the unbranded AppleScript path.
|
|
89
|
+
* Optional — informational, never fails doctor.
|
|
90
|
+
*/
|
|
91
|
+
export function notifierRow(present: boolean): CheckResult {
|
|
92
|
+
const label = "native macOS notifications";
|
|
93
|
+
if (present) {
|
|
94
|
+
return {
|
|
95
|
+
label,
|
|
96
|
+
pass: true,
|
|
97
|
+
detail: "bundled — branded banners and click-to-jump are ready",
|
|
98
|
+
optional: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
label,
|
|
103
|
+
pass: false,
|
|
104
|
+
detail:
|
|
105
|
+
"native helper missing — reinstall tmux-ide; unbranded AppleScript banners remain available",
|
|
106
|
+
optional: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
48
110
|
function check(
|
|
49
111
|
label: string,
|
|
50
112
|
fn: () => string,
|
|
@@ -67,7 +129,13 @@ export async function doctor({
|
|
|
67
129
|
|
|
68
130
|
checks.push(
|
|
69
131
|
check("tmux installed", () => {
|
|
70
|
-
|
|
132
|
+
try {
|
|
133
|
+
execSync("which tmux", { stdio: "ignore" });
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(
|
|
136
|
+
"not found on PATH — install it (macOS: `brew install tmux`; Debian/Ubuntu: `sudo apt install tmux`)",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
71
139
|
return "found";
|
|
72
140
|
}),
|
|
73
141
|
);
|
|
@@ -153,37 +221,49 @@ export async function doctor({
|
|
|
153
221
|
),
|
|
154
222
|
);
|
|
155
223
|
|
|
156
|
-
|
|
224
|
+
// Optional tunnel CLIs: probe quietly (a missing binary must not leak
|
|
225
|
+
// "command not found" stderr into doctor's own output) and fail with a
|
|
226
|
+
// plain hint rather than execSync's raw error.
|
|
227
|
+
const tunnelCli = (label: string, cmd: string): CheckResult =>
|
|
157
228
|
check(
|
|
158
|
-
|
|
159
|
-
() => {
|
|
160
|
-
const version = execSync("tailscale version", { encoding: "utf-8" }).trim().split("\n")[0]!;
|
|
161
|
-
return version;
|
|
162
|
-
},
|
|
163
|
-
{ optional: true },
|
|
164
|
-
),
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
checks.push(
|
|
168
|
-
check(
|
|
169
|
-
"ngrok CLI",
|
|
229
|
+
label,
|
|
170
230
|
() => {
|
|
171
|
-
|
|
172
|
-
|
|
231
|
+
try {
|
|
232
|
+
return execSync(cmd, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] })
|
|
233
|
+
.trim()
|
|
234
|
+
.split("\n")[0]!;
|
|
235
|
+
} catch {
|
|
236
|
+
throw new Error("not found (optional — used for remote access tunnels)");
|
|
237
|
+
}
|
|
173
238
|
},
|
|
174
239
|
{ optional: true },
|
|
175
|
-
)
|
|
176
|
-
);
|
|
240
|
+
);
|
|
241
|
+
checks.push(tunnelCli("tailscale CLI", "tailscale version"));
|
|
242
|
+
checks.push(tunnelCli("ngrok CLI", "ngrok version"));
|
|
243
|
+
checks.push(tunnelCli("cloudflared CLI", "cloudflared --version"));
|
|
177
244
|
|
|
178
245
|
checks.push(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
()
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
246
|
+
(() => {
|
|
247
|
+
// Claude hooks target: the settings file `integration install claude`
|
|
248
|
+
// (and the npm postinstall) will write. Writability of the file itself
|
|
249
|
+
// when it exists, else of its nearest existing ancestor directory.
|
|
250
|
+
const settingsPath = claudeSettingsPath();
|
|
251
|
+
const fileExists = existsSync(settingsPath);
|
|
252
|
+
let probe = fileExists ? settingsPath : dirname(settingsPath);
|
|
253
|
+
while (!existsSync(probe)) {
|
|
254
|
+
const parent = dirname(probe);
|
|
255
|
+
if (parent === probe) break;
|
|
256
|
+
probe = parent;
|
|
257
|
+
}
|
|
258
|
+
let writable = false;
|
|
259
|
+
try {
|
|
260
|
+
accessSync(probe, constants.W_OK);
|
|
261
|
+
writable = true;
|
|
262
|
+
} catch {
|
|
263
|
+
// leave writable=false — the row explains the fix
|
|
264
|
+
}
|
|
265
|
+
return hooksTargetRow({ settingsPath, fileExists, writable });
|
|
266
|
+
})(),
|
|
187
267
|
);
|
|
188
268
|
|
|
189
269
|
checks.push(
|
|
@@ -222,6 +302,11 @@ export async function doctor({
|
|
|
222
302
|
),
|
|
223
303
|
);
|
|
224
304
|
|
|
305
|
+
// Native branded sender: only when the macOS channel is actually on.
|
|
306
|
+
if (process.platform === "darwin" && readNotificationPrefs().macos) {
|
|
307
|
+
checks.push(notifierRow(resolveNativeMacosNotifierPath() !== null));
|
|
308
|
+
}
|
|
309
|
+
|
|
225
310
|
// Agent integrations: one row per agent discovered on PATH (absent → no row).
|
|
226
311
|
checks.push(...agentIntegrationRows(discoverAgents()));
|
|
227
312
|
|
|
@@ -17,6 +17,21 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { execFileSync } from "node:child_process";
|
|
19
19
|
import { claudeIntegrationStatus } from "../tui/integrations/claude.ts";
|
|
20
|
+
import { opencodeIntegrationStatus } from "../tui/integrations/opencode.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How a kind's `@agent_session_id` (the `restore --resume-agents` key) gets
|
|
24
|
+
* captured:
|
|
25
|
+
* - `"hooks"` — the agent's own lifecycle hooks stamp it (claude; needs
|
|
26
|
+
* `integration install`).
|
|
27
|
+
* - `"plugin"` — an in-process plugin stamps it (opencode; needs
|
|
28
|
+
* `integration install`).
|
|
29
|
+
* - `"probe"` — the chrome updater discovers it from the agent's own on-disk
|
|
30
|
+
* session state (codex, cursor; automatic, nothing to install).
|
|
31
|
+
* - `null` — no defensible capture surface (see
|
|
32
|
+
* {@link ../tui/detect/session-id.ts} for the per-kind evidence).
|
|
33
|
+
*/
|
|
34
|
+
export type CaptureMechanism = "hooks" | "plugin" | "probe" | null;
|
|
20
35
|
|
|
21
36
|
/** A coding agent tmux-ide knows how to detect. */
|
|
22
37
|
export interface KnownAgent {
|
|
@@ -26,19 +41,24 @@ export interface KnownAgent {
|
|
|
26
41
|
bin: string;
|
|
27
42
|
/** True → tmux-ide ships a lifecycle-integration installer for this agent. */
|
|
28
43
|
integration: boolean;
|
|
44
|
+
/** How this kind's session id is captured for `restore --resume-agents`. */
|
|
45
|
+
capture: CaptureMechanism;
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
/**
|
|
32
49
|
* The agents tmux-ide recognizes. `integration: true` means we HAVE an installer
|
|
33
|
-
* (
|
|
34
|
-
* screen-manifest scraping only
|
|
50
|
+
* (claude's lifecycle hooks, opencode's plugin); the rest are detected via
|
|
51
|
+
* screen-manifest scraping only. `capture` records each kind's session-id
|
|
52
|
+
* story ({@link CaptureMechanism}).
|
|
35
53
|
*/
|
|
36
54
|
export const KNOWN_AGENTS: readonly KnownAgent[] = [
|
|
37
|
-
{ id: "claude", bin: "claude", integration: true },
|
|
38
|
-
{ id: "codex", bin: "codex", integration: false },
|
|
39
|
-
{ id: "opencode", bin: "opencode", integration:
|
|
40
|
-
{ id: "gemini", bin: "gemini", integration: false },
|
|
41
|
-
{ id: "aider", bin: "aider", integration: false },
|
|
55
|
+
{ id: "claude", bin: "claude", integration: true, capture: "hooks" },
|
|
56
|
+
{ id: "codex", bin: "codex", integration: false, capture: "probe" },
|
|
57
|
+
{ id: "opencode", bin: "opencode", integration: true, capture: "plugin" },
|
|
58
|
+
{ id: "gemini", bin: "gemini", integration: false, capture: null },
|
|
59
|
+
{ id: "aider", bin: "aider", integration: false, capture: null },
|
|
60
|
+
{ id: "cursor", bin: "cursor-agent", integration: false, capture: "probe" },
|
|
61
|
+
{ id: "copilot", bin: "copilot", integration: false, capture: null },
|
|
42
62
|
];
|
|
43
63
|
|
|
44
64
|
/** One probed agent: its registry facts plus what the PATH/integration probe found. */
|
|
@@ -55,6 +75,15 @@ export interface DiscoveredAgent {
|
|
|
55
75
|
* integrate (there's nothing to install) and for any agent absent from PATH.
|
|
56
76
|
*/
|
|
57
77
|
installed: boolean;
|
|
78
|
+
/** How this kind's session id is captured (copied from the registry). */
|
|
79
|
+
capture: CaptureMechanism;
|
|
80
|
+
/**
|
|
81
|
+
* Whether session-id capture is LIVE for this kind on this machine:
|
|
82
|
+
* `"probe"` capture is automatic whenever the binary is present; hook/plugin
|
|
83
|
+
* capture requires the integration to be installed; `null` capture is never
|
|
84
|
+
* active.
|
|
85
|
+
*/
|
|
86
|
+
captureActive: boolean;
|
|
58
87
|
}
|
|
59
88
|
|
|
60
89
|
/** Resolve a binary to its absolute path, or null. Must never throw. */
|
|
@@ -84,14 +113,15 @@ const defaultWhich: WhichRunner = (bin) => {
|
|
|
84
113
|
};
|
|
85
114
|
|
|
86
115
|
/**
|
|
87
|
-
* Default integration probe:
|
|
88
|
-
*
|
|
89
|
-
* discovery never throws.
|
|
116
|
+
* Default integration probe: claude's hooks and opencode's plugin are the two
|
|
117
|
+
* shipped installers. Reads the real settings/plugin file; any failure degrades
|
|
118
|
+
* to false so discovery never throws.
|
|
90
119
|
*/
|
|
91
120
|
const defaultIntegrationProbe: IntegrationProbe = (agentId) => {
|
|
92
|
-
if (agentId !== "claude") return false;
|
|
93
121
|
try {
|
|
94
|
-
return claudeIntegrationStatus().installed;
|
|
122
|
+
if (agentId === "claude") return claudeIntegrationStatus().installed;
|
|
123
|
+
if (agentId === "opencode") return opencodeIntegrationStatus().installed;
|
|
124
|
+
return false;
|
|
95
125
|
} catch {
|
|
96
126
|
return false;
|
|
97
127
|
}
|
|
@@ -111,7 +141,17 @@ export function discoverAgents(
|
|
|
111
141
|
const path = which(agent.bin);
|
|
112
142
|
const present = path !== null;
|
|
113
143
|
const installed = present && agent.integration ? isInstalled(agent.id) : false;
|
|
114
|
-
|
|
144
|
+
const captureActive =
|
|
145
|
+
agent.capture === "probe" ? present : agent.capture !== null ? installed : false;
|
|
146
|
+
return {
|
|
147
|
+
id: agent.id,
|
|
148
|
+
bin: agent.bin,
|
|
149
|
+
integration: agent.integration,
|
|
150
|
+
path,
|
|
151
|
+
installed,
|
|
152
|
+
capture: agent.capture,
|
|
153
|
+
captureActive,
|
|
154
|
+
};
|
|
115
155
|
});
|
|
116
156
|
}
|
|
117
157
|
|