@paradow/ledge 0.15.1 → 0.16.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 +5 -1
- package/dist/cli/{attach-Y4OMZXAH.js → attach-5H727FCW.js} +4 -4
- package/dist/cli/{bridge-6K523KUX.js → bridge-LXMOQN6Q.js} +1 -1
- package/dist/cli/{chunk-QCEVJMXI.js → chunk-DBSCO3HI.js} +63 -25
- package/dist/cli/{chunk-PXO4FFJR.js → chunk-DEXDXDO4.js} +1 -1
- package/dist/cli/{chunk-KGEF6OIU.js → chunk-H36JD4E3.js} +61 -10
- package/dist/cli/chunk-J4ICZEGP.js +6 -0
- package/dist/cli/{chunk-4IBQ4PG6.js → chunk-MQICEVKR.js} +3 -3
- package/dist/cli/{chunk-2LQITRTR.js → chunk-Q5WWH7FG.js} +50 -9
- package/dist/cli/{chunk-IMSPQUFC.js → chunk-QJVODYJ5.js} +2 -2
- package/dist/cli/{chunk-ZANOMSLW.js → chunk-S4ANPQGA.js} +12 -10
- package/dist/cli/{chunk-NKYJF3VT.js → chunk-TB47EHML.js} +2 -2
- package/dist/cli/{daemon-TBYCUHC3.js → daemon-DKH63BGV.js} +406 -62
- package/dist/cli/{hook-PRPEB3T7.js → hook-LY2YTK5U.js} +27 -13
- package/dist/cli/index.js +17 -12
- package/dist/cli/{launch-JS74JGW6.js → launch-UQEFFPBN.js} +4 -4
- package/dist/cli/{send-JX6O7AQR.js → send-EY63XQXI.js} +4 -4
- package/dist/cli/{setup-LWYZEVNE.js → setup-3N2EOJXS.js} +1 -1
- package/dist/cli/{status-Q6TQXVLF.js → status-ZQSYXILE.js} +2 -2
- package/dist/cli/{uninstall-TFC4LALT.js → uninstall-W2OMKU3B.js} +6 -2
- package/dist/cli/{version-5BLH6OA6.js → version-6BMBIXHN.js} +1 -1
- package/dist/cli/{who-KAAEASAA.js → who-VGIMGQNS.js} +3 -3
- package/package.json +1 -1
- package/dist/cli/chunk-LKQ5DDQ4.js +0 -6
package/README.md
CHANGED
|
@@ -39,13 +39,15 @@ The daemon is spawned from the built CLI (`dist/cli/index.js`), so rebuild after
|
|
|
39
39
|
|
|
40
40
|
```sh
|
|
41
41
|
ledge claude # start Claude Code in a tracked tmux session (tab renamed to the bot)
|
|
42
|
-
ledge claude --name max # pick the
|
|
42
|
+
ledge claude --name max # pick the session's Ledge name
|
|
43
43
|
ledge status # list bots and what they wait for
|
|
44
44
|
ledge attach <id> # put this terminal on a running session (what the overlay opens a tab with)
|
|
45
45
|
ledge daemon # run the daemon in the foreground (normally run by the systemd service)
|
|
46
46
|
ledge uninstall # undo ledge setup: hooks, service and ~/.local/bin/ledge (--purge: ~/.ledge too)
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
When a Claude it launched exits, Ledge appends a `custom-title` « name - topic » to its transcript, so `claude --resume` lists the conversation that way, unless the user named it (`claude -n`, `/rename`).
|
|
50
|
+
|
|
49
51
|
`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
52
|
|
|
51
53
|
```sh
|
|
@@ -89,6 +91,8 @@ The question goes on stdin, never in argv, and `LEDGE_CHAT=1` is set so Ledge's
|
|
|
89
91
|
- `~/.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
92
|
- 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
93
|
|
|
94
|
+
- Close card: asked to close sessions, the harness ends its answer with one `ledge-close` block per session (8 at most; the daemon takes the id, name and tool from its own store). The card's **Close** sends `session.close { qid, rows? }`: the daemon sends the interrupt keys and waits up to 10 s for a free prompt, types the agent's exit command (`/exit`) once it is free, then waits up to 15 s for the session to end. A pending request is only interrupted, never answered or typed into: a hook's request for the main thread is recorded as a permission denied (or a question dismissed) by the close; a request read off the screen, or a helper's, is interrupted with nothing recorded (a helper's keeps the prompt busy, so that close ends in the kill). The tmux session is killed when the prompt is still not free after 10 s (nothing is typed) or when the agent is still running 15 s after the exit command. The session ends `exited`, not archived, so `session.resume` brings it back. The phone's chat shows the same card, and its **Fermer** / **Fermer tout (n)** send `chat.close` through the gateway, which needs a passkey assertion bound to the `qid` with purpose `close` (a `launch` or `decide` assertion is never accepted); without a registered passkey the gateway answers `confirm_required` and the app shows the setup hint. `rows` is validated like `chat.launch`'s (ints 0..7, unique, 1 to 8), a failed row is only `failed: true` (no error text reaches the phone), and `chat.close` is volatile: never replayed after a page reload. Asked again for rows still being closed (a reload mid-close), the gateway answers `applied`: that close answers for them. Two cards naming one session share its close: the second waits for it and marks its row with the same outcome, never `a close is already in progress`. There is no `ledge close` for agents: only Ledge's chat closes sessions.
|
|
95
|
+
|
|
92
96
|
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
97
|
|
|
94
98
|
## Tests
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
attachEnv
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-MQICEVKR.js";
|
|
4
4
|
import {
|
|
5
5
|
connect
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
6
|
+
} from "./chunk-DEXDXDO4.js";
|
|
7
|
+
import "./chunk-H36JD4E3.js";
|
|
8
8
|
import {
|
|
9
9
|
PROTOCOL_PORT,
|
|
10
10
|
backendOf
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-Q5WWH7FG.js";
|
|
12
12
|
import {
|
|
13
13
|
realTmux
|
|
14
14
|
} from "./chunk-2H5FJH4Q.js";
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ledgeTitleRest
|
|
3
|
+
} from "./chunk-H36JD4E3.js";
|
|
1
4
|
import {
|
|
2
5
|
cleanTopic,
|
|
3
6
|
sessionTitle
|
|
4
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-Q5WWH7FG.js";
|
|
5
8
|
|
|
6
9
|
// src/daemon/crossSession.ts
|
|
7
10
|
var FIRST_LINE = /^Another Claude session sent a message:\s*\n/;
|
|
@@ -21,14 +24,21 @@ function crossSession(text) {
|
|
|
21
24
|
}
|
|
22
25
|
var LEDGE_CLOSE = "</ledge-message>";
|
|
23
26
|
var LEDGE_BLOCK = /^<ledge-message\b([^>]*)>([\s\S]*?)(?:<\/ledge-message>|$)/;
|
|
24
|
-
var
|
|
27
|
+
var messageNote = (from, to) => `(message de la session ${from}, pas de l'utilisateur ; pour r\xE9pondre : ${to === "codex" ? `ledge send ${from} "\u2026"` : `SendMessage vers ${from}`})`;
|
|
28
|
+
var NOTE_SENDER = /^\(message de la session (.+?), pas de l'utilisateur ; /;
|
|
29
|
+
function stripNote(text) {
|
|
30
|
+
const body = text.trimStart();
|
|
31
|
+
const sender = NOTE_SENDER.exec(body)?.[1];
|
|
32
|
+
const lead = sender === void 0 ? void 0 : [messageNote(sender, "codex"), messageNote(sender, "claude")].find((n) => body.startsWith(n));
|
|
33
|
+
return lead ? { sender, text: body.slice(lead.length).trim() } : { text };
|
|
34
|
+
}
|
|
25
35
|
var ESCAPES = /\x1b(?:\[[0-?]*[ -/]*[@-~]|[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)|[ -/]*[0-~])/g;
|
|
26
36
|
var CONTROLS = /[\x00-\x1f\x7f-\x9f]/g;
|
|
27
37
|
var typable = (text, newline) => text.replace(ESCAPES, "").replace(/\r\n|\r|\n/g, newline).replace(CONTROLS, " ");
|
|
28
38
|
var ledgeBody = (text) => typable(text.replaceAll("<", "\u2039"), " \u23CE ");
|
|
29
39
|
function formatLedgeMessage(m) {
|
|
30
40
|
const from = typable(m.from, " ").replace(/["<>]/g, "").trim();
|
|
31
|
-
return `<ledge-message from="${from}" tool="${m.tool}">${
|
|
41
|
+
return `<ledge-message from="${from}" tool="${m.tool}">${messageNote(from, m.to)} ${ledgeBody(m.text)}${LEDGE_CLOSE}`;
|
|
32
42
|
}
|
|
33
43
|
function ledgeMessages(text) {
|
|
34
44
|
const m = LEDGE_BLOCK.exec(text.trimStart());
|
|
@@ -37,9 +47,7 @@ function ledgeMessages(text) {
|
|
|
37
47
|
const from = attrs.get("from");
|
|
38
48
|
const tool = attrs.get("tool");
|
|
39
49
|
if (!from) return void 0;
|
|
40
|
-
|
|
41
|
-
const lead = [note(from, "codex"), note(from, "claude")].find((n) => body.startsWith(n));
|
|
42
|
-
return [{ from, ...tool ? { tool } : {}, text: (lead ? body.slice(lead.length) : body).trim() }];
|
|
50
|
+
return [{ from, ...tool ? { tool } : {}, text: stripNote(m[2]).text.trim() }];
|
|
43
51
|
}
|
|
44
52
|
var NOTICE_HEAD = /^\[Cross-session ([\w -]+?) notice\]/;
|
|
45
53
|
var NOTICE_SPLIT = /\n\s*(?=\[Cross-session [\w -]+? notice\])/;
|
|
@@ -315,8 +323,13 @@ function reduceEvent(s, ev) {
|
|
|
315
323
|
return { ...s, state: "exited", pending: void 0, exitCode: null, exitedAt: s.exitedAt ?? at, lastActivityAt: at };
|
|
316
324
|
case "PaneChanged":
|
|
317
325
|
return s.lastActivityAt === at ? s : { ...s, lastActivityAt: at };
|
|
318
|
-
case "PaneTitle":
|
|
319
|
-
|
|
326
|
+
case "PaneTitle": {
|
|
327
|
+
if (s.topicSource === "user") return s;
|
|
328
|
+
const t = ev.text ?? "";
|
|
329
|
+
const rest = ledgeTitleRest(t, s.name);
|
|
330
|
+
if (rest === "") return s.topicSource === "pane" && s.topic !== void 0 && ledgeTitleRest(s.topic, s.name) === "" ? withTopic(s, "", void 0) : s;
|
|
331
|
+
return withTopic(s, rest ?? t, "pane");
|
|
332
|
+
}
|
|
320
333
|
case "Renamed":
|
|
321
334
|
return withTopic(s, ev.text ?? "", ev.text?.trim() ? "user" : void 0);
|
|
322
335
|
case "PaneIdle": {
|
|
@@ -455,37 +468,61 @@ function parsePeer(path) {
|
|
|
455
468
|
return void 0;
|
|
456
469
|
}
|
|
457
470
|
}
|
|
458
|
-
function
|
|
471
|
+
function pidAlive(pid) {
|
|
472
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
473
|
+
try {
|
|
474
|
+
process.kill(pid, 0);
|
|
475
|
+
return true;
|
|
476
|
+
} catch (e) {
|
|
477
|
+
return e.code === "EPERM";
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function readPeers(dir = defaultDir(), alive = pidAlive) {
|
|
459
481
|
let names;
|
|
460
482
|
try {
|
|
461
483
|
names = readdirSync(dir);
|
|
462
484
|
} catch {
|
|
463
485
|
return [];
|
|
464
486
|
}
|
|
465
|
-
return names.filter((n) => n.endsWith(".json")).flatMap((n) => parsePeer(join(dir, n)) ?? []);
|
|
487
|
+
return names.filter((n) => n.endsWith(".json")).flatMap((n) => parsePeer(join(dir, n)) ?? []).filter((p) => alive(p.pid));
|
|
488
|
+
}
|
|
489
|
+
function addressOf(s, peers) {
|
|
490
|
+
const t = s.transcriptPath && basename2(s.transcriptPath, ".jsonl");
|
|
491
|
+
const byTranscript = t ? peers.filter((p) => p.sessionId === t) : [];
|
|
492
|
+
const found = byTranscript.length ? byTranscript : peers.filter((p) => s.agentSessionId && p.sessionId === s.agentSessionId);
|
|
493
|
+
return found.length === 1 ? found[0].address : void 0;
|
|
466
494
|
}
|
|
467
495
|
var bareName = (name) => name.replace(/ \[[^\]]*\]$/, "");
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
if (
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
if (direct) found.push(direct);
|
|
475
|
-
}
|
|
476
|
-
for (const { sessionId: id } of found) {
|
|
477
|
-
const s = sessions.find((s2) => s2.agentSessionId === id || s2.transcriptPath?.endsWith(`/${id}.jsonl`));
|
|
478
|
-
if (s) return s;
|
|
496
|
+
var sessionOf = (id, sessions) => sessions.find((s) => s.agentSessionId === id || s.transcriptPath?.endsWith(`/${id}.jsonl`));
|
|
497
|
+
function lookup(to, sessions, peers) {
|
|
498
|
+
const name = bareName(to);
|
|
499
|
+
if (name.startsWith("uds:")) {
|
|
500
|
+
const via = peers.find((p) => p.address === name);
|
|
501
|
+
return { target: via && sessionOf(via.sessionId, sessions), via };
|
|
479
502
|
}
|
|
480
|
-
|
|
503
|
+
const t = sessions.find((s) => s.name === name && s.state !== "exited" && !s.archivedAt);
|
|
504
|
+
if (t) return { target: t };
|
|
505
|
+
const named = peers.filter((p) => p.name === name);
|
|
506
|
+
if (named.length > 1) return { reason: `plusieurs sessions s'appellent ${name}` };
|
|
507
|
+
return { target: named[0] && sessionOf(named[0].sessionId, sessions) };
|
|
481
508
|
}
|
|
482
509
|
function ledgePeer(target, peers, sessions) {
|
|
483
|
-
|
|
484
|
-
|
|
510
|
+
return lookup(target, sessions, peers).target;
|
|
511
|
+
}
|
|
512
|
+
function resolveTarget(to, sessions, peers) {
|
|
513
|
+
const { target: t, via, reason } = lookup(to, sessions, peers);
|
|
514
|
+
if (reason) return { outcome: "refused", reason };
|
|
515
|
+
if (!t) return { outcome: "unknown" };
|
|
516
|
+
if (t.source === "hook_only") return { outcome: "refused", reason: `${t.name} n'a pas \xE9t\xE9 lanc\xE9e par Ledge : impossible de lui \xE9crire` };
|
|
517
|
+
if (t.tool !== "claude") return { outcome: "ledge", target: t };
|
|
518
|
+
const address = via?.address ?? addressOf(t, peers);
|
|
519
|
+
return address ? { outcome: "native", target: t, address } : { outcome: "refused", reason: `${t.name} n'est pas joignable` };
|
|
485
520
|
}
|
|
486
521
|
|
|
487
522
|
export {
|
|
488
523
|
crossSession,
|
|
524
|
+
messageNote,
|
|
525
|
+
stripNote,
|
|
489
526
|
ledgeBody,
|
|
490
527
|
formatLedgeMessage,
|
|
491
528
|
ledgeMessages,
|
|
@@ -508,5 +545,6 @@ export {
|
|
|
508
545
|
gitSummary,
|
|
509
546
|
readPeers,
|
|
510
547
|
bareName,
|
|
511
|
-
ledgePeer
|
|
548
|
+
ledgePeer,
|
|
549
|
+
resolveTarget
|
|
512
550
|
};
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
|
+
NAMES,
|
|
2
3
|
pickName
|
|
3
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-Q5WWH7FG.js";
|
|
4
5
|
import {
|
|
5
6
|
paths
|
|
6
7
|
} from "./chunk-R5462SQM.js";
|
|
7
8
|
|
|
8
9
|
// src/daemon/transcript.ts
|
|
9
|
-
import { closeSync, fstatSync, openSync, readSync } from "fs";
|
|
10
|
+
import { appendFileSync, closeSync, fstatSync, openSync, readFileSync, readSync, statSync } from "fs";
|
|
10
11
|
import { homedir } from "os";
|
|
11
|
-
import { join } from "path";
|
|
12
|
+
import { basename, join } from "path";
|
|
13
|
+
|
|
14
|
+
// src/names.ts
|
|
15
|
+
var ledgeShaped = (n, own) => n === own || NAMES.includes(n.replace(/-\d+$/, ""));
|
|
16
|
+
function ledgeTitleRest(title, own) {
|
|
17
|
+
if (ledgeShaped(title, own)) return "";
|
|
18
|
+
const i = title.startsWith(`${own} - `) ? own.length : title.indexOf(" - ");
|
|
19
|
+
return i > 0 && ledgeShaped(title.slice(0, i), own) ? title.slice(i + 3).trim() : void 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/daemon/transcript.ts
|
|
12
23
|
var TAIL_BYTES = 512 * 1024;
|
|
13
24
|
function tailLines(path, bytes = TAIL_BYTES) {
|
|
14
25
|
let fd;
|
|
@@ -122,9 +133,50 @@ function claudeOpenCall(path, tool) {
|
|
|
122
133
|
function claudeToolAnswered(path, id) {
|
|
123
134
|
return claudeCalls(path).some((c) => c.id === id && c.answered);
|
|
124
135
|
}
|
|
136
|
+
var LABEL_MAX = 80;
|
|
137
|
+
function resumeLabel(name, topic) {
|
|
138
|
+
const label = `${name} - ${topic}`.replace(/\s+/g, " ").trim();
|
|
139
|
+
return label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 1)}\u2026` : label;
|
|
140
|
+
}
|
|
141
|
+
function lastTitle(lines) {
|
|
142
|
+
let last;
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
if (!line.includes('"custom-title"')) continue;
|
|
145
|
+
try {
|
|
146
|
+
const e = JSON.parse(line);
|
|
147
|
+
if (e.type === "custom-title" && typeof e.customTitle === "string") last = { customTitle: e.customTitle, ledge: e.ledge };
|
|
148
|
+
} catch {
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return last;
|
|
152
|
+
}
|
|
153
|
+
var ledgeOwned = (t, name) => t.ledge === true || t.customTitle.startsWith(`${name} - `) || ledgeTitleRest(t.customTitle, name) === "";
|
|
154
|
+
function labelTranscript(path, name, label) {
|
|
155
|
+
try {
|
|
156
|
+
const last = lastTitle(tailLines(path)) ?? (statSync(path).size > TAIL_BYTES ? lastTitle(readFileSync(path, "utf8").split("\n")) : void 0);
|
|
157
|
+
if (last && (last.customTitle === label || !ledgeOwned(last, name))) return false;
|
|
158
|
+
const fd = openSync(path, "r");
|
|
159
|
+
let lead = "";
|
|
160
|
+
try {
|
|
161
|
+
const size = fstatSync(fd).size;
|
|
162
|
+
if (size > 0) {
|
|
163
|
+
const b = Buffer.alloc(1);
|
|
164
|
+
readSync(fd, b, 0, 1, size - 1);
|
|
165
|
+
if (b[0] !== 10) lead = "\n";
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
closeSync(fd);
|
|
169
|
+
}
|
|
170
|
+
appendFileSync(path, `${lead}${JSON.stringify({ type: "custom-title", customTitle: label, sessionId: basename(path, ".jsonl"), ledge: true })}
|
|
171
|
+
`);
|
|
172
|
+
return true;
|
|
173
|
+
} catch (e) {
|
|
174
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
125
177
|
|
|
126
178
|
// src/spawn.ts
|
|
127
|
-
import { basename, join as join2 } from "path";
|
|
179
|
+
import { basename as basename2, join as join2 } from "path";
|
|
128
180
|
import { randomBytes, randomUUID } from "crypto";
|
|
129
181
|
var AGENT_BIN = { claude: "claude", codex: "codex", cursor: "cursor-agent", omp: "omp" };
|
|
130
182
|
var CODEX_ENV_VAR = "LEDGE_CODEX_SESSION";
|
|
@@ -132,10 +184,6 @@ var CODEX_HOOK_VAR = "LEDGE_CODEX_HOOK_SESSION";
|
|
|
132
184
|
function agentIdArgs(tool, id) {
|
|
133
185
|
return tool === "codex" ? ["-c", `shell_environment_policy.set.${CODEX_HOOK_VAR}="${id}"`] : [];
|
|
134
186
|
}
|
|
135
|
-
var CLAUDE_OWN_NAME = /^(-n|--name)(=|$)/;
|
|
136
|
-
function agentNameArgs(tool, name, args) {
|
|
137
|
-
return tool === "claude" && !args.some((a) => CLAUDE_OWN_NAME.test(a)) ? ["-n", name] : [];
|
|
138
|
-
}
|
|
139
187
|
var CLAUDE_OWN_SESSION = /^(--resume|-r|--continue|-c|--session-id|--fork-session)(=|$)/;
|
|
140
188
|
function claudeSessionId(tool, args) {
|
|
141
189
|
return tool === "claude" && !args.some((a) => CLAUDE_OWN_SESSION.test(a)) ? randomUUID() : void 0;
|
|
@@ -169,10 +217,10 @@ async function spawnSession(opts) {
|
|
|
169
217
|
id = randomBytes(2).toString("hex");
|
|
170
218
|
while (takenIds.has(id));
|
|
171
219
|
const name = pickName(opts.taken.names, opts.name);
|
|
172
|
-
const project =
|
|
220
|
+
const project = basename2(opts.cwd) || opts.cwd;
|
|
173
221
|
const tmuxSession = `ledge-${id}`;
|
|
174
222
|
const agentSessionId = opts.command ? void 0 : claudeSessionId(opts.tool, opts.args);
|
|
175
|
-
const agent = opts.command ?? [AGENT_BIN[opts.tool], ...agentIdArgs(opts.tool, id), ...agentSessionId ? ["--session-id", agentSessionId] : [], ...
|
|
223
|
+
const agent = opts.command ?? [AGENT_BIN[opts.tool], ...agentIdArgs(opts.tool, id), ...agentSessionId ? ["--session-id", agentSessionId] : [], ...opts.args];
|
|
176
224
|
const env = opts.env ?? process.env;
|
|
177
225
|
const transcriptPath = agentSessionId ? claudeTranscriptPath(opts.cwd, agentSessionId, env) : void 0;
|
|
178
226
|
const command = opts.recordExit ? ["sh", "-c", '"$@"; echo $? > "$0"', exitFile(id), ...agent] : agent;
|
|
@@ -182,11 +230,14 @@ async function spawnSession(opts) {
|
|
|
182
230
|
}
|
|
183
231
|
|
|
184
232
|
export {
|
|
233
|
+
ledgeTitleRest,
|
|
185
234
|
agentMessages,
|
|
186
235
|
codexPlan,
|
|
187
236
|
claudePlan,
|
|
188
237
|
claudeOpenCall,
|
|
189
238
|
claudeToolAnswered,
|
|
239
|
+
resumeLabel,
|
|
240
|
+
labelTranscript,
|
|
190
241
|
CODEX_ENV_VAR,
|
|
191
242
|
CODEX_HOOK_VAR,
|
|
192
243
|
exitFile,
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DaemonRefusedError,
|
|
3
3
|
connect
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-DEXDXDO4.js";
|
|
5
5
|
import {
|
|
6
6
|
exitFile,
|
|
7
7
|
spawnSession
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-H36JD4E3.js";
|
|
9
9
|
import {
|
|
10
10
|
PROTOCOL_PORT
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-Q5WWH7FG.js";
|
|
12
12
|
import {
|
|
13
13
|
realTmux
|
|
14
14
|
} from "./chunk-2H5FJH4Q.js";
|
|
@@ -145,6 +145,15 @@ var ChatRelaySchema = z2.object({
|
|
|
145
145
|
sentAt: z2.string().datetime().optional(),
|
|
146
146
|
error: z2.string().optional()
|
|
147
147
|
});
|
|
148
|
+
var ChatCloseSchema = z2.object({
|
|
149
|
+
id: z2.string().min(1),
|
|
150
|
+
name: z2.string(),
|
|
151
|
+
tool: ToolSchema,
|
|
152
|
+
closedAt: z2.string().datetime().optional(),
|
|
153
|
+
how: z2.enum(["exit", "killed"]).optional(),
|
|
154
|
+
error: z2.string().optional()
|
|
155
|
+
});
|
|
156
|
+
var ChatClosesSchema = z2.array(ChatCloseSchema).min(1).max(MAX_PROPOSALS);
|
|
148
157
|
var ChatProjectProposalSchema = z2.object({
|
|
149
158
|
name: z2.string().min(1).max(80),
|
|
150
159
|
description: z2.string().max(500).optional(),
|
|
@@ -165,6 +174,7 @@ var ChatTurnSchema = z2.object({
|
|
|
165
174
|
proposal: ChatProposalSchema.optional(),
|
|
166
175
|
proposals: ChatProposalsSchema.optional(),
|
|
167
176
|
relay: ChatRelaySchema.optional(),
|
|
177
|
+
closes: ChatClosesSchema.optional(),
|
|
168
178
|
projectProposal: ChatProjectProposalSchema.optional()
|
|
169
179
|
});
|
|
170
180
|
var proposalRows = (t) => t?.proposals ?? (t?.proposal ? [t.proposal] : []);
|
|
@@ -396,10 +406,12 @@ var base = { id: z12.string().min(1), at: z12.string().datetime(), turn: z12.num
|
|
|
396
406
|
var ThreadItemSchema = z12.discriminatedUnion("kind", [
|
|
397
407
|
z12.object({ kind: z12.literal("prompt"), ...base, text: z12.string() }),
|
|
398
408
|
z12.object({ kind: z12.literal("message"), ...base, text: z12.string() }),
|
|
399
|
-
/** `id` is the harness's tool_use_id when it gives one, so the result updates the same row
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
409
|
+
/** `id` is the harness's tool_use_id when it gives one, so the result updates the same row; `byClose`: a question
|
|
410
|
+
* dismissed by Ledge closing the session. */
|
|
411
|
+
z12.object({ kind: z12.literal("tool"), ...base, tool: z12.string(), label: z12.string(), input: z12.string().optional(), output: z12.string().optional(), error: z12.string().optional(), status: ToolStatusSchema, durationMs: z12.number().nonnegative().optional(), files: z12.array(FileChangeSchema).default([]), byClose: z12.literal(true).optional() }),
|
|
412
|
+
/** `unknown`: answered in the terminal, the daemon only saw the prompt go away; `always`: allowed and not asked again;
|
|
413
|
+
* `byClose`: denied by Ledge closing the session. */
|
|
414
|
+
z12.object({ kind: z12.literal("permission"), ...base, tool: z12.string(), text: z12.string(), decision: z12.enum(["accept", "deny", "always", "unknown"]).optional(), byClose: z12.literal(true).optional() }),
|
|
403
415
|
z12.object({ kind: z12.literal("reply"), ...base, text: z12.string() }),
|
|
404
416
|
z12.object({ kind: z12.literal("helper"), ...base, task: z12.string(), agentType: z12.string().optional(), state: z12.enum(["working", "exited"]) }),
|
|
405
417
|
/** A message this session sent to another agent; `id` is the tool_use_id, `to` the address it used, `peer` the Ledge session id, `received` the paired item's id in the peer's thread, `via` set when Ledge's mailbox carries it. */
|
|
@@ -548,6 +560,8 @@ var SessionEventSchema = z15.object({
|
|
|
548
560
|
transcriptPath: z15.string().optional(),
|
|
549
561
|
/** Decided: what was chosen when the daemon knows it. */
|
|
550
562
|
decision: DecisionSchema.optional(),
|
|
563
|
+
/** Decided: the request was refused by Ledge closing the session (its interrupt keys), not by the user's answer. */
|
|
564
|
+
byClose: z15.literal(true).optional(),
|
|
551
565
|
/** The agent's own session id when the hook names it, so the daemon can resume that conversation. */
|
|
552
566
|
agentSessionId: z15.string().optional()
|
|
553
567
|
});
|
|
@@ -594,6 +608,8 @@ var ServerMessageSchema = z15.discriminatedUnion("type", [
|
|
|
594
608
|
z15.object({ type: z15.literal("session.thread.item"), id: z15.string(), item: ThreadItemSchema }),
|
|
595
609
|
/** Answer to the requester of a `session.launch`: the session exists (its `session.updated` is broadcast as usual). */
|
|
596
610
|
z15.object({ type: z15.literal("session.launched"), id: z15.string() }),
|
|
611
|
+
/** Answer to the requester of a `session.close`, once per row closed: the session `id` and how it ended. */
|
|
612
|
+
z15.object({ type: z15.literal("session.closed"), qid: z15.string(), row: z15.number().int().min(0), id: z15.string(), how: z15.enum(["exit", "killed"]) }),
|
|
597
613
|
z15.object({ type: z15.literal("recents"), recents: z15.array(RecentSchema) }),
|
|
598
614
|
/** Answer to the requester of a `folder.list` or `folder.create`: `path` resolved (`~` expanded), `parent` null at the
|
|
599
615
|
* root, `entries` its subfolders by name (hidden ones left out). A failure keeps the asked path and says why in `error`,
|
|
@@ -612,7 +628,7 @@ var ServerMessageSchema = z15.discriminatedUnion("type", [
|
|
|
612
628
|
/** 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. */
|
|
613
629
|
z15.object({ type: z15.literal("chat.step"), qid: z15.string(), step: ChatStepSchema }),
|
|
614
630
|
z15.object({ type: z15.literal("chat.delta"), qid: z15.string(), text: z15.string() }),
|
|
615
|
-
z15.object({ type: z15.literal("chat.done"), qid: z15.string(), text: z15.string(), at: z15.string().datetime(), proposal: ChatProposalSchema.optional(), proposals: ChatProposalsSchema.optional(), relay: ChatRelaySchema.optional(), projectProposal: ChatProjectProposalSchema.optional() }),
|
|
631
|
+
z15.object({ type: z15.literal("chat.done"), qid: z15.string(), text: z15.string(), at: z15.string().datetime(), proposal: ChatProposalSchema.optional(), proposals: ChatProposalsSchema.optional(), relay: ChatRelaySchema.optional(), closes: ChatClosesSchema.optional(), projectProposal: ChatProjectProposalSchema.optional() }),
|
|
616
632
|
z15.object({ type: z15.literal("chat.error"), qid: z15.string(), message: z15.string() }),
|
|
617
633
|
z15.object({ type: z15.literal("chat.history"), turns: z15.array(ChatTurnSchema) }),
|
|
618
634
|
/** The daemon updating itself: npm is running, then the service restarts (the socket closes right after). */
|
|
@@ -641,6 +657,7 @@ var ServerMessageSchema = z15.discriminatedUnion("type", [
|
|
|
641
657
|
]);
|
|
642
658
|
var MESSAGE_MAX = 2e4;
|
|
643
659
|
var SUMMARY_MAX = 500;
|
|
660
|
+
var launchRows = z15.array(z15.number().int().min(0).max(MAX_PROPOSALS - 1)).min(1).max(MAX_PROPOSALS).refine((r) => new Set(r).size === r.length, "a row twice");
|
|
644
661
|
var ClientMessageSchema = z15.discriminatedUnion("type", [
|
|
645
662
|
z15.object({ type: z15.literal("chat.route"), qid: z15.string().min(1).max(100), text: z15.string().min(1).max(2e4), tool: ToolSchema.optional(), context: RoutingContextSchema }),
|
|
646
663
|
DocumentReadSchema,
|
|
@@ -660,7 +677,7 @@ var ClientMessageSchema = z15.discriminatedUnion("type", [
|
|
|
660
677
|
continue: z15.boolean().optional(),
|
|
661
678
|
qid: z15.string().min(1).optional(),
|
|
662
679
|
/** With `qid`: the card's rows to launch (#24), indexes into the turn's `proposals`, each once; the first row without it. */
|
|
663
|
-
rows:
|
|
680
|
+
rows: launchRows.optional(),
|
|
664
681
|
/** Folders in addition to `cwd` (Claude: `--add-dir`); resolved like `cwd`. */
|
|
665
682
|
dirs: z15.array(z15.string().min(1)).max(20).optional(),
|
|
666
683
|
/** The agent's model and effort (#58), the agent's own default when left out; with `qid`, for every row launched. */
|
|
@@ -697,6 +714,8 @@ var ClientMessageSchema = z15.discriminatedUnion("type", [
|
|
|
697
714
|
z15.object({ type: z15.literal("session.relay"), qid: z15.string().min(1) }),
|
|
698
715
|
/** Takes the card's queued instruction back out of the queue. */
|
|
699
716
|
z15.object({ type: z15.literal("session.relay.cancel"), qid: z15.string().min(1) }),
|
|
717
|
+
/** The chat's close card: closes the turn's sessions, `rows` (indexes into its `closes`) or all of them. */
|
|
718
|
+
z15.object({ type: z15.literal("session.close"), qid: z15.string().min(1), rows: launchRows.optional() }),
|
|
700
719
|
/** `always` and `note` only where the harness offers them (`offers`); a note goes with `deny`, for the agent to read. */
|
|
701
720
|
z15.object({
|
|
702
721
|
type: z15.literal("session.decide"),
|
|
@@ -857,7 +876,8 @@ var ProjectBlockSchema = z16.object({
|
|
|
857
876
|
repos: z16.array(z16.object({ path: z16.string().min(1).max(4096), why: z16.string().trim().min(1).max(200) })).min(1).max(50),
|
|
858
877
|
addTo: z16.string().trim().max(80).optional()
|
|
859
878
|
});
|
|
860
|
-
var
|
|
879
|
+
var CloseBlockSchema = z16.object({ session: z16.string().trim().min(1) });
|
|
880
|
+
var BLOCK = /```ledge-(session|relay|project|close)[ \t]*\n?([\s\S]*?)(?:```|$)/g;
|
|
861
881
|
function extractProposal(answer) {
|
|
862
882
|
const found = [];
|
|
863
883
|
const text = answer.replace(BLOCK, (_m, kind, body) => {
|
|
@@ -883,6 +903,14 @@ function parseRelayBlock(raw) {
|
|
|
883
903
|
return null;
|
|
884
904
|
}
|
|
885
905
|
}
|
|
906
|
+
function parseCloseBlock(raw) {
|
|
907
|
+
try {
|
|
908
|
+
const r = CloseBlockSchema.safeParse(JSON.parse(raw));
|
|
909
|
+
return r.success ? r.data : null;
|
|
910
|
+
} catch {
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
886
914
|
function parseProjectBlock(raw) {
|
|
887
915
|
try {
|
|
888
916
|
const r = ProjectBlockSchema.safeParse(JSON.parse(raw));
|
|
@@ -1084,7 +1112,7 @@ var AssertionSchema = z20.object({
|
|
|
1084
1112
|
authenticatorData: B64U2.max(4096),
|
|
1085
1113
|
signature: B64U2.max(1024)
|
|
1086
1114
|
});
|
|
1087
|
-
var ConfirmPurposeSchema = z20.enum(["register", "decide", "launch"]);
|
|
1115
|
+
var ConfirmPurposeSchema = z20.enum(["register", "decide", "launch", "close"]);
|
|
1088
1116
|
var PhoneChatStepSchema = ChatStepSchema.pick({ kind: true, id: true, name: true }).strict();
|
|
1089
1117
|
var PhoneChatProposalSchema = z20.object({
|
|
1090
1118
|
project: z20.string().max(80),
|
|
@@ -1102,6 +1130,14 @@ var PhoneChatRelaySchema = z20.object({
|
|
|
1102
1130
|
sentAt: z20.string().datetime().optional(),
|
|
1103
1131
|
failed: z20.literal(true).optional()
|
|
1104
1132
|
}).strict();
|
|
1133
|
+
var PhoneChatCloseSchema = z20.object({
|
|
1134
|
+
id: z20.string().min(1),
|
|
1135
|
+
name: z20.string(),
|
|
1136
|
+
tool: ToolSchema,
|
|
1137
|
+
how: z20.enum(["exit", "killed"]).optional(),
|
|
1138
|
+
closedAt: z20.string().datetime().optional(),
|
|
1139
|
+
failed: z20.literal(true).optional()
|
|
1140
|
+
}).strict();
|
|
1105
1141
|
var ChatErrorCodeSchema = z20.enum(["busy", "timeout", "cancelled", "failed"]);
|
|
1106
1142
|
var PhoneChatTurnSchema = z20.object({
|
|
1107
1143
|
qid: OperationIdSchema,
|
|
@@ -1113,11 +1149,13 @@ var PhoneChatTurnSchema = z20.object({
|
|
|
1113
1149
|
error: ChatErrorCodeSchema.optional(),
|
|
1114
1150
|
proposals: z20.array(PhoneChatProposalSchema).max(MAX_PROPOSALS).optional(),
|
|
1115
1151
|
relay: PhoneChatRelaySchema.optional(),
|
|
1152
|
+
closes: z20.array(PhoneChatCloseSchema).min(1).max(MAX_PROPOSALS).optional(),
|
|
1116
1153
|
cut: z20.literal(true).optional()
|
|
1117
1154
|
}).strict();
|
|
1118
1155
|
var CHAT_HISTORY_BUDGET = 393216;
|
|
1119
1156
|
var sessionId = z20.string().min(1).max(100);
|
|
1120
1157
|
var PhoneExpectSchema = PendingSchema.pick({ kind: true, text: true }).extend({ key: z20.string().min(1).max(64) });
|
|
1158
|
+
var CardRowsSchema = z20.array(z20.number().int().min(0).max(MAX_PROPOSALS - 1)).min(1).max(MAX_PROPOSALS).refine((r) => new Set(r).size === r.length, "a row twice");
|
|
1121
1159
|
var PhoneClientMessageSchema = z20.discriminatedUnion("type", [
|
|
1122
1160
|
z20.object({ type: z20.literal("snapshot.request") }),
|
|
1123
1161
|
z20.object({ type: z20.literal("session.thread"), id: sessionId }),
|
|
@@ -1171,7 +1209,8 @@ var PhoneClientMessageSchema = z20.discriminatedUnion("type", [
|
|
|
1171
1209
|
z20.object({ type: z20.literal("chat.reset") }),
|
|
1172
1210
|
z20.object({ type: z20.literal("chat.history") }),
|
|
1173
1211
|
z20.object({ type: z20.literal("chat.relay"), op: OperationIdSchema, qid: OperationIdSchema }),
|
|
1174
|
-
z20.object({ type: z20.literal("chat.launch"), op: OperationIdSchema, qid: OperationIdSchema, rows:
|
|
1212
|
+
z20.object({ type: z20.literal("chat.launch"), op: OperationIdSchema, qid: OperationIdSchema, rows: CardRowsSchema.optional(), assertion: AssertionSchema.optional() }),
|
|
1213
|
+
z20.object({ type: z20.literal("chat.close"), op: OperationIdSchema, qid: OperationIdSchema, rows: CardRowsSchema.optional(), assertion: AssertionSchema.optional() }),
|
|
1175
1214
|
/** Whether the phone's app is in front: a device that is visible gets no push. */
|
|
1176
1215
|
z20.object({ type: z20.literal("presence"), visible: z20.boolean() })
|
|
1177
1216
|
]);
|
|
@@ -1345,10 +1384,12 @@ export {
|
|
|
1345
1384
|
SUMMARY_MAX,
|
|
1346
1385
|
parseClientMessage,
|
|
1347
1386
|
parseServerMessage,
|
|
1387
|
+
NAMES,
|
|
1348
1388
|
pickName,
|
|
1349
1389
|
extractProposal,
|
|
1350
1390
|
parseProposalBlock,
|
|
1351
1391
|
parseRelayBlock,
|
|
1392
|
+
parseCloseBlock,
|
|
1352
1393
|
parseProjectBlock,
|
|
1353
1394
|
TOPIC_MAX,
|
|
1354
1395
|
cleanTopic,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
connect,
|
|
3
3
|
sendMessageAndAwaitAck
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-DEXDXDO4.js";
|
|
5
5
|
import {
|
|
6
6
|
CODEX_HOOK_VAR
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-H36JD4E3.js";
|
|
8
8
|
|
|
9
9
|
// src/cli/send.ts
|
|
10
10
|
import { randomUUID } from "crypto";
|
|
@@ -241,15 +241,15 @@ var BIN_MARK = "#!/bin/sh\n# Written by `ledge setup`";
|
|
|
241
241
|
var shQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
242
242
|
async function writeBin(opts) {
|
|
243
243
|
const st = await lstat(opts.link).catch(() => null);
|
|
244
|
-
|
|
245
|
-
const was = st.isSymbolicLink() ? await readlink(opts.link) : null;
|
|
244
|
+
const was = st?.isSymbolicLink() ? await readlink(opts.link) : null;
|
|
246
245
|
if (was !== null && !was.endsWith("/dist/cli/index.js")) return null;
|
|
247
|
-
const current = was === null && st.isFile() ? await readFile2(opts.link, "utf8").catch(() => "") : "";
|
|
248
|
-
if (was === null && !current.startsWith(BIN_MARK)) return null;
|
|
246
|
+
const current = st && was === null && st.isFile() ? await readFile2(opts.link, "utf8").catch(() => "") : "";
|
|
247
|
+
if (st && was === null && !current.startsWith(BIN_MARK)) return null;
|
|
249
248
|
const script = `${BIN_MARK}: the hooks run \`ledge\` wherever node is not on the PATH.
|
|
250
249
|
exec ${shQuote(opts.node)} ${shQuote(opts.target)} "$@"
|
|
251
250
|
`;
|
|
252
251
|
if (current === script) return null;
|
|
252
|
+
await mkdir(dirname(opts.link), { recursive: true });
|
|
253
253
|
const tmp = `${opts.link}.tmp`;
|
|
254
254
|
await writeFile(tmp, script, { mode: 493 });
|
|
255
255
|
await rename(tmp, opts.link);
|
|
@@ -271,13 +271,15 @@ async function runSetup(opts = {}) {
|
|
|
271
271
|
if (!await isDir(cursorDir)) out.push(`Cursor Agent not found (${cursorDir} missing), Cursor Agent hooks skipped`);
|
|
272
272
|
else if (await mergeJsonFile(join(cursorDir, "hooks.json"), mergeCursorHooks, "hooks.json")) out.push(`Cursor Agent hooks added in ${join(cursorDir, "hooks.json")}`);
|
|
273
273
|
else out.push("Cursor Agent hooks already present");
|
|
274
|
-
if ((opts.service ?? true) && !process.argv[1]?.endsWith(".ts")) {
|
|
275
|
-
const line = await writeBin({ link: join(homedir(), ".local", "bin", "ledge"), node: process.execPath, target: await realpath(process.argv[1]) });
|
|
276
|
-
if (line) out.push(line);
|
|
277
|
-
}
|
|
278
274
|
if (opts.service ?? true) {
|
|
279
|
-
|
|
280
|
-
|
|
275
|
+
const cli = opts.cli ?? process.argv[1] ?? "";
|
|
276
|
+
if (cli.endsWith(".ts")) out.push("systemd service skipped: run setup from the built CLI (pnpm --filter @paradow/ledge build, then ledge setup)");
|
|
277
|
+
else {
|
|
278
|
+
const script = await realpath(cli);
|
|
279
|
+
const line = await writeBin({ link: opts.link ?? join(homedir(), ".local", "bin", "ledge"), node: process.execPath, target: script });
|
|
280
|
+
if (line) out.push(line);
|
|
281
|
+
out.push(...await installService({ script, unitDir: opts.unitDir, run: opts.run }));
|
|
282
|
+
}
|
|
281
283
|
}
|
|
282
284
|
return out;
|
|
283
285
|
}
|