@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.
@@ -0,0 +1,288 @@
1
+ import {
2
+ QUESTION_TOOLS
3
+ } from "./chunk-ZPF5I3LS.js";
4
+ import {
5
+ CODEX_ENV_VAR,
6
+ CODEX_HOOK_VAR,
7
+ log
8
+ } from "./chunk-OUIDBDIH.js";
9
+ import "./chunk-6ZTCETRG.js";
10
+ import {
11
+ connect
12
+ } from "./chunk-W5XAZ5IS.js";
13
+ import "./chunk-6GMDFIGB.js";
14
+
15
+ // src/cli/hook.ts
16
+ import { readFileSync, openSync, fstatSync, readSync, closeSync } from "fs";
17
+ import { basename, join } from "path";
18
+ var str = (v) => typeof v === "string" ? v : void 0;
19
+ var PROMPT_MAX = 500;
20
+ var promptText = (p) => {
21
+ const t = str(p.prompt)?.trim();
22
+ return t ? { text: t.slice(0, PROMPT_MAX) } : {};
23
+ };
24
+ function describeTool(tool, input) {
25
+ const i = input && typeof input === "object" ? input : {};
26
+ if (tool === "Bash") return str(i.command) ?? "";
27
+ if (tool === "Edit" || tool === "Write" || tool === "MultiEdit" || tool === "Read") return str(i.file_path) ?? "";
28
+ if (tool === "ExitPlanMode") return str(i.plan)?.split("\n")[0] ?? "plan";
29
+ if (tool === "apply_patch") {
30
+ const files = [...(str(i.command) ?? "").matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1].trim());
31
+ return files.length ? files.join(", ") : "patch";
32
+ }
33
+ return str(i.description) ?? JSON.stringify(i).slice(0, 200);
34
+ }
35
+ var REPLY_MAX = 24e3;
36
+ function lastReply(p) {
37
+ const text = str(p.last_assistant_message)?.trim();
38
+ if (!text) return void 0;
39
+ return text.length > REPLY_MAX ? `${text.slice(0, REPLY_MAX)}
40
+
41
+ [\u2026]` : text;
42
+ }
43
+ var NON_TERMINAL_END_REASONS = /* @__PURE__ */ new Set(["clear", "resume"]);
44
+ function mapClaudeHook(eventName, payload, at) {
45
+ return mapHook("claude", eventName, payload, at);
46
+ }
47
+ function underCodexDaemon(read = (p) => readFileSync(p, "utf8")) {
48
+ let pid = process.pid;
49
+ for (let depth = 0; depth < 8; depth++) {
50
+ try {
51
+ const stat = read(`/proc/${pid}/stat`);
52
+ pid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
53
+ if (!pid || pid <= 1) return false;
54
+ if (read(`/proc/${pid}/cmdline`).split("\0").includes("app-server")) return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+ function codexLedgeId(env = process.env, daemon = underCodexDaemon) {
62
+ if (env[CODEX_HOOK_VAR]) return env[CODEX_HOOK_VAR];
63
+ if (env[CODEX_ENV_VAR] && !daemon()) return env[CODEX_ENV_VAR];
64
+ return void 0;
65
+ }
66
+ function mapHook(agent, eventName, payload, at) {
67
+ if (!payload || typeof payload !== "object") return null;
68
+ const p = payload;
69
+ const ledgeId = (agent === "codex" ? codexLedgeId() : process.env.LEDGE_SESSION) || void 0;
70
+ const launched = !!ledgeId;
71
+ const id = ledgeId || str(p.session_id);
72
+ if (!id) return null;
73
+ const cwd = str(p.cwd) || void 0;
74
+ const event = mapEvent(eventName, p, at, launched);
75
+ if (!event) return null;
76
+ const task = agent === "claude" && event.helperId ? helperDescription(str(p.transcript_path), event.helperId) : void 0;
77
+ return { id, event: task ? { ...event, task } : event, agent, ...cwd ? { cwd } : {} };
78
+ }
79
+ function helperDescription(transcriptPath, agentId) {
80
+ if (!transcriptPath?.endsWith(".jsonl") || !/^[\w-]+$/.test(agentId)) return void 0;
81
+ try {
82
+ const meta = JSON.parse(readFileSync(join(transcriptPath.slice(0, -".jsonl".length), "subagents", `agent-${agentId}.meta.json`), "utf8"));
83
+ return str(meta.description)?.split("\n")[0]?.trim() || void 0;
84
+ } catch {
85
+ return void 0;
86
+ }
87
+ }
88
+ function mapEvent(eventName, p, at, launched) {
89
+ const tool = str(p.tool_name);
90
+ const agentId = str(p.agent_id) || void 0;
91
+ const base = { at, source: "hook", ...agentId ? { helperId: agentId } : {} };
92
+ switch (eventName) {
93
+ case "SubagentStart":
94
+ case "SubagentStop": {
95
+ if (!agentId) return null;
96
+ const type = str(p.agent_type) || void 0;
97
+ const task = eventName === "SubagentStart" ? (str(p.description) || str(p.prompt))?.split("\n")[0] : void 0;
98
+ return { name: eventName, ...base, ...type ? { tool: type } : {}, ...task ? { text: task } : {} };
99
+ }
100
+ case "PostToolUse":
101
+ return { name: "PostToolUse", ...base, tool };
102
+ case "PostToolUseFailure":
103
+ return { name: "PostToolUseFailure", ...base, tool, ...p.is_interrupt === true ? { interrupt: true } : {} };
104
+ case "StopFailure": {
105
+ const error = str(p.error);
106
+ const details = str(p.error_details);
107
+ const text = [error, details].filter(Boolean).join(": ").slice(0, 300) || void 0;
108
+ return { name: "StopFailure", ...base, ...text ? { text } : {} };
109
+ }
110
+ // Codex: Esc on a running turn or on an approval. Like Claude's interrupted tool run, no Stop follows.
111
+ case "Interrupt":
112
+ return { name: "PostToolUseFailure", ...base, interrupt: true };
113
+ case "SessionStart":
114
+ return { name: "SessionStart", ...base };
115
+ case "UserPromptSubmit":
116
+ return { name: "UserPromptSubmit", ...base, ...promptText(p) };
117
+ case "Stop": {
118
+ const text = lastReply(p);
119
+ return { name: "Stop", ...base, ...text ? { text } : {} };
120
+ }
121
+ case "SessionEnd":
122
+ if (launched) return null;
123
+ if (NON_TERMINAL_END_REASONS.has(str(p.reason) ?? "")) return null;
124
+ return { name: "SessionEnd", ...base, exitCode: null };
125
+ case "PermissionRequest":
126
+ return { name: "PermissionRequest", ...base, tool, text: describeTool(tool, p.tool_input) };
127
+ case "PreToolUse": {
128
+ if (tool && QUESTION_TOOLS.has(tool)) {
129
+ const qs = p.tool_input?.questions ?? [];
130
+ const q = qs[0];
131
+ const options = (q?.options ?? []).map((o) => str(o.label) ?? "").filter(Boolean);
132
+ return { name: "PreToolUse", ...base, tool, text: str(q?.question) ?? "", options };
133
+ }
134
+ return { name: "PreToolUse", ...base, tool, text: describeTool(tool, p.tool_input) };
135
+ }
136
+ case "Notification": {
137
+ const type = str(p.notification_type) ?? "";
138
+ const message = str(p.message) ?? "";
139
+ const kind = type === "permission_prompt" || /permission/i.test(message) ? "permission" : void 0;
140
+ return { name: "Notification", ...base, kind, text: message };
141
+ }
142
+ default:
143
+ return null;
144
+ }
145
+ }
146
+ function describeCursorTool(input) {
147
+ const i = input && typeof input === "object" ? input : {};
148
+ return str(i.command) ?? str(i.file_path) ?? str(i.path) ?? str(i.description) ?? JSON.stringify(i).slice(0, 200);
149
+ }
150
+ function readTranscript(path, bytes = 512 * 1024) {
151
+ let raw;
152
+ try {
153
+ const fd = openSync(path, "r");
154
+ try {
155
+ const size = fstatSync(fd).size;
156
+ const len = Math.min(size, bytes);
157
+ const buf = Buffer.alloc(len);
158
+ readSync(fd, buf, 0, len, size - len);
159
+ raw = buf.toString("utf8");
160
+ if (len < size) raw = raw.slice(raw.indexOf("\n") + 1);
161
+ } finally {
162
+ closeSync(fd);
163
+ }
164
+ } catch {
165
+ return [];
166
+ }
167
+ const out = [];
168
+ for (const line of raw.split("\n")) {
169
+ try {
170
+ if (line.trim()) out.push(JSON.parse(line));
171
+ } catch {
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+ var blocks = (e) => e.message?.content ?? [];
177
+ var compact = (t) => t.replace(/\s+/g, "");
178
+ function cursorReply(text, entries) {
179
+ let u = entries.length - 1;
180
+ while (u >= 0 && entries[u].role !== "user") u--;
181
+ const parts = entries.slice(u + 1).filter((e) => e.role === "assistant").flatMap(blocks).filter((b) => b.type === "text" && b.text && b.text !== "[REDACTED]").map((b) => b.text.trim()).filter(Boolean);
182
+ const joined = parts.join("\n\n");
183
+ return parts.length > 1 && compact(joined) === compact(text) ? joined : text;
184
+ }
185
+ function unwrapTool(name, input) {
186
+ const i = input && typeof input === "object" ? input : {};
187
+ if (name === "CallDynamicTool" && str(i.toolName)) return { name: str(i.toolName), input: i.arguments && typeof i.arguments === "object" ? i.arguments : {} };
188
+ return { name, input: i };
189
+ }
190
+ function mapCursorHook(eventName, payload, at, env = process.env) {
191
+ if (!payload || typeof payload !== "object") return null;
192
+ const p = payload;
193
+ const sessionId = str(p.session_id) || str(p.conversation_id);
194
+ if (!sessionId) return null;
195
+ const transcript = env.CURSOR_TRANSCRIPT_PATH || void 0;
196
+ const parentId = transcript ? basename(transcript, ".jsonl") : void 0;
197
+ const generation = str(p.generation_id);
198
+ const helper = parentId ? parentId !== sessionId : eventName !== "sessionStart" && generation === sessionId;
199
+ const id = env.LEDGE_SESSION || (helper ? parentId : sessionId);
200
+ if (!id) return null;
201
+ const roots = Array.isArray(p.workspace_roots) ? p.workspace_roots : [];
202
+ const cwd = str(roots[0]) || void 0;
203
+ const event = helper ? mapCursorHelperEvent(eventName, p, at, sessionId) : mapCursorEvent(eventName, p, at, !!env.LEDGE_SESSION, transcript);
204
+ return event ? { id, event, agent: "cursor", ...cwd ? { cwd } : {} } : null;
205
+ }
206
+ function mapCursorHelperEvent(eventName, p, at, helperId) {
207
+ const base = { at, source: "hook", helperId };
208
+ const tool = str(p.tool_name);
209
+ switch (eventName) {
210
+ case "preToolUse":
211
+ return { name: "SubagentStart", ...base };
212
+ case "postToolUse":
213
+ return { name: "PostToolUse", ...base, tool };
214
+ case "postToolUseFailure":
215
+ return { name: "PostToolUseFailure", ...base, tool };
216
+ default:
217
+ return null;
218
+ }
219
+ }
220
+ function mapCursorEvent(eventName, p, at, launched, transcript) {
221
+ const { name: tool, input } = unwrapTool(str(p.tool_name), p.tool_input);
222
+ const base = { at, source: "hook" };
223
+ switch (eventName) {
224
+ case "sessionStart":
225
+ return { name: "SessionStart", ...base };
226
+ case "beforeSubmitPrompt":
227
+ return { name: "UserPromptSubmit", ...base, ...promptText(p) };
228
+ case "preToolUse": {
229
+ if (tool === "Task") {
230
+ const callId = (str(p.tool_use_id) ?? "").split("\n")[0] || at;
231
+ const task = str(input.description) || str(input.prompt)?.split("\n")[0] || "";
232
+ return { name: "SubagentStart", ...base, helperId: `task:${callId}`, tool: str(input.subagent_type) || "subagent", ...task ? { text: task } : {} };
233
+ }
234
+ return { name: "PreToolUse", ...base, tool, text: describeCursorTool(input) };
235
+ }
236
+ case "postToolUse":
237
+ return { name: "PostToolUse", ...base, tool };
238
+ case "postToolUseFailure":
239
+ return { name: "PostToolUseFailure", ...base, tool, ...p.is_interrupt === true ? { interrupt: true } : {} };
240
+ case "stop":
241
+ if (str(p.status) === "error") return { name: "StopFailure", ...base };
242
+ return { name: "Stop", ...base };
243
+ case "afterAgentResponse": {
244
+ const raw = str(p.text) ?? "";
245
+ const text = lastReply({ last_assistant_message: raw && transcript ? cursorReply(raw, readTranscript(transcript)) : raw });
246
+ return { name: "Stop", ...base, ...text ? { text } : {} };
247
+ }
248
+ case "sessionEnd":
249
+ if (launched) return null;
250
+ return { name: "SessionEnd", ...base, exitCode: null };
251
+ default:
252
+ return null;
253
+ }
254
+ }
255
+ async function runHook(eventName, stdin, opts = {}) {
256
+ const timeoutMs = opts.timeoutMs ?? 100;
257
+ const deadline = Date.now() + timeoutMs;
258
+ const agent = opts.tool ?? "claude";
259
+ const label = agent === "claude" ? eventName : `${agent} ${eventName}`;
260
+ if (process.env.LEDGE_CHAT === "1") {
261
+ log(`hook ${label} ignored: Ledge chat`);
262
+ return;
263
+ }
264
+ try {
265
+ const payload = JSON.parse(stdin || "{}");
266
+ const mapped = agent === "cursor" ? mapCursorHook(eventName, payload, (/* @__PURE__ */ new Date()).toISOString()) : mapHook(agent, eventName, payload, (/* @__PURE__ */ new Date()).toISOString());
267
+ if (!mapped) {
268
+ log(`hook ${label} ignored: unknown event, malformed payload, non-terminal SessionEnd or subagent event without a session of its own`);
269
+ return;
270
+ }
271
+ const client = await connect({ port: opts.port, timeoutMs });
272
+ client.send({ type: "session.event", id: mapped.id, event: mapped.event, agent: mapped.agent, ...mapped.cwd ? { cwd: mapped.cwd } : {} });
273
+ await Promise.race([client.close(), new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]);
274
+ } catch (e) {
275
+ log(`hook ${label} dropped: ${e.message}`);
276
+ }
277
+ }
278
+ export {
279
+ REPLY_MAX,
280
+ codexLedgeId,
281
+ cursorReply,
282
+ mapClaudeHook,
283
+ mapCursorHook,
284
+ mapHook,
285
+ readTranscript,
286
+ runHook,
287
+ underCodexDaemon
288
+ };
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ var USAGE = `ledge <claude|codex|cursor> [--name <bot>] [-- agent args] start an agent in a tracked session
5
+ ledge attach <id> put this terminal on a running session
6
+ ledge daemon run the daemon in the foreground
7
+ ledge hook [codex|cursor] <Event> receive a hook (reads JSON on stdin)
8
+ ledge setup install hooks and ~/.ledge
9
+ ledge uninstall [--purge] remove hooks, service and link (--purge: ~/.ledge too)
10
+ ledge status list sessions
11
+ ledge --version print the installed version`;
12
+ async function hookMain(args) {
13
+ const tool = args[0] === "codex" || args[0] === "cursor" ? args[0] : void 0;
14
+ const event = (tool ? args[1] : args[0]) ?? "";
15
+ try {
16
+ const chunks = [];
17
+ for await (const c of process.stdin) chunks.push(c);
18
+ const { runHook } = await import("./hook-HP23ZISG.js");
19
+ await runHook(event, Buffer.concat(chunks).toString("utf8"), { tool });
20
+ } catch {
21
+ }
22
+ if (tool === "cursor") process.stdout.write("{}\n");
23
+ }
24
+ async function main(argv) {
25
+ const [cmd, ...rest] = argv;
26
+ switch (cmd) {
27
+ case "daemon": {
28
+ const { startDaemon } = await import("./daemon-6DDF7YZD.js");
29
+ await startDaemon();
30
+ await new Promise(() => {
31
+ });
32
+ return 0;
33
+ }
34
+ case "--version":
35
+ case "version": {
36
+ const { LEDGE_VERSION } = await import("./version-IBPGTVPF.js");
37
+ console.log(LEDGE_VERSION);
38
+ return 0;
39
+ }
40
+ case "setup": {
41
+ const { runSetup } = await import("./setup-KUWSQLVG.js");
42
+ for (const l of await runSetup()) console.log(l);
43
+ return 0;
44
+ }
45
+ case "uninstall": {
46
+ const { runUninstall } = await import("./uninstall-5GHUYN7P.js");
47
+ const r = await runUninstall({ purge: rest.includes("--purge") });
48
+ for (const l of r.lines) console.log(l);
49
+ return r.ok ? 0 : 1;
50
+ }
51
+ case "status": {
52
+ const { printStatus } = await import("./status-IW2FESH4.js");
53
+ console.log(await printStatus());
54
+ return 0;
55
+ }
56
+ case "claude":
57
+ case "codex":
58
+ case "cursor": {
59
+ const { launch } = await import("./launch-DFB54ANN.js");
60
+ return launch(cmd, rest);
61
+ }
62
+ case "attach": {
63
+ const { attach } = await import("./attach-YBGTDBQI.js");
64
+ return attach(rest[0] ?? "");
65
+ }
66
+ default:
67
+ console.log(USAGE);
68
+ return cmd ? 1 : 0;
69
+ }
70
+ }
71
+ if (process.argv[2] === "hook") {
72
+ hookMain(process.argv.slice(3)).then(() => process.exit(0), () => process.exit(0));
73
+ } else {
74
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => {
75
+ console.error(`ledge: ${e.message}`);
76
+ process.exit(1);
77
+ });
78
+ }
@@ -0,0 +1,26 @@
1
+ import {
2
+ DaemonError,
3
+ attachEnv,
4
+ createSession,
5
+ ensureDaemon,
6
+ launch,
7
+ parseLaunchArgs
8
+ } from "./chunk-Y44XZY6I.js";
9
+ import "./chunk-7ICDC2LK.js";
10
+ import {
11
+ agentEnv,
12
+ exitFile
13
+ } from "./chunk-OUIDBDIH.js";
14
+ import "./chunk-6ZTCETRG.js";
15
+ import "./chunk-W5XAZ5IS.js";
16
+ import "./chunk-6GMDFIGB.js";
17
+ export {
18
+ DaemonError,
19
+ agentEnv,
20
+ attachEnv,
21
+ createSession,
22
+ ensureDaemon,
23
+ exitFile,
24
+ launch,
25
+ parseLaunchArgs
26
+ };
@@ -0,0 +1,33 @@
1
+ import {
2
+ CLAUDE_EVENTS,
3
+ CODEX_EVENTS,
4
+ CURSOR_EVENTS,
5
+ SERVICE,
6
+ execRunner,
7
+ installService,
8
+ isDir,
9
+ mergeClaudeHooks,
10
+ mergeCodexHooks,
11
+ mergeCursorHooks,
12
+ relinkBin,
13
+ runSetup,
14
+ stopDetachedDaemon,
15
+ systemdUnit
16
+ } from "./chunk-XDRYMOVF.js";
17
+ import "./chunk-6ZTCETRG.js";
18
+ export {
19
+ CLAUDE_EVENTS,
20
+ CODEX_EVENTS,
21
+ CURSOR_EVENTS,
22
+ SERVICE,
23
+ execRunner,
24
+ installService,
25
+ isDir,
26
+ mergeClaudeHooks,
27
+ mergeCodexHooks,
28
+ mergeCursorHooks,
29
+ relinkBin,
30
+ runSetup,
31
+ stopDetachedDaemon,
32
+ systemdUnit
33
+ };
@@ -0,0 +1,26 @@
1
+ import {
2
+ connect
3
+ } from "./chunk-W5XAZ5IS.js";
4
+ import "./chunk-6GMDFIGB.js";
5
+
6
+ // src/cli/status.ts
7
+ async function printStatus(opts = {}) {
8
+ let client;
9
+ try {
10
+ client = await connect({ port: opts.port, timeoutMs: 800 });
11
+ } catch {
12
+ return "daemon not running (start a session with `ledge claude`, or run `ledge daemon`)";
13
+ }
14
+ const snap = await client.next("snapshot");
15
+ await client.close();
16
+ if (snap.sessions.length === 0) return "no sessions";
17
+ const rows = snap.sessions.map((s) => {
18
+ const age = Math.round((Date.now() - Date.parse(s.startedAt)) / 6e4);
19
+ const ask = s.pending ? ` \xB7 ${s.pending.kind}: ${s.pending.text}` : "";
20
+ return `${s.name.padEnd(8)} ${s.tool.padEnd(7)} ${s.state.padEnd(19)} ${s.project.padEnd(28)} ${age}m${ask}${s.source === "hook_only" ? " \xB7 read only" : ""}`;
21
+ });
22
+ return rows.join("\n");
23
+ }
24
+ export {
25
+ printStatus
26
+ };
@@ -0,0 +1,154 @@
1
+ import {
2
+ SERVICE,
3
+ execRunner,
4
+ stopDetachedDaemon
5
+ } from "./chunk-XDRYMOVF.js";
6
+ import {
7
+ paths
8
+ } from "./chunk-6ZTCETRG.js";
9
+
10
+ // src/cli/uninstall.ts
11
+ import { readFile, writeFile, rename, rm, lstat, realpath } from "fs/promises";
12
+ import { homedir } from "os";
13
+ import { join } from "path";
14
+ var LEDGE_HOOK = /^ledge hook (?:(?:codex|cursor) )?[A-Za-z]+$/;
15
+ var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
16
+ function prune(config, file, keep) {
17
+ if (!isObject(config)) throw new Error(`${file} root is not an object, not modified`);
18
+ if (config.hooks === void 0) return { settings: config, removed: 0 };
19
+ if (!isObject(config.hooks)) throw new Error(`${file} "hooks" is not an object, not modified`);
20
+ const hooks = {};
21
+ let removed = 0;
22
+ for (const [ev, list] of Object.entries(config.hooks)) {
23
+ if (!Array.isArray(list)) throw new Error(`${file} hooks.${ev} is not an array, not modified`);
24
+ const kept = [];
25
+ for (const entry of list) {
26
+ const next = keep(entry);
27
+ if (next === null) removed++;
28
+ else {
29
+ if (next !== entry) removed++;
30
+ kept.push(next);
31
+ }
32
+ }
33
+ if (kept.length || !list.length) hooks[ev] = kept;
34
+ }
35
+ const { hooks: _drop, ...rest } = config;
36
+ return { settings: Object.keys(hooks).length ? { ...rest, hooks } : rest, removed };
37
+ }
38
+ function removeClaudeHooks(settings, file = "settings.json") {
39
+ return prune(settings, file, (entry) => {
40
+ if (!isObject(entry) || !Array.isArray(entry.hooks)) return entry;
41
+ const others = entry.hooks.filter((h) => !(isObject(h) && typeof h.command === "string" && LEDGE_HOOK.test(h.command)));
42
+ if (others.length === entry.hooks.length) return entry;
43
+ return others.length ? { ...entry, hooks: others } : null;
44
+ });
45
+ }
46
+ function removeCursorHooks(config) {
47
+ return prune(config, "hooks.json", (entry) => isObject(entry) && typeof entry.command === "string" && LEDGE_HOOK.test(entry.command) ? null : entry);
48
+ }
49
+ async function pruneJsonFile(path, remove, name) {
50
+ let raw;
51
+ try {
52
+ raw = await readFile(path, "utf8");
53
+ } catch (e) {
54
+ if (e.code === "ENOENT") return null;
55
+ throw e;
56
+ }
57
+ let current;
58
+ try {
59
+ current = JSON.parse(raw);
60
+ } catch {
61
+ throw new Error(`${name} is not valid JSON, not modified`);
62
+ }
63
+ const { settings, removed } = remove(current);
64
+ if (!removed) return 0;
65
+ const tmp = `${path}.ledge-${process.pid}.tmp`;
66
+ await writeFile(tmp, JSON.stringify(settings, null, 2) + "\n");
67
+ await rename(tmp, path);
68
+ return removed;
69
+ }
70
+ async function removeService(opts = {}) {
71
+ const out = [];
72
+ const run = opts.run ?? execRunner;
73
+ const unit = join(opts.unitDir ?? join(homedir(), ".config", "systemd", "user"), SERVICE);
74
+ let present = true;
75
+ try {
76
+ await lstat(unit);
77
+ } catch {
78
+ present = false;
79
+ }
80
+ if (present) {
81
+ const r = await run("systemctl", ["--user", "disable", "--now", SERVICE]);
82
+ if (r.code !== 0) out.push(`systemctl --user disable --now ${SERVICE} failed (${r.stderr.trim() || `exit ${r.code}`}), removing the unit anyway`);
83
+ await rm(unit, { force: true });
84
+ await run("systemctl", ["--user", "daemon-reload"]);
85
+ out.push(`${SERVICE} stopped and removed`);
86
+ } else out.push(`${SERVICE} not installed`);
87
+ const pid = await stopDetachedDaemon(opts.pidFile ?? paths().pid);
88
+ if (pid !== null) out.push(`detached daemon (pid ${pid}) stopped`);
89
+ return out;
90
+ }
91
+ async function runningSessions(run, socket) {
92
+ const r = await run("tmux", ["-L", socket, "list-sessions", "-F", "#{session_name}"]);
93
+ return r.code === 0 ? r.stdout.split("\n").filter(Boolean).length : 0;
94
+ }
95
+ async function removeLink(link, script) {
96
+ try {
97
+ if (!(await lstat(link)).isSymbolicLink()) return false;
98
+ if (await realpath(link) !== await realpath(script)) return false;
99
+ } catch {
100
+ return false;
101
+ }
102
+ await rm(link, { force: true });
103
+ return true;
104
+ }
105
+ async function runUninstall(opts = {}) {
106
+ const lines = [];
107
+ let ok = true;
108
+ const step = async (f) => {
109
+ try {
110
+ await f();
111
+ } catch (e) {
112
+ ok = false;
113
+ lines.push(`error: ${e.message}`);
114
+ }
115
+ };
116
+ const run = opts.run ?? execRunner;
117
+ const files = [
118
+ ["Claude Code", opts.settingsPath ?? join(homedir(), ".claude", "settings.json"), (c) => removeClaudeHooks(c, "settings.json")],
119
+ ["Codex", join(opts.codexDir ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"), "hooks.json"), (c) => removeClaudeHooks(c, "hooks.json")],
120
+ ["Cursor Agent", join(opts.cursorDir ?? join(homedir(), ".cursor"), "hooks.json"), removeCursorHooks]
121
+ ];
122
+ for (const [agent, path, remove] of files) {
123
+ await step(async () => {
124
+ const n = await pruneJsonFile(path, remove, `${agent} ${path}`);
125
+ lines.push(n === null ? `${agent}: ${path} missing, nothing to remove` : n ? `${agent}: ${n} Ledge hook${n > 1 ? "s" : ""} removed from ${path}` : `${agent}: no Ledge hook in ${path}`);
126
+ });
127
+ }
128
+ if (opts.service ?? true) await step(async () => {
129
+ lines.push(...await removeService({ unitDir: opts.unitDir, run }));
130
+ });
131
+ await step(async () => {
132
+ const n = await runningSessions(run, opts.tmuxSocket ?? "ledge");
133
+ if (n) lines.push(`${n} session${n > 1 ? "s" : ""} still running on Ledge's tmux server, left alone: \`tmux -L ${opts.tmuxSocket ?? "ledge"} kill-server\` ends ${n > 1 ? "them" : "it"}`);
134
+ });
135
+ const dir = paths().dir;
136
+ if (opts.purge) await step(async () => {
137
+ await rm(dir, { recursive: true, force: true });
138
+ lines.push(`${dir} removed`);
139
+ });
140
+ else lines.push(`${dir} kept (session journal and log): \`ledge uninstall --purge\` removes it`);
141
+ const script = opts.script ?? process.argv[1];
142
+ const link = opts.link ?? join(homedir(), ".local", "bin", "ledge");
143
+ if (script) await step(async () => {
144
+ if (await removeLink(link, script)) lines.push(`${link} removed`);
145
+ });
146
+ lines.push(ok ? "Ledge is uninstalled from WSL. On Windows: Settings > Apps > Installed apps > Ledge > Uninstall." : "Uninstall finished with errors, see above.");
147
+ return { lines, ok };
148
+ }
149
+ export {
150
+ removeClaudeHooks,
151
+ removeCursorHooks,
152
+ removeService,
153
+ runUninstall
154
+ };
@@ -0,0 +1,6 @@
1
+ import {
2
+ LEDGE_VERSION
3
+ } from "./chunk-ATG6CVVY.js";
4
+ export {
5
+ LEDGE_VERSION
6
+ };
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@paradow/ledge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.1",
4
+ "description": "Follow and answer your WSL agent sessions (Claude Code, Codex, Cursor Agent) from the Ledge island on Windows.",
5
+ "type": "module",
6
+ "bin": { "ledge": "dist/cli/index.js" },
7
+ "files": ["dist"],
8
+ "engines": { "node": ">=22" },
9
+ "repository": { "type": "git", "url": "git+https://github.com/Paradow-dev/ledge.git", "directory": "packages/ledge" },
10
+ "homepage": "https://github.com/Paradow-dev/ledge#readme",
11
+ "license": "UNLICENSED",
12
+ "publishConfig": { "access": "public", "provenance": true },
13
+ "scripts": {
14
+ "dev": "tsx src/cli/index.ts",
15
+ "build": "tsup",
16
+ "prepublishOnly": "pnpm build",
17
+ "test": "vitest run",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "ws": "^8.18.0",
22
+ "zod": "^3.23.8"
23
+ },
24
+ "devDependencies": {
25
+ "@ledge/protocol": "workspace:*",
26
+ "@types/node": "^22.7.0",
27
+ "@types/ws": "^8.5.12",
28
+ "tsup": "^8.3.0"
29
+ }
30
+ }