@paradow/ledge 0.0.0-stage → 0.3.1
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 +106 -2
- package/dist/cli/attach-YBGTDBQI.js +53 -0
- package/dist/cli/chunk-6GMDFIGB.js +306 -0
- package/dist/cli/chunk-6ZTCETRG.js +38 -0
- package/dist/cli/chunk-7ICDC2LK.js +88 -0
- package/dist/cli/chunk-ATG6CVVY.js +6 -0
- package/dist/cli/chunk-OUIDBDIH.js +59 -0
- package/dist/cli/chunk-W5XAZ5IS.js +77 -0
- package/dist/cli/chunk-XDRYMOVF.js +267 -0
- package/dist/cli/chunk-Y44XZY6I.js +197 -0
- package/dist/cli/chunk-ZPF5I3LS.js +216 -0
- package/dist/cli/daemon-6DDF7YZD.js +1893 -0
- package/dist/cli/hook-HP23ZISG.js +288 -0
- package/dist/cli/index.js +78 -0
- package/dist/cli/launch-DFB54ANN.js +26 -0
- package/dist/cli/setup-KUWSQLVG.js +33 -0
- package/dist/cli/status-IW2FESH4.js +26 -0
- package/dist/cli/uninstall-5GHUYN7P.js +154 -0
- package/dist/cli/version-IBPGTVPF.js +6 -0
- package/package.json +28 -4
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROTOCOL_PORT,
|
|
3
|
+
parseServerMessage
|
|
4
|
+
} from "./chunk-6GMDFIGB.js";
|
|
5
|
+
|
|
6
|
+
// src/client.ts
|
|
7
|
+
import WebSocket from "ws";
|
|
8
|
+
function connect(opts = {}) {
|
|
9
|
+
const port = opts.port ?? PROTOCOL_PORT;
|
|
10
|
+
const host = opts.host ?? "127.0.0.1";
|
|
11
|
+
const ws = new WebSocket(`ws://${host}:${port}`);
|
|
12
|
+
const queue = [];
|
|
13
|
+
const MAX_QUEUED = 100;
|
|
14
|
+
const waiters = [];
|
|
15
|
+
ws.on("message", (data) => {
|
|
16
|
+
let msg;
|
|
17
|
+
try {
|
|
18
|
+
msg = parseServerMessage(JSON.parse(String(data)));
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const i = waiters.findIndex((w) => w.type === msg.type);
|
|
23
|
+
if (i >= 0) waiters.splice(i, 1)[0].resolve(msg);
|
|
24
|
+
else {
|
|
25
|
+
queue.push(msg);
|
|
26
|
+
if (queue.length > MAX_QUEUED) queue.shift();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const client = {
|
|
30
|
+
send: (msg) => ws.send(JSON.stringify(msg)),
|
|
31
|
+
next(type, timeoutMs = 1500) {
|
|
32
|
+
const i = queue.findIndex((m) => m.type === type);
|
|
33
|
+
if (i >= 0) return Promise.resolve(queue.splice(i, 1)[0]);
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const w = { type, resolve: (m) => {
|
|
36
|
+
clearTimeout(t);
|
|
37
|
+
resolve(m);
|
|
38
|
+
} };
|
|
39
|
+
const t = setTimeout(() => {
|
|
40
|
+
waiters.splice(waiters.indexOf(w), 1);
|
|
41
|
+
reject(new Error(`timeout waiting for ${type}`));
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
waiters.push(w);
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
close: () => new Promise((res) => {
|
|
47
|
+
if (ws.readyState === WebSocket.CLOSED) return res();
|
|
48
|
+
ws.once("close", () => res());
|
|
49
|
+
ws.close();
|
|
50
|
+
})
|
|
51
|
+
};
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const t = setTimeout(() => {
|
|
54
|
+
ws.terminate();
|
|
55
|
+
reject(new Error("connect timeout"));
|
|
56
|
+
}, opts.timeoutMs ?? 1e3);
|
|
57
|
+
ws.once("open", () => {
|
|
58
|
+
clearTimeout(t);
|
|
59
|
+
resolve(client);
|
|
60
|
+
});
|
|
61
|
+
let settled = false;
|
|
62
|
+
ws.once("open", () => {
|
|
63
|
+
settled = true;
|
|
64
|
+
});
|
|
65
|
+
ws.on("error", (e) => {
|
|
66
|
+
if (!settled) {
|
|
67
|
+
settled = true;
|
|
68
|
+
clearTimeout(t);
|
|
69
|
+
reject(e);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
connect
|
|
77
|
+
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ensureLedgeDir,
|
|
3
|
+
paths
|
|
4
|
+
} from "./chunk-6ZTCETRG.js";
|
|
5
|
+
|
|
6
|
+
// src/cli/setup.ts
|
|
7
|
+
import { readFile, writeFile, mkdir, rename, realpath, rm, stat, lstat, readlink, symlink, unlink } from "fs/promises";
|
|
8
|
+
import { execFile } from "child_process";
|
|
9
|
+
import { homedir, userInfo } from "os";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
var CLAUDE_EVENTS = [
|
|
12
|
+
"SessionStart",
|
|
13
|
+
"UserPromptSubmit",
|
|
14
|
+
"PreToolUse",
|
|
15
|
+
"PermissionRequest",
|
|
16
|
+
"Notification",
|
|
17
|
+
"Stop",
|
|
18
|
+
"SessionEnd",
|
|
19
|
+
"PostToolUse",
|
|
20
|
+
"PostToolUseFailure",
|
|
21
|
+
"StopFailure",
|
|
22
|
+
"SubagentStart",
|
|
23
|
+
"SubagentStop"
|
|
24
|
+
];
|
|
25
|
+
var CODEX_EVENTS = [
|
|
26
|
+
"SessionStart",
|
|
27
|
+
"UserPromptSubmit",
|
|
28
|
+
"PreToolUse",
|
|
29
|
+
"PermissionRequest",
|
|
30
|
+
"PostToolUse",
|
|
31
|
+
"Stop",
|
|
32
|
+
"SubagentStart",
|
|
33
|
+
"SubagentStop",
|
|
34
|
+
"Interrupt",
|
|
35
|
+
"SessionEnd"
|
|
36
|
+
];
|
|
37
|
+
function mergeHooks(settings, events, command, file) {
|
|
38
|
+
if (settings === void 0) settings = {};
|
|
39
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new Error(`${file} root is not an object, not modified`);
|
|
40
|
+
const base = settings;
|
|
41
|
+
if (base.hooks !== void 0 && (!base.hooks || typeof base.hooks !== "object" || Array.isArray(base.hooks))) throw new Error(`${file} "hooks" is not an object, not modified`);
|
|
42
|
+
const hooks = { ...base.hooks ?? {} };
|
|
43
|
+
let changed = false;
|
|
44
|
+
for (const ev of events) {
|
|
45
|
+
if (hooks[ev] !== void 0 && !Array.isArray(hooks[ev])) throw new Error(`${file} hooks.${ev} is not an array, not modified`);
|
|
46
|
+
const list = [...hooks[ev] ?? []];
|
|
47
|
+
const mine = `${command} ${ev}`;
|
|
48
|
+
if (!list.some((e) => e.hooks?.some((h) => h.command === mine))) {
|
|
49
|
+
list.push({ hooks: [{ type: "command", command: mine, timeout: 5 }] });
|
|
50
|
+
changed = true;
|
|
51
|
+
}
|
|
52
|
+
hooks[ev] = list;
|
|
53
|
+
}
|
|
54
|
+
return { settings: { ...base, hooks }, changed };
|
|
55
|
+
}
|
|
56
|
+
function mergeClaudeHooks(settings, command = "ledge hook") {
|
|
57
|
+
return mergeHooks(settings, CLAUDE_EVENTS, command, "settings.json");
|
|
58
|
+
}
|
|
59
|
+
function mergeCodexHooks(hooks, command = "ledge hook codex") {
|
|
60
|
+
return mergeHooks(hooks, CODEX_EVENTS, command, "hooks.json");
|
|
61
|
+
}
|
|
62
|
+
var CURSOR_EVENTS = [
|
|
63
|
+
"sessionStart",
|
|
64
|
+
"beforeSubmitPrompt",
|
|
65
|
+
"preToolUse",
|
|
66
|
+
"postToolUse",
|
|
67
|
+
"postToolUseFailure",
|
|
68
|
+
"stop",
|
|
69
|
+
"afterAgentResponse",
|
|
70
|
+
"sessionEnd"
|
|
71
|
+
];
|
|
72
|
+
function mergeCursorHooks(config, command = "ledge hook cursor") {
|
|
73
|
+
if (config === void 0) config = {};
|
|
74
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("hooks.json root is not an object, not modified");
|
|
75
|
+
const base = config;
|
|
76
|
+
if (base.hooks !== void 0 && (!base.hooks || typeof base.hooks !== "object" || Array.isArray(base.hooks))) throw new Error('hooks.json "hooks" is not an object, not modified');
|
|
77
|
+
const hooks = { ...base.hooks ?? {} };
|
|
78
|
+
let changed = base.version === void 0;
|
|
79
|
+
for (const ev of CURSOR_EVENTS) {
|
|
80
|
+
if (hooks[ev] !== void 0 && !Array.isArray(hooks[ev])) throw new Error(`hooks.json hooks.${ev} is not an array, not modified`);
|
|
81
|
+
const list = [...hooks[ev] ?? []];
|
|
82
|
+
const mine = `${command} ${ev}`;
|
|
83
|
+
if (!list.some((e) => e?.command === mine)) {
|
|
84
|
+
list.push({ command: mine, timeout: 5 });
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
hooks[ev] = list;
|
|
88
|
+
}
|
|
89
|
+
return { settings: { version: 1, ...base, hooks }, changed };
|
|
90
|
+
}
|
|
91
|
+
async function mergeJsonFile(path, merge, name) {
|
|
92
|
+
let raw = null;
|
|
93
|
+
try {
|
|
94
|
+
raw = await readFile(path, "utf8");
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.code !== "ENOENT") throw e;
|
|
97
|
+
}
|
|
98
|
+
let current;
|
|
99
|
+
if (raw !== null) {
|
|
100
|
+
try {
|
|
101
|
+
current = JSON.parse(raw);
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error(`${name} is not valid JSON, not modified`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const { settings, changed } = merge(current);
|
|
107
|
+
if (!changed) return false;
|
|
108
|
+
await mkdir(dirname(path), { recursive: true });
|
|
109
|
+
const tmp = `${path}.ledge-${process.pid}.tmp`;
|
|
110
|
+
await writeFile(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
111
|
+
await rename(tmp, path);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
var SERVICE = "ledge-daemon.service";
|
|
115
|
+
var execRunner = (cmd, args) => new Promise((resolve) => {
|
|
116
|
+
execFile(cmd, args, (err, stdout, stderr) => {
|
|
117
|
+
const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
|
|
118
|
+
resolve({ code, stdout: String(stdout), stderr: String(stderr) });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
function systemdUnit(opts) {
|
|
122
|
+
return `[Unit]
|
|
123
|
+
Description=Ledge daemon (agent sessions for the Windows overlay)
|
|
124
|
+
|
|
125
|
+
[Service]
|
|
126
|
+
ExecStart=${opts.node} ${opts.script} daemon
|
|
127
|
+
Restart=on-failure
|
|
128
|
+
RestartSec=2
|
|
129
|
+
|
|
130
|
+
[Install]
|
|
131
|
+
WantedBy=default.target
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
async function writeIfChanged(path, content) {
|
|
135
|
+
let current = null;
|
|
136
|
+
try {
|
|
137
|
+
current = await readFile(path, "utf8");
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (e.code !== "ENOENT") throw e;
|
|
140
|
+
}
|
|
141
|
+
if (current === content) return false;
|
|
142
|
+
await mkdir(dirname(path), { recursive: true });
|
|
143
|
+
const tmp = `${path}.ledge-${process.pid}.tmp`;
|
|
144
|
+
await writeFile(tmp, content);
|
|
145
|
+
await rename(tmp, path);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
async function stopDetachedDaemon(pidFile) {
|
|
149
|
+
let pid;
|
|
150
|
+
try {
|
|
151
|
+
pid = Number((await readFile(pidFile, "utf8")).trim());
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
156
|
+
try {
|
|
157
|
+
process.kill(pid, 0);
|
|
158
|
+
} catch {
|
|
159
|
+
await rm(pidFile, { force: true });
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
process.kill(pid, "SIGTERM");
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const deadline = Date.now() + 3e3;
|
|
168
|
+
while (Date.now() < deadline) {
|
|
169
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
170
|
+
try {
|
|
171
|
+
process.kill(pid, 0);
|
|
172
|
+
} catch {
|
|
173
|
+
return pid;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return pid;
|
|
177
|
+
}
|
|
178
|
+
async function installService(opts = {}) {
|
|
179
|
+
const out = [];
|
|
180
|
+
const run = opts.run ?? execRunner;
|
|
181
|
+
const unitDir = opts.unitDir ?? join(homedir(), ".config", "systemd", "user");
|
|
182
|
+
const node = opts.node ?? await realpath(process.execPath);
|
|
183
|
+
const script = opts.script ?? await realpath(process.argv[1]);
|
|
184
|
+
const changed = await writeIfChanged(join(unitDir, SERVICE), systemdUnit({ node, script }));
|
|
185
|
+
const reload = await run("systemctl", ["--user", "daemon-reload"]);
|
|
186
|
+
if (reload.code !== 0) throw new Error(`systemd user session unavailable (systemctl --user daemon-reload failed: ${reload.stderr.trim() || reload.code}); the daemon will still be started on demand by ledge claude`);
|
|
187
|
+
const active = (await run("systemctl", ["--user", "is-active", SERVICE])).code === 0;
|
|
188
|
+
if (active && changed) {
|
|
189
|
+
const r = await run("systemctl", ["--user", "restart", SERVICE]);
|
|
190
|
+
if (r.code !== 0) throw new Error(`systemctl --user restart ${SERVICE} failed: ${r.stderr.trim()}`);
|
|
191
|
+
out.push(`${SERVICE} updated and restarted`);
|
|
192
|
+
} else if (active) {
|
|
193
|
+
out.push(`${SERVICE} already running`);
|
|
194
|
+
} else {
|
|
195
|
+
const pid = await stopDetachedDaemon(paths().pid);
|
|
196
|
+
if (pid !== null) out.push(`detached daemon (pid ${pid}) stopped, the service takes over`);
|
|
197
|
+
const r = await run("systemctl", ["--user", "enable", "--now", SERVICE]);
|
|
198
|
+
if (r.code !== 0) throw new Error(`systemctl --user enable --now ${SERVICE} failed: ${r.stderr.trim()}`);
|
|
199
|
+
out.push(`${SERVICE} installed and started (journalctl --user -u ${SERVICE} for its output)`);
|
|
200
|
+
}
|
|
201
|
+
const user = process.env.USER ?? userInfo().username;
|
|
202
|
+
const linger = await run("loginctl", ["show-user", user, "-p", "Linger"]);
|
|
203
|
+
if (linger.code === 0 && /^Linger=yes/m.test(linger.stdout)) return out;
|
|
204
|
+
const enable = await run("loginctl", ["enable-linger"]);
|
|
205
|
+
if (enable.code === 0) out.push("linger enabled: the daemon starts with WSL, before any shell is open");
|
|
206
|
+
else out.push(`linger not enabled (${enable.stderr.trim() || `exit ${enable.code}`}): the daemon starts with your first WSL shell; run \`sudo loginctl enable-linger ${user}\` to start it at WSL boot`);
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
async function isDir(path) {
|
|
210
|
+
try {
|
|
211
|
+
return (await stat(path)).isDirectory();
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function relinkBin(opts) {
|
|
217
|
+
const st = await lstat(opts.link).catch(() => null);
|
|
218
|
+
if (!st?.isSymbolicLink()) return null;
|
|
219
|
+
const current = await readlink(opts.link);
|
|
220
|
+
if (current === opts.target || !current.endsWith("/dist/cli/index.js")) return null;
|
|
221
|
+
await unlink(opts.link);
|
|
222
|
+
await symlink(opts.target, opts.link);
|
|
223
|
+
return `${opts.link} now points to ${opts.target} (was ${current})`;
|
|
224
|
+
}
|
|
225
|
+
async function runSetup(opts = {}) {
|
|
226
|
+
const out = [];
|
|
227
|
+
await ensureLedgeDir();
|
|
228
|
+
out.push("~/.ledge ready (tmux.conf written if missing)");
|
|
229
|
+
const settingsPath = opts.settingsPath ?? join(homedir(), ".claude", "settings.json");
|
|
230
|
+
if (await mergeJsonFile(settingsPath, mergeClaudeHooks, "settings.json")) out.push(`Claude Code hooks added in ${settingsPath}`);
|
|
231
|
+
else out.push("Claude Code hooks already present");
|
|
232
|
+
const codexDir = opts.codexDir ?? process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
233
|
+
if (!await isDir(codexDir)) out.push(`Codex not found (${codexDir} missing), Codex hooks skipped`);
|
|
234
|
+
else if (await mergeJsonFile(join(codexDir, "hooks.json"), mergeCodexHooks, "hooks.json")) {
|
|
235
|
+
out.push(`Codex hooks added in ${join(codexDir, "hooks.json")}; Codex asks to review them on its next start: trust them there or from Ledge`);
|
|
236
|
+
} else out.push("Codex hooks already present");
|
|
237
|
+
const cursorDir = opts.cursorDir ?? join(homedir(), ".cursor");
|
|
238
|
+
if (!await isDir(cursorDir)) out.push(`Cursor Agent not found (${cursorDir} missing), Cursor Agent hooks skipped`);
|
|
239
|
+
else if (await mergeJsonFile(join(cursorDir, "hooks.json"), mergeCursorHooks, "hooks.json")) out.push(`Cursor Agent hooks added in ${join(cursorDir, "hooks.json")}`);
|
|
240
|
+
else out.push("Cursor Agent hooks already present");
|
|
241
|
+
if ((opts.service ?? true) && !process.argv[1]?.endsWith(".ts")) {
|
|
242
|
+
const line = await relinkBin({ link: join(homedir(), ".local", "bin", "ledge"), target: await realpath(process.argv[1]) });
|
|
243
|
+
if (line) out.push(line);
|
|
244
|
+
}
|
|
245
|
+
if (opts.service ?? true) {
|
|
246
|
+
if (process.argv[1]?.endsWith(".ts")) out.push("systemd service skipped: run setup from the built CLI (pnpm --filter @paradow/ledge build, then ledge setup)");
|
|
247
|
+
else out.push(...await installService());
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export {
|
|
253
|
+
CLAUDE_EVENTS,
|
|
254
|
+
CODEX_EVENTS,
|
|
255
|
+
mergeClaudeHooks,
|
|
256
|
+
mergeCodexHooks,
|
|
257
|
+
CURSOR_EVENTS,
|
|
258
|
+
mergeCursorHooks,
|
|
259
|
+
SERVICE,
|
|
260
|
+
execRunner,
|
|
261
|
+
systemdUnit,
|
|
262
|
+
stopDetachedDaemon,
|
|
263
|
+
installService,
|
|
264
|
+
isDir,
|
|
265
|
+
relinkBin,
|
|
266
|
+
runSetup
|
|
267
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
realTmux
|
|
3
|
+
} from "./chunk-7ICDC2LK.js";
|
|
4
|
+
import {
|
|
5
|
+
exitFile,
|
|
6
|
+
log,
|
|
7
|
+
spawnSession
|
|
8
|
+
} from "./chunk-OUIDBDIH.js";
|
|
9
|
+
import {
|
|
10
|
+
ensureLedgeDir
|
|
11
|
+
} from "./chunk-6ZTCETRG.js";
|
|
12
|
+
import {
|
|
13
|
+
connect
|
|
14
|
+
} from "./chunk-W5XAZ5IS.js";
|
|
15
|
+
import {
|
|
16
|
+
PROTOCOL_PORT
|
|
17
|
+
} from "./chunk-6GMDFIGB.js";
|
|
18
|
+
|
|
19
|
+
// src/cli/launch.ts
|
|
20
|
+
import { spawn } from "child_process";
|
|
21
|
+
import { readFile, rm } from "fs/promises";
|
|
22
|
+
import { createConnection } from "net";
|
|
23
|
+
var TMUX_SOCKET = "ledge";
|
|
24
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
function parseLaunchArgs(argv) {
|
|
26
|
+
const args = [];
|
|
27
|
+
let name;
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a === "--name") {
|
|
31
|
+
name = argv[++i];
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (a === "--") {
|
|
35
|
+
args.push(...argv.slice(i + 1));
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
args.push(a);
|
|
39
|
+
}
|
|
40
|
+
return { name, args };
|
|
41
|
+
}
|
|
42
|
+
function portOpen(port) {
|
|
43
|
+
return new Promise((res) => {
|
|
44
|
+
const s = createConnection({ host: "127.0.0.1", port });
|
|
45
|
+
s.once("connect", () => {
|
|
46
|
+
s.end();
|
|
47
|
+
res(true);
|
|
48
|
+
});
|
|
49
|
+
s.once("error", () => res(false));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function spawnDetachedDaemon() {
|
|
53
|
+
const child = spawn(process.execPath, [process.argv[1], "daemon"], { detached: true, stdio: "ignore" });
|
|
54
|
+
child.unref();
|
|
55
|
+
}
|
|
56
|
+
var closeQuietly = (c) => Promise.race([c.close(), sleep(500)]);
|
|
57
|
+
async function probe(port, timeoutMs) {
|
|
58
|
+
let client;
|
|
59
|
+
try {
|
|
60
|
+
client = await connect({ port, timeoutMs });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (e.message === "connect timeout") return { client: null, state: "timeout" };
|
|
63
|
+
return { client: null, state: await portOpen(port) ? "foreign" : "refused" };
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await client.next("snapshot", timeoutMs);
|
|
67
|
+
return { client };
|
|
68
|
+
} catch {
|
|
69
|
+
void closeQuietly(client);
|
|
70
|
+
return { client: null, state: "foreign" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function ensureDaemon(opts = {}) {
|
|
74
|
+
await ensureLedgeDir();
|
|
75
|
+
const port = opts.port ?? PROTOCOL_PORT;
|
|
76
|
+
const timeoutMs = opts.timeoutMs ?? 2e3;
|
|
77
|
+
const first = await probe(port, timeoutMs);
|
|
78
|
+
if (first.client) return { client: first.client };
|
|
79
|
+
if (first.state === "foreign") {
|
|
80
|
+
log(`port ${port} is open but does not speak the Ledge protocol`);
|
|
81
|
+
return { client: null, reason: "foreign" };
|
|
82
|
+
}
|
|
83
|
+
if (first.state === "timeout") {
|
|
84
|
+
log(`port ${port} accepted a connection but did not answer within ${timeoutMs} ms`);
|
|
85
|
+
return { client: null, reason: "unresponsive" };
|
|
86
|
+
}
|
|
87
|
+
(opts.spawnDaemon ?? spawnDetachedDaemon)();
|
|
88
|
+
for (let i = 0; i < 3; i++) {
|
|
89
|
+
await sleep(opts.retryDelayMs ?? 500);
|
|
90
|
+
const r = await probe(port, timeoutMs);
|
|
91
|
+
if (r.client) return { client: r.client };
|
|
92
|
+
}
|
|
93
|
+
return { client: null, reason: "unreachable" };
|
|
94
|
+
}
|
|
95
|
+
var DaemonError = class extends Error {
|
|
96
|
+
};
|
|
97
|
+
async function createSession(opts) {
|
|
98
|
+
const names = [];
|
|
99
|
+
const ids = [];
|
|
100
|
+
if (opts.client) {
|
|
101
|
+
let snap;
|
|
102
|
+
try {
|
|
103
|
+
opts.client.send({ type: "snapshot.request" });
|
|
104
|
+
snap = await opts.client.next("snapshot");
|
|
105
|
+
} catch (e) {
|
|
106
|
+
throw new DaemonError(`daemon did not answer: ${e.message}`);
|
|
107
|
+
}
|
|
108
|
+
for (const s of snap.sessions) {
|
|
109
|
+
names.push(s.name);
|
|
110
|
+
ids.push(s.id);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const session = await spawnSession({ ...opts, taken: { names, ids } });
|
|
114
|
+
try {
|
|
115
|
+
opts.client?.send({ type: "session.register", session });
|
|
116
|
+
} catch (e) {
|
|
117
|
+
log(`register failed for ${session.id}: ${e.message}`);
|
|
118
|
+
}
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
function attachEnv(env) {
|
|
122
|
+
const { TMUX: _ignored, ...rest } = env;
|
|
123
|
+
return rest;
|
|
124
|
+
}
|
|
125
|
+
function attachInherit(args, env) {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const child = spawn("tmux", args, { stdio: "inherit", env });
|
|
128
|
+
child.on("error", () => resolve(1));
|
|
129
|
+
child.on("exit", (c) => resolve(c ?? 1));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async function readExitCode(id) {
|
|
133
|
+
const file = exitFile(id);
|
|
134
|
+
let raw = null;
|
|
135
|
+
try {
|
|
136
|
+
raw = await readFile(file, "utf8");
|
|
137
|
+
} catch {
|
|
138
|
+
}
|
|
139
|
+
await rm(file, { force: true });
|
|
140
|
+
const t = raw?.trim();
|
|
141
|
+
return t && /^\d+$/.test(t) ? Number(t) : null;
|
|
142
|
+
}
|
|
143
|
+
var UNTRACKED = {
|
|
144
|
+
foreign: (port) => `ledge: port ${port} is used by another program, session not tracked`,
|
|
145
|
+
unresponsive: () => "ledge: daemon not responding, session not tracked",
|
|
146
|
+
unreachable: () => "ledge: daemon not running, session not tracked"
|
|
147
|
+
};
|
|
148
|
+
async function launch(tool, argv, opts = {}) {
|
|
149
|
+
await ensureLedgeDir();
|
|
150
|
+
const { name, args } = parseLaunchArgs(argv);
|
|
151
|
+
const port = opts.port ?? PROTOCOL_PORT;
|
|
152
|
+
const socket = opts.tmuxSocket ?? TMUX_SOCKET;
|
|
153
|
+
const tmux = realTmux(socket);
|
|
154
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
155
|
+
const daemon = await ensureDaemon({ port, spawnDaemon: opts.spawnDaemon, retryDelayMs: opts.retryDelayMs });
|
|
156
|
+
let client = daemon.client;
|
|
157
|
+
if (!daemon.client) console.error(UNTRACKED[daemon.reason](port));
|
|
158
|
+
const create = (c) => createSession({ tool, args, command: opts.command, recordExit: true, name, cwd, tmux, client: c });
|
|
159
|
+
let session;
|
|
160
|
+
try {
|
|
161
|
+
session = await create(client);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
if (!(e instanceof DaemonError)) throw e;
|
|
164
|
+
log(e.message);
|
|
165
|
+
if (client) void closeQuietly(client);
|
|
166
|
+
client = null;
|
|
167
|
+
console.error(UNTRACKED.unresponsive(port));
|
|
168
|
+
session = await create(null);
|
|
169
|
+
}
|
|
170
|
+
if (client) await closeQuietly(client);
|
|
171
|
+
const tmuxName = session.tmuxSession;
|
|
172
|
+
if (process.stdout.isTTY) process.stdout.write(`\x1B]0;${session.title}\x07`);
|
|
173
|
+
const attachCode = await (opts.attach ?? attachInherit)(tmux.attachArgs(tmuxName), attachEnv(process.env));
|
|
174
|
+
if (await tmux.hasSession(tmuxName)) {
|
|
175
|
+
if (attachCode !== 0) console.error(`ledge: could not attach; ${session.name} keeps running in tmux session ${tmuxName} (tmux -L ${socket} attach -t ${tmuxName})`);
|
|
176
|
+
return attachCode;
|
|
177
|
+
}
|
|
178
|
+
const exitCode = await readExitCode(session.id);
|
|
179
|
+
if (client) {
|
|
180
|
+
try {
|
|
181
|
+
const c = await connect({ port, timeoutMs: 800 });
|
|
182
|
+
c.send({ type: "session.unregister", id: session.id, exitCode });
|
|
183
|
+
await closeQuietly(c);
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return exitCode ?? 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export {
|
|
191
|
+
parseLaunchArgs,
|
|
192
|
+
ensureDaemon,
|
|
193
|
+
DaemonError,
|
|
194
|
+
createSession,
|
|
195
|
+
attachEnv,
|
|
196
|
+
launch
|
|
197
|
+
};
|