@paradow/ledge 0.4.0 → 0.5.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.
@@ -1,17 +1,17 @@
1
1
  import {
2
2
  attachEnv
3
- } from "./chunk-ZPR4LCDM.js";
3
+ } from "./chunk-QHDPDZOJ.js";
4
+ import "./chunk-U4A4XKDM.js";
4
5
  import {
5
6
  realTmux
6
- } from "./chunk-DMV5VW4Q.js";
7
- import "./chunk-B73JDPSW.js";
8
- import "./chunk-6ZTCETRG.js";
7
+ } from "./chunk-XZMLFV6H.js";
8
+ import "./chunk-P2SHPHAN.js";
9
9
  import {
10
10
  connect
11
- } from "./chunk-34DHZSGH.js";
11
+ } from "./chunk-5HVWJTTJ.js";
12
12
  import {
13
13
  PROTOCOL_PORT
14
- } from "./chunk-YT4T7DL2.js";
14
+ } from "./chunk-Y4ODVTTN.js";
15
15
 
16
16
  // src/cli/attach.ts
17
17
  import { spawn } from "child_process";
@@ -0,0 +1,60 @@
1
+ import {
2
+ MAX_FRAME,
3
+ readFrames
4
+ } from "./chunk-OLLQBK6N.js";
5
+ import {
6
+ PROTOCOL_PORT,
7
+ parseClientMessage,
8
+ parseServerMessage
9
+ } from "./chunk-Y4ODVTTN.js";
10
+
11
+ // src/cli/bridge.ts
12
+ import WebSocket from "ws";
13
+ function bridge(opts = {}) {
14
+ const input = opts.input ?? process.stdin, output = opts.output ?? process.stdout;
15
+ const ws = new WebSocket(`ws://127.0.0.1:${opts.port ?? PROTOCOL_PORT}`, { maxPayload: MAX_FRAME, handshakeTimeout: 1e4 });
16
+ return new Promise((resolve, reject) => {
17
+ let settled = false, off = () => {
18
+ };
19
+ const finish = (error) => {
20
+ if (settled) return;
21
+ settled = true;
22
+ off();
23
+ input.pause();
24
+ input.off("end", end);
25
+ input.off("error", fail);
26
+ output.off("error", fail);
27
+ output.off("drain", drain);
28
+ ws.terminate();
29
+ error ? reject(error) : resolve();
30
+ };
31
+ const fail = (e) => finish(e);
32
+ const end = () => ws.close();
33
+ const drain = () => ws.resume();
34
+ input.on("error", fail);
35
+ output.on("error", fail);
36
+ output.on("drain", drain);
37
+ ws.on("open", () => {
38
+ off = readFrames(input, (raw) => {
39
+ const msg = parseClientMessage(raw);
40
+ if (ws.bufferedAmount > MAX_FRAME) throw new Error("Ledge bridge is overloaded");
41
+ ws.send(JSON.stringify(msg));
42
+ }, fail);
43
+ input.on("end", end);
44
+ if (input.readableEnded) end();
45
+ });
46
+ ws.on("message", (data) => {
47
+ try {
48
+ if (!output.write(`${JSON.stringify(parseServerMessage(JSON.parse(String(data))))}
49
+ `)) ws.pause();
50
+ } catch (e) {
51
+ fail(e);
52
+ }
53
+ });
54
+ ws.on("error", fail);
55
+ ws.on("close", (code) => finish(code === 1e3 || code === 1005 ? void 0 : new Error(`Ledge daemon disconnected (${code})`)));
56
+ });
57
+ }
58
+ export {
59
+ bridge
60
+ };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  PROTOCOL_PORT,
3
3
  parseServerMessage
4
- } from "./chunk-YT4T7DL2.js";
4
+ } from "./chunk-Y4ODVTTN.js";
5
5
 
6
6
  // src/client.ts
7
7
  import WebSocket from "ws";
@@ -1,13 +1,35 @@
1
+ import {
2
+ run
3
+ } from "./chunk-XZMLFV6H.js";
1
4
  import {
2
5
  ensureLedgeDir,
3
6
  paths
4
- } from "./chunk-6ZTCETRG.js";
7
+ } from "./chunk-P2SHPHAN.js";
5
8
 
6
9
  // src/cli/setup.ts
7
- import { readFile, writeFile, mkdir, rename, realpath, rm, stat, lstat, readlink, symlink, unlink } from "fs/promises";
10
+ import { readFile as readFile2, writeFile, mkdir, rename, realpath, rm, stat, lstat, readlink, symlink, unlink } from "fs/promises";
8
11
  import { execFile } from "child_process";
9
12
  import { homedir, userInfo } from "os";
10
13
  import { join, dirname } from "path";
14
+
15
+ // src/update/guard.ts
16
+ import { readFile } from "fs/promises";
17
+ var inDaemonService = (cgroup) => cgroup.split("\n").some((l) => l.split("/").includes(SERVICE));
18
+ async function tmuxServerAtRisk(opts = {}) {
19
+ const runTmux = opts.runTmux ?? run;
20
+ const readCgroup = opts.readCgroup ?? ((pid2) => readFile(`/proc/${pid2}/cgroup`, "utf8"));
21
+ const r = await runTmux(["-L", opts.socket ?? "ledge", "list-sessions", "-F", "#{pid}"]);
22
+ if (r.code !== 0) return false;
23
+ const pid = r.stdout.split("\n").map((s) => s.trim()).find(Boolean);
24
+ if (!pid) return false;
25
+ try {
26
+ return inDaemonService(await readCgroup(pid));
27
+ } catch {
28
+ return true;
29
+ }
30
+ }
31
+
32
+ // src/cli/setup.ts
11
33
  var CLAUDE_EVENTS = [
12
34
  "SessionStart",
13
35
  "UserPromptSubmit",
@@ -91,7 +113,7 @@ function mergeCursorHooks(config, command = "ledge hook cursor") {
91
113
  async function mergeJsonFile(path, merge, name) {
92
114
  let raw = null;
93
115
  try {
94
- raw = await readFile(path, "utf8");
116
+ raw = await readFile2(path, "utf8");
95
117
  } catch (e) {
96
118
  if (e.code !== "ENOENT") throw e;
97
119
  }
@@ -134,7 +156,7 @@ WantedBy=default.target
134
156
  async function writeIfChanged(path, content) {
135
157
  let current = null;
136
158
  try {
137
- current = await readFile(path, "utf8");
159
+ current = await readFile2(path, "utf8");
138
160
  } catch (e) {
139
161
  if (e.code !== "ENOENT") throw e;
140
162
  }
@@ -148,7 +170,7 @@ async function writeIfChanged(path, content) {
148
170
  async function stopDetachedDaemon(pidFile) {
149
171
  let pid;
150
172
  try {
151
- pid = Number((await readFile(pidFile, "utf8")).trim());
173
+ pid = Number((await readFile2(pidFile, "utf8")).trim());
152
174
  } catch {
153
175
  return null;
154
176
  }
@@ -177,16 +199,18 @@ async function stopDetachedDaemon(pidFile) {
177
199
  }
178
200
  async function installService(opts = {}) {
179
201
  const out = [];
180
- const run = opts.run ?? execRunner;
202
+ const run2 = opts.run ?? execRunner;
181
203
  const unitDir = opts.unitDir ?? join(homedir(), ".config", "systemd", "user");
182
204
  const node = opts.node ?? await realpath(process.execPath);
183
205
  const script = opts.script ?? await realpath(process.argv[1]);
184
206
  const changed = await writeIfChanged(join(unitDir, SERVICE), systemdUnit({ node, script }));
185
- const reload = await run("systemctl", ["--user", "daemon-reload"]);
207
+ const reload = await run2("systemctl", ["--user", "daemon-reload"]);
186
208
  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]);
209
+ const active = (await run2("systemctl", ["--user", "is-active", SERVICE])).code === 0;
210
+ if (active && changed && await (opts.atRisk ?? tmuxServerAtRisk)()) {
211
+ out.push(`${SERVICE} unit updated but not restarted: the tmux server of your agents runs inside the service and would stop with it; close the sessions, then run ledge setup again (or systemctl --user restart ${SERVICE})`);
212
+ } else if (active && changed) {
213
+ const r = await run2("systemctl", ["--user", "restart", SERVICE]);
190
214
  if (r.code !== 0) throw new Error(`systemctl --user restart ${SERVICE} failed: ${r.stderr.trim()}`);
191
215
  out.push(`${SERVICE} updated and restarted`);
192
216
  } else if (active) {
@@ -194,14 +218,14 @@ async function installService(opts = {}) {
194
218
  } else {
195
219
  const pid = await stopDetachedDaemon(paths().pid);
196
220
  if (pid !== null) out.push(`detached daemon (pid ${pid}) stopped, the service takes over`);
197
- const r = await run("systemctl", ["--user", "enable", "--now", SERVICE]);
221
+ const r = await run2("systemctl", ["--user", "enable", "--now", SERVICE]);
198
222
  if (r.code !== 0) throw new Error(`systemctl --user enable --now ${SERVICE} failed: ${r.stderr.trim()}`);
199
223
  out.push(`${SERVICE} installed and started (journalctl --user -u ${SERVICE} for its output)`);
200
224
  }
201
225
  const user = process.env.USER ?? userInfo().username;
202
- const linger = await run("loginctl", ["show-user", user, "-p", "Linger"]);
226
+ const linger = await run2("loginctl", ["show-user", user, "-p", "Linger"]);
203
227
  if (linger.code === 0 && /^Linger=yes/m.test(linger.stdout)) return out;
204
- const enable = await run("loginctl", ["enable-linger"]);
228
+ const enable = await run2("loginctl", ["enable-linger"]);
205
229
  if (enable.code === 0) out.push("linger enabled: the daemon starts with WSL, before any shell is open");
206
230
  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
231
  return out;
@@ -263,5 +287,7 @@ export {
263
287
  installService,
264
288
  isDir,
265
289
  relinkBin,
266
- runSetup
290
+ runSetup,
291
+ inDaemonService,
292
+ tmuxServerAtRisk
267
293
  };
@@ -0,0 +1,6 @@
1
+ // src/version.ts
2
+ var LEDGE_VERSION = true ? "0.5.0" : "0.0.0-dev";
3
+
4
+ export {
5
+ LEDGE_VERSION
6
+ };
@@ -0,0 +1,41 @@
1
+ // src/transport/frames.ts
2
+ var MAX_FRAME = 4 * 1024 * 1024;
3
+ function readFrames(input, frame, fail) {
4
+ let buffer = "", stopped = false;
5
+ input.setEncoding("utf8");
6
+ const data = (chunk) => {
7
+ if (stopped) return;
8
+ buffer += chunk;
9
+ try {
10
+ let i;
11
+ while ((i = buffer.indexOf("\n")) >= 0) {
12
+ const line = buffer.slice(0, i);
13
+ buffer = buffer.slice(i + 1);
14
+ if (Buffer.byteLength(line) > MAX_FRAME) throw new Error("Ledge frame too large");
15
+ if (line.trim()) frame(JSON.parse(line));
16
+ }
17
+ if (Buffer.byteLength(buffer) > MAX_FRAME) throw new Error("Ledge frame too large");
18
+ } catch (e) {
19
+ stopped = true;
20
+ fail(e instanceof Error ? e : new Error(String(e)));
21
+ }
22
+ };
23
+ const end = () => {
24
+ if (!stopped && buffer.trim()) {
25
+ stopped = true;
26
+ fail(new Error("Incomplete Ledge frame"));
27
+ }
28
+ };
29
+ input.on("data", data);
30
+ input.on("end", end);
31
+ return () => {
32
+ stopped = true;
33
+ input.off("data", data);
34
+ input.off("end", end);
35
+ };
36
+ }
37
+
38
+ export {
39
+ MAX_FRAME,
40
+ readFrames
41
+ };
@@ -17,7 +17,7 @@ function ledgeDir() {
17
17
  }
18
18
  function paths() {
19
19
  const dir = ledgeDir();
20
- return { dir, tmuxConf: join(dir, "tmux.conf"), journal: join(dir, "sessions.json"), log: join(dir, "daemon.log"), pid: join(dir, "daemon.pid") };
20
+ return { dir, tmuxConf: join(dir, "tmux.conf"), journal: join(dir, "sessions.json"), log: join(dir, "daemon.log"), pid: join(dir, "daemon.pid"), history: join(dir, "history") };
21
21
  }
22
22
  async function ensureLedgeDir() {
23
23
  const p = paths();
@@ -32,7 +32,18 @@ async function ensureLedgeDir() {
32
32
  ${TITLE_LINES}`);
33
33
  }
34
34
 
35
+ // src/log.ts
36
+ import { appendFileSync } from "fs";
37
+ function log(line) {
38
+ try {
39
+ appendFileSync(paths().log, `${(/* @__PURE__ */ new Date()).toISOString()} | ${line}
40
+ `);
41
+ } catch {
42
+ }
43
+ }
44
+
35
45
  export {
36
46
  paths,
37
- ensureLedgeDir
47
+ ensureLedgeDir,
48
+ log
38
49
  };
@@ -1,20 +1,20 @@
1
- import {
2
- realTmux
3
- } from "./chunk-DMV5VW4Q.js";
4
1
  import {
5
2
  exitFile,
6
- log,
7
3
  spawnSession
8
- } from "./chunk-B73JDPSW.js";
4
+ } from "./chunk-U4A4XKDM.js";
5
+ import {
6
+ realTmux
7
+ } from "./chunk-XZMLFV6H.js";
9
8
  import {
10
- ensureLedgeDir
11
- } from "./chunk-6ZTCETRG.js";
9
+ ensureLedgeDir,
10
+ log
11
+ } from "./chunk-P2SHPHAN.js";
12
12
  import {
13
13
  connect
14
- } from "./chunk-34DHZSGH.js";
14
+ } from "./chunk-5HVWJTTJ.js";
15
15
  import {
16
16
  PROTOCOL_PORT
17
- } from "./chunk-YT4T7DL2.js";
17
+ } from "./chunk-Y4ODVTTN.js";
18
18
 
19
19
  // src/cli/launch.ts
20
20
  import { spawn } from "child_process";
@@ -1,20 +1,21 @@
1
1
  import {
2
2
  cleanTopic,
3
3
  sessionTitle
4
- } from "./chunk-YT4T7DL2.js";
4
+ } from "./chunk-Y4ODVTTN.js";
5
5
 
6
6
  // src/daemon/state.ts
7
7
  var HOOK_PRIORITY_MS = 2e3;
8
8
  var hooksReportPrompts = (s) => s.tool === "claude";
9
9
  var QUESTION_TOOLS = /* @__PURE__ */ new Set(["AskUserQuestion", "request_user_input"]);
10
10
  var DIFF_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "apply_patch"]);
11
+ var details = (ev) => ev.optionDetails?.some(Boolean) ? { optionDetails: ev.optionDetails } : {};
11
12
  function pendingFor(ev) {
12
13
  if (ev.name === "PermissionRequest") {
13
14
  const diff = !!ev.tool && DIFF_TOOLS.has(ev.tool);
14
15
  return { kind: diff ? "diff" : "permission", text: ev.text ?? "", tool: ev.tool, source: "hook" };
15
16
  }
16
17
  if (ev.name === "PreToolUse" && ev.tool && QUESTION_TOOLS.has(ev.tool)) {
17
- return { kind: "question", text: ev.text ?? "", options: ev.options, source: "hook", ...ev.tool !== "AskUserQuestion" ? { tool: ev.tool } : {} };
18
+ return { kind: "question", text: ev.text ?? "", options: ev.options, ...details(ev), source: "hook", ...ev.tool !== "AskUserQuestion" ? { tool: ev.tool } : {} };
18
19
  }
19
20
  if (ev.name === "PreToolUse" && ev.tool === "ExitPlanMode") {
20
21
  return { kind: "plan", text: ev.text ?? "", source: "hook" };
@@ -23,7 +24,7 @@ function pendingFor(ev) {
23
24
  return { kind: "permission", text: ev.text ?? "", source: "hook" };
24
25
  }
25
26
  if (ev.name === "PanePrompt") {
26
- return { kind: ev.kind ?? "permission", text: ev.text ?? "", options: ev.options, source: "pane" };
27
+ return { kind: ev.kind ?? "permission", text: ev.text ?? "", options: ev.options, ...details(ev), source: "pane" };
27
28
  }
28
29
  return void 0;
29
30
  }
@@ -34,7 +35,10 @@ function mergePending(current, incoming) {
34
35
  if (current.helperId !== incoming.helperId) return current;
35
36
  if (current.source === "hook" && (current.kind === "plan" || current.kind === "question") && incoming.source === "hook") return current;
36
37
  const kind = incoming.kind;
37
- return { ...current, ...incoming, kind, text: incoming.text || current.text, tool: incoming.tool ?? current.tool, options: incoming.options ?? current.options };
38
+ const options = incoming.options ?? current.options;
39
+ const optionDetails = incoming.options ? incoming.optionDetails : current.optionDetails;
40
+ const { optionDetails: _old, ...rest } = { ...current, ...incoming };
41
+ return { ...rest, kind, text: incoming.text || current.text, tool: incoming.tool ?? current.tool, options, ...optionDetails ? { optionDetails } : {} };
38
42
  }
39
43
  function pendingTool(p) {
40
44
  if (p.kind === "question") return p.tool ?? "AskUserQuestion";
@@ -114,7 +118,7 @@ function withoutReply(s) {
114
118
  return rest;
115
119
  }
116
120
  function samePending(a, b) {
117
- return a.kind === b.kind && a.text === b.text && a.tool === b.tool && a.source === b.source && JSON.stringify(a.options) === JSON.stringify(b.options);
121
+ return a.kind === b.kind && a.text === b.text && a.tool === b.tool && a.source === b.source && JSON.stringify(a.options) === JSON.stringify(b.options) && JSON.stringify(a.optionDetails) === JSON.stringify(b.optionDetails);
118
122
  }
119
123
  function endCursorHelpers(s, ev) {
120
124
  if (s.tool !== "cursor" || ev.source !== "hook" || ev.helperId || !s.helpers.some((h) => h.state === "working")) return s;
@@ -1,19 +1,9 @@
1
1
  import {
2
2
  paths
3
- } from "./chunk-6ZTCETRG.js";
3
+ } from "./chunk-P2SHPHAN.js";
4
4
  import {
5
5
  pickName
6
- } from "./chunk-YT4T7DL2.js";
7
-
8
- // src/log.ts
9
- import { appendFileSync } from "fs";
10
- function log(line) {
11
- try {
12
- appendFileSync(paths().log, `${(/* @__PURE__ */ new Date()).toISOString()} | ${line}
13
- `);
14
- } catch {
15
- }
16
- }
6
+ } from "./chunk-Y4ODVTTN.js";
17
7
 
18
8
  // src/spawn.ts
19
9
  import { basename, join } from "path";
@@ -50,7 +40,6 @@ async function spawnSession(opts) {
50
40
  }
51
41
 
52
42
  export {
53
- log,
54
43
  CODEX_ENV_VAR,
55
44
  CODEX_HOOK_VAR,
56
45
  exitFile,
@@ -1,9 +1,7 @@
1
1
  import {
2
- log
3
- } from "./chunk-B73JDPSW.js";
4
- import {
2
+ log,
5
3
  paths
6
- } from "./chunk-6ZTCETRG.js";
4
+ } from "./chunk-P2SHPHAN.js";
7
5
 
8
6
  // src/tmux.ts
9
7
  import { execFile } from "child_process";