@paradow/ledge 0.0.0-stage → 0.3.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 CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # @paradow/ledge (WSL side)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Ledge daemon and CLI: follow and answer your WSL agent sessions (Claude Code, Codex, Cursor Agent) from the Ledge island on Windows. See the [repository README](https://github.com/Paradow-dev/ledge#readme) for the whole picture.
4
+
5
+ ## Install
6
+
7
+ Node >= 22 is required.
8
+
9
+ ```sh
10
+ npm i -g @paradow/ledge && ledge setup
11
+ ```
12
+
13
+ ## Update
14
+
15
+ From the Ledge island: the card in the list updates the daemon, then the Windows app. By hand:
16
+
17
+ ```sh
18
+ npm i -g @paradow/ledge@latest && systemctl --user restart ledge-daemon
19
+ ```
20
+
21
+ A daemon started from a clone (see below) is not touched by the island's button.
22
+
23
+ ## Install for development
24
+
25
+ Run from the repository root:
26
+
27
+ ```sh
28
+ pnpm install
29
+ pnpm --filter @paradow/ledge build
30
+ chmod +x packages/ledge/dist/cli/index.js
31
+ mkdir -p ~/.local/bin
32
+ ln -sf "$PWD/packages/ledge/dist/cli/index.js" ~/.local/bin/ledge # ~/.local/bin must be on the PATH Claude Code uses for hooks
33
+ ledge setup
34
+ ```
35
+
36
+ The daemon is spawned from the built CLI (`dist/cli/index.js`), so rebuild after changes (`pnpm --filter @paradow/ledge build`).
37
+
38
+ ## Use
39
+
40
+ ```sh
41
+ ledge claude # start Claude Code in a tracked tmux session (tab renamed to the bot)
42
+ ledge claude --name max # pick the bot's name
43
+ ledge status # list bots and what they wait for
44
+ ledge attach <id> # put this terminal on a running session (what the overlay opens a tab with)
45
+ ledge daemon # run the daemon in the foreground (normally run by the systemd service)
46
+ ledge uninstall # undo ledge setup: hooks, service and the ~/.local/bin link (--purge: ~/.ledge too)
47
+ ```
48
+
49
+ `ledge setup` installs `ledge-daemon.service` as a systemd user unit, starts it and enables linger, so the daemon runs from WSL boot and restarts after a crash. Rerun it after a node upgrade or a repository move: the unit is rewritten and the service restarted. Useful commands:
50
+
51
+ ```sh
52
+ systemctl --user status ledge-daemon.service
53
+ journalctl --user -u ledge-daemon.service -f
54
+ systemctl --user restart ledge-daemon.service # after pnpm --filter @paradow/ledge build
55
+ systemctl --user disable --now ledge-daemon.service # back to on-demand start by ledge claude
56
+ ```
57
+
58
+ ## Where things live
59
+
60
+ ```text
61
+ ~/.ledge/tmux.conf dedicated tmux server config (status off, mouse on)
62
+ ~/.ledge/sessions.json journal, reloaded when the daemon restarts
63
+ ~/.ledge/daemon.log dropped hooks, failed injections, poll errors
64
+ ~/.ledge/chat.json the day's chat conversation (date, session id)
65
+ ~/.ledge/chat/context/ files the chat reads (sessions.md, pane-*, reply-*, git-*)
66
+ ~/.ledge/daemon.pid PID of the running daemon, removed when it stops cleanly
67
+ ~/.config/systemd/user/ledge-daemon.service user unit written by `ledge setup`
68
+ ~/.ledge/exit-<id> transient: the agent's exit code, written by the launcher's wrapper when the agent ends and read and deleted by `ledge claude`
69
+ ```
70
+
71
+ ## Ask Ledge (chat)
72
+
73
+ The overlay's ask field sends a question to the daemon (`chat.ask`), which answers it with one headless, read-only run of the chosen harness (Settings › Default harness; Claude Code when omitted):
74
+
75
+ ```sh
76
+ # Claude Code (default)
77
+ claude -p --restricted --tools Read,Grep,Glob --permission-mode dontAsk --output-format stream-json --verbose --include-partial-messages --max-turns 16 --append-system-prompt <persona> --add-dir <each session folder> --session-id <id> # --resume <id> for the next questions of the day
78
+
79
+ # Codex
80
+ codex exec --sandbox read-only --json --skip-git-repo-check --ignore-user-config -C <context> -c 'sandbox_permissions=["disk-full-read-access"]' - # exec resume <id> … for the next questions
81
+
82
+ # Cursor Agent
83
+ cursor-agent -p --mode ask --output-format stream-json --stream-partial-output --sandbox enabled --trust --workspace <context> --add-dir <each session folder> # --resume <id> for the next questions
84
+ ```
85
+
86
+ The question goes on stdin, never in argv, and `LEDGE_CHAT=1` is set so Ledge's own hooks ignore the run. An inherited `ANTHROPIC_API_KEY` is dropped so Claude Code keeps its own login. `--restricted` ignores the user, project and local settings, so none of your hooks run and no setting can widen the tools. Only `Read`, `Grep` and `Glob` are allowed on Claude Code, and anything else is denied rather than asked. Codex runs in a read-only sandbox; Cursor Agent runs in ask mode.
87
+
88
+ - `~/.ledge/chat/context/` is the folder the harness runs in, rewritten before each question: `sessions.md` (every session, its state and its file names), `pane-<id>.txt` (last terminal lines), `reply-<id>.md` (last reply), `git-<id>.txt` (status, log and diff stat). A source that fails or is empty is just left out. The folder is deleted when the turn ends.
89
+ - `~/.ledge/chat.json` holds the day's conversation (its date, session id and which tool it belongs to); a new day, a `chat.reset`, or a change of harness starts a new conversation, and a question after a failed resume starts a new one too.
90
+ - One question at a time; a turn that takes more than 180 s is stopped and the chat says it timed out (no retry). `chat.reset` is refused while a question is running.
91
+
92
+ Session names and tab titles: Ledge sets `set-titles` in `~/.ledge/tmux.conf`. A tmux server that is already running re-reads `tmux.conf` only after it restarts, so `set-titles` applies to new tmux servers. The tab string is `${name} · ${topic ?? project}`; `session.rename` from the overlay updates it.
93
+
94
+ ## Tests
95
+
96
+ ```sh
97
+ pnpm --filter @paradow/ledge test # unit + tmux + end-to-end (needs tmux, no AI)
98
+ pnpm --filter @paradow/ledge test state # only the transition table
99
+ ```
100
+
101
+ ## Refreshing pane fixtures
102
+
103
+ Run from `packages/ledge` (the output path is relative to it):
104
+
105
+ ```sh
106
+ tmux -L ledge capture-pane -p -J -t ledge-<id> -S -30 > test/fixtures/panes/<name>.txt
107
+ ```
@@ -0,0 +1,53 @@
1
+ import {
2
+ attachEnv
3
+ } from "./chunk-Y44XZY6I.js";
4
+ import {
5
+ realTmux
6
+ } from "./chunk-7ICDC2LK.js";
7
+ import "./chunk-OUIDBDIH.js";
8
+ import "./chunk-6ZTCETRG.js";
9
+ import {
10
+ connect
11
+ } from "./chunk-W5XAZ5IS.js";
12
+ import {
13
+ PROTOCOL_PORT
14
+ } from "./chunk-6GMDFIGB.js";
15
+
16
+ // src/cli/attach.ts
17
+ import { spawn } from "child_process";
18
+ var TMUX_SOCKET = "ledge";
19
+ function attachInherit(args, env) {
20
+ return new Promise((resolve) => {
21
+ const child = spawn("tmux", args, { stdio: "inherit", env });
22
+ child.on("error", () => resolve(1));
23
+ child.on("exit", (c) => resolve(c ?? 1));
24
+ });
25
+ }
26
+ async function attach(id, opts = {}) {
27
+ if (!id) {
28
+ console.error("ledge attach: missing session id");
29
+ return 2;
30
+ }
31
+ const tmux = realTmux(opts.tmuxSocket ?? TMUX_SOCKET);
32
+ const tmuxName = `ledge-${id}`;
33
+ if (!await tmux.hasSession(tmuxName)) {
34
+ console.error(`ledge attach: no session ${id}`);
35
+ return 1;
36
+ }
37
+ let title = tmuxName;
38
+ try {
39
+ const c = await connect({ port: opts.port ?? PROTOCOL_PORT, timeoutMs: 800 });
40
+ const snap = await c.next("snapshot");
41
+ title = snap.sessions.find((s) => s.id === id)?.title ?? title;
42
+ await Promise.race([c.close(), new Promise((r) => setTimeout(r, 500))]);
43
+ } catch {
44
+ }
45
+ const write = opts.write ?? ((s) => {
46
+ if (process.stdout.isTTY) process.stdout.write(s);
47
+ });
48
+ write(`\x1B]0;${title}\x07`);
49
+ return (opts.attach ?? attachInherit)(tmux.attachArgs(tmuxName), attachEnv(process.env));
50
+ }
51
+ export {
52
+ attach
53
+ };
@@ -0,0 +1,306 @@
1
+ // ../protocol/src/messages.ts
2
+ import { z as z4 } from "zod";
3
+
4
+ // ../protocol/src/session.ts
5
+ import { z } from "zod";
6
+ var ToolSchema = z.enum(["claude", "codex", "cursor"]);
7
+ var SessionStateSchema = z.enum(["starting", "working", "waiting_input", "waiting_permission", "idle", "exited", "error"]);
8
+ var PendingSchema = z.object({
9
+ kind: z.enum(["question", "permission", "plan", "diff"]),
10
+ text: z.string(),
11
+ tool: z.string().optional(),
12
+ options: z.array(z.string()).optional(),
13
+ source: z.enum(["hook", "pane"]),
14
+ /** Set when the request comes from a helper (subagent): it is routed through its parent. */
15
+ helperId: z.string().optional(),
16
+ /** For a helper-routed request: the state the parent returns to once it is answered (main thread may stop meanwhile). */
17
+ parentState: SessionStateSchema.optional()
18
+ });
19
+ var ReplySchema = z.object({
20
+ text: z.string(),
21
+ at: z.string().datetime()
22
+ });
23
+ var HelperSchema = z.object({
24
+ id: z.string(),
25
+ kind: z.string(),
26
+ task: z.string(),
27
+ state: SessionStateSchema
28
+ });
29
+ var TopicSourceSchema = z.enum(["user", "pane", "prompt"]);
30
+ var SessionSchema = z.object({
31
+ id: z.string().min(1),
32
+ name: z.string().min(1),
33
+ tool: ToolSchema,
34
+ cwd: z.string(),
35
+ project: z.string(),
36
+ title: z.string(),
37
+ /** One line saying what the session is about (spec 2026-10-11 §3). */
38
+ topic: z.string().optional(),
39
+ topicSource: TopicSourceSchema.optional(),
40
+ /** The repository (its main worktree's folder name and absolute path), the checked-out branch, and whether `cwd` is a linked worktree (§4). */
41
+ repo: z.string().optional(),
42
+ repoPath: z.string().optional(),
43
+ branch: z.string().optional(),
44
+ worktree: z.boolean().optional(),
45
+ tmuxSession: z.string().nullable(),
46
+ state: SessionStateSchema,
47
+ pending: PendingSchema.optional(),
48
+ helpers: z.array(HelperSchema).default([]),
49
+ lastReply: ReplySchema.optional(),
50
+ startedAt: z.string().datetime(),
51
+ lastActivityAt: z.string().datetime(),
52
+ lastHookAt: z.string().datetime().optional(),
53
+ exitCode: z.number().int().nullable().optional(),
54
+ exitedAt: z.string().datetime().optional(),
55
+ lastError: z.string().optional(),
56
+ source: z.enum(["launcher", "hook_only"])
57
+ });
58
+
59
+ // ../protocol/src/chat.ts
60
+ import { z as z2 } from "zod";
61
+ var ChatStepSchema = z2.object({
62
+ kind: z2.enum(["sessions", "pane", "reply", "git", "repo"]),
63
+ id: z2.string().optional(),
64
+ name: z2.string().optional(),
65
+ path: z2.string().optional()
66
+ });
67
+ var ChatTurnSchema = z2.object({
68
+ qid: z2.string().min(1),
69
+ question: z2.string(),
70
+ askedAt: z2.string().datetime(),
71
+ steps: z2.array(ChatStepSchema),
72
+ answer: z2.string().optional(),
73
+ answeredAt: z2.string().datetime().optional(),
74
+ error: z2.string().optional()
75
+ });
76
+
77
+ // ../protocol/src/documents.ts
78
+ import { z as z3 } from "zod";
79
+ var DOCUMENT_MAX_BYTES = 1048576;
80
+ var path = z3.string().min(1).max(4096);
81
+ var requestId = z3.string().min(1);
82
+ var DocumentReadSchema = z3.object({
83
+ type: z3.literal("document.read"),
84
+ requestId,
85
+ path,
86
+ sessionId: z3.string().min(1).optional(),
87
+ basePath: path.optional()
88
+ });
89
+ var DocumentContentSchema = z3.object({
90
+ type: z3.literal("document.content"),
91
+ requestId,
92
+ path,
93
+ text: z3.string(),
94
+ bytes: z3.number().int().nonnegative(),
95
+ modifiedAt: z3.string().datetime()
96
+ });
97
+ var DocumentErrorCodeSchema = z3.enum([
98
+ "not_found",
99
+ "not_file",
100
+ "unsupported_path",
101
+ "unsupported_type",
102
+ "permission_denied",
103
+ "too_large",
104
+ "invalid_utf8",
105
+ "ambiguous_path",
106
+ "session_missing",
107
+ "read_failed"
108
+ ]);
109
+ var DocumentErrorSchema = z3.object({
110
+ type: z3.literal("document.error"),
111
+ requestId,
112
+ path,
113
+ code: DocumentErrorCodeSchema
114
+ });
115
+
116
+ // ../protocol/src/messages.ts
117
+ var PROTOCOL_PORT = 47831;
118
+ var SessionEventSchema = z4.object({
119
+ name: z4.enum(["SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "Notification", "Stop", "SessionEnd", "PaneChanged", "PaneGone", "PanePrompt", "PaneIdle", "InjectFailed", "Decided", "PostToolUse", "PostToolUseFailure", "StopFailure", "SubagentStart", "SubagentStop", "PaneTitle", "Renamed"]),
120
+ at: z4.string().datetime(),
121
+ source: z4.enum(["hook", "pane", "daemon"]),
122
+ tool: z4.string().optional(),
123
+ text: z4.string().optional(),
124
+ options: z4.array(z4.string()).optional(),
125
+ kind: z4.enum(["question", "permission", "plan", "diff"]).optional(),
126
+ exitCode: z4.number().int().nullable().optional(),
127
+ helperId: z4.string().optional(),
128
+ /** The helper's description, once known (Claude Code: its `.meta.json`, written after SubagentStart). */
129
+ task: z4.string().optional(),
130
+ /** PostToolUseFailure: the user interrupted the turn (Esc); Claude then waits for input with no Stop. */
131
+ interrupt: z4.boolean().optional()
132
+ });
133
+ var RecentSchema = z4.object({ cwd: z4.string().min(1), project: z4.string().min(1), lastAt: z4.string().datetime() });
134
+ var SEMVER = /^\d+\.\d+\.\d+$/;
135
+ var DaemonInfoSchema = z4.object({ version: z4.string(), install: z4.enum(["npm", "git", "unknown"]), systemd: z4.boolean(), manual: z4.string().optional() });
136
+ var UpdateFailureSchema = z4.enum(["git-install", "no-systemd", "sessions-at-risk", "npm-failed", "busy"]);
137
+ var ServerMessageSchema = z4.discriminatedUnion("type", [
138
+ DocumentContentSchema,
139
+ DocumentErrorSchema,
140
+ /** `recents`: the folders of past sessions, most recent first. `launcher`: the command that runs `ledge` inside WSL
141
+ * (`[node, cli]`), so the overlay can attach a terminal to a session with `<launcher> attach <id>`. `daemon`: absent
142
+ * from daemons older than the auto-update. */
143
+ z4.object({ type: z4.literal("snapshot"), sessions: z4.array(SessionSchema), recents: z4.array(RecentSchema).optional(), launcher: z4.array(z4.string()).optional(), daemon: DaemonInfoSchema.optional() }),
144
+ z4.object({ type: z4.literal("session.updated"), session: SessionSchema }),
145
+ z4.object({ type: z4.literal("session.removed"), id: z4.string() }),
146
+ z4.object({ type: z4.literal("session.preview"), id: z4.string(), lines: z4.array(z4.string()) }),
147
+ /** Answer to the requester of a `session.launch`: the session exists (its `session.updated` is broadcast as usual). */
148
+ z4.object({ type: z4.literal("session.launched"), id: z4.string() }),
149
+ z4.object({ type: z4.literal("recents"), recents: z4.array(RecentSchema) }),
150
+ /** Answer to the requester of a `folder.list` or `folder.create`: `path` resolved (`~` expanded), `parent` null at the
151
+ * root, `entries` its subfolders by name (hidden ones left out). A failure keeps the asked path and says why in `error`,
152
+ * never in an `error` message, which the launch form reads as a failed launch. */
153
+ z4.object({ type: z4.literal("folder.listing"), path: z4.string(), parent: z4.string().nullable(), entries: z4.array(z4.string()), error: z4.string().optional() }),
154
+ /** The chat (spec 2026-10-11 §5): what Ledge reads, its words as they come, the final text, or why it stopped; the day's turns after each snapshot. */
155
+ z4.object({ type: z4.literal("chat.step"), qid: z4.string(), step: ChatStepSchema }),
156
+ z4.object({ type: z4.literal("chat.delta"), qid: z4.string(), text: z4.string() }),
157
+ z4.object({ type: z4.literal("chat.done"), qid: z4.string(), text: z4.string(), at: z4.string().datetime() }),
158
+ z4.object({ type: z4.literal("chat.error"), qid: z4.string(), message: z4.string() }),
159
+ z4.object({ type: z4.literal("chat.history"), turns: z4.array(ChatTurnSchema) }),
160
+ /** The daemon updating itself: npm is running, then the service restarts (the socket closes right after). */
161
+ z4.object({ type: z4.literal("update.progress"), version: z4.string(), step: z4.enum(["installing", "restarting"]) }),
162
+ /** Nothing was changed. `detail`: the useful end of npm's output; `command`: what the person can run instead. */
163
+ z4.object({ type: z4.literal("update.failed"), version: z4.string(), reason: UpdateFailureSchema, detail: z4.string().optional(), command: z4.string().optional() }),
164
+ z4.object({ type: z4.literal("error"), message: z4.string(), id: z4.string().optional() })
165
+ ]);
166
+ var ClientMessageSchema = z4.discriminatedUnion("type", [
167
+ DocumentReadSchema,
168
+ /** The overlay starts a session: `cwd` is a WSL path (`~` allowed), `name` a preferred bot name (the daemon picks a
169
+ * free one otherwise), `prompt` the agent's first instruction, `continue` resumes the folder's last conversation. */
170
+ z4.object({ type: z4.literal("session.launch"), tool: ToolSchema, cwd: z4.string().min(1), name: z4.string().optional(), prompt: z4.string().optional(), continue: z4.boolean().optional() }),
171
+ /** The launch form's folder browser: list a folder's subfolders (home when no `path`), or make `name` inside `parent`
172
+ * and list it. Both are answered with a `folder.listing`. */
173
+ z4.object({ type: z4.literal("folder.list"), path: z4.string().optional() }),
174
+ z4.object({ type: z4.literal("folder.create"), parent: z4.string().min(1), name: z4.string().min(1) }),
175
+ z4.object({ type: z4.literal("session.reply"), id: z4.string(), text: z4.string().min(1) }),
176
+ z4.object({ type: z4.literal("session.decide"), id: z4.string(), decision: z4.enum(["accept", "deny"]) }),
177
+ z4.object({ type: z4.literal("session.focus"), id: z4.string() }),
178
+ z4.object({ type: z4.literal("session.kill"), id: z4.string() }),
179
+ z4.object({ type: z4.literal("session.interrupt"), id: z4.string() }),
180
+ /** Removes a finished (exited) or read-only (hook_only) session from the list; refused for a live launcher session. */
181
+ z4.object({ type: z4.literal("session.dismiss"), id: z4.string() }),
182
+ /** The user names the session's topic from the overlay (500 characters at most, the hook's prompt cap); an empty topic hands back to the automatic sources. */
183
+ z4.object({ type: z4.literal("session.rename"), id: z4.string(), topic: z4.string().max(500) }),
184
+ z4.object({ type: z4.literal("session.register"), session: SessionSchema }),
185
+ /** `agent` and `cwd` describe the session when the daemon does not know it yet (a hook from an agent started without ledge). */
186
+ z4.object({ type: z4.literal("session.event"), id: z4.string(), event: SessionEventSchema, agent: ToolSchema.optional(), cwd: z4.string().optional() }),
187
+ z4.object({ type: z4.literal("session.unregister"), id: z4.string(), exitCode: z4.number().int().nullable() }),
188
+ z4.object({ type: z4.literal("snapshot.request") }),
189
+ z4.object({ type: z4.literal("chat.ask"), qid: z4.string().min(1), text: z4.string().min(1), tool: ToolSchema.optional() }),
190
+ z4.object({ type: z4.literal("chat.cancel"), qid: z4.string().min(1) }),
191
+ /** Starts a new conversation with Ledge: the day's turns are cleared and the next question opens a new harness session. */
192
+ z4.object({ type: z4.literal("chat.reset") }),
193
+ /** Installs `@paradow/ledge@version` and restarts the service (spec 2026-10-12 auto-update §6). */
194
+ z4.object({ type: z4.literal("update.daemon"), version: z4.string().regex(SEMVER) })
195
+ ]);
196
+ function parseClientMessage(raw) {
197
+ return ClientMessageSchema.parse(raw);
198
+ }
199
+ function parseServerMessage(raw) {
200
+ return ServerMessageSchema.parse(raw);
201
+ }
202
+
203
+ // ../protocol/src/names.ts
204
+ var NAMES = [
205
+ "Otto",
206
+ "Pim",
207
+ "Nova",
208
+ "Bix",
209
+ "Lu",
210
+ "Max",
211
+ "Ida",
212
+ "Rex",
213
+ "Zoe",
214
+ "Kit",
215
+ "Ari",
216
+ "Bo",
217
+ "Cal",
218
+ "Dee",
219
+ "Eli",
220
+ "Fay",
221
+ "Gus",
222
+ "Hal",
223
+ "Ivy",
224
+ "Jax",
225
+ "Kai",
226
+ "Lio",
227
+ "Mo",
228
+ "Nia",
229
+ "Oz",
230
+ "Pax",
231
+ "Quin",
232
+ "Rae",
233
+ "Sol",
234
+ "Tad",
235
+ "Uma",
236
+ "Vic",
237
+ "Wyn",
238
+ "Xen",
239
+ "Yan",
240
+ "Zia",
241
+ "Abe",
242
+ "Bea",
243
+ "Cy",
244
+ "Dot",
245
+ "Eva",
246
+ "Fin",
247
+ "Gil",
248
+ "Hen",
249
+ "Iro",
250
+ "Jo",
251
+ "Kip",
252
+ "Lex",
253
+ "Mia",
254
+ "Ned",
255
+ "Ola",
256
+ "Pat",
257
+ "Rio",
258
+ "Sam",
259
+ "Tia",
260
+ "Ulf",
261
+ "Val",
262
+ "Wes",
263
+ "Yui",
264
+ "Zed",
265
+ "Ash",
266
+ "Bry",
267
+ "Cleo",
268
+ "Dax"
269
+ ];
270
+ var cap = (s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
271
+ function pickName(taken, preferred) {
272
+ const used = new Set(taken);
273
+ const free = (n) => !used.has(n);
274
+ const wanted = preferred?.trim();
275
+ if (wanted) {
276
+ const base = cap(wanted);
277
+ if (free(base)) return base;
278
+ for (let i = 2; ; i++) if (free(`${base}-${i}`)) return `${base}-${i}`;
279
+ }
280
+ const first = NAMES.find(free);
281
+ if (first) return first;
282
+ for (let i = 2; ; i++) if (free(`${NAMES[0]}-${i}`)) return `${NAMES[0]}-${i}`;
283
+ }
284
+
285
+ // ../protocol/src/topic.ts
286
+ var TOPIC_MAX = 80;
287
+ function cleanTopic(text) {
288
+ const line = text.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? "";
289
+ const flat = line.replace(/\s+/g, " ");
290
+ return flat.length > TOPIC_MAX ? `${flat.slice(0, TOPIC_MAX - 1).trimEnd()}\u2026` : flat;
291
+ }
292
+ function sessionTitle(s) {
293
+ return `${s.name} \xB7 ${s.topic || s.project}`;
294
+ }
295
+
296
+ export {
297
+ DOCUMENT_MAX_BYTES,
298
+ SessionSchema,
299
+ PROTOCOL_PORT,
300
+ RecentSchema,
301
+ parseClientMessage,
302
+ parseServerMessage,
303
+ pickName,
304
+ cleanTopic,
305
+ sessionTitle
306
+ };
@@ -0,0 +1,38 @@
1
+ // src/paths.ts
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import { mkdir, writeFile, readFile } from "fs/promises";
5
+ var TITLE_LINES = `set -g set-titles on
6
+ set -g set-titles-string "#{@ledge_title}"
7
+ `;
8
+ var TMUX_CONF = `set -g status off
9
+ set -g mouse on
10
+ set -g default-terminal "tmux-256color"
11
+ set -sg escape-time 10
12
+ set -g history-limit 20000
13
+ set -g remain-on-exit off
14
+ ${TITLE_LINES}`;
15
+ function ledgeDir() {
16
+ return process.env.LEDGE_HOME ?? join(homedir(), ".ledge");
17
+ }
18
+ function paths() {
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") };
21
+ }
22
+ async function ensureLedgeDir() {
23
+ const p = paths();
24
+ await mkdir(p.dir, { recursive: true });
25
+ let conf = null;
26
+ try {
27
+ conf = await readFile(p.tmuxConf, "utf8");
28
+ } catch {
29
+ }
30
+ if (conf === null) await writeFile(p.tmuxConf, TMUX_CONF);
31
+ else if (!conf.includes("set-titles")) await writeFile(p.tmuxConf, `${conf.trimEnd()}
32
+ ${TITLE_LINES}`);
33
+ }
34
+
35
+ export {
36
+ paths,
37
+ ensureLedgeDir
38
+ };
@@ -0,0 +1,88 @@
1
+ import {
2
+ log
3
+ } from "./chunk-OUIDBDIH.js";
4
+ import {
5
+ paths
6
+ } from "./chunk-6ZTCETRG.js";
7
+
8
+ // src/tmux.ts
9
+ import { execFile } from "child_process";
10
+ import { existsSync } from "fs";
11
+ import { join } from "path";
12
+ function runFile(file, args, env, opts) {
13
+ return new Promise((resolve) => {
14
+ execFile(file, args, { maxBuffer: 4 * 1024 * 1024, env, ...opts?.timeoutMs ? { timeout: opts.timeoutMs, killSignal: "SIGTERM" } : {} }, (err, stdout, stderr) => {
15
+ const code = err && opts?.timeoutMs && err.killed ? 124 : err && typeof err.code === "number" ? err.code : err ? 1 : 0;
16
+ resolve({ code, stdout: String(stdout), stderr: String(stderr) });
17
+ });
18
+ });
19
+ }
20
+ var run = (args, env) => runFile("tmux", args, env);
21
+ function systemdAvailable(env = process.env, exists = existsSync) {
22
+ return !!env.XDG_RUNTIME_DIR && exists(join(env.XDG_RUNTIME_DIR, "systemd", "private"));
23
+ }
24
+ function newSessionCommand(tmuxArgs, scoped, unit) {
25
+ if (!scoped) return { file: "tmux", args: tmuxArgs };
26
+ return { file: "systemd-run", args: ["--user", "--scope", "--quiet", "--collect", `--unit=${unit}`, "--", "tmux", ...tmuxArgs] };
27
+ }
28
+ function realTmux(socket = "ledge", opts = {}) {
29
+ const scope = opts.scope ?? (async () => systemdAvailable());
30
+ const base = () => ["-L", socket, "-f", paths().tmuxConf];
31
+ const t = (args, env) => run([...base(), ...args], env);
32
+ return {
33
+ async newSession({ name, cwd, env, command }) {
34
+ const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
35
+ const clientEnv = env.PATH === void 0 ? void 0 : { ...process.env, PATH: env.PATH };
36
+ const args = [...base(), "new-session", "-d", "-s", name, "-c", cwd, "-x", "200", "-y", "50", ...envArgs, "--", ...command];
37
+ const serverUp = (await t(["list-sessions"])).code === 0;
38
+ const scoped = !serverUp && await scope();
39
+ const { file, args: argv } = newSessionCommand(args, scoped, `ledge-tmux-${Date.now()}`);
40
+ let r = await runFile(file, argv, clientEnv);
41
+ if (r.code !== 0 && scoped) {
42
+ log(`tmux: scoped start failed (${r.stderr.trim()}), starting unscoped`);
43
+ r = await runFile("tmux", args, clientEnv);
44
+ }
45
+ if (r.code !== 0) throw new Error(`tmux new-session failed: ${r.stderr.trim()}`);
46
+ },
47
+ async sendText(name, text) {
48
+ if (/[\r\n]/.test(text)) throw new Error("sendText: text must not contain newlines; flatten it first");
49
+ const arg = text.endsWith(";") ? `${text.slice(0, -1)}\\;` : text;
50
+ const r = await t(["send-keys", "-t", `=${name}:`, "-l", "--", arg]);
51
+ if (r.code !== 0) throw new Error(`tmux send-keys failed: ${r.stderr.trim()}`);
52
+ },
53
+ async sendKeys(name, keys) {
54
+ const r = await t(["send-keys", "-t", `=${name}:`, ...keys]);
55
+ if (r.code !== 0) throw new Error(`tmux send-keys failed: ${r.stderr.trim()}`);
56
+ },
57
+ async capture(name, lines) {
58
+ const r = await t(["capture-pane", "-p", "-J", "-t", `=${name}:`, "-S", String(-lines)]);
59
+ if (r.code !== 0) return null;
60
+ return r.stdout.replace(/\s+$/g, "").split("\n").slice(-lines).join("\n");
61
+ },
62
+ async hasSession(name) {
63
+ return (await t(["has-session", "-t", `=${name}`])).code === 0;
64
+ },
65
+ async listSessions() {
66
+ const r = await t(["list-sessions", "-F", "#{session_name}"]);
67
+ if (r.code === 0) return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
68
+ if (/no server running|error connecting to/i.test(r.stderr)) return [];
69
+ throw new Error(`tmux list-sessions failed: ${r.stderr.trim()}`);
70
+ },
71
+ async setOption(name, option, value) {
72
+ const r = await t(["set-option", "-t", `=${name}:`, option, value]);
73
+ if (r.code !== 0) throw new Error(`tmux set-option failed: ${r.stderr.trim()}`);
74
+ },
75
+ async killSession(name) {
76
+ await t(["kill-session", "-t", `=${name}`]);
77
+ },
78
+ attachArgs(name) {
79
+ return [...base(), "attach-session", "-t", `=${name}`];
80
+ }
81
+ };
82
+ }
83
+
84
+ export {
85
+ runFile,
86
+ run,
87
+ realTmux
88
+ };
@@ -0,0 +1,59 @@
1
+ import {
2
+ paths
3
+ } from "./chunk-6ZTCETRG.js";
4
+ import {
5
+ pickName
6
+ } from "./chunk-6GMDFIGB.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
+ }
17
+
18
+ // src/spawn.ts
19
+ import { basename, join } from "path";
20
+ import { randomBytes } from "crypto";
21
+ var AGENT_BIN = { claude: "claude", codex: "codex", cursor: "cursor-agent" };
22
+ var CODEX_ENV_VAR = "LEDGE_CODEX_SESSION";
23
+ var CODEX_HOOK_VAR = "LEDGE_CODEX_HOOK_SESSION";
24
+ function agentIdArgs(tool, id) {
25
+ return tool === "codex" ? ["-c", `shell_environment_policy.set.${CODEX_HOOK_VAR}="${id}"`] : [];
26
+ }
27
+ function exitFile(id) {
28
+ return join(paths().dir, `exit-${id}`);
29
+ }
30
+ function agentEnv(base, extra) {
31
+ const env = {};
32
+ for (const [k, v] of Object.entries(base)) if (v !== void 0 && k !== "TMUX" && !k.startsWith("TMUX_")) env[k] = v;
33
+ return { ...env, ...extra };
34
+ }
35
+ async function spawnSession(opts) {
36
+ const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
37
+ const takenIds = new Set(opts.taken.ids);
38
+ let id;
39
+ do
40
+ id = randomBytes(2).toString("hex");
41
+ while (takenIds.has(id));
42
+ const name = pickName(opts.taken.names, opts.name);
43
+ const project = basename(opts.cwd) || opts.cwd;
44
+ const tmuxSession = `ledge-${id}`;
45
+ const agent = opts.command ?? [AGENT_BIN[opts.tool], ...agentIdArgs(opts.tool, id), ...opts.args];
46
+ const command = opts.recordExit ? ["sh", "-c", '"$@"; echo $? > "$0"', exitFile(id), ...agent] : agent;
47
+ await opts.tmux.newSession({ name: tmuxSession, cwd: opts.cwd, env: agentEnv(opts.env ?? process.env, { LEDGE_SESSION: id, LEDGE_NAME: name, ...opts.tool === "codex" ? { [CODEX_ENV_VAR]: id } : {} }), command });
48
+ const at = now();
49
+ return { id, name, tool: opts.tool, cwd: opts.cwd, project, title: `${name} \xB7 ${project}`, tmuxSession, state: "starting", helpers: [], startedAt: at, lastActivityAt: at, source: "launcher" };
50
+ }
51
+
52
+ export {
53
+ log,
54
+ CODEX_ENV_VAR,
55
+ CODEX_HOOK_VAR,
56
+ exitFile,
57
+ agentEnv,
58
+ spawnSession
59
+ };
@@ -0,0 +1,6 @@
1
+ // src/version.ts
2
+ var LEDGE_VERSION = true ? "0.3.0" : "0.0.0-dev";
3
+
4
+ export {
5
+ LEDGE_VERSION
6
+ };