@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 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 bot's name
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-4IBQ4PG6.js";
3
+ } from "./chunk-MQICEVKR.js";
4
4
  import {
5
5
  connect
6
- } from "./chunk-PXO4FFJR.js";
7
- import "./chunk-KGEF6OIU.js";
6
+ } from "./chunk-DEXDXDO4.js";
7
+ import "./chunk-H36JD4E3.js";
8
8
  import {
9
9
  PROTOCOL_PORT,
10
10
  backendOf
11
- } from "./chunk-2LQITRTR.js";
11
+ } from "./chunk-Q5WWH7FG.js";
12
12
  import {
13
13
  realTmux
14
14
  } from "./chunk-2H5FJH4Q.js";
@@ -7,7 +7,7 @@ import {
7
7
  parseClientMessage,
8
8
  parseServerMessage,
9
9
  readDaemonToken
10
- } from "./chunk-2LQITRTR.js";
10
+ } from "./chunk-Q5WWH7FG.js";
11
11
  import "./chunk-R5462SQM.js";
12
12
 
13
13
  // src/cli/bridge.ts
@@ -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-2LQITRTR.js";
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 note = (from, to) => `(message de la session ${from}, pas de l'utilisateur ; pour r\xE9pondre : ${to === "codex" ? `ledge send ${from} "\u2026"` : `SendMessage vers ${from}`})`;
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}">${note(from, m.to)} ${ledgeBody(m.text)}${LEDGE_CLOSE}`;
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
- const body = m[2].trimStart();
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
- return s.topicSource === "user" ? s : withTopic(s, ev.text ?? "", "pane");
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 readPeers(dir = defaultDir()) {
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
- function peerFor(target, peers, sessions, dir = defaultDir()) {
469
- const name = bareName(target);
470
- const found = peers.filter((p) => p.name === name || p.address === name);
471
- if (!found.length && name.startsWith("uds:") && name.endsWith(".sock")) {
472
- const pid = basename2(name, ".sock");
473
- const direct = /^\d+$/.test(pid) ? parsePeer(join(dir, `${pid}.json`)) : void 0;
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
- return void 0;
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
- const name = bareName(target);
484
- return peerFor(target, peers, sessions) ?? sessions.find((s) => s.name === name && s.state !== "exited");
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
  };
@@ -4,7 +4,7 @@ import {
4
4
  SUMMARY_MAX,
5
5
  parseServerMessage,
6
6
  readDaemonToken
7
- } from "./chunk-2LQITRTR.js";
7
+ } from "./chunk-Q5WWH7FG.js";
8
8
 
9
9
  // src/client.ts
10
10
  import WebSocket from "ws";
@@ -1,14 +1,25 @@
1
1
  import {
2
+ NAMES,
2
3
  pickName
3
- } from "./chunk-2LQITRTR.js";
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 = basename(opts.cwd) || opts.cwd;
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] : [], ...agentNameArgs(opts.tool, name, opts.args), ...opts.args];
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,
@@ -0,0 +1,6 @@
1
+ // src/version.ts
2
+ var LEDGE_VERSION = true ? "0.16.1" : "0.0.0-dev";
3
+
4
+ export {
5
+ LEDGE_VERSION
6
+ };
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  DaemonRefusedError,
3
3
  connect
4
- } from "./chunk-PXO4FFJR.js";
4
+ } from "./chunk-DEXDXDO4.js";
5
5
  import {
6
6
  exitFile,
7
7
  spawnSession
8
- } from "./chunk-KGEF6OIU.js";
8
+ } from "./chunk-H36JD4E3.js";
9
9
  import {
10
10
  PROTOCOL_PORT
11
- } from "./chunk-2LQITRTR.js";
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
- 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([]) }),
401
- /** `unknown`: answered in the terminal, the daemon only saw the prompt go away; `always`: allowed and not asked again. */
402
- z12.object({ kind: z12.literal("permission"), ...base, tool: z12.string(), text: z12.string(), decision: z12.enum(["accept", "deny", "always", "unknown"]).optional() }),
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: 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").optional(),
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 BLOCK = /```ledge-(session|relay|project)[ \t]*\n?([\s\S]*?)(?:```|$)/g;
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: 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").optional(), assertion: AssertionSchema.optional() }),
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-PXO4FFJR.js";
4
+ } from "./chunk-DEXDXDO4.js";
5
5
  import {
6
6
  CODEX_HOOK_VAR
7
- } from "./chunk-KGEF6OIU.js";
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
- if (!st) return null;
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
- if (process.argv[1]?.endsWith(".ts")) out.push("systemd service skipped: run setup from the built CLI (pnpm --filter @paradow/ledge build, then ledge setup)");
280
- else out.push(...await installService());
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
  }
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  connect
3
- } from "./chunk-PXO4FFJR.js";
3
+ } from "./chunk-DEXDXDO4.js";
4
4
  import {
5
5
  isArchived
6
- } from "./chunk-2LQITRTR.js";
6
+ } from "./chunk-Q5WWH7FG.js";
7
7
 
8
8
  // src/cli/who.ts
9
9
  import { basename } from "path";