@paradow/ledge 0.15.0 → 0.16.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
@@ -30,7 +30,7 @@ pnpm --filter @paradow/ledge build
30
30
  chmod +x packages/ledge/dist/cli/index.js
31
31
  mkdir -p ~/.local/bin
32
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
33
+ ledge setup # the link becomes a script that runs the CLI with this node (a session Ledge launches may have no node on its PATH: nvm)
34
34
  ```
35
35
 
36
36
  The daemon is spawned from the built CLI (`dist/cli/index.js`), so rebuild after changes (`pnpm --filter @paradow/ledge build`).
@@ -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
- ledge uninstall # undo ledge setup: hooks, service and the ~/.local/bin link (--purge: ~/.ledge too)
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
@@ -0,0 +1,6 @@
1
+ // src/version.ts
2
+ var LEDGE_VERSION = true ? "0.16.0" : "0.0.0-dev";
3
+
4
+ export {
5
+ LEDGE_VERSION
6
+ };
@@ -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": {
@@ -356,9 +369,10 @@ function reduceEvent(s, ev) {
356
369
  case "PanePrompt": {
357
370
  const incoming = pendingFor(ev);
358
371
  const moved = s.pending?.source === "hook" && s.pending.kind === "question" && incoming.kind === "question" && !sameQuestion(incoming.text, s.pending.text);
359
- if (s.pending?.source === "hook" && !moved) return s;
372
+ const bare = s.pending?.source === "hook" && s.pending.kind === "permission" && !s.pending.tool;
373
+ if (s.pending?.source === "hook" && !moved && !bare) return s;
360
374
  if (!moved && hooksReportPrompts(s) && lastHookAt && Date.parse(at) - Date.parse(lastHookAt) < HOOK_PRIORITY_MS) return s;
361
- let pending = moved ? { ...incoming, ...s.pending.helperId ? { helperId: s.pending.helperId } : {} } : mergePending(s.pending, incoming);
375
+ let pending = moved ? { ...incoming, ...s.pending.helperId ? { helperId: s.pending.helperId } : {} } : bare ? incoming : mergePending(s.pending, incoming);
362
376
  if (pending.kind === "plan" && !pending.body && s.tool === "codex" && s.lastReply) pending = { ...pending, body: s.lastReply.text };
363
377
  if (s.pending && samePending(s.pending, pending)) return s;
364
378
  const state = pending.kind === "question" ? "waiting_input" : "waiting_permission";
@@ -454,37 +468,61 @@ function parsePeer(path) {
454
468
  return void 0;
455
469
  }
456
470
  }
457
- 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) {
458
481
  let names;
459
482
  try {
460
483
  names = readdirSync(dir);
461
484
  } catch {
462
485
  return [];
463
486
  }
464
- 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;
465
494
  }
466
495
  var bareName = (name) => name.replace(/ \[[^\]]*\]$/, "");
467
- function peerFor(target, peers, sessions, dir = defaultDir()) {
468
- const name = bareName(target);
469
- const found = peers.filter((p) => p.name === name || p.address === name);
470
- if (!found.length && name.startsWith("uds:") && name.endsWith(".sock")) {
471
- const pid = basename2(name, ".sock");
472
- const direct = /^\d+$/.test(pid) ? parsePeer(join(dir, `${pid}.json`)) : void 0;
473
- if (direct) found.push(direct);
474
- }
475
- for (const { sessionId: id } of found) {
476
- const s = sessions.find((s2) => s2.agentSessionId === id || s2.transcriptPath?.endsWith(`/${id}.jsonl`));
477
- 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 };
478
502
  }
479
- 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) };
480
508
  }
481
509
  function ledgePeer(target, peers, sessions) {
482
- const name = bareName(target);
483
- 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` };
484
520
  }
485
521
 
486
522
  export {
487
523
  crossSession,
524
+ messageNote,
525
+ stripNote,
488
526
  ledgeBody,
489
527
  formatLedgeMessage,
490
528
  ledgeMessages,
@@ -507,5 +545,6 @@ export {
507
545
  gitSummary,
508
546
  readPeers,
509
547
  bareName,
510
- ledgePeer
548
+ ledgePeer,
549
+ resolveTarget
511
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,
@@ -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";