@paradow/ledge 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @paradow/ledge (WSL side)
2
2
 
3
- The Ledge daemon and CLI: follow and answer your WSL agent sessions (Claude Code, Codex, Cursor Agent) from the Ledge island on Windows. See the [repository README](https://github.com/Paradow-dev/ledge#readme) for the whole picture.
3
+ The Ledge daemon and CLI: follow and answer your WSL agent sessions (Claude Code, Codex, Cursor Agent) from the Ledge island on Windows. See the [repository README](https://github.com/Lyssaen/ledge#readme) for the whole picture.
4
4
 
5
5
  ## Install
6
6
 
@@ -1,17 +1,17 @@
1
1
  import {
2
2
  attachEnv
3
- } from "./chunk-Y44XZY6I.js";
3
+ } from "./chunk-ZPR4LCDM.js";
4
4
  import {
5
5
  realTmux
6
- } from "./chunk-7ICDC2LK.js";
7
- import "./chunk-OUIDBDIH.js";
6
+ } from "./chunk-DMV5VW4Q.js";
7
+ import "./chunk-B73JDPSW.js";
8
8
  import "./chunk-6ZTCETRG.js";
9
9
  import {
10
10
  connect
11
- } from "./chunk-W5XAZ5IS.js";
11
+ } from "./chunk-34DHZSGH.js";
12
12
  import {
13
13
  PROTOCOL_PORT
14
- } from "./chunk-6GMDFIGB.js";
14
+ } from "./chunk-YT4T7DL2.js";
15
15
 
16
16
  // src/cli/attach.ts
17
17
  import { spawn } from "child_process";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  PROTOCOL_PORT,
3
3
  parseServerMessage
4
- } from "./chunk-6GMDFIGB.js";
4
+ } from "./chunk-YT4T7DL2.js";
5
5
 
6
6
  // src/client.ts
7
7
  import WebSocket from "ws";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  cleanTopic,
3
3
  sessionTitle
4
- } from "./chunk-6GMDFIGB.js";
4
+ } from "./chunk-YT4T7DL2.js";
5
5
 
6
6
  // src/daemon/state.ts
7
7
  var HOOK_PRIORITY_MS = 2e3;
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-6ZTCETRG.js";
4
4
  import {
5
5
  pickName
6
- } from "./chunk-6GMDFIGB.js";
6
+ } from "./chunk-YT4T7DL2.js";
7
7
 
8
8
  // src/log.ts
9
9
  import { appendFileSync } from "fs";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  log
3
- } from "./chunk-OUIDBDIH.js";
3
+ } from "./chunk-B73JDPSW.js";
4
4
  import {
5
5
  paths
6
6
  } from "./chunk-6ZTCETRG.js";
@@ -0,0 +1,6 @@
1
+ // src/version.ts
2
+ var LEDGE_VERSION = true ? "0.4.0" : "0.0.0-dev";
3
+
4
+ export {
5
+ LEDGE_VERSION
6
+ };
@@ -64,6 +64,23 @@ var ChatStepSchema = z2.object({
64
64
  name: z2.string().optional(),
65
65
  path: z2.string().optional()
66
66
  });
67
+ var ChatProposalSchema = z2.object({
68
+ cwd: z2.string().min(1),
69
+ project: z2.string(),
70
+ prompt: z2.string().optional(),
71
+ tool: ToolSchema,
72
+ launchedId: z2.string().optional(),
73
+ error: z2.string().optional()
74
+ });
75
+ var ChatRelaySchema = z2.object({
76
+ id: z2.string().min(1),
77
+ name: z2.string(),
78
+ tool: ToolSchema,
79
+ text: z2.string().min(1),
80
+ status: z2.enum(["queued", "sent"]).optional(),
81
+ sentAt: z2.string().datetime().optional(),
82
+ error: z2.string().optional()
83
+ });
67
84
  var ChatTurnSchema = z2.object({
68
85
  qid: z2.string().min(1),
69
86
  question: z2.string(),
@@ -71,7 +88,9 @@ var ChatTurnSchema = z2.object({
71
88
  steps: z2.array(ChatStepSchema),
72
89
  answer: z2.string().optional(),
73
90
  answeredAt: z2.string().datetime().optional(),
74
- error: z2.string().optional()
91
+ error: z2.string().optional(),
92
+ proposal: ChatProposalSchema.optional(),
93
+ relay: ChatRelaySchema.optional()
75
94
  });
76
95
 
77
96
  // ../protocol/src/documents.ts
@@ -154,7 +173,7 @@ var ServerMessageSchema = z4.discriminatedUnion("type", [
154
173
  /** The chat (spec 2026-10-11 §5): what Ledge reads, its words as they come, the final text, or why it stopped; the day's turns after each snapshot. */
155
174
  z4.object({ type: z4.literal("chat.step"), qid: z4.string(), step: ChatStepSchema }),
156
175
  z4.object({ type: z4.literal("chat.delta"), qid: z4.string(), text: z4.string() }),
157
- z4.object({ type: z4.literal("chat.done"), qid: z4.string(), text: z4.string(), at: z4.string().datetime() }),
176
+ z4.object({ type: z4.literal("chat.done"), qid: z4.string(), text: z4.string(), at: z4.string().datetime(), proposal: ChatProposalSchema.optional(), relay: ChatRelaySchema.optional() }),
158
177
  z4.object({ type: z4.literal("chat.error"), qid: z4.string(), message: z4.string() }),
159
178
  z4.object({ type: z4.literal("chat.history"), turns: z4.array(ChatTurnSchema) }),
160
179
  /** The daemon updating itself: npm is running, then the service restarts (the socket closes right after). */
@@ -166,13 +185,18 @@ var ServerMessageSchema = z4.discriminatedUnion("type", [
166
185
  var ClientMessageSchema = z4.discriminatedUnion("type", [
167
186
  DocumentReadSchema,
168
187
  /** The overlay starts a session: `cwd` is a WSL path (`~` allowed), `name` a preferred bot name (the daemon picks a
169
- * free one otherwise), `prompt` the agent's first instruction, `continue` resumes the folder's last conversation. */
170
- z4.object({ type: z4.literal("session.launch"), tool: ToolSchema, cwd: z4.string().min(1), name: z4.string().optional(), prompt: z4.string().optional(), continue: z4.boolean().optional() }),
188
+ * free one otherwise), `prompt` the agent's first instruction, `continue` resumes the folder's last conversation.
189
+ * With `qid` it is the chat card of that turn: the daemon launches the turn's proposal and ignores the other fields. */
190
+ z4.object({ type: z4.literal("session.launch"), tool: ToolSchema, cwd: z4.string().min(1), name: z4.string().optional(), prompt: z4.string().optional(), continue: z4.boolean().optional(), qid: z4.string().min(1).optional() }),
171
191
  /** The launch form's folder browser: list a folder's subfolders (home when no `path`), or make `name` inside `parent`
172
192
  * and list it. Both are answered with a `folder.listing`. */
173
193
  z4.object({ type: z4.literal("folder.list"), path: z4.string().optional() }),
174
194
  z4.object({ type: z4.literal("folder.create"), parent: z4.string().min(1), name: z4.string().min(1) }),
175
195
  z4.object({ type: z4.literal("session.reply"), id: z4.string(), text: z4.string().min(1) }),
196
+ /** The chat card's instruction (spec 2026-10-12 relay §4): sent now if the session is free, else queued until it is. */
197
+ z4.object({ type: z4.literal("session.relay"), qid: z4.string().min(1) }),
198
+ /** Takes the card's queued instruction back out of the queue. */
199
+ z4.object({ type: z4.literal("session.relay.cancel"), qid: z4.string().min(1) }),
176
200
  z4.object({ type: z4.literal("session.decide"), id: z4.string(), decision: z4.enum(["accept", "deny"]) }),
177
201
  z4.object({ type: z4.literal("session.focus"), id: z4.string() }),
178
202
  z4.object({ type: z4.literal("session.kill"), id: z4.string() }),
@@ -282,6 +306,37 @@ function pickName(taken, preferred) {
282
306
  for (let i = 2; ; i++) if (free(`${NAMES[0]}-${i}`)) return `${NAMES[0]}-${i}`;
283
307
  }
284
308
 
309
+ // ../protocol/src/proposal.ts
310
+ import { z as z5 } from "zod";
311
+ var ProposalBlockSchema = z5.object({ cwd: z5.string().trim().min(1), prompt: z5.string().optional() });
312
+ var RelayBlockSchema = z5.object({ session: z5.string().trim().min(1), text: z5.string().trim().min(1) });
313
+ var BLOCK = /```ledge-(session|relay)[ \t]*\n?([\s\S]*?)(?:```|$)/g;
314
+ function extractProposal(answer) {
315
+ const found = [];
316
+ const text = answer.replace(BLOCK, (_m, kind, body) => {
317
+ found.push({ kind, raw: body.trim() });
318
+ return "";
319
+ });
320
+ const last = found.at(-1);
321
+ return last ? { text: text.replace(/\n{3,}/g, "\n\n").trim(), kind: last.kind, raw: last.raw } : { text: answer, kind: null, raw: null };
322
+ }
323
+ function parseProposalBlock(raw) {
324
+ try {
325
+ const r = ProposalBlockSchema.safeParse(JSON.parse(raw));
326
+ return r.success ? r.data : null;
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+ function parseRelayBlock(raw) {
332
+ try {
333
+ const r = RelayBlockSchema.safeParse(JSON.parse(raw));
334
+ return r.success ? r.data : null;
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
285
340
  // ../protocol/src/topic.ts
286
341
  var TOPIC_MAX = 80;
287
342
  function cleanTopic(text) {
@@ -294,13 +349,16 @@ function sessionTitle(s) {
294
349
  }
295
350
 
296
351
  export {
297
- DOCUMENT_MAX_BYTES,
298
352
  SessionSchema,
353
+ DOCUMENT_MAX_BYTES,
299
354
  PROTOCOL_PORT,
300
355
  RecentSchema,
301
356
  parseClientMessage,
302
357
  parseServerMessage,
303
358
  pickName,
359
+ extractProposal,
360
+ parseProposalBlock,
361
+ parseRelayBlock,
304
362
  cleanTopic,
305
363
  sessionTitle
306
364
  };
@@ -1,20 +1,20 @@
1
1
  import {
2
2
  realTmux
3
- } from "./chunk-7ICDC2LK.js";
3
+ } from "./chunk-DMV5VW4Q.js";
4
4
  import {
5
5
  exitFile,
6
6
  log,
7
7
  spawnSession
8
- } from "./chunk-OUIDBDIH.js";
8
+ } from "./chunk-B73JDPSW.js";
9
9
  import {
10
10
  ensureLedgeDir
11
11
  } from "./chunk-6ZTCETRG.js";
12
12
  import {
13
13
  connect
14
- } from "./chunk-W5XAZ5IS.js";
14
+ } from "./chunk-34DHZSGH.js";
15
15
  import {
16
16
  PROTOCOL_PORT
17
- } from "./chunk-6GMDFIGB.js";
17
+ } from "./chunk-YT4T7DL2.js";
18
18
 
19
19
  // src/cli/launch.ts
20
20
  import { spawn } from "child_process";
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  reduce
3
- } from "./chunk-ZPF5I3LS.js";
3
+ } from "./chunk-5OJZV5MV.js";
4
4
  import {
5
5
  LEDGE_VERSION
6
- } from "./chunk-JW5Y7ZLT.js";
6
+ } from "./chunk-Y74YLRHP.js";
7
7
  import {
8
8
  SERVICE
9
9
  } from "./chunk-XDRYMOVF.js";
@@ -11,11 +11,11 @@ import {
11
11
  realTmux,
12
12
  run,
13
13
  runFile
14
- } from "./chunk-7ICDC2LK.js";
14
+ } from "./chunk-DMV5VW4Q.js";
15
15
  import {
16
16
  log,
17
17
  spawnSession
18
- } from "./chunk-OUIDBDIH.js";
18
+ } from "./chunk-B73JDPSW.js";
19
19
  import {
20
20
  ensureLedgeDir,
21
21
  paths
@@ -25,9 +25,12 @@ import {
25
25
  PROTOCOL_PORT,
26
26
  RecentSchema,
27
27
  SessionSchema,
28
+ extractProposal,
28
29
  parseClientMessage,
30
+ parseProposalBlock,
31
+ parseRelayBlock,
29
32
  pickName
30
- } from "./chunk-6GMDFIGB.js";
33
+ } from "./chunk-YT4T7DL2.js";
31
34
 
32
35
  // src/daemon/index.ts
33
36
  import { writeFile as writeFile4, rm as rm3 } from "fs/promises";
@@ -154,7 +157,7 @@ var Store = class {
154
157
  };
155
158
 
156
159
  // src/daemon/server.ts
157
- import { basename as basename3 } from "path";
160
+ import { basename as basename4 } from "path";
158
161
  import { WebSocketServer, WebSocket } from "ws";
159
162
 
160
163
  // src/adapters/claude.ts
@@ -976,7 +979,7 @@ function loginShellEnv(opts = {}) {
976
979
  // src/chat/service.ts
977
980
  import { randomUUID } from "crypto";
978
981
  import { mkdir as mkdir4, readFile as readFile2, rm as rm2, writeFile as writeFile3 } from "fs/promises";
979
- import { dirname as dirname4, join as join3 } from "path";
982
+ import { basename as basename3, dirname as dirname4, join as join3 } from "path";
980
983
 
981
984
  // src/chat/context.ts
982
985
  import { mkdir as mkdir3, rm, writeFile as writeFile2 } from "fs/promises";
@@ -1005,6 +1008,7 @@ async function writeContext(dir, input) {
1005
1008
  `- repository: ${s.repo ?? s.project}${s.branch ? `, branch ${s.branch}${s.worktree ? " (linked worktree)" : ""}` : ""}`,
1006
1009
  `- started: ${s.startedAt}, last activity: ${s.lastActivityAt}`
1007
1010
  );
1011
+ if (s.source === "hook_only") lines.push("- read-only: Ledge cannot send to it");
1008
1012
  if (s.pending) lines.push(`- pending ${s.pending.kind}: ${s.pending.text}`);
1009
1013
  const live = s.helpers.filter((h) => h.state !== "exited");
1010
1014
  if (live.length) lines.push(`- subagents: ${live.map((h) => h.task || h.kind).join("; ")}`);
@@ -1032,6 +1036,15 @@ async function writeContext(dir, input) {
1032
1036
  }
1033
1037
  await writeFile2(join2(dir, "sessions.md"), lines.join("\n"));
1034
1038
  steps.set("sessions.md", { kind: "sessions" });
1039
+ const recents = input.recents ?? [];
1040
+ await writeFile2(join2(dir, "folders.md"), [
1041
+ "# Recent folders",
1042
+ "",
1043
+ "The folders the user started sessions in, most recent first.",
1044
+ "",
1045
+ ...recents.length ? recents.map((r) => `- ${r.project}: ${r.cwd} (last used ${r.lastAt})`) : ["- (none yet)"],
1046
+ ""
1047
+ ].join("\n"));
1035
1048
  const repoPaths = [...new Set(input.sessions.map((s) => s.repoPath ?? s.cwd).filter((p) => p && !coversHome(p)).map((p) => resolve3(p)))];
1036
1049
  return { dir, steps, repoPaths };
1037
1050
  }
@@ -1091,10 +1104,10 @@ function runJsonl(file, args, o, parse, onEvent, spawnImpl = nodeSpawn) {
1091
1104
  }
1092
1105
 
1093
1106
  // src/chat/claude.ts
1094
- var PERSONA = `Tu es Ledge, le compagnon qui veille sur les sessions d'agents (Claude Code, Codex, Cursor Agent) de l'utilisateur. Tu lis, tu ne touches \xE0 rien : pas de commande, pas d'\xE9criture, jamais d'instruction envoy\xE9e \xE0 une session.
1095
- Tes sources : le dossier courant contient sessions.md (toutes les sessions, leur \xE9tat et le nom de leurs fichiers), pane-<id>.txt (les derni\xE8res lignes du terminal d'une session), reply-<id>.md (sa derni\xE8re r\xE9ponse) et git-<id>.txt (status, log et diff --stat de son dossier). Les d\xE9p\xF4ts eux-m\xEAmes sont lisibles (Read, Grep, Glob) pour aller plus loin.
1107
+ var PERSONA = `Tu es Ledge, le compagnon qui veille sur les sessions d'agents (Claude Code, Codex, Cursor Agent) de l'utilisateur. Tu lis, tu ne touches \xE0 rien : seulement des commandes de lecture (cat, ls, rg\u2026), aucune commande qui modifie quoi que ce soit, pas d'\xE9criture, et tu n'envoies jamais rien toi-m\xEAme \xE0 une session.
1108
+ Tes sources : le dossier courant contient sessions.md (toutes les sessions, leur \xE9tat et le nom de leurs fichiers), pane-<id>.txt (les derni\xE8res lignes du terminal d'une session), reply-<id>.md (sa derni\xE8re r\xE9ponse), git-<id>.txt (status, log et diff --stat de son dossier) et folders.md (les dossiers r\xE9cents o\xF9 l'utilisateur a lanc\xE9 des sessions). Les d\xE9p\xF4ts eux-m\xEAmes sont lisibles, avec les outils de lecture de ton harnais, pour aller plus loin.
1096
1109
  Commence toujours par lire sessions.md. Ne lis que ce que la question demande.
1097
- R\xE9ponds dans la langue de la question, en 3 \xE0 10 lignes, en Markdown simple : paragraphes courts, listes \xE0 puces, \`code\` pour les commandes, fichiers et branches. Nomme chaque bot par son pr\xE9nom en gras (**Nova**) la premi\xE8re fois. Dis d'o\xF9 vient chaque fait et de quand il date quand cela compte. Si une information manque, dis-le au lieu de supposer. Tu ne proposes jamais d'agir \xE0 la place de l'utilisateur ; tu peux lui dire quel bot ouvrir.`;
1110
+ R\xE9ponds dans la langue de la question, en 3 \xE0 10 lignes, en Markdown simple : paragraphes courts, listes \xE0 puces, \`code\` pour les commandes, fichiers et branches. Nomme chaque bot par son pr\xE9nom en gras (**Nova**) la premi\xE8re fois. Dis d'o\xF9 vient chaque fait et de quand il date quand cela compte. Si une information manque, dis-le au lieu de supposer. Tu n'agis jamais toi-m\xEAme ; tu peux lui dire quel bot ouvrir. Quand l'utilisateur demande explicitement d'ouvrir ou de lancer une session, et seulement si le dossier est s\xFBr \u2014 un chemin absolu lu dans sessions.md ou folders.md, ou donn\xE9 tel quel par l'utilisateur \u2014, r\xE9ponds en une ou deux lignes puis termine par un bloc \`\`\`ledge-session contenant {"cwd": "<chemin>", "prompt": "<l'instruction, s'il y en a une>"}. Si plusieurs dossiers conviennent ou aucun, ne mets pas de bloc : cite les candidats et demande lequel. Quand l'utilisateur demande explicitement de dire, demander ou envoyer quelque chose \xE0 une session, et seulement si une seule session de sessions.md correspond et qu'elle n'est pas en lecture seule, r\xE9ponds en une ou deux lignes puis termine par un bloc \`\`\`ledge-relay contenant {"session": "<id>", "text": "<l'instruction, formul\xE9e pour l'agent>"}. Si plusieurs sessions conviennent ou aucune, ne mets pas de bloc : cite les candidats et demande laquelle.`;
1098
1111
  function claudeArgs(o) {
1099
1112
  return [
1100
1113
  "-p",
@@ -1256,10 +1269,10 @@ function createChat(deps) {
1256
1269
  const CANCELLED = { ok: false, message: "cancelled", resumed: false };
1257
1270
  function rollDay() {
1258
1271
  const day = today();
1259
- if (turnsDay !== day) {
1260
- turns = [];
1261
- turnsDay = day;
1262
- }
1272
+ if (turnsDay === day) return false;
1273
+ turns = [];
1274
+ turnsDay = day;
1275
+ return true;
1263
1276
  }
1264
1277
  async function daySession(fresh, tool) {
1265
1278
  const day = today();
@@ -1282,6 +1295,44 @@ function createChat(deps) {
1282
1295
  return p === null ? null : p.split("\n");
1283
1296
  };
1284
1297
  const contextDir = join3(deps.home, "chat", "context");
1298
+ async function proposalFrom(raw, tool, qid) {
1299
+ const block = parseProposalBlock(raw);
1300
+ if (!block) {
1301
+ log(`chat ${qid}: proposal ignored, invalid block: ${raw.slice(0, 200)}`);
1302
+ return void 0;
1303
+ }
1304
+ try {
1305
+ const cwd = await resolveFolder(block.cwd, deps.userHome);
1306
+ const prompt = block.prompt?.trim();
1307
+ if (prompt?.startsWith("-")) {
1308
+ log(`chat ${qid}: proposal ignored: prompt starts with "-"`);
1309
+ return void 0;
1310
+ }
1311
+ return { cwd, project: basename3(cwd), tool, ...prompt ? { prompt } : {} };
1312
+ } catch (e) {
1313
+ log(`chat ${qid}: proposal ignored: ${e.message}`);
1314
+ return void 0;
1315
+ }
1316
+ }
1317
+ function relayFrom(raw, qid) {
1318
+ const block = parseRelayBlock(raw);
1319
+ if (!block) {
1320
+ log(`chat ${qid}: relay ignored, invalid block: ${raw.slice(0, 200)}`);
1321
+ return void 0;
1322
+ }
1323
+ if (block.text.trimStart().startsWith("!")) {
1324
+ log(`chat ${qid}: relay ignored: bash-mode instruction ${block.session}`);
1325
+ return void 0;
1326
+ }
1327
+ const s = deps.store.get(block.session);
1328
+ const why = !s ? "unknown session" : s.source === "hook_only" ? "read-only session" : s.state === "exited" ? "session has exited" : null;
1329
+ if (why) {
1330
+ log(`chat ${qid}: relay ignored: ${why} ${block.session}`);
1331
+ return void 0;
1332
+ }
1333
+ return { id: s.id, name: s.name, tool: s.tool, text: block.text };
1334
+ }
1335
+ const turnOf = (qid) => turns.find((t) => t.qid === qid);
1285
1336
  async function attempt(turn, emit, fresh, mine, tool) {
1286
1337
  try {
1287
1338
  return await run3(turn, emit, fresh, mine, tool);
@@ -1292,7 +1343,7 @@ function createChat(deps) {
1292
1343
  }
1293
1344
  }
1294
1345
  async function run3(turn, emit, fresh, mine, tool) {
1295
- const ctx = await writeContext(contextDir, { sessions: deps.store.all(), now: now(), capture, git: (cwd) => gitSummary(cwd) });
1346
+ const ctx = await writeContext(contextDir, { sessions: deps.store.all(), recents: deps.store.recents(), now: now(), capture, git: (cwd) => gitSummary(cwd) });
1296
1347
  if (mine.cancelled) return CANCELLED;
1297
1348
  const { sessionId, resume } = await daySession(fresh, tool);
1298
1349
  const label2 = TOOL_LABEL[tool];
@@ -1331,13 +1382,49 @@ function createChat(deps) {
1331
1382
  if (timedOut) return { ok: false, message: `${label2} did not answer within 3 minutes`, resumed: false };
1332
1383
  if (r.code !== 0) return { ok: false, message: r.code === null ? `${label2} could not start: ${r.stderr.trim() || "not found in PATH"}` : r.stderr.trim() || `${label2} exited with code ${r.code}`, resumed: resume };
1333
1384
  if (resultError) return { ok: false, message: resultError, resumed: false };
1334
- turn.answer = answer;
1385
+ const { text, kind, raw } = extractProposal(answer);
1386
+ const proposal = kind === "session" ? await proposalFrom(raw, tool, turn.qid) : void 0;
1387
+ const relay = kind === "relay" ? relayFrom(raw, turn.qid) : void 0;
1388
+ turn.answer = text;
1335
1389
  turn.answeredAt = now();
1336
- emit({ type: "chat.done", qid: turn.qid, text: answer, at: turn.answeredAt });
1390
+ if (proposal) turn.proposal = proposal;
1391
+ if (relay) turn.relay = relay;
1392
+ emit({ type: "chat.done", qid: turn.qid, text, at: turn.answeredAt, ...proposal ? { proposal } : {}, ...relay ? { relay } : {} });
1337
1393
  return { ok: true };
1338
1394
  }
1339
1395
  return {
1340
1396
  history: () => turns,
1397
+ proposal: (qid) => turnOf(qid)?.proposal,
1398
+ markLaunched(qid, id, emit) {
1399
+ const t = turnOf(qid);
1400
+ if (!t?.proposal) return;
1401
+ const next = { ...t.proposal, launchedId: id };
1402
+ delete next.error;
1403
+ t.proposal = next;
1404
+ emit({ type: "chat.history", turns });
1405
+ },
1406
+ markFailed(qid, message, emit) {
1407
+ const t = turnOf(qid);
1408
+ if (!t?.proposal) return;
1409
+ t.proposal = { ...t.proposal, error: message };
1410
+ emit({ type: "chat.history", turns });
1411
+ },
1412
+ relay: (qid) => {
1413
+ rollDay();
1414
+ return turnOf(qid)?.relay;
1415
+ },
1416
+ markRelay(qid, patch, emit) {
1417
+ const t = turnOf(qid);
1418
+ if (!t?.relay) return;
1419
+ const { id, name, tool, text } = t.relay;
1420
+ t.relay = { id, name, tool, text, ...patch };
1421
+ emit({ type: "chat.history", turns });
1422
+ },
1423
+ // A roll caught here is silent otherwise: the overlays would keep showing yesterday's queued card.
1424
+ queuedRelays(emit) {
1425
+ if (rollDay()) emit?.({ type: "chat.history", turns });
1426
+ return turns.flatMap((t) => t.relay?.status === "queued" ? [{ qid: t.qid, relay: t.relay }] : []);
1427
+ },
1341
1428
  cancel(qid) {
1342
1429
  if (current?.qid === qid) {
1343
1430
  current.cancelled = true;
@@ -1447,6 +1534,88 @@ async function readDocument(request, context) {
1447
1534
  }
1448
1535
  }
1449
1536
 
1537
+ // src/daemon/relay.ts
1538
+ var free = (s) => !!s && (s.state === "idle" || s.state === "error") && !s.pending;
1539
+ var stopped = (name) => `${name} stopped before it got the instruction`;
1540
+ function createRelay(d) {
1541
+ const now = d.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
1542
+ const typing = /* @__PURE__ */ new Set();
1543
+ const sending = /* @__PURE__ */ new Set();
1544
+ async function deliver(qid, r) {
1545
+ typing.add(r.id);
1546
+ sending.add(qid);
1547
+ try {
1548
+ let refused;
1549
+ try {
1550
+ refused = await d.replyTo(r.id, r.text);
1551
+ } catch (e) {
1552
+ refused = e.message;
1553
+ }
1554
+ if (refused) {
1555
+ log(`relay ${qid} to ${r.id} failed: ${refused}`);
1556
+ d.chat.markRelay(qid, { error: refused }, d.broadcast);
1557
+ } else d.chat.markRelay(qid, { status: "sent", sentAt: now() }, d.broadcast);
1558
+ return refused;
1559
+ } finally {
1560
+ typing.delete(r.id);
1561
+ sending.delete(qid);
1562
+ }
1563
+ }
1564
+ function kick(id) {
1565
+ const q = d.chat.queuedRelays(d.broadcast).find((x) => x.relay.id === id);
1566
+ if (!q || typing.has(id) || !free(d.store.get(id))) return;
1567
+ void deliverAndKick(q.qid, q.relay).catch((e) => log(`relay ${q.qid} failed: ${e.message}`));
1568
+ }
1569
+ async function deliverAndKick(qid, r) {
1570
+ const refused = await deliver(qid, r);
1571
+ if (refused) kick(r.id);
1572
+ return refused;
1573
+ }
1574
+ const unsubscribe = d.store.onChange((c) => {
1575
+ if (c.type === "recents") return;
1576
+ const id = c.type === "updated" ? c.session.id : c.id;
1577
+ const queued = d.chat.queuedRelays(d.broadcast).filter((q) => q.relay.id === id);
1578
+ if (queued.length === 0) return;
1579
+ const s = c.type === "updated" ? c.session : void 0;
1580
+ if (!s || s.state === "exited") {
1581
+ for (const q of queued) if (!sending.has(q.qid)) d.chat.markRelay(q.qid, { error: stopped(q.relay.name) }, d.broadcast);
1582
+ return;
1583
+ }
1584
+ if (free(s) && !typing.has(id)) kick(id);
1585
+ });
1586
+ return {
1587
+ async request(qid) {
1588
+ const r = d.chat.relay(qid);
1589
+ if (!r) throw new Error("unknown relay");
1590
+ if (r.status === "sent" || sending.has(qid)) throw new Error("already sent");
1591
+ if (r.status === "queued") throw new Error("already queued");
1592
+ const s = d.store.get(r.id);
1593
+ if (!s || s.state === "exited") {
1594
+ const m = stopped(r.name);
1595
+ d.chat.markRelay(qid, { error: m }, d.broadcast);
1596
+ throw new Error(m);
1597
+ }
1598
+ if (free(s) && !typing.has(r.id)) {
1599
+ if (d.chat.queuedRelays().some((q) => q.relay.id === r.id)) {
1600
+ d.chat.markRelay(qid, { status: "queued" }, d.broadcast);
1601
+ kick(r.id);
1602
+ return;
1603
+ }
1604
+ const refused = await deliverAndKick(qid, r);
1605
+ if (refused) throw new Error(refused);
1606
+ return;
1607
+ }
1608
+ d.chat.markRelay(qid, { status: "queued" }, d.broadcast);
1609
+ },
1610
+ cancel(qid) {
1611
+ if (sending.has(qid)) throw new Error("already sent");
1612
+ if (d.chat.relay(qid)?.status !== "queued") throw new Error("nothing queued");
1613
+ d.chat.markRelay(qid, {}, d.broadcast);
1614
+ },
1615
+ close: unsubscribe
1616
+ };
1617
+ }
1618
+
1450
1619
  // src/daemon/server.ts
1451
1620
  var CONTINUE_ARGS = { claude: ["--continue"], codex: ["resume", "--last"], cursor: ["--continue"] };
1452
1621
  function launchArgs(msg) {
@@ -1476,7 +1645,7 @@ async function startServer(opts) {
1476
1645
  const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
1477
1646
  const launcher = opts.launcher ?? [process.execPath, process.argv[1] ?? "ledge"];
1478
1647
  const agentEnv = opts.agentEnv ?? (async () => ({ ...process.env, ...await loginShellEnv() }));
1479
- const chat = opts.chat ?? createChat({ store, tmux, home: paths().dir, env: agentEnv, bin: opts.claudeBin, now });
1648
+ const chat = opts.chat ?? createChat({ store, tmux, home: paths().dir, userHome: opts.home, env: agentEnv, bin: opts.claudeBin, now });
1480
1649
  const wss = new WebSocketServer({
1481
1650
  host: opts.host ?? "127.0.0.1",
1482
1651
  port: opts.port ?? PROTOCOL_PORT,
@@ -1497,6 +1666,7 @@ async function startServer(opts) {
1497
1666
  const broadcast = (msg) => {
1498
1667
  for (const c of wss.clients) send(c, msg);
1499
1668
  };
1669
+ const relay = createRelay({ chat, store, replyTo, broadcast, now });
1500
1670
  const previews = /* @__PURE__ */ new Map();
1501
1671
  const titles = /* @__PURE__ */ new Map();
1502
1672
  const unsubscribe = store.onChange((c) => {
@@ -1582,6 +1752,29 @@ async function startServer(opts) {
1582
1752
  const NO_PROMPT = "no prompt visible in the terminal, open the tab";
1583
1753
  const NOT_TAKEN = "the terminal did not take it";
1584
1754
  const FREE_TEXT = "free-text answers are not supported yet, answer in the terminal";
1755
+ async function replyTo(id, text) {
1756
+ const s = store.get(id);
1757
+ if (!s) return "unknown session";
1758
+ if (s.source === "hook_only") return READ_ONLY;
1759
+ if (s.pending && s.pending.kind !== "question") return `a ${s.pending.kind} prompt takes accept or deny, not a reply`;
1760
+ if (text.trim() === "") return "empty reply";
1761
+ if (s.pending?.kind === "question" && !/^[1-9]$/.test(text.trim())) return FREE_TEXT;
1762
+ const pending = s.pending;
1763
+ const adapter = adapterFor(s.tool);
1764
+ await injectAndVerify(id, (pane) => adapter.replyPlan(text, pending, pane || void 0), !!pending);
1765
+ return null;
1766
+ }
1767
+ const chatLaunches = /* @__PURE__ */ new Set();
1768
+ async function launch(req) {
1769
+ const cwd = await resolveFolder(req.cwd, opts.home);
1770
+ const live = store.all();
1771
+ const env = await agentEnv();
1772
+ const session = await spawnSession({ tool: req.tool, args: launchArgs(req), name: req.name, cwd, tmux, now, env, taken: { names: live.filter((s) => s.state !== "exited").map((s) => s.name), ids: live.map((s) => s.id) } });
1773
+ store.upsert(session);
1774
+ store.touchRecent(session.cwd, session.project, session.startedAt);
1775
+ log(`launched ${session.name} (${session.tool}) in ${session.cwd} as ${session.id}`);
1776
+ return session;
1777
+ }
1585
1778
  async function handle(ws, msg) {
1586
1779
  switch (msg.type) {
1587
1780
  case "document.read": {
@@ -1618,14 +1811,23 @@ async function startServer(opts) {
1618
1811
  return;
1619
1812
  }
1620
1813
  case "session.launch": {
1621
- const cwd = await resolveFolder(msg.cwd, opts.home);
1622
- const live = store.all();
1623
- const env = await agentEnv();
1624
- const session = await spawnSession({ tool: msg.tool, args: launchArgs(msg), name: msg.name, cwd, tmux, now, env, taken: { names: live.filter((s) => s.state !== "exited").map((s) => s.name), ids: live.map((s) => s.id) } });
1625
- store.upsert(session);
1626
- store.touchRecent(session.cwd, session.project, session.startedAt);
1627
- log(`launched ${session.name} (${session.tool}) in ${session.cwd} as ${session.id}`);
1628
- return send(ws, { type: "session.launched", id: session.id });
1814
+ if (msg.qid === void 0) return send(ws, { type: "session.launched", id: (await launch(msg)).id });
1815
+ const qid = msg.qid;
1816
+ const p = chat.proposal(qid);
1817
+ if (!p) return send(ws, { type: "error", message: "unknown proposal" });
1818
+ if (p.launchedId || chatLaunches.has(qid)) return send(ws, { type: "error", message: "already launched" });
1819
+ chatLaunches.add(qid);
1820
+ try {
1821
+ const session = await launch({ tool: p.tool, cwd: p.cwd, prompt: p.prompt });
1822
+ send(ws, { type: "session.launched", id: session.id });
1823
+ chat.markLaunched(qid, session.id, broadcast);
1824
+ } catch (e) {
1825
+ chat.markFailed(qid, e.message, broadcast);
1826
+ throw e;
1827
+ } finally {
1828
+ chatLaunches.delete(qid);
1829
+ }
1830
+ return;
1629
1831
  }
1630
1832
  case "folder.list":
1631
1833
  return send(ws, await listFolder(msg.path, opts.home));
@@ -1639,7 +1841,7 @@ async function startServer(opts) {
1639
1841
  if (!before) {
1640
1842
  const taken = store.all().map((s) => s.name);
1641
1843
  const cwd = msg.cwd ?? "";
1642
- const base = { id: msg.id, name: pickName(taken), tool: msg.agent ?? "claude", cwd, project: basename3(cwd) || "unknown", title: "untracked session", tmuxSession: null, state: "working", helpers: [], startedAt: msg.event.at, lastActivityAt: msg.event.at, source: "hook_only" };
1844
+ const base = { id: msg.id, name: pickName(taken), tool: msg.agent ?? "claude", cwd, project: basename4(cwd) || "unknown", title: "untracked session", tmuxSession: null, state: "working", helpers: [], startedAt: msg.event.at, lastActivityAt: msg.event.at, source: "hook_only" };
1643
1845
  store.upsert(reduce(base, msg.event));
1644
1846
  } else store.apply(msg.id, msg.event);
1645
1847
  logDroppedRequest(msg.id, msg.event, before, store.get(msg.id));
@@ -1663,15 +1865,9 @@ async function startServer(opts) {
1663
1865
  return injectAndVerify(msg.id, (pane) => adapter.keysFor(msg.decision, pending, pane), true);
1664
1866
  }
1665
1867
  case "session.reply": {
1666
- const s = store.get(msg.id);
1667
- if (!s) return send(ws, { type: "error", id: msg.id, message: "unknown session" });
1668
- if (s.source === "hook_only") return send(ws, { type: "error", id: msg.id, message: READ_ONLY });
1669
- if (s.pending && s.pending.kind !== "question") return send(ws, { type: "error", id: msg.id, message: `a ${s.pending.kind} prompt takes accept or deny, not a reply` });
1670
- if (msg.text.trim() === "") return send(ws, { type: "error", id: msg.id, message: "empty reply" });
1671
- if (s.pending?.kind === "question" && !/^[1-9]$/.test(msg.text.trim())) return send(ws, { type: "error", id: msg.id, message: FREE_TEXT });
1672
- const pending = s.pending;
1673
- const adapter = adapterFor(s.tool);
1674
- return injectAndVerify(msg.id, (pane) => adapter.replyPlan(msg.text, pending, pane || void 0), !!pending);
1868
+ const refused = await replyTo(msg.id, msg.text);
1869
+ if (refused) send(ws, { type: "error", id: msg.id, message: refused });
1870
+ return;
1675
1871
  }
1676
1872
  case "session.interrupt": {
1677
1873
  const s = store.get(msg.id);
@@ -1695,6 +1891,11 @@ async function startServer(opts) {
1695
1891
  store.remove(msg.id);
1696
1892
  return;
1697
1893
  }
1894
+ // A chat card's instruction: a refusal reaches the requester as an `error`, the card's state everyone through chat.history.
1895
+ case "session.relay":
1896
+ return relay.request(msg.qid);
1897
+ case "session.relay.cancel":
1898
+ return relay.cancel(msg.qid);
1698
1899
  // Broadcast: every overlay shows the same progress, and the one that asked may be the one that reconnects.
1699
1900
  // A busy refusal goes to the asker only: it must not fail the flow the other overlays follow.
1700
1901
  case "update.daemon":
@@ -1734,6 +1935,7 @@ async function startServer(opts) {
1734
1935
  preview,
1735
1936
  close: () => new Promise((res) => {
1736
1937
  unsubscribe();
1938
+ relay.close();
1737
1939
  for (const c of wss.clients) c.terminate();
1738
1940
  wss.close(() => res());
1739
1941
  })
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  QUESTION_TOOLS
3
- } from "./chunk-ZPF5I3LS.js";
3
+ } from "./chunk-5OJZV5MV.js";
4
4
  import {
5
5
  CODEX_ENV_VAR,
6
6
  CODEX_HOOK_VAR,
7
7
  log
8
- } from "./chunk-OUIDBDIH.js";
8
+ } from "./chunk-B73JDPSW.js";
9
9
  import "./chunk-6ZTCETRG.js";
10
10
  import {
11
11
  connect
12
- } from "./chunk-W5XAZ5IS.js";
13
- import "./chunk-6GMDFIGB.js";
12
+ } from "./chunk-34DHZSGH.js";
13
+ import "./chunk-YT4T7DL2.js";
14
14
 
15
15
  // src/cli/hook.ts
16
16
  import { readFileSync, openSync, fstatSync, readSync, closeSync } from "fs";
package/dist/cli/index.js CHANGED
@@ -15,7 +15,7 @@ async function hookMain(args) {
15
15
  try {
16
16
  const chunks = [];
17
17
  for await (const c of process.stdin) chunks.push(c);
18
- const { runHook } = await import("./hook-HP23ZISG.js");
18
+ const { runHook } = await import("./hook-QH44D3JC.js");
19
19
  await runHook(event, Buffer.concat(chunks).toString("utf8"), { tool });
20
20
  } catch {
21
21
  }
@@ -25,7 +25,7 @@ async function main(argv) {
25
25
  const [cmd, ...rest] = argv;
26
26
  switch (cmd) {
27
27
  case "daemon": {
28
- const { startDaemon } = await import("./daemon-R4YWK3YV.js");
28
+ const { startDaemon } = await import("./daemon-2KHF4XUC.js");
29
29
  await startDaemon();
30
30
  await new Promise(() => {
31
31
  });
@@ -33,7 +33,7 @@ async function main(argv) {
33
33
  }
34
34
  case "--version":
35
35
  case "version": {
36
- const { LEDGE_VERSION } = await import("./version-3OPYJ4FQ.js");
36
+ const { LEDGE_VERSION } = await import("./version-J6BW646U.js");
37
37
  console.log(LEDGE_VERSION);
38
38
  return 0;
39
39
  }
@@ -49,18 +49,18 @@ async function main(argv) {
49
49
  return r.ok ? 0 : 1;
50
50
  }
51
51
  case "status": {
52
- const { printStatus } = await import("./status-IW2FESH4.js");
52
+ const { printStatus } = await import("./status-E2VZMTAF.js");
53
53
  console.log(await printStatus());
54
54
  return 0;
55
55
  }
56
56
  case "claude":
57
57
  case "codex":
58
58
  case "cursor": {
59
- const { launch } = await import("./launch-DFB54ANN.js");
59
+ const { launch } = await import("./launch-CPNM5RX7.js");
60
60
  return launch(cmd, rest);
61
61
  }
62
62
  case "attach": {
63
- const { attach } = await import("./attach-YBGTDBQI.js");
63
+ const { attach } = await import("./attach-IKK5XQGC.js");
64
64
  return attach(rest[0] ?? "");
65
65
  }
66
66
  default:
@@ -5,15 +5,15 @@ import {
5
5
  ensureDaemon,
6
6
  launch,
7
7
  parseLaunchArgs
8
- } from "./chunk-Y44XZY6I.js";
9
- import "./chunk-7ICDC2LK.js";
8
+ } from "./chunk-ZPR4LCDM.js";
9
+ import "./chunk-DMV5VW4Q.js";
10
10
  import {
11
11
  agentEnv,
12
12
  exitFile
13
- } from "./chunk-OUIDBDIH.js";
13
+ } from "./chunk-B73JDPSW.js";
14
14
  import "./chunk-6ZTCETRG.js";
15
- import "./chunk-W5XAZ5IS.js";
16
- import "./chunk-6GMDFIGB.js";
15
+ import "./chunk-34DHZSGH.js";
16
+ import "./chunk-YT4T7DL2.js";
17
17
  export {
18
18
  DaemonError,
19
19
  agentEnv,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  connect
3
- } from "./chunk-W5XAZ5IS.js";
4
- import "./chunk-6GMDFIGB.js";
3
+ } from "./chunk-34DHZSGH.js";
4
+ import "./chunk-YT4T7DL2.js";
5
5
 
6
6
  // src/cli/status.ts
7
7
  async function printStatus(opts = {}) {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  LEDGE_VERSION
3
- } from "./chunk-JW5Y7ZLT.js";
3
+ } from "./chunk-Y74YLRHP.js";
4
4
  export {
5
5
  LEDGE_VERSION
6
6
  };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@paradow/ledge",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Follow and answer your WSL agent sessions (Claude Code, Codex, Cursor Agent) from the Ledge island on Windows.",
5
5
  "type": "module",
6
6
  "bin": { "ledge": "dist/cli/index.js" },
7
7
  "files": ["dist"],
8
8
  "engines": { "node": ">=22" },
9
- "repository": { "type": "git", "url": "git+https://github.com/Paradow-dev/ledge.git", "directory": "packages/ledge" },
10
- "homepage": "https://github.com/Paradow-dev/ledge#readme",
9
+ "repository": { "type": "git", "url": "git+https://github.com/Lyssaen/ledge.git", "directory": "packages/ledge" },
10
+ "homepage": "https://github.com/Lyssaen/ledge#readme",
11
11
  "license": "UNLICENSED",
12
12
  "publishConfig": { "access": "public", "provenance": true },
13
13
  "scripts": {
@@ -1,6 +0,0 @@
1
- // src/version.ts
2
- var LEDGE_VERSION = true ? "0.3.2" : "0.0.0-dev";
3
-
4
- export {
5
- LEDGE_VERSION
6
- };