@paradow/ledge 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1893 @@
1
+ import {
2
+ reduce
3
+ } from "./chunk-ZPF5I3LS.js";
4
+ import {
5
+ LEDGE_VERSION
6
+ } from "./chunk-QYQIJW5G.js";
7
+ import {
8
+ SERVICE
9
+ } from "./chunk-XDRYMOVF.js";
10
+ import {
11
+ realTmux,
12
+ run,
13
+ runFile
14
+ } from "./chunk-7ICDC2LK.js";
15
+ import {
16
+ log,
17
+ spawnSession
18
+ } from "./chunk-OUIDBDIH.js";
19
+ import {
20
+ ensureLedgeDir,
21
+ paths
22
+ } from "./chunk-6ZTCETRG.js";
23
+ import {
24
+ DOCUMENT_MAX_BYTES,
25
+ PROTOCOL_PORT,
26
+ RecentSchema,
27
+ SessionSchema,
28
+ parseClientMessage,
29
+ pickName
30
+ } from "./chunk-6GMDFIGB.js";
31
+
32
+ // src/daemon/index.ts
33
+ import { writeFile as writeFile4, rm as rm3 } from "fs/promises";
34
+
35
+ // src/daemon/store.ts
36
+ import { readFile, writeFile, rename, mkdir } from "fs/promises";
37
+ import { dirname } from "path";
38
+ var MAX_RECENTS = 12;
39
+ var Store = class {
40
+ sessions = /* @__PURE__ */ new Map();
41
+ recentList = [];
42
+ listeners = /* @__PURE__ */ new Set();
43
+ timer = null;
44
+ saving = Promise.resolve();
45
+ journalPath;
46
+ constructor(opts = {}) {
47
+ this.journalPath = opts.journalPath ?? paths().journal;
48
+ }
49
+ async load() {
50
+ let raw;
51
+ try {
52
+ raw = await readFile(this.journalPath, "utf8");
53
+ } catch {
54
+ return;
55
+ }
56
+ if (!raw.trim()) return;
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(raw);
60
+ } catch (e) {
61
+ log(`journal unreadable, starting empty: ${e.message}`);
62
+ return;
63
+ }
64
+ const list = parsed?.sessions;
65
+ if (!Array.isArray(list)) {
66
+ log("journal unreadable, starting empty: no sessions array");
67
+ return;
68
+ }
69
+ for (const item of list) {
70
+ const r = SessionSchema.safeParse(item);
71
+ if (r.success) this.sessions.set(r.data.id, r.data);
72
+ else log(`journal entry dropped: ${r.error.issues[0]?.message ?? "invalid"}`);
73
+ }
74
+ const recents = parsed?.recents;
75
+ if (Array.isArray(recents)) {
76
+ for (const item of recents) {
77
+ const r = RecentSchema.safeParse(item);
78
+ if (r.success) this.recentList.push(r.data);
79
+ }
80
+ this.recentList = this.recentList.slice(0, MAX_RECENTS);
81
+ }
82
+ }
83
+ all() {
84
+ return [...this.sessions.values()];
85
+ }
86
+ get(id) {
87
+ return this.sessions.get(id);
88
+ }
89
+ /** The folders of past sessions, most recent first. */
90
+ recents() {
91
+ return [...this.recentList];
92
+ }
93
+ upsert(session) {
94
+ this.sessions.set(session.id, session);
95
+ this.emit({ type: "updated", session });
96
+ }
97
+ /** Records a session's folder as the most recent one (one entry per folder, `MAX_RECENTS` kept). */
98
+ touchRecent(cwd, project, at) {
99
+ this.recentList = [{ cwd, project, lastAt: at }, ...this.recentList.filter((r) => r.cwd !== cwd)].slice(0, MAX_RECENTS);
100
+ this.emit({ type: "recents", recents: this.recents() });
101
+ }
102
+ /** Facts no event carries (the session's repository): the changed fields only, nothing emitted when they are equal. */
103
+ patch(id, fields) {
104
+ const cur = this.sessions.get(id);
105
+ if (!cur) return;
106
+ if (Object.keys(fields).every((k) => cur[k] === fields[k])) return;
107
+ this.upsert({ ...cur, ...fields });
108
+ }
109
+ apply(id, event) {
110
+ const cur = this.sessions.get(id);
111
+ if (!cur) return void 0;
112
+ const next = reduce(cur, event);
113
+ if (next === cur) return cur;
114
+ this.sessions.set(id, next);
115
+ this.emit({ type: "updated", session: next });
116
+ return next;
117
+ }
118
+ remove(id) {
119
+ if (!this.sessions.delete(id)) return;
120
+ this.emit({ type: "removed", id });
121
+ }
122
+ onChange(listener) {
123
+ this.listeners.add(listener);
124
+ return () => this.listeners.delete(listener);
125
+ }
126
+ save() {
127
+ if (this.timer) {
128
+ clearTimeout(this.timer);
129
+ this.timer = null;
130
+ }
131
+ const run3 = this.saving.then(() => this.write());
132
+ this.saving = run3.catch(() => void 0);
133
+ return run3;
134
+ }
135
+ async write() {
136
+ await mkdir(dirname(this.journalPath), { recursive: true });
137
+ const tmp = `${this.journalPath}.tmp`;
138
+ await writeFile(tmp, JSON.stringify({ savedAt: (/* @__PURE__ */ new Date()).toISOString(), sessions: this.all(), recents: this.recentList }, null, 2));
139
+ await rename(tmp, this.journalPath);
140
+ }
141
+ emit(c) {
142
+ for (const l of this.listeners) {
143
+ try {
144
+ l(c);
145
+ } catch (e) {
146
+ log(`listener failed: ${e.message}`);
147
+ }
148
+ }
149
+ if (this.timer) clearTimeout(this.timer);
150
+ this.timer = setTimeout(() => {
151
+ this.save().catch((e) => log(`journal save failed: ${e.message}`));
152
+ }, 200);
153
+ }
154
+ };
155
+
156
+ // src/daemon/server.ts
157
+ import { basename as basename3 } from "path";
158
+ import { WebSocketServer, WebSocket } from "ws";
159
+
160
+ // src/adapters/claude.ts
161
+ var OPTION = /^\s*(?:❯\s*)?(\d+)\.\s+(\S.*?)\s*$/;
162
+ var FOOTER = /^\s*(?:(?:Enter to select\b.*)?Esc to cancel\b|ctrl\+g to edit\b)/;
163
+ var PLAN_FOOTER = /^\s*ctrl\+g to edit\b/;
164
+ var REVIEW_QUESTION = /^\s*Ready to submit your answers\?$/;
165
+ var RULE = /^─{10,}$/;
166
+ var DASHED = /^╌{10,}$/;
167
+ var FOOTER_SLACK = 2;
168
+ var isBlank = (l) => l.trim() === "";
169
+ var isRule = (l) => RULE.test(l.trim()) || DASHED.test(l.trim());
170
+ function footerIndex(lines) {
171
+ let nonBlank = 0;
172
+ for (let i = lines.length - 1; i >= 0 && nonBlank <= FOOTER_SLACK; i--) {
173
+ if (isBlank(lines[i])) continue;
174
+ if (FOOTER.test(lines[i])) return i;
175
+ nonBlank++;
176
+ }
177
+ return -1;
178
+ }
179
+ function reviewEnd(lines) {
180
+ let last = lines.length - 1;
181
+ while (last >= 0 && isBlank(lines[last])) last--;
182
+ if (last < 1 || lines[last].match(OPTION)?.[1] !== "2" || lines[last - 1].match(OPTION)?.[1] !== "1") return -1;
183
+ let q = last - 2;
184
+ while (q >= 0 && isBlank(lines[q])) q--;
185
+ return q >= 0 && REVIEW_QUESTION.test(lines[q]) ? last + 1 : -1;
186
+ }
187
+ function dialogEnd(lines) {
188
+ const footer = footerIndex(lines);
189
+ return footer >= 0 ? footer : reviewEnd(lines);
190
+ }
191
+ function optionsBetween(lines, from, footer) {
192
+ const options = [];
193
+ for (let i = from; i < footer; i++) {
194
+ const m = lines[i].match(OPTION);
195
+ if (m && Number(m[1]) === options.length + 1) options.push(m[2].replace(/\s+✔$/, ""));
196
+ }
197
+ return options;
198
+ }
199
+ function parseDialog(pane) {
200
+ const lines = pane.split("\n").map((l) => l.replace(/\s+$/, ""));
201
+ const footer = dialogEnd(lines);
202
+ if (footer < 0) return null;
203
+ let first = -1;
204
+ for (let i = footer - 1; i >= 0; i--) {
205
+ const m = lines[i].match(OPTION);
206
+ if (m && m[1] === "1") {
207
+ first = i;
208
+ break;
209
+ }
210
+ }
211
+ if (first < 0) return null;
212
+ const options = optionsBetween(lines, first, footer);
213
+ let q = first - 1;
214
+ while (q >= 0 && isBlank(lines[q])) q--;
215
+ const questionLines = [];
216
+ while (q >= 0 && !isBlank(lines[q]) && !isRule(lines[q]) && questionLines.length < 3) questionLines.unshift(lines[q--].trim());
217
+ if (questionLines.length === 0) return null;
218
+ let top = q;
219
+ while (top >= 0 && !RULE.test(lines[top].trim())) top--;
220
+ if (top < 0) return null;
221
+ return {
222
+ kind: /Enter to select/.test(lines[footer] ?? "") || REVIEW_QUESTION.test(questionLines.join(" ")) ? "question" : PLAN_FOOTER.test(lines[footer] ?? "") ? "plan" : "permission",
223
+ title: lines.slice(top + 1, q + 1),
224
+ question: questionLines.join(" "),
225
+ options
226
+ };
227
+ }
228
+ function permissionText(header, question) {
229
+ const nonBlank = header.filter((l) => !isBlank(l));
230
+ const titleAndNext = nonBlank.slice(0, 2).filter((l) => !DASHED.test(l.trim())).map((l) => l.trim()).join(" ");
231
+ if (/\bfile$/i.test(nonBlank[0]?.trim() ?? "")) return titleAndNext || question;
232
+ const d1 = header.findIndex((l) => DASHED.test(l.trim()));
233
+ const d2 = d1 < 0 ? -1 : header.findIndex((l, i) => i > d1 && DASHED.test(l.trim()));
234
+ if (d2 > d1 + 1) {
235
+ const body = header.slice(d1 + 1, d2).map((l) => l.trim()).filter(Boolean).join("\n");
236
+ if (body) return body;
237
+ }
238
+ return titleAndNext || question;
239
+ }
240
+ var TRUST_FOOTER = /^\s*Enter to confirm\b.*Esc to cancel\b/;
241
+ var TRUST_OPTION = /\btrust this folder\b/i;
242
+ function parseTrust(pane) {
243
+ const lines = pane.split("\n").map((l) => l.replace(/\s+$/, ""));
244
+ let footer = lines.length - 1;
245
+ while (footer >= 0 && isBlank(lines[footer])) footer--;
246
+ if (footer < 0 || !TRUST_FOOTER.test(lines[footer])) return null;
247
+ let o = footer - 1;
248
+ while (o >= 0 && isBlank(lines[o])) o--;
249
+ const block = [];
250
+ while (o >= 0 && !isBlank(lines[o])) block.unshift(lines[o--]);
251
+ const options = block.map((l) => l.trim().replace(/^❯\s*/, ""));
252
+ const trust = options.findIndex((l) => TRUST_OPTION.test(l));
253
+ const selected = block.findIndex((l) => l.trim().startsWith("\u276F"));
254
+ if (trust < 0 || selected < 0) return null;
255
+ const at = lines.findIndex((l) => /^\s*Accessing workspace:/.test(l));
256
+ const path = at < 0 ? "" : lines.slice(at + 1, footer).find((l) => !isBlank(l))?.trim() ?? "";
257
+ return { path, options, selected, trust };
258
+ }
259
+ function detectPrompt(pane) {
260
+ const t = parseTrust(pane);
261
+ if (t) return { kind: "permission", text: `Trust this folder? ${t.path}`.trim(), options: t.options };
262
+ const d = parseDialog(pane);
263
+ if (!d) return null;
264
+ if (d.kind === "question") return { kind: "question", text: d.question, options: d.options };
265
+ if (d.kind === "plan") return { kind: "plan", text: d.question, options: d.options };
266
+ return { kind: "permission", text: permissionText(d.title, d.question), options: d.options };
267
+ }
268
+ function promptStillVisible(lastLines2) {
269
+ const lines = lastLines2.split("\n").map((l) => l.replace(/\s+$/, ""));
270
+ if (lines.some((l) => TRUST_FOOTER.test(l)) && lines.some((l) => TRUST_OPTION.test(l))) return true;
271
+ const footer = dialogEnd(lines);
272
+ return footer > 0 && lines.slice(0, footer).some((l) => OPTION.test(l));
273
+ }
274
+ var squash = (t) => t.replace(/\s+/g, " ").trim();
275
+ function promptGone(pane, pending) {
276
+ if (pending.kind !== "question" || !squash(pending.text)) return !promptStillVisible(pane.replace(/\s+$/, "").split("\n").slice(-5).join("\n"));
277
+ const m = detectPrompt(pane);
278
+ if (!m || m.kind !== "question") return true;
279
+ const shown = squash(m.text);
280
+ const asked = squash(pending.text);
281
+ return !(shown === asked || asked.startsWith(shown) || shown.startsWith(asked));
282
+ }
283
+ var STATUS_SLACK = 3;
284
+ var INPUT_MAX = 12;
285
+ var isInputRule = (l) => /^─{10,}$/.test(l.trim());
286
+ var isInputTop = (l) => /^─{10,}(?: \S.* ─+)?$/.test(l.trim());
287
+ function inputBox(lines) {
288
+ let end = lines.length;
289
+ while (end > 0 && isBlank(lines[end - 1])) end--;
290
+ let bottom = -1;
291
+ let below = 0;
292
+ for (let i = end - 1; i >= 0 && below <= STATUS_SLACK; i--) {
293
+ if (isInputRule(lines[i])) {
294
+ bottom = i;
295
+ break;
296
+ }
297
+ if (!isBlank(lines[i])) below++;
298
+ }
299
+ if (bottom < 0) return null;
300
+ for (let top = bottom - 1; top >= 0 && bottom - top <= INPUT_MAX + 1; top--) {
301
+ if (!isInputTop(lines[top])) continue;
302
+ return lines[top + 1].trimStart().startsWith("\u276F") ? { top, bottom } : null;
303
+ }
304
+ return null;
305
+ }
306
+ var TITLE_RULE = /^─{10,} (\S.*?) ─+$/;
307
+ function paneTitle(pane) {
308
+ const lines = pane.split("\n").map((l) => l.replace(/\s+$/, ""));
309
+ const box = inputBox(lines);
310
+ const m = box ? TITLE_RULE.exec(lines[box.top].trim()) : null;
311
+ return m ? m[1] : null;
312
+ }
313
+ function previewLines(pane) {
314
+ const lines = pane.split("\n").map((l) => l.replace(/\s+$/, ""));
315
+ const box = inputBox(lines);
316
+ return box ? lines.slice(0, box.top) : lines;
317
+ }
318
+ var BUSY = /\besc to interrupt\b/;
319
+ function atRest(pane) {
320
+ const lines = pane.split("\n").map((l) => l.replace(/\s+$/, ""));
321
+ if (dialogEnd(lines) >= 0 || parseTrust(pane)) return false;
322
+ const box = inputBox(lines);
323
+ return !!box && !lines.slice(box.bottom + 1).some((l) => BUSY.test(l));
324
+ }
325
+ var digit = (n) => [{ text: String(n) }];
326
+ function optionNumber(text, pending) {
327
+ return pending?.kind === "question" && /^[1-9]$/.test(text) ? Number(text) : null;
328
+ }
329
+ var PLAN_ACCEPT = /\bmanually approve\b/i;
330
+ var claudeAdapter = {
331
+ tool: "claude",
332
+ detectPrompt,
333
+ promptStillVisible,
334
+ promptGone,
335
+ previewLines,
336
+ atRest,
337
+ paneTitle,
338
+ keysFor(decision, pending, pane) {
339
+ if (decision === "deny") return [{ keys: ["Escape"] }];
340
+ const t = pane ? parseTrust(pane) : null;
341
+ if (t) {
342
+ const step = t.trust > t.selected ? "Down" : "Up";
343
+ return [{ keys: [...Array(Math.abs(t.trust - t.selected)).fill(step), "Enter"] }];
344
+ }
345
+ const d = pane ? parseDialog(pane) : null;
346
+ if (pending.kind === "plan" && d?.kind === "plan") {
347
+ const i = d.options.findIndex((o) => PLAN_ACCEPT.test(o));
348
+ return digit(i < 0 ? 1 : i + 1);
349
+ }
350
+ return digit(1);
351
+ },
352
+ replyPlan(text, pending) {
353
+ const flat = text.replace(/\s*[\r\n]+\s*/g, " ").trim();
354
+ const n = optionNumber(flat, pending);
355
+ if (n !== null) return digit(n);
356
+ return [{ text: flat }, { keys: ["Enter"] }];
357
+ },
358
+ interruptKeys: () => ["Escape"]
359
+ };
360
+
361
+ // src/adapters/codex.ts
362
+ var APPROVAL_FOOTER = /^\s*Press enter to confirm or esc to cancel\s*$/;
363
+ var QUESTION_FOOTER = /\benter to submit\b/;
364
+ var SELECT_FOOTER = /^\s*enter select · esc back\s*$/;
365
+ var CONFIRM_FOOTER = /^\s*enter confirm · esc skip\s*$/;
366
+ var FOOTERS = [APPROVAL_FOOTER, QUESTION_FOOTER, SELECT_FOOTER, CONFIRM_FOOTER];
367
+ var OPTION2 = /^\s*(?:›\s*)?(\d+)\.\s+(\S.*)$/;
368
+ var HEADER_SPAN = 25;
369
+ var isBlank2 = (l) => l.trim() === "";
370
+ var isFooter = (l) => FOOTERS.some((f) => f.test(l));
371
+ var split = (pane) => pane.split("\n").map((l) => l.replace(/\s+$/, ""));
372
+ function lastLine(lines) {
373
+ let i = lines.length - 1;
374
+ while (i >= 0 && isBlank2(lines[i])) i--;
375
+ return i;
376
+ }
377
+ var label = (text) => text.split(/\s{2,}/)[0].replace(/\s+\([a-z]+\)$/, "").trim();
378
+ function parseDialog2(pane) {
379
+ const lines = split(pane);
380
+ const footer = lastLine(lines);
381
+ if (footer < 0 || !isFooter(lines[footer])) return null;
382
+ let end = footer - 1;
383
+ while (end >= 0 && isBlank2(lines[end])) end--;
384
+ let start = end;
385
+ while (start > 0 && !isBlank2(lines[start - 1])) start--;
386
+ const raw = [];
387
+ for (let i = start; i <= end; i++) {
388
+ const m = lines[i].match(OPTION2);
389
+ if (m && Number(m[1]) === raw.length + 1) raw.push(m[2]);
390
+ else if (raw.length > 0) raw[raw.length - 1] += ` ${lines[i].trim()}`;
391
+ else return null;
392
+ }
393
+ if (raw.length === 0) return null;
394
+ const header = [];
395
+ for (let i = start - 1; i >= 0 && start - i <= HEADER_SPAN; i--) {
396
+ const l = lines[i];
397
+ if (!isBlank2(l) && !/^\s/.test(l)) break;
398
+ header.unshift(l);
399
+ }
400
+ const f = lines[footer];
401
+ const kind = APPROVAL_FOOTER.test(f) ? "approval" : QUESTION_FOOTER.test(f) ? "question" : SELECT_FOOTER.test(f) ? "plan" : "confirm";
402
+ if (kind === "plan" && !header.some((l) => /^\s*Implement this plan\?\s*$/.test(l))) return null;
403
+ return { kind, header, options: raw.map(label) };
404
+ }
405
+ function run2(header, from) {
406
+ const out = [];
407
+ for (let i = from; i < header.length && !isBlank2(header[i]); i++) out.push(header[i].trim());
408
+ return out;
409
+ }
410
+ function approvalText(header) {
411
+ const cmd = header.findIndex((l) => /^\s*\$\s/.test(l));
412
+ if (cmd >= 0) return run2(header, cmd).join(" ").replace(/^\$\s+/, "");
413
+ const dest = header.findIndex((l) => /^\s*Destination:\s*$/.test(l));
414
+ if (dest >= 0) {
415
+ const path = run2(header, dest + 1).join("");
416
+ if (path) return `Edit ${path}`;
417
+ }
418
+ return header.map((l) => l.trim()).find((l) => /^Would you like to\b/.test(l)) ?? "approval";
419
+ }
420
+ function questionText(header) {
421
+ const out = [];
422
+ for (let i = header.length - 1; i >= 0; i--) {
423
+ const l = header[i].trim();
424
+ if (!l) {
425
+ if (out.length) break;
426
+ continue;
427
+ }
428
+ if (/^Questions? \d+\/\d+\b/.test(l)) break;
429
+ out.unshift(l);
430
+ if (out.length === 3) break;
431
+ }
432
+ return out.join(" ");
433
+ }
434
+ function confirmText(header) {
435
+ const lines = header.map((l) => l.trim()).filter(Boolean);
436
+ if (lines[0] === "Hooks need review") return `Trust the new Codex hooks? ${lines[1] ?? ""}`.trim();
437
+ return lines[0] ?? "confirm";
438
+ }
439
+ function detectPrompt2(pane) {
440
+ const d = parseDialog2(pane);
441
+ if (!d) return null;
442
+ if (d.kind === "question") return { kind: "question", text: questionText(d.header), options: d.options };
443
+ if (d.kind === "plan") return { kind: "plan", text: "Implement this plan?", options: d.options };
444
+ if (d.kind === "confirm") return { kind: "permission", text: confirmText(d.header), options: d.options };
445
+ return { kind: "permission", text: approvalText(d.header), options: d.options };
446
+ }
447
+ function promptStillVisible2(lastLines2) {
448
+ const lines = split(lastLines2);
449
+ const i = lastLine(lines);
450
+ return i >= 0 && isFooter(lines[i]) && lines.slice(0, i).some((l) => OPTION2.test(l));
451
+ }
452
+ var BELOW_INPUT = 10;
453
+ var BUSY2 = /\besc to interrupt\b/;
454
+ var BUSY_SPAN = 6;
455
+ function inputLine(lines) {
456
+ let below = 0;
457
+ for (let i = lastLine(lines); i >= 0 && below <= BELOW_INPUT; i--) {
458
+ if (/^›(?:\s|$)/.test(lines[i])) return i;
459
+ if (isBlank2(lines[i])) continue;
460
+ if (!/^\s/.test(lines[i])) return -1;
461
+ below++;
462
+ }
463
+ return -1;
464
+ }
465
+ function previewLines2(pane) {
466
+ const lines = split(pane);
467
+ if (parseDialog2(pane)) return lines;
468
+ const input = inputLine(lines);
469
+ return input < 0 ? lines : lines.slice(0, input);
470
+ }
471
+ function atRest2(pane) {
472
+ const lines = split(pane);
473
+ const last = lastLine(lines);
474
+ if (last < 0 || isFooter(lines[last])) return false;
475
+ const input = inputLine(lines);
476
+ return input >= 0 && !lines.slice(Math.max(0, input - BUSY_SPAN), input).some((l) => BUSY2.test(l));
477
+ }
478
+ var digit2 = (n) => [{ text: String(n) }];
479
+ function optionNumber2(options, re) {
480
+ const i = options.findIndex((o) => re.test(o));
481
+ return i < 0 ? null : i + 1;
482
+ }
483
+ var REPLY_ENTER_DELAY_MS = 300;
484
+ var codexAdapter = {
485
+ tool: "codex",
486
+ detectPrompt: detectPrompt2,
487
+ promptStillVisible: promptStillVisible2,
488
+ previewLines: previewLines2,
489
+ atRest: atRest2,
490
+ keysFor(decision, pending, pane) {
491
+ const d = pane ? parseDialog2(pane) : null;
492
+ if (decision === "deny") {
493
+ const no = d?.kind === "plan" ? optionNumber2(d.options, /^No\b/) : null;
494
+ return no ? digit2(no) : [{ keys: ["Escape"] }];
495
+ }
496
+ if (d?.kind === "confirm") {
497
+ const trust = optionNumber2(d.options, /^Trust all\b|^Yes\b/i);
498
+ if (!trust) throw new Error("no trust option in this dialog, answer in the terminal");
499
+ return [{ text: String(trust) }, { keys: ["Enter"] }];
500
+ }
501
+ if (d?.kind === "plan" || pending.kind === "plan") {
502
+ return digit2((d && (optionNumber2(d.options, /^Yes, implement this plan$/) ?? optionNumber2(d.options, /^Yes\b/))) ?? 1);
503
+ }
504
+ if (pending.kind === "question") return digit2(1);
505
+ return [{ text: "y" }];
506
+ },
507
+ replyPlan(text, pending) {
508
+ const flat = text.replace(/\s*[\r\n]+\s*/g, " ").trim();
509
+ if (pending?.kind === "question" && /^[1-9]$/.test(flat)) return digit2(Number(flat));
510
+ return [{ text: flat }, { waitMs: REPLY_ENTER_DELAY_MS }, { keys: ["Enter"] }];
511
+ },
512
+ // Esc interrupts the turn; Ctrl+C clears the draft and a second one quits Codex.
513
+ interruptKeys: () => ["Escape"]
514
+ };
515
+
516
+ // src/adapters/cursor.ts
517
+ var rtrim = (l) => l.replace(/\s+$/, "");
518
+ var isBlank3 = (l) => l.trim() === "";
519
+ var toLines = (pane) => pane.split("\n").map(rtrim);
520
+ function lastNonBlank(lines) {
521
+ let i = lines.length - 1;
522
+ while (i >= 0 && isBlank3(lines[i])) i--;
523
+ return i;
524
+ }
525
+ var DIALOG_FOOTER = /\(esc or n\)$/;
526
+ var DIALOG_ACCEPT = /\(y\)$/;
527
+ var RULE2 = /^─{10,}$/;
528
+ var DIALOG_MAX = 40;
529
+ function parseDialog3(lines) {
530
+ const end = lastNonBlank(lines);
531
+ if (end < 0 || !DIALOG_FOOTER.test(lines[end])) return null;
532
+ let rule = end - 1;
533
+ while (rule >= 0 && end - rule <= DIALOG_MAX && !RULE2.test(lines[rule].trim())) rule--;
534
+ if (rule < 0 || !RULE2.test(lines[rule].trim())) return null;
535
+ const body = lines.slice(rule + 1, end + 1).map((l) => l.trim());
536
+ if (!body.some((l) => DIALOG_ACCEPT.test(l))) return null;
537
+ const q = body.findIndex((l) => l.endsWith("?") && !/\((y|tab|shift\+tab)\)$/.test(l));
538
+ if (q < 0) return null;
539
+ const subject = body.slice(0, q).filter(Boolean).join(" ");
540
+ return { question: body[q], subject };
541
+ }
542
+ function dialogText(d) {
543
+ const cmd = d.subject.match(/^\$\s+(.+?)\s+in\s+\S+$/);
544
+ if (cmd) return cmd[1];
545
+ return d.subject ? `${d.question} ${d.subject}` : d.question;
546
+ }
547
+ var TRUST_TITLE = /Workspace Trust Required/;
548
+ var TRUST_OPTION2 = /\[a\] Trust this workspace/;
549
+ var TRUST_FOOTER2 = /Use arrow keys to navigate, Enter to select, or press the key shown/;
550
+ var boxed = (l) => l.trim().replace(/^│/, "").replace(/│$/, "").trim();
551
+ function parseTrust2(lines) {
552
+ const end = lastNonBlank(lines);
553
+ if (end < 0 || !lines[end].trim().startsWith("\u2570")) return null;
554
+ let top = end - 1;
555
+ while (top >= 0 && !lines[top].trim().startsWith("\u256D")) top--;
556
+ if (top < 0) return null;
557
+ const box = lines.slice(top + 1, end).map(boxed);
558
+ if (!box.some((l) => TRUST_TITLE.test(l)) || !box.some((l) => TRUST_OPTION2.test(l)) || !box.some((l) => TRUST_FOOTER2.test(l))) return null;
559
+ const ask = box.findIndex((l) => /^Do you trust\b/.test(l));
560
+ const opt = box.findIndex((l) => TRUST_OPTION2.test(l));
561
+ const path = ask < 0 ? "" : box.slice(ask + 1, opt).filter(Boolean).join("");
562
+ return { path };
563
+ }
564
+ function squareBox(lines) {
565
+ const end = lastNonBlank(lines);
566
+ if (end < 0 || !lines[end].trim().startsWith("\u2514")) return null;
567
+ let top = end - 1;
568
+ while (top >= 0 && !lines[top].trim().startsWith("\u250C")) top--;
569
+ return lines.slice(top + 1, end).map(boxed);
570
+ }
571
+ var QUESTION_FOOTER2 = /Space select\b.*\bEsc to skip\b/;
572
+ var CHOICE = /^(›\s*)?\[([ x])\]\s+(.*)$/;
573
+ var OTHER = /^Other:/;
574
+ function parseQuestion(lines) {
575
+ const box = squareBox(lines);
576
+ if (!box || !box.some((l) => QUESTION_FOOTER2.test(l))) return null;
577
+ const choices = box.map((l) => l.match(CHOICE)).filter((m) => !!m && !OTHER.test(m[3]));
578
+ if (!choices.length) return null;
579
+ const first = box.findIndex((l) => CHOICE.test(l));
580
+ const question = box.slice(0, first).reverse().find((l) => /^\d+\.\s/.test(l))?.replace(/^\d+\.\s+/, "") ?? "";
581
+ return { question, options: choices.map((m) => m[3].trim()), cursor: choices.findIndex((m) => !!m[1]), checked: choices.map((m) => m[2] === "x") };
582
+ }
583
+ var PLAN_ASK = /^Ready to build\?$/;
584
+ var PLAN_OPTION = /^(?:→\s*)?\d+\.\s+(.+?)\s+\([^)]*\)$/;
585
+ var REVISE = /Describe how to revise the plan\b/;
586
+ function parsePlan(lines) {
587
+ const box = squareBox(lines);
588
+ if (!box) return null;
589
+ const ask = box.findIndex((l) => PLAN_ASK.test(l));
590
+ if (ask < 0) return null;
591
+ const options = box.slice(ask + 1).map((l) => l.match(PLAN_OPTION)?.[1]).filter((o) => !!o);
592
+ if (!options.length) return null;
593
+ const head = box.findIndex((l) => l === "Suggested Plan");
594
+ const title = head < 0 ? "" : box.slice(head + 1).find((l) => l !== "") ?? "";
595
+ return { title: title || "Ready to build?", options };
596
+ }
597
+ var TELL_INSTEAD = /Tell the agent what to do instead\b/;
598
+ var INPUT = /^\s{1,3}→ /;
599
+ var BUSY3 = /\bctrl\+c to stop\b/;
600
+ var STATUS_SLACK2 = 6;
601
+ function inputLine2(lines) {
602
+ let below = 0;
603
+ for (let i = lastNonBlank(lines); i >= 0 && below <= STATUS_SLACK2; i--) {
604
+ if (INPUT.test(lines[i])) return i;
605
+ if (!isBlank3(lines[i])) below++;
606
+ }
607
+ return -1;
608
+ }
609
+ function detectPrompt3(pane) {
610
+ const lines = toLines(pane);
611
+ const t = parseTrust2(lines);
612
+ if (t) return { kind: "permission", text: `Trust this folder? ${t.path}`.trim(), options: ["Trust this workspace", "Quit"] };
613
+ const q = parseQuestion(lines);
614
+ if (q) return { kind: "question", text: q.question, options: q.options };
615
+ const plan = parsePlan(lines);
616
+ if (plan) return { kind: "plan", text: plan.title, options: plan.options };
617
+ const d = parseDialog3(lines);
618
+ return d ? { kind: "permission", text: dialogText(d) } : null;
619
+ }
620
+ var anyDialog = (lines) => !!(parseDialog3(lines) || parseTrust2(lines) || parseQuestion(lines) || parsePlan(lines));
621
+ function promptStillVisible3(lastLines2) {
622
+ const lines = toLines(lastLines2);
623
+ const end = lastNonBlank(lines);
624
+ if (end >= 0 && lines[end].trim().startsWith("\u2570") && lines.some((l) => TRUST_FOOTER2.test(l))) return true;
625
+ if (lines.some((l) => TELL_INSTEAD.test(l))) return true;
626
+ if (end >= 0 && lines[end].trim().startsWith("\u2514") && lines.some((l) => QUESTION_FOOTER2.test(l) || /\(p or Esc\)\s*│?$/.test(l))) return true;
627
+ return end >= 0 && DIALOG_FOOTER.test(lines[end]) && lines.some((l) => DIALOG_ACCEPT.test(l.trim()));
628
+ }
629
+ var compact = (s) => s.replace(/\s+/g, "");
630
+ var typed = (text) => (lastLines2) => {
631
+ const lines = toLines(lastLines2);
632
+ const want = compact(`\u2192 ${text}`);
633
+ for (let i = lines.length - 1; i >= 0; i--) {
634
+ if (INPUT.test(lines[i]) && compact(lines.slice(i).join("")).startsWith(want)) return true;
635
+ }
636
+ return false;
637
+ };
638
+ var inSkipField = (lastLines2) => toLines(lastLines2).some((l) => TELL_INSTEAD.test(l));
639
+ var inDialog = (lastLines2) => !inSkipField(lastLines2) && promptStillVisible3(lastLines2);
640
+ function promptGone2(pane, pending) {
641
+ const lines = toLines(pane);
642
+ if (inSkipField(lines.slice(0, lastNonBlank(lines) + 1).slice(-5).join("\n"))) return false;
643
+ const m = detectPrompt3(pane);
644
+ return !m || m.kind !== pending.kind || m.text !== pending.text;
645
+ }
646
+ var onChoice = (i, what) => (lastLines2) => {
647
+ const q = parseQuestion(toLines(lastLines2));
648
+ return !!q && (what === "cursor" ? q.cursor === i : !!q.checked[i]);
649
+ };
650
+ function answerPlan(q, n) {
651
+ const i = n - 1;
652
+ if (i < 0 || i >= q.options.length) throw new Error(`the question has no option ${n}`);
653
+ const from = q.cursor < 0 ? 0 : q.cursor;
654
+ const moves = Array(Math.abs(i - from)).fill(i > from ? "Down" : "Up");
655
+ return [
656
+ ...moves.length ? [{ keys: moves }, { until: onChoice(i, "cursor") }] : [],
657
+ ...q.checked[i] ? [] : [{ text: " " }, { until: onChoice(i, "checked") }],
658
+ { keys: ["Enter"] }
659
+ ];
660
+ }
661
+ function previewLines3(pane) {
662
+ const lines = toLines(pane);
663
+ if (anyDialog(lines)) return lines;
664
+ const i = inputLine2(lines);
665
+ return i < 0 ? lines : lines.slice(0, i);
666
+ }
667
+ function atRest3(pane) {
668
+ const lines = toLines(pane);
669
+ if (anyDialog(lines)) return false;
670
+ const i = inputLine2(lines);
671
+ return i >= 0 && !TELL_INSTEAD.test(lines[i]) && !lines.slice(i).some((l) => BUSY3.test(l));
672
+ }
673
+ var cursorAdapter = {
674
+ tool: "cursor",
675
+ detectPrompt: detectPrompt3,
676
+ promptStillVisible: promptStillVisible3,
677
+ previewLines: previewLines3,
678
+ atRest: atRest3,
679
+ promptGone: promptGone2,
680
+ keysFor(decision, _pending, pane) {
681
+ const lines = pane ? toLines(pane) : [];
682
+ if (parseTrust2(lines)) return [{ text: decision === "accept" ? "a" : "q" }];
683
+ const q = parseQuestion(lines);
684
+ if (q) return decision === "accept" ? answerPlan(q, 1) : [{ keys: ["Escape"] }];
685
+ if (parsePlan(lines)) return decision === "accept" ? [{ text: "b" }] : [{ text: "p" }, { until: (p) => REVISE.test(p) }];
686
+ if (lines.some((l) => TELL_INSTEAD.test(l))) {
687
+ return decision === "accept" ? [{ keys: ["Escape"] }, { until: inDialog }, { text: "y" }] : [{ keys: ["Enter"] }];
688
+ }
689
+ return decision === "accept" ? [{ text: "y" }] : [{ text: "n" }, { until: inSkipField }, { keys: ["Enter"] }];
690
+ },
691
+ replyPlan(text, pending, pane) {
692
+ const q = pending?.kind === "question" && /^[1-9]$/.test(text.trim()) && pane ? parseQuestion(toLines(pane)) : null;
693
+ if (q) return answerPlan(q, Number(text.trim()));
694
+ const flat = text.replace(/\s*[\r\n]+\s*/g, " ").trim();
695
+ return [{ text: flat }, { until: typed(flat) }, { keys: ["Enter"] }];
696
+ },
697
+ // Ctrl-C aborts a running turn; at rest it only clears the input line, it never quits.
698
+ interruptKeys: () => ["C-c"]
699
+ };
700
+
701
+ // src/adapters/preview.ts
702
+ var PREVIEW_LINES = 30;
703
+ var PREVIEW_CAPTURE_LINES = 200;
704
+ var isBlank4 = (l) => l.trim() === "";
705
+ function tidyLines(lines) {
706
+ const out = [];
707
+ for (const raw of lines) {
708
+ const l = raw.replace(/\s+$/, "");
709
+ if (l === "" && (out.length === 0 || out[out.length - 1] === "")) continue;
710
+ out.push(l);
711
+ }
712
+ while (out.length > 0 && isBlank4(out[out.length - 1])) out.pop();
713
+ return out;
714
+ }
715
+ function buildPreview(pane, adapter) {
716
+ const lines = adapter?.previewLines ? adapter.previewLines(pane) : pane.split("\n");
717
+ const last = tidyLines(lines).slice(-PREVIEW_LINES);
718
+ while (last.length > 0 && last[0] === "") last.shift();
719
+ return last;
720
+ }
721
+
722
+ // src/adapters/index.ts
723
+ function adapterFor(tool) {
724
+ if (tool === "claude") return claudeAdapter;
725
+ if (tool === "codex") return codexAdapter;
726
+ if (tool === "cursor") return cursorAdapter;
727
+ throw new Error(`adapter for ${tool} not implemented yet`);
728
+ }
729
+
730
+ // src/adapters/verify.ts
731
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
732
+ async function verifyGone(capture, stillVisible, opts = {}) {
733
+ const attempts = opts.attempts ?? 3;
734
+ const interval = opts.intervalMs ?? 200;
735
+ for (let i = 0; i < attempts; i++) {
736
+ await sleep(interval);
737
+ const pane = await capture();
738
+ if (pane !== null && !stillVisible(pane)) return true;
739
+ }
740
+ return false;
741
+ }
742
+
743
+ // src/daemon/poller.ts
744
+ import { createHash } from "crypto";
745
+
746
+ // src/daemon/git.ts
747
+ import { execFile } from "child_process";
748
+ import { basename, dirname as dirname2, resolve } from "path";
749
+ var execGit = (file, args, cwd) => new Promise((res) => {
750
+ execFile(file, args, { cwd, timeout: 3e3, maxBuffer: 1024 * 1024 }, (err, stdout) => res({ code: err ? 1 : 0, stdout: String(stdout) }));
751
+ });
752
+ async function gitInfo(cwd, exec = execGit) {
753
+ const r = await exec("git", ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir", "--abbrev-ref", "HEAD"], cwd);
754
+ if (r.code !== 0) return null;
755
+ const [toplevel = "", common = "", abbrev = ""] = r.stdout.split("\n").map((l) => l.trim());
756
+ if (!toplevel || !common || !abbrev) return null;
757
+ const worktree = resolve(common) !== resolve(toplevel, ".git");
758
+ const repoPath = worktree ? dirname2(resolve(common)) : resolve(toplevel);
759
+ let branch = abbrev;
760
+ if (abbrev === "HEAD") {
761
+ const sha = await exec("git", ["rev-parse", "--short", "HEAD"], cwd);
762
+ if (sha.code !== 0 || !sha.stdout.trim()) return null;
763
+ branch = sha.stdout.trim();
764
+ }
765
+ return { repo: basename(repoPath), repoPath, branch, worktree };
766
+ }
767
+ async function gitSummary(cwd, exec = execGit) {
768
+ const status = await exec("git", ["status", "--short", "--branch"], cwd);
769
+ if (status.code !== 0) return null;
770
+ const log2 = await exec("git", ["log", "--oneline", "-10"], cwd);
771
+ const diff = await exec("git", ["diff", "--stat"], cwd);
772
+ return [`# git status --short --branch`, status.stdout.trimEnd(), "", "# git log --oneline -10", log2.stdout.trimEnd(), "", "# git diff --stat", diff.stdout.trimEnd() || "(clean)"].join("\n");
773
+ }
774
+
775
+ // src/daemon/poller.ts
776
+ var DETECT_LINES = 60;
777
+ var REST_MS = 2e3;
778
+ function startPoller(opts) {
779
+ const { store, tmux } = opts;
780
+ const adapterFor2 = opts.adapterFor ?? adapterFor;
781
+ const git = opts.gitInfo ?? gitInfo;
782
+ const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
783
+ const deadCheckMs = opts.deadCheckMs ?? 5e3;
784
+ const retireMs = opts.retireMs ?? 10 * 60 * 1e3;
785
+ const hashes = /* @__PURE__ */ new Map();
786
+ const previews = /* @__PURE__ */ new Map();
787
+ const matches = /* @__PURE__ */ new Map();
788
+ const titles = /* @__PURE__ */ new Map();
789
+ const restSince = /* @__PURE__ */ new Map();
790
+ const gitSeen = /* @__PURE__ */ new Set();
791
+ const forget = (id) => {
792
+ hashes.delete(id);
793
+ matches.delete(id);
794
+ previews.delete(id);
795
+ restSince.delete(id);
796
+ titles.delete(id);
797
+ gitSeen.delete(id);
798
+ };
799
+ let lastDeadCheck = 0;
800
+ let running = false;
801
+ async function tick() {
802
+ if (running) return;
803
+ running = true;
804
+ try {
805
+ const t = Date.parse(now());
806
+ const deadCheck = t - lastDeadCheck >= deadCheckMs;
807
+ if (deadCheck) lastDeadCheck = t;
808
+ for (const id of previews.keys()) if (!store.get(id)) previews.delete(id);
809
+ for (const s of store.all()) {
810
+ try {
811
+ await pollOne(s, t, deadCheck);
812
+ } catch (e) {
813
+ log(`poll failed for ${s.id}: ${e.message}`);
814
+ }
815
+ }
816
+ } finally {
817
+ running = false;
818
+ }
819
+ }
820
+ async function pollOne(s, t, deadCheck) {
821
+ if (s.state === "exited") {
822
+ forget(s.id);
823
+ if (s.exitedAt && t - Date.parse(s.exitedAt) > retireMs) store.remove(s.id);
824
+ return;
825
+ }
826
+ if ((deadCheck || !gitSeen.has(s.id)) && s.cwd) {
827
+ gitSeen.add(s.id);
828
+ const g = await git(s.cwd);
829
+ if (g) store.patch(s.id, g);
830
+ }
831
+ if (!s.tmuxSession) return;
832
+ if (deadCheck && !await tmux.hasSession(s.tmuxSession)) {
833
+ store.apply(s.id, { name: "PaneGone", at: now(), source: "daemon" });
834
+ forget(s.id);
835
+ return;
836
+ }
837
+ const full = await tmux.capture(s.tmuxSession, PREVIEW_CAPTURE_LINES);
838
+ if (full === null) return;
839
+ const pane = full.split("\n").slice(-DETECT_LINES).join("\n");
840
+ const h = createHash("sha1").update(full).digest("hex");
841
+ const prev = hashes.get(s.id);
842
+ if (prev === h) {
843
+ const m2 = matches.get(s.id);
844
+ if (m2 && !store.get(s.id)?.pending) store.apply(s.id, { name: "PanePrompt", at: now(), source: "pane", kind: m2.kind, text: m2.text, options: m2.options });
845
+ rested(s.id, t);
846
+ return;
847
+ }
848
+ hashes.set(s.id, h);
849
+ if (prev !== void 0) store.apply(s.id, { name: "PaneChanged", at: now(), source: "pane" });
850
+ let adapter;
851
+ try {
852
+ adapter = adapterFor2(s.tool);
853
+ } catch {
854
+ adapter = null;
855
+ }
856
+ const lines = buildPreview(full, adapter);
857
+ const key = lines.join("\n");
858
+ if (previews.get(s.id) !== key) {
859
+ previews.set(s.id, key);
860
+ opts.broadcastPreview?.(s.id, lines);
861
+ }
862
+ if (!adapter) return;
863
+ const title = adapter.paneTitle?.(pane) ?? null;
864
+ if (title && title !== titles.get(s.id)) {
865
+ titles.set(s.id, title);
866
+ store.apply(s.id, { name: "PaneTitle", at: now(), source: "pane", text: title });
867
+ }
868
+ if (adapter.atRest?.(pane)) {
869
+ if (!restSince.has(s.id)) restSince.set(s.id, t);
870
+ } else restSince.delete(s.id);
871
+ const m = adapter.detectPrompt(pane);
872
+ if (!m) {
873
+ matches.delete(s.id);
874
+ rested(s.id, t);
875
+ return;
876
+ }
877
+ matches.set(s.id, m);
878
+ store.apply(s.id, { name: "PanePrompt", at: now(), source: "pane", kind: m.kind, text: m.text, options: m.options });
879
+ }
880
+ function rested(id, t) {
881
+ const since = restSince.get(id);
882
+ if (since !== void 0 && t - since >= REST_MS) store.apply(id, { name: "PaneIdle", at: now(), source: "pane" });
883
+ }
884
+ const timer = setInterval(() => {
885
+ void tick();
886
+ }, opts.intervalMs ?? 1e3);
887
+ return { stop: () => clearInterval(timer), tick };
888
+ }
889
+
890
+ // src/daemon/folders.ts
891
+ import { mkdir as mkdir2, readdir, stat } from "fs/promises";
892
+ import { homedir } from "os";
893
+ import { dirname as dirname3, isAbsolute, join, resolve as resolve2 } from "path";
894
+ async function resolveFolder(input, home = homedir()) {
895
+ const raw = input.trim();
896
+ const expanded = raw === "~" ? home : raw.startsWith("~/") ? resolve2(home, raw.slice(2)) : raw;
897
+ if (!isAbsolute(expanded)) throw new Error(`not an absolute path: ${raw}`);
898
+ const path = resolve2(expanded);
899
+ let s;
900
+ try {
901
+ s = await stat(path);
902
+ } catch {
903
+ throw new Error(`no such folder: ${path}`);
904
+ }
905
+ if (!s.isDirectory()) throw new Error(`not a folder: ${path}`);
906
+ return path;
907
+ }
908
+ var byName = new Intl.Collator(void 0, { numeric: true, sensitivity: "base" });
909
+ async function subfolders(path) {
910
+ const found = await Promise.all((await readdir(path, { withFileTypes: true })).map(async (d) => {
911
+ if (d.name.startsWith(".")) return null;
912
+ if (d.isDirectory()) return d.name;
913
+ if (!d.isSymbolicLink()) return null;
914
+ try {
915
+ return (await stat(join(path, d.name))).isDirectory() ? d.name : null;
916
+ } catch {
917
+ return null;
918
+ }
919
+ }));
920
+ return found.filter((n) => n !== null).sort(byName.compare);
921
+ }
922
+ async function listFolder(input, home = homedir()) {
923
+ const asked = input?.trim() ? input.trim() : "~";
924
+ try {
925
+ const path = await resolveFolder(asked, home);
926
+ const parent = dirname3(path);
927
+ return { type: "folder.listing", path, parent: parent === path ? null : parent, entries: await subfolders(path) };
928
+ } catch (e) {
929
+ return { type: "folder.listing", path: asked, parent: null, entries: [], error: e.message };
930
+ }
931
+ }
932
+ async function createFolder(parent, name, home = homedir()) {
933
+ const at = await listFolder(parent, home);
934
+ if (at.error) return at;
935
+ const clean = name.trim();
936
+ if (!clean || clean === "." || clean === ".." || /[/\0]/.test(clean)) return { ...at, error: `invalid folder name: ${name}` };
937
+ try {
938
+ await mkdir2(join(at.path, clean));
939
+ } catch (e) {
940
+ const code = e.code;
941
+ return { ...at, error: code === "EEXIST" ? `already exists: ${clean}` : `cannot create ${clean}: ${e.message}` };
942
+ }
943
+ return listFolder(join(at.path, clean), home);
944
+ }
945
+
946
+ // src/shell-path.ts
947
+ import { execFile as execFile2 } from "child_process";
948
+ function envCommand(shell) {
949
+ return [shell, "-l", "-c", "env -0"];
950
+ }
951
+ var SHELL_LOCAL = /* @__PURE__ */ new Set(["PWD", "OLDPWD", "SHLVL", "_"]);
952
+ function parseEnv(out) {
953
+ const env = {};
954
+ for (const entry of out.split("\0")) {
955
+ const eq = entry.indexOf("=");
956
+ if (eq <= 0) continue;
957
+ const key = entry.slice(0, eq);
958
+ if (!SHELL_LOCAL.has(key)) env[key] = entry.slice(eq + 1);
959
+ }
960
+ return env;
961
+ }
962
+ function loginShellEnv(opts = {}) {
963
+ const shell = opts.shell ?? process.env.SHELL;
964
+ if (!shell) return Promise.resolve({});
965
+ const [bin, ...args] = (opts.command ?? envCommand)(shell);
966
+ return new Promise((resolve5) => {
967
+ execFile2(bin, args, { timeout: opts.timeoutMs ?? 5e3, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
968
+ const env = err ? {} : parseEnv(String(stdout));
969
+ if (env.PATH) return resolve5(env);
970
+ log(`login shell environment unavailable (${shell}: ${err ? err.message.split("\n")[0] : "printed no PATH"}), launching with the daemon's`);
971
+ resolve5({});
972
+ });
973
+ });
974
+ }
975
+
976
+ // src/chat/service.ts
977
+ import { randomUUID } from "crypto";
978
+ 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";
980
+
981
+ // src/chat/context.ts
982
+ import { mkdir as mkdir3, rm, writeFile as writeFile2 } from "fs/promises";
983
+ import { homedir as homedir2 } from "os";
984
+ import { basename as basename2, join as join2, resolve as resolve3 } from "path";
985
+ var STATE = {
986
+ starting: "starting",
987
+ working: "working",
988
+ waiting_permission: "waiting for the user's approval",
989
+ waiting_input: "asking the user a question",
990
+ idle: "idle, waiting for instructions",
991
+ error: "in error",
992
+ exited: "exited"
993
+ };
994
+ async function writeContext(dir, input) {
995
+ await rm(dir, { recursive: true, force: true });
996
+ await mkdir3(dir, { recursive: true });
997
+ const steps = /* @__PURE__ */ new Map();
998
+ const lines = [`# Ledge sessions at ${input.now}`, "", "Each session below lists the files that describe it, in this folder.", ""];
999
+ for (const s of input.sessions) {
1000
+ lines.push(
1001
+ `## ${s.name} (${s.tool}) \u2014 ${STATE[s.state]}`,
1002
+ `- id: ${s.id}`,
1003
+ `- topic: ${s.topic ?? "(none yet)"}`,
1004
+ `- folder: ${s.cwd}`,
1005
+ `- repository: ${s.repo ?? s.project}${s.branch ? `, branch ${s.branch}${s.worktree ? " (linked worktree)" : ""}` : ""}`,
1006
+ `- started: ${s.startedAt}, last activity: ${s.lastActivityAt}`
1007
+ );
1008
+ if (s.pending) lines.push(`- pending ${s.pending.kind}: ${s.pending.text}`);
1009
+ const live = s.helpers.filter((h) => h.state !== "exited");
1010
+ if (live.length) lines.push(`- subagents: ${live.map((h) => h.task || h.kind).join("; ")}`);
1011
+ const pane = await input.capture(s).catch(() => null);
1012
+ if (pane?.length) {
1013
+ const f = `pane-${s.id}.txt`;
1014
+ await writeFile2(join2(dir, f), pane.join("\n"));
1015
+ steps.set(f, { kind: "pane", id: s.id, name: s.name });
1016
+ lines.push(`- terminal, last lines: ${f}`);
1017
+ }
1018
+ if (s.lastReply?.text.trim()) {
1019
+ const f = `reply-${s.id}.md`;
1020
+ await writeFile2(join2(dir, f), s.lastReply.text);
1021
+ steps.set(f, { kind: "reply", id: s.id, name: s.name });
1022
+ lines.push(`- last reply (${s.lastReply.at}): ${f}`);
1023
+ }
1024
+ const g = await input.git(s.cwd).catch(() => null);
1025
+ if (g) {
1026
+ const f = `git-${s.id}.txt`;
1027
+ await writeFile2(join2(dir, f), g);
1028
+ steps.set(f, { kind: "git", id: s.id, name: s.name });
1029
+ lines.push(`- git status, log and diff stat: ${f}`);
1030
+ }
1031
+ lines.push("");
1032
+ }
1033
+ await writeFile2(join2(dir, "sessions.md"), lines.join("\n"));
1034
+ steps.set("sessions.md", { kind: "sessions" });
1035
+ const repoPaths = [...new Set(input.sessions.map((s) => s.repoPath ?? s.cwd).filter((p) => p && !coversHome(p)).map((p) => resolve3(p)))];
1036
+ return { dir, steps, repoPaths };
1037
+ }
1038
+ function coversHome(path) {
1039
+ const p = resolve3(path);
1040
+ const home = resolve3(homedir2());
1041
+ return p === home || home.startsWith(p.endsWith("/") ? p : `${p}/`);
1042
+ }
1043
+ function stepFor(path, ctx) {
1044
+ const abs = resolve3(path);
1045
+ if (abs.startsWith(`${resolve3(ctx.dir)}/`)) return ctx.steps.get(basename2(abs)) ?? null;
1046
+ const repo = ctx.repoPaths.find((r) => abs.startsWith(`${resolve3(r)}/`));
1047
+ return repo ? { kind: "repo", name: basename2(repo), path: abs.slice(resolve3(repo).length + 1) } : { kind: "repo", path: abs };
1048
+ }
1049
+
1050
+ // src/chat/claude.ts
1051
+ import { spawn as nodeSpawn2 } from "child_process";
1052
+
1053
+ // src/chat/run.ts
1054
+ import { spawn as nodeSpawn } from "child_process";
1055
+ function runJsonl(file, args, o, parse, onEvent, spawnImpl = nodeSpawn) {
1056
+ const child = spawnImpl(file, args, { cwd: o.cwd, env: o.env, stdio: ["pipe", "pipe", "pipe"] });
1057
+ let buf = "";
1058
+ let stderr = "";
1059
+ const feed = (line) => {
1060
+ for (const e of parse(line)) onEvent(e);
1061
+ };
1062
+ child.stdout.setEncoding("utf8");
1063
+ child.stdout.on("data", (chunk) => {
1064
+ buf += chunk;
1065
+ let i;
1066
+ while ((i = buf.indexOf("\n")) >= 0) {
1067
+ feed(buf.slice(0, i));
1068
+ buf = buf.slice(i + 1);
1069
+ }
1070
+ });
1071
+ child.stderr.setEncoding("utf8");
1072
+ child.stderr.on("data", (c) => {
1073
+ stderr = (stderr + c).slice(-2e3);
1074
+ });
1075
+ const done = new Promise((res) => {
1076
+ child.on("error", (e) => {
1077
+ stderr = `${stderr}${e.message}`.slice(-2e3);
1078
+ res({ code: null, stderr });
1079
+ });
1080
+ child.on("close", (code) => {
1081
+ if (buf.trim()) feed(buf);
1082
+ res({ code, stderr });
1083
+ });
1084
+ });
1085
+ child.stdin.on("error", () => {
1086
+ });
1087
+ child.stdin.end(o.question);
1088
+ return { done, kill: () => {
1089
+ child.kill("SIGTERM");
1090
+ } };
1091
+ }
1092
+
1093
+ // 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.
1096
+ 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.`;
1098
+ function claudeArgs(o) {
1099
+ return [
1100
+ "-p",
1101
+ "--restricted",
1102
+ "--tools",
1103
+ "Read,Grep,Glob",
1104
+ "--permission-mode",
1105
+ "dontAsk",
1106
+ "--output-format",
1107
+ "stream-json",
1108
+ "--verbose",
1109
+ "--include-partial-messages",
1110
+ "--max-turns",
1111
+ String(o.maxTurns ?? 16),
1112
+ "--append-system-prompt",
1113
+ PERSONA,
1114
+ ...o.addDirs.flatMap((d) => ["--add-dir", d]),
1115
+ ...o.resume ? ["--resume", o.sessionId] : ["--session-id", o.sessionId]
1116
+ ];
1117
+ }
1118
+ var obj = (v) => v && typeof v === "object" ? v : {};
1119
+ var str = (v) => typeof v === "string" ? v : void 0;
1120
+ function parseLine(line) {
1121
+ let m;
1122
+ try {
1123
+ m = obj(JSON.parse(line));
1124
+ } catch {
1125
+ return [];
1126
+ }
1127
+ if (m.type === "system" && m.subtype === "init" && str(m.session_id)) return [{ type: "init", sessionId: str(m.session_id) }];
1128
+ if (m.type === "stream_event") {
1129
+ const ev = obj(m.event);
1130
+ const d = obj(ev.delta);
1131
+ return ev.type === "content_block_delta" && d.type === "text_delta" && str(d.text) !== void 0 ? [{ type: "delta", text: str(d.text) }] : [];
1132
+ }
1133
+ if (m.type === "assistant") {
1134
+ const content = obj(m.message).content;
1135
+ return (Array.isArray(content) ? content : []).map(obj).filter((b) => b.type === "tool_use").map((b) => {
1136
+ const i = obj(b.input);
1137
+ const path = str(i.file_path) ?? str(i.path);
1138
+ return { type: "tool", tool: String(b.name ?? ""), ...path ? { path } : {} };
1139
+ });
1140
+ }
1141
+ if (m.type === "result") return [{ type: "result", text: str(m.result) ?? "", isError: m.is_error === true || m.subtype !== void 0 && m.subtype !== "success" }];
1142
+ return [];
1143
+ }
1144
+ function runClaude(o, onEvent, spawnImpl = nodeSpawn2) {
1145
+ const [file = "claude", ...pre] = o.bin ?? ["claude"];
1146
+ return runJsonl(file, [...pre, ...claudeArgs(o)], { cwd: o.cwd, env: o.env, question: o.question }, parseLine, onEvent, spawnImpl);
1147
+ }
1148
+
1149
+ // src/chat/codex.ts
1150
+ import { spawn as nodeSpawn3 } from "child_process";
1151
+ function codexArgs(o) {
1152
+ const flags = ["--sandbox", "read-only", "--json", "--skip-git-repo-check", "--ignore-user-config", "-C", o.cwd, "-c", 'sandbox_permissions=["disk-full-read-access"]', "-"];
1153
+ return o.resume ? ["exec", "resume", o.sessionId, ...flags] : ["exec", ...flags];
1154
+ }
1155
+ var obj2 = (v) => v && typeof v === "object" ? v : {};
1156
+ var str2 = (v) => typeof v === "string" ? v : void 0;
1157
+ var pathIn = (s) => s.match(/(\/\S+)/)?.[1];
1158
+ function parseCodexLine(line) {
1159
+ let m;
1160
+ try {
1161
+ m = obj2(JSON.parse(line));
1162
+ } catch {
1163
+ return [];
1164
+ }
1165
+ if (m.type === "thread.started" && str2(m.thread_id)) return [{ type: "init", sessionId: str2(m.thread_id) }];
1166
+ const item = obj2(m.item);
1167
+ if (m.type === "item.started" || m.type === "item.completed") {
1168
+ if (item.type === "agent_message" && str2(item.text)) {
1169
+ const text = str2(item.text);
1170
+ return [{ type: "delta", text }, { type: "result", text, isError: false }];
1171
+ }
1172
+ const command = str2(item.command);
1173
+ const path = command ? pathIn(command) : str2(item.path) ?? str2(obj2(item.changes).path);
1174
+ if (item.type && item.type !== "agent_message") return [{ type: "tool", tool: String(item.type), ...path ? { path } : {} }];
1175
+ }
1176
+ if (m.type === "turn.failed" || m.type === "error") {
1177
+ const err = obj2(m.error);
1178
+ return [{ type: "result", text: str2(err.message) ?? str2(m.message) ?? "", isError: true }];
1179
+ }
1180
+ return [];
1181
+ }
1182
+ function runCodex(o, onEvent, spawnImpl = nodeSpawn3) {
1183
+ const [file = "codex", ...pre] = o.bin ?? ["codex"];
1184
+ return runJsonl(file, [...pre, ...codexArgs(o)], { cwd: o.cwd, env: o.env, question: `${PERSONA}
1185
+
1186
+ ${o.question}` }, parseCodexLine, onEvent, spawnImpl);
1187
+ }
1188
+
1189
+ // src/chat/cursor.ts
1190
+ import { spawn as nodeSpawn4 } from "child_process";
1191
+ function cursorArgs(o) {
1192
+ return [
1193
+ "-p",
1194
+ "--mode",
1195
+ "ask",
1196
+ "--output-format",
1197
+ "stream-json",
1198
+ "--stream-partial-output",
1199
+ "--sandbox",
1200
+ "enabled",
1201
+ "--trust",
1202
+ "--workspace",
1203
+ o.cwd,
1204
+ ...o.addDirs.flatMap((d) => ["--add-dir", d]),
1205
+ ...o.resume ? ["--resume", o.sessionId] : []
1206
+ ];
1207
+ }
1208
+ var obj3 = (v) => v && typeof v === "object" ? v : {};
1209
+ var str3 = (v) => typeof v === "string" ? v : void 0;
1210
+ function parseCursorLine(line) {
1211
+ const known = parseLine(line);
1212
+ if (known.length) return known;
1213
+ let m;
1214
+ try {
1215
+ m = obj3(JSON.parse(line));
1216
+ } catch {
1217
+ return [];
1218
+ }
1219
+ if (m.type === "tool_call") {
1220
+ const call = obj3(m.tool_call);
1221
+ const read = obj3(call.readToolCall);
1222
+ const args = obj3(read.args ?? call.args);
1223
+ const path = str3(args.path) ?? str3(args.file_path);
1224
+ return [{ type: "tool", tool: read.args ? "Read" : String(call.name ?? "tool"), ...path ? { path } : {} }];
1225
+ }
1226
+ if (m.type === "assistant") {
1227
+ const content = obj3(m.message).content;
1228
+ return (Array.isArray(content) ? content : []).map(obj3).filter((b) => b.type === "text" && str3(b.text)).map((b) => ({ type: "delta", text: str3(b.text) }));
1229
+ }
1230
+ return [];
1231
+ }
1232
+ function runCursor(o, onEvent, spawnImpl = nodeSpawn4) {
1233
+ const [file = "cursor-agent", ...pre] = o.bin ?? ["cursor-agent"];
1234
+ return runJsonl(file, [...pre, ...cursorArgs(o)], { cwd: o.cwd, env: o.env, question: `${PERSONA}
1235
+
1236
+ ${o.question}` }, parseCursorLine, onEvent, spawnImpl);
1237
+ }
1238
+
1239
+ // src/chat/service.ts
1240
+ var TOOL_LABEL = { claude: "Claude Code", codex: "Codex", cursor: "Cursor Agent" };
1241
+ var runTool = (tool, o, onEvent, spawn) => tool === "codex" ? runCodex(o, onEvent, spawn) : tool === "cursor" ? runCursor(o, onEvent, spawn) : runClaude(o, onEvent, spawn);
1242
+ var ONE_AT_A_TIME = "one question at a time: wait for the answer";
1243
+ var HISTORY_MAX = 50;
1244
+ var PANE_LINES = 40;
1245
+ var sameStep = (a, b) => a.kind === b.kind && a.id === b.id && a.path === b.path;
1246
+ function createChat(deps) {
1247
+ const now = deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
1248
+ const today = deps.today ?? (() => now().slice(0, 10));
1249
+ const uuid = deps.uuid ?? randomUUID;
1250
+ const dayFile = join3(deps.home, "chat.json");
1251
+ let turns = [];
1252
+ let turnsDay = "";
1253
+ let busy = false;
1254
+ let freshNext = false;
1255
+ let current = null;
1256
+ const CANCELLED = { ok: false, message: "cancelled", resumed: false };
1257
+ function rollDay() {
1258
+ const day = today();
1259
+ if (turnsDay !== day) {
1260
+ turns = [];
1261
+ turnsDay = day;
1262
+ }
1263
+ }
1264
+ async function daySession(fresh, tool) {
1265
+ const day = today();
1266
+ if (!fresh && !freshNext) {
1267
+ try {
1268
+ const saved = JSON.parse(await readFile2(dayFile, "utf8"));
1269
+ if (saved.day === day && saved.sessionId && (saved.tool ?? "claude") === tool) return { sessionId: saved.sessionId, resume: true };
1270
+ } catch {
1271
+ }
1272
+ }
1273
+ const sessionId = uuid();
1274
+ await mkdir4(dirname4(dayFile), { recursive: true });
1275
+ await writeFile3(dayFile, JSON.stringify({ day, sessionId, tool }));
1276
+ freshNext = false;
1277
+ return { sessionId, resume: false };
1278
+ }
1279
+ const capture = async (s) => {
1280
+ if (!s.tmuxSession) return null;
1281
+ const p = await deps.tmux.capture(s.tmuxSession, PANE_LINES);
1282
+ return p === null ? null : p.split("\n");
1283
+ };
1284
+ const contextDir = join3(deps.home, "chat", "context");
1285
+ async function attempt(turn, emit, fresh, mine, tool) {
1286
+ try {
1287
+ return await run3(turn, emit, fresh, mine, tool);
1288
+ } finally {
1289
+ mine.handle = null;
1290
+ await rm2(contextDir, { recursive: true, force: true }).catch(() => {
1291
+ });
1292
+ }
1293
+ }
1294
+ 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) });
1296
+ if (mine.cancelled) return CANCELLED;
1297
+ const { sessionId, resume } = await daySession(fresh, tool);
1298
+ const label2 = TOOL_LABEL[tool];
1299
+ const env = { ...await deps.env(), LEDGE_CHAT: "1" };
1300
+ delete env.LEDGE_SESSION;
1301
+ delete env.LEDGE_NAME;
1302
+ delete env.ANTHROPIC_API_KEY;
1303
+ if (mine.cancelled) return CANCELLED;
1304
+ let answer = "";
1305
+ let resultError = null;
1306
+ const handle = runTool(tool, { question: turn.question, cwd: ctx.dir, addDirs: ctx.repoPaths, sessionId, resume, env, bin: deps.bins?.[tool] ?? deps.bin }, (e) => {
1307
+ if (e.type === "tool") {
1308
+ const step = e.path ? stepFor(e.path, ctx) : null;
1309
+ if (step && !turn.steps.some((s) => sameStep(s, step))) {
1310
+ turn.steps.push(step);
1311
+ emit({ type: "chat.step", qid: turn.qid, step });
1312
+ }
1313
+ } else if (e.type === "delta") {
1314
+ answer += e.text;
1315
+ emit({ type: "chat.delta", qid: turn.qid, text: e.text });
1316
+ } else if (e.type === "result") {
1317
+ if (e.text) answer = e.text;
1318
+ if (e.isError) resultError = e.text || `${label2} reported an error`;
1319
+ }
1320
+ }, deps.spawn);
1321
+ mine.handle = handle;
1322
+ let timedOut = false;
1323
+ const timer = setTimeout(() => {
1324
+ log(`chat ${turn.qid}: timed out`);
1325
+ timedOut = true;
1326
+ handle.kill();
1327
+ }, deps.timeoutMs ?? 18e4);
1328
+ const r = await handle.done;
1329
+ clearTimeout(timer);
1330
+ if (mine.cancelled) return CANCELLED;
1331
+ if (timedOut) return { ok: false, message: `${label2} did not answer within 3 minutes`, resumed: false };
1332
+ 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
+ if (resultError) return { ok: false, message: resultError, resumed: false };
1334
+ turn.answer = answer;
1335
+ turn.answeredAt = now();
1336
+ emit({ type: "chat.done", qid: turn.qid, text: answer, at: turn.answeredAt });
1337
+ return { ok: true };
1338
+ }
1339
+ return {
1340
+ history: () => turns,
1341
+ cancel(qid) {
1342
+ if (current?.qid === qid) {
1343
+ current.cancelled = true;
1344
+ current.handle?.kill();
1345
+ }
1346
+ },
1347
+ async reset(emit) {
1348
+ if (busy) {
1349
+ log("chat reset ignored: a question is running");
1350
+ return;
1351
+ }
1352
+ freshNext = true;
1353
+ turns = [];
1354
+ turnsDay = today();
1355
+ emit({ type: "chat.history", turns });
1356
+ await rm2(dayFile, { force: true }).catch((e) => log(`chat reset: day file not removed: ${e.message}`));
1357
+ },
1358
+ async ask(qid, text, emit, tool = "claude") {
1359
+ if (busy) return emit({ type: "chat.error", qid, message: ONE_AT_A_TIME });
1360
+ busy = true;
1361
+ const mine = current = { qid, handle: null, cancelled: false };
1362
+ const turn = { qid, question: text, askedAt: now(), steps: [] };
1363
+ try {
1364
+ rollDay();
1365
+ turns = [...turns, turn].slice(-HISTORY_MAX);
1366
+ emit({ type: "chat.history", turns });
1367
+ let r = await attempt(turn, emit, false, mine, tool);
1368
+ if (!r.ok && r.resumed && !mine.cancelled) {
1369
+ log(`chat ${qid}: resume failed (${r.message}), starting a new day session`);
1370
+ turn.steps.length = 0;
1371
+ r = await attempt(turn, emit, true, mine, tool);
1372
+ }
1373
+ if (!r.ok) {
1374
+ turn.error = r.message;
1375
+ emit({ type: "chat.error", qid, message: r.message });
1376
+ }
1377
+ } catch (e) {
1378
+ log(`chat ${qid} failed: ${e.message}`);
1379
+ turn.error = e.message;
1380
+ emit({ type: "chat.error", qid, message: turn.error });
1381
+ } finally {
1382
+ busy = false;
1383
+ current = null;
1384
+ }
1385
+ }
1386
+ };
1387
+ }
1388
+
1389
+ // src/daemon/documents.ts
1390
+ import { constants } from "fs";
1391
+ import { open, realpath } from "fs/promises";
1392
+ import { isAbsolute as isAbsolute2, dirname as dirname5, extname, resolve as resolve4 } from "path";
1393
+ var markdown = (path) => /^\.(?:md|markdown)$/i.test(extname(path));
1394
+ var failure = (request, code) => ({
1395
+ type: "document.error",
1396
+ requestId: request.requestId,
1397
+ path: request.path,
1398
+ code
1399
+ });
1400
+ var errorCode = (e) => {
1401
+ const code = e.code;
1402
+ return code === "ENOENT" ? "not_found" : code === "EACCES" || code === "EPERM" ? "permission_denied" : code === "EISDIR" || code === "ENXIO" ? "not_file" : "read_failed";
1403
+ };
1404
+ async function readDocument(request, context) {
1405
+ const source = request.path;
1406
+ if (/^(?:file:|[a-zA-Z]:[\\/]|\\\\)/.test(source) || source.includes("\0")) return failure(request, "unsupported_path");
1407
+ if (request.basePath && (!isAbsolute2(request.basePath) || !markdown(request.basePath))) return failure(request, "unsupported_path");
1408
+ const expanded = source === "~" ? context.home : source.startsWith("~/") ? resolve4(context.home, source.slice(2)) : source;
1409
+ const base = request.basePath ? dirname5(request.basePath) : context.cwd;
1410
+ if (!isAbsolute2(expanded) && !base) return failure(request, "ambiguous_path");
1411
+ const path = resolve4(base ?? context.home, expanded);
1412
+ if (!markdown(path)) return failure(request, "unsupported_type");
1413
+ let file;
1414
+ try {
1415
+ file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
1416
+ const stat2 = await file.stat();
1417
+ if (!stat2.isFile()) return failure(request, "not_file");
1418
+ if (stat2.size > DOCUMENT_MAX_BYTES) return failure(request, "too_large");
1419
+ const chunk = Buffer.allocUnsafe(64 * 1024);
1420
+ const parts = [];
1421
+ let bytes = 0;
1422
+ while (bytes <= DOCUMENT_MAX_BYTES) {
1423
+ const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, DOCUMENT_MAX_BYTES + 1 - bytes), null);
1424
+ if (!bytesRead) break;
1425
+ parts.push(Buffer.from(chunk.subarray(0, bytesRead)));
1426
+ bytes += bytesRead;
1427
+ }
1428
+ if (bytes > DOCUMENT_MAX_BYTES) return failure(request, "too_large");
1429
+ let text;
1430
+ try {
1431
+ text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts, bytes));
1432
+ } catch {
1433
+ return failure(request, "invalid_utf8");
1434
+ }
1435
+ return {
1436
+ type: "document.content",
1437
+ requestId: request.requestId,
1438
+ path: await realpath(path),
1439
+ text,
1440
+ bytes,
1441
+ modifiedAt: stat2.mtime.toISOString()
1442
+ };
1443
+ } catch (e) {
1444
+ return failure(request, errorCode(e));
1445
+ } finally {
1446
+ await file?.close();
1447
+ }
1448
+ }
1449
+
1450
+ // src/daemon/server.ts
1451
+ var CONTINUE_ARGS = { claude: ["--continue"], codex: ["resume", "--last"], cursor: ["--continue"] };
1452
+ function launchArgs(msg) {
1453
+ const prompt = msg.prompt?.trim();
1454
+ return [...msg.continue ? CONTINUE_ARGS[msg.tool] : [], ...prompt ? [prompt] : []];
1455
+ }
1456
+ var ALLOWED_ORIGINS = [
1457
+ "tauri://localhost",
1458
+ "http://tauri.localhost",
1459
+ "https://tauri.localhost",
1460
+ "http://localhost:1420",
1461
+ "http://127.0.0.1:1420"
1462
+ ];
1463
+ function originAllowed(origin) {
1464
+ return origin === void 0 || ALLOWED_ORIGINS.includes(origin);
1465
+ }
1466
+ function logDroppedRequest(id, ev, before, after) {
1467
+ if (ev.name !== "PermissionRequest" || !after || after.pending !== before?.pending) return;
1468
+ const held = before?.pending;
1469
+ if (held && !held.helperId && !ev.helperId) return;
1470
+ const who = ev.helperId ? `helper ${ev.helperId}` : "main thread";
1471
+ const why = after.state === "exited" ? "the session has exited" : held ? `${held.helperId ? `helper ${held.helperId}` : "the main thread"} already holds the pending request` : "nothing was set";
1472
+ log(`${who} permission for ${ev.tool ?? "a tool"} on ${id} not routed: ${why}`);
1473
+ }
1474
+ async function startServer(opts) {
1475
+ const { store, tmux } = opts;
1476
+ const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
1477
+ const launcher = opts.launcher ?? [process.execPath, process.argv[1] ?? "ledge"];
1478
+ 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 });
1480
+ const wss = new WebSocketServer({
1481
+ host: opts.host ?? "127.0.0.1",
1482
+ port: opts.port ?? PROTOCOL_PORT,
1483
+ verifyClient: ({ req }) => {
1484
+ const ok = originAllowed(req.headers.origin);
1485
+ if (!ok) log(`refused a connection from origin ${String(req.headers.origin)}`);
1486
+ return ok;
1487
+ }
1488
+ });
1489
+ await new Promise((res, rej) => {
1490
+ wss.once("listening", res);
1491
+ wss.once("error", rej);
1492
+ });
1493
+ const port = wss.address().port;
1494
+ const send = (ws, msg) => {
1495
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
1496
+ };
1497
+ const broadcast = (msg) => {
1498
+ for (const c of wss.clients) send(c, msg);
1499
+ };
1500
+ const previews = /* @__PURE__ */ new Map();
1501
+ const titles = /* @__PURE__ */ new Map();
1502
+ const unsubscribe = store.onChange((c) => {
1503
+ if (c.type === "recents") return broadcast({ type: "recents", recents: c.recents });
1504
+ if (c.type === "updated" && c.session.tmuxSession && titles.get(c.session.id) !== c.session.title) {
1505
+ titles.set(c.session.id, c.session.title);
1506
+ tmux.setOption(c.session.tmuxSession, "@ledge_title", c.session.title).catch((e) => log(`tab title not set for ${c.session.id}: ${e.message}`));
1507
+ }
1508
+ if (c.type === "removed") {
1509
+ previews.delete(c.id);
1510
+ titles.delete(c.id);
1511
+ }
1512
+ broadcast(c.type === "updated" ? { type: "session.updated", session: c.session } : { type: "session.removed", id: c.id });
1513
+ });
1514
+ const sendSnapshot = (ws) => {
1515
+ send(ws, { type: "snapshot", sessions: store.all(), recents: store.recents(), launcher, ...opts.daemon ? { daemon: opts.daemon } : {} });
1516
+ for (const [id, lines] of previews) if (store.get(id)) send(ws, { type: "session.preview", id, lines });
1517
+ send(ws, { type: "chat.history", turns: chat.history() });
1518
+ };
1519
+ const preview = (id, lines) => {
1520
+ if (!store.get(id)) return;
1521
+ previews.set(id, lines);
1522
+ broadcast({ type: "session.preview", id, lines });
1523
+ };
1524
+ const UNTIL_MS = opts.untilMs ?? 1500;
1525
+ const UNTIL_POLL_MS = 50;
1526
+ const UNTIL_LINES = 30;
1527
+ async function runPlan(s, plan) {
1528
+ if (!s.tmuxSession) throw new Error("session has no terminal (started without ledge)");
1529
+ for (const step of plan) {
1530
+ if ("text" in step) await tmux.sendText(s.tmuxSession, step.text);
1531
+ else if ("waitMs" in step) await new Promise((r) => setTimeout(r, step.waitMs));
1532
+ else if ("keys" in step) await tmux.sendKeys(s.tmuxSession, step.keys);
1533
+ else {
1534
+ const end = Date.now() + UNTIL_MS;
1535
+ for (; ; ) {
1536
+ const pane = await tmux.capture(s.tmuxSession, UNTIL_LINES);
1537
+ if (pane !== null && step.until(pane)) break;
1538
+ if (Date.now() >= end) throw new Error(NOT_TAKEN);
1539
+ await new Promise((r) => setTimeout(r, UNTIL_POLL_MS));
1540
+ }
1541
+ }
1542
+ }
1543
+ }
1544
+ const inFlight = /* @__PURE__ */ new Set();
1545
+ async function injectAndVerify(id, planFor, requirePrompt) {
1546
+ const s = store.get(id);
1547
+ if (!s) throw new Error(`unknown session ${id}`);
1548
+ if (inFlight.has(id)) throw new Error("a decision is already in progress");
1549
+ inFlight.add(id);
1550
+ try {
1551
+ const adapter = adapterFor(s.tool);
1552
+ let before = null;
1553
+ if (requirePrompt) {
1554
+ before = s.tmuxSession ? await tmux.capture(s.tmuxSession, DETECT_LINES) : null;
1555
+ if (before === null || !adapter.promptStillVisible(before.split("\n").slice(-5).join("\n"))) throw new Error(NO_PROMPT);
1556
+ }
1557
+ const plan = typeof planFor === "function" ? planFor(before ?? "") : planFor;
1558
+ const decided = s.pending;
1559
+ const stillTheSame = () => {
1560
+ const cur = store.get(id)?.pending;
1561
+ return cur === decided || !!cur && !!decided && cur.kind === decided.kind && cur.text === decided.text;
1562
+ };
1563
+ try {
1564
+ await runPlan(s, plan);
1565
+ } catch (e) {
1566
+ store.apply(id, { name: "InjectFailed", at: now(), source: "daemon", text: e.message });
1567
+ throw e;
1568
+ }
1569
+ const gone = decided && adapter.promptGone ? await verifyGone(() => tmux.capture(s.tmuxSession, DETECT_LINES), (p) => !adapter.promptGone(p, decided), opts.verify) : await verifyGone(() => tmux.capture(s.tmuxSession, 5), (p) => adapter.promptStillVisible(p), opts.verify);
1570
+ if (!stillTheSame()) return;
1571
+ if (gone) {
1572
+ store.apply(id, { name: "Decided", at: now(), source: "daemon" });
1573
+ return;
1574
+ }
1575
+ store.apply(id, { name: "InjectFailed", at: now(), source: "daemon", text: NOT_TAKEN });
1576
+ throw new Error(NOT_TAKEN);
1577
+ } finally {
1578
+ inFlight.delete(id);
1579
+ }
1580
+ }
1581
+ const READ_ONLY = "session started without ledge, read only";
1582
+ const NO_PROMPT = "no prompt visible in the terminal, open the tab";
1583
+ const NOT_TAKEN = "the terminal did not take it";
1584
+ const FREE_TEXT = "free-text answers are not supported yet, answer in the terminal";
1585
+ async function handle(ws, msg) {
1586
+ switch (msg.type) {
1587
+ case "document.read": {
1588
+ const session = msg.sessionId ? store.get(msg.sessionId) : void 0;
1589
+ if (msg.sessionId && !session) return send(ws, { type: "document.error", requestId: msg.requestId, path: msg.path, code: "session_missing" });
1590
+ return send(ws, await readDocument(msg, { cwd: session?.cwd, home: opts.home ?? process.env.HOME ?? "" }));
1591
+ }
1592
+ case "snapshot.request":
1593
+ return sendSnapshot(ws);
1594
+ case "session.register": {
1595
+ let incoming = msg.session;
1596
+ const existing = store.get(incoming.id);
1597
+ const others = store.all().filter((o) => o.id !== incoming.id);
1598
+ if (others.some((o) => o.state !== "exited" && o.name === incoming.name)) {
1599
+ const old = incoming.name;
1600
+ const name = pickName(others.map((o) => o.name));
1601
+ const title = incoming.title.startsWith(`${old} \xB7`) ? `${name}${incoming.title.slice(old.length)}` : incoming.title;
1602
+ incoming = { ...incoming, name, title };
1603
+ }
1604
+ if (existing && existing.source === "hook_only") {
1605
+ store.upsert({
1606
+ ...existing,
1607
+ ...incoming,
1608
+ state: existing.state,
1609
+ pending: existing.pending,
1610
+ lastActivityAt: existing.lastActivityAt,
1611
+ lastHookAt: existing.lastHookAt,
1612
+ exitCode: existing.exitCode,
1613
+ exitedAt: existing.exitedAt,
1614
+ source: "launcher"
1615
+ });
1616
+ } else store.upsert(incoming);
1617
+ if (incoming.cwd) store.touchRecent(incoming.cwd, incoming.project, incoming.startedAt);
1618
+ return;
1619
+ }
1620
+ 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 });
1629
+ }
1630
+ case "folder.list":
1631
+ return send(ws, await listFolder(msg.path, opts.home));
1632
+ case "folder.create": {
1633
+ const listing = await createFolder(msg.parent, msg.name, opts.home);
1634
+ if (!listing.error) log(`created folder ${listing.path}`);
1635
+ return send(ws, listing);
1636
+ }
1637
+ case "session.event": {
1638
+ const before = store.get(msg.id);
1639
+ if (!before) {
1640
+ const taken = store.all().map((s) => s.name);
1641
+ 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" };
1643
+ store.upsert(reduce(base, msg.event));
1644
+ } else store.apply(msg.id, msg.event);
1645
+ logDroppedRequest(msg.id, msg.event, before, store.get(msg.id));
1646
+ return;
1647
+ }
1648
+ case "session.rename": {
1649
+ if (!store.get(msg.id)) return send(ws, { type: "error", id: msg.id, message: "unknown session" });
1650
+ store.apply(msg.id, { name: "Renamed", at: now(), source: "daemon", text: msg.topic });
1651
+ return;
1652
+ }
1653
+ case "session.unregister":
1654
+ store.apply(msg.id, { name: "SessionEnd", at: now(), source: "hook", exitCode: msg.exitCode });
1655
+ return;
1656
+ case "session.decide": {
1657
+ const s = store.get(msg.id);
1658
+ if (!s) return send(ws, { type: "error", id: msg.id, message: "unknown session" });
1659
+ if (s.source === "hook_only") return send(ws, { type: "error", id: msg.id, message: READ_ONLY });
1660
+ if (!s.pending) return send(ws, { type: "error", id: msg.id, message: "nothing to decide" });
1661
+ const pending = s.pending;
1662
+ const adapter = adapterFor(s.tool);
1663
+ return injectAndVerify(msg.id, (pane) => adapter.keysFor(msg.decision, pending, pane), true);
1664
+ }
1665
+ 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);
1675
+ }
1676
+ case "session.interrupt": {
1677
+ const s = store.get(msg.id);
1678
+ if (s?.source === "hook_only") return send(ws, { type: "error", id: msg.id, message: READ_ONLY });
1679
+ if (!s?.tmuxSession) return;
1680
+ await tmux.sendKeys(s.tmuxSession, adapterFor(s.tool).interruptKeys());
1681
+ return;
1682
+ }
1683
+ case "session.kill": {
1684
+ const s = store.get(msg.id);
1685
+ if (!s) return;
1686
+ if (s.source === "hook_only") return send(ws, { type: "error", id: msg.id, message: READ_ONLY });
1687
+ if (s.tmuxSession) await tmux.killSession(s.tmuxSession);
1688
+ store.apply(msg.id, { name: "PaneGone", at: now(), source: "daemon" });
1689
+ return;
1690
+ }
1691
+ case "session.dismiss": {
1692
+ const s = store.get(msg.id);
1693
+ if (!s) return send(ws, { type: "error", id: msg.id, message: "unknown session" });
1694
+ if (s.state !== "exited" && s.source !== "hook_only") return send(ws, { type: "error", id: msg.id, message: "the session is still running, kill it first" });
1695
+ store.remove(msg.id);
1696
+ return;
1697
+ }
1698
+ // Broadcast: every overlay shows the same progress, and the one that asked may be the one that reconnects.
1699
+ // A busy refusal goes to the asker only: it must not fail the flow the other overlays follow.
1700
+ case "update.daemon":
1701
+ if (!opts.updater) return send(ws, { type: "update.failed", version: msg.version, reason: "no-systemd" });
1702
+ return opts.updater.update(msg.version, broadcast, (m) => send(ws, m));
1703
+ case "chat.ask":
1704
+ return chat.ask(msg.qid, msg.text, broadcast, msg.tool);
1705
+ case "chat.cancel":
1706
+ return chat.cancel(msg.qid);
1707
+ case "chat.reset":
1708
+ return chat.reset(broadcast);
1709
+ case "session.focus":
1710
+ return;
1711
+ }
1712
+ }
1713
+ wss.on("connection", (ws) => {
1714
+ ws.on("error", (e) => log(`socket error: ${e.message}`));
1715
+ sendSnapshot(ws);
1716
+ ws.on("message", (data) => {
1717
+ let msg;
1718
+ try {
1719
+ msg = parseClientMessage(JSON.parse(String(data)));
1720
+ } catch (e) {
1721
+ log(`invalid message: ${e.message}`);
1722
+ return send(ws, { type: "error", message: "invalid message" });
1723
+ }
1724
+ handle(ws, msg).catch((e) => {
1725
+ if (msg.type === "document.read") return send(ws, { type: "document.error", requestId: msg.requestId, path: msg.path, code: "read_failed" });
1726
+ log(`${msg.type} failed: ${e.message}`);
1727
+ send(ws, { type: "error", id: "id" in msg ? msg.id : void 0, message: e.message });
1728
+ });
1729
+ });
1730
+ });
1731
+ return {
1732
+ port,
1733
+ broadcast,
1734
+ preview,
1735
+ close: () => new Promise((res) => {
1736
+ unsubscribe();
1737
+ for (const c of wss.clients) c.terminate();
1738
+ wss.close(() => res());
1739
+ })
1740
+ };
1741
+ }
1742
+
1743
+ // src/update/self.ts
1744
+ import { existsSync } from "fs";
1745
+ import { readFile as readFile4, realpath as realpath2 } from "fs/promises";
1746
+ import { dirname as dirname6, join as join4 } from "path";
1747
+
1748
+ // src/update/guard.ts
1749
+ import { readFile as readFile3 } from "fs/promises";
1750
+ var inDaemonService = (cgroup) => cgroup.split("\n").some((l) => l.split("/").includes(SERVICE));
1751
+ async function tmuxServerAtRisk(opts = {}) {
1752
+ const runTmux = opts.runTmux ?? run;
1753
+ const readCgroup = opts.readCgroup ?? ((pid2) => readFile3(`/proc/${pid2}/cgroup`, "utf8"));
1754
+ const r = await runTmux(["-L", opts.socket ?? "ledge", "list-sessions", "-F", "#{pid}"]);
1755
+ if (r.code !== 0) return false;
1756
+ const pid = r.stdout.split("\n").map((s) => s.trim()).find(Boolean);
1757
+ if (!pid) return false;
1758
+ try {
1759
+ return inDaemonService(await readCgroup(pid));
1760
+ } catch {
1761
+ return true;
1762
+ }
1763
+ }
1764
+
1765
+ // src/update/self.ts
1766
+ function gitRoot(script, exists = existsSync) {
1767
+ for (let dir = dirname6(script); ; dir = dirname6(dir)) {
1768
+ if (exists(join4(dir, ".git"))) return dir;
1769
+ if (dirname6(dir) === dir) return null;
1770
+ }
1771
+ }
1772
+ function installKind(script, exists = existsSync) {
1773
+ if (script.includes("/node_modules/@paradow/ledge/")) return "npm";
1774
+ return gitRoot(script, exists) ? "git" : "unknown";
1775
+ }
1776
+ var underSystemd = (env, cgroup) => !!env.INVOCATION_ID && inDaemonService(cgroup);
1777
+ var manualCommand = (root) => `cd ${root} && git pull && pnpm install && pnpm --filter @paradow/ledge build && systemctl --user restart ${SERVICE.replace(/\.service$/, "")}`;
1778
+ async function daemonInfo() {
1779
+ const script = await realpath2(process.argv[1] ?? "").catch(() => process.argv[1] ?? "");
1780
+ const cgroup = await readFile4("/proc/self/cgroup", "utf8").catch(() => "");
1781
+ const install = installKind(script);
1782
+ const root = install === "git" ? gitRoot(script) : null;
1783
+ return { version: LEDGE_VERSION, install, systemd: underSystemd(process.env, cgroup), ...root ? { manual: manualCommand(root) } : {} };
1784
+ }
1785
+
1786
+ // src/update/run-update.ts
1787
+ import { dirname as dirname7, join as join5 } from "path";
1788
+ var PACKAGE = "@paradow/ledge";
1789
+ var NPM_TIMEOUT_MS = 3 * 6e4;
1790
+ var RESTART_COMMAND = "systemctl --user restart ledge-daemon";
1791
+ var npmCommand = (execPath, version) => ({ file: join5(dirname7(execPath), "npm"), args: ["i", "-g", `${PACKAGE}@${version}`] });
1792
+ var lastLines = (output, n = 3) => output.split("\n").map((l) => l.trimEnd()).filter(Boolean).slice(-n).join("\n");
1793
+ var npmEnv = (execPath, env) => ({ ...env, PATH: `${dirname7(execPath)}:${env.PATH ?? ""}` });
1794
+ function createDaemonUpdater(deps) {
1795
+ let busy = false;
1796
+ let restarting = false;
1797
+ return {
1798
+ running: () => busy,
1799
+ /** `reply` reaches only the asking client: a busy refusal must not fail the flow other overlays are following. */
1800
+ async update(version, send, reply = send) {
1801
+ if (busy) return reply({ type: "update.failed", version, reason: "busy" });
1802
+ const info = deps.info();
1803
+ const cmd = npmCommand(deps.execPath ?? process.execPath, version);
1804
+ const shown = `${cmd.file} ${cmd.args.join(" ")}`;
1805
+ if (info.install !== "npm") return send({ type: "update.failed", version, reason: "git-install", ...info.manual ? { command: info.manual } : {} });
1806
+ if (!info.systemd) return send({ type: "update.failed", version, reason: "no-systemd", command: shown });
1807
+ busy = true;
1808
+ try {
1809
+ if (await deps.atRisk()) return send({ type: "update.failed", version, reason: "sessions-at-risk" });
1810
+ send({ type: "update.progress", version, step: "installing" });
1811
+ log(`update: ${shown}`);
1812
+ const r = await deps.npm(cmd);
1813
+ if (r.code !== 0) {
1814
+ log(`update: npm failed (${r.code})
1815
+ ${r.output}`);
1816
+ return send({ type: "update.failed", version, reason: "npm-failed", detail: lastLines(r.output), command: shown });
1817
+ }
1818
+ send({ type: "update.progress", version, step: "restarting" });
1819
+ await deps.save();
1820
+ const code = await deps.restart();
1821
+ if (code !== 0) {
1822
+ log(`update: systemctl restart failed (${code})`);
1823
+ return send({ type: "update.failed", version, reason: "no-systemd", command: RESTART_COMMAND });
1824
+ }
1825
+ restarting = true;
1826
+ } catch (e) {
1827
+ log(`update: ${e.stack ?? String(e)}`);
1828
+ send({ type: "update.failed", version, reason: "npm-failed", detail: e instanceof Error ? e.message : String(e) });
1829
+ } finally {
1830
+ if (!restarting) busy = false;
1831
+ }
1832
+ }
1833
+ };
1834
+ }
1835
+ function realUpdaterDeps(opts) {
1836
+ return {
1837
+ info: () => opts.info,
1838
+ atRisk: () => tmuxServerAtRisk(),
1839
+ npm: async (cmd) => {
1840
+ const env = npmEnv(process.execPath, { ...process.env, ...await loginShellEnv() });
1841
+ const r = await runFile(cmd.file, cmd.args, env, { timeoutMs: NPM_TIMEOUT_MS });
1842
+ const output = `${r.stdout}
1843
+ ${r.stderr}`;
1844
+ return r.code === 124 ? { code: 124, output: `npm timed out after 3 minutes
1845
+ ${output}` } : { code: r.code, output };
1846
+ },
1847
+ // --no-block: this systemctl runs inside the cgroup being stopped; the job must not depend on it staying alive.
1848
+ restart: async () => (await runFile("systemctl", ["--user", "restart", "--no-block", SERVICE])).code,
1849
+ save: opts.save
1850
+ };
1851
+ }
1852
+
1853
+ // src/daemon/index.ts
1854
+ async function startDaemon(opts = {}) {
1855
+ await ensureLedgeDir();
1856
+ const store = new Store();
1857
+ await store.load();
1858
+ const tmux = opts.tmux ?? realTmux(opts.tmuxSocket ?? "ledge");
1859
+ let live = null;
1860
+ try {
1861
+ live = new Set(await tmux.listSessions());
1862
+ } catch (e) {
1863
+ log(`startup prune skipped, tmux unavailable: ${e.message}`);
1864
+ }
1865
+ if (live) for (const s of store.all()) {
1866
+ if (s.tmuxSession && !live.has(s.tmuxSession)) store.remove(s.id);
1867
+ }
1868
+ const daemon = await daemonInfo();
1869
+ const updater = createDaemonUpdater(realUpdaterDeps({ info: daemon, save: () => store.save() }));
1870
+ const server = await startServer({ store, tmux, port: opts.port, daemon, updater });
1871
+ const poller = startPoller({ store, tmux, broadcastPreview: server.preview });
1872
+ await writeFile4(paths().pid, String(process.pid));
1873
+ log(`daemon started on ${server.port} with ${store.all().length} sessions`);
1874
+ let stopping = null;
1875
+ const stop = () => stopping ??= (async () => {
1876
+ process.off("SIGTERM", onSignal);
1877
+ process.off("SIGINT", onSignal);
1878
+ poller.stop();
1879
+ await server.close();
1880
+ await store.save();
1881
+ await rm3(paths().pid, { force: true });
1882
+ log("daemon stopped");
1883
+ })();
1884
+ const onSignal = () => {
1885
+ stop().finally(() => process.exit(0));
1886
+ };
1887
+ process.once("SIGTERM", onSignal);
1888
+ process.once("SIGINT", onSignal);
1889
+ return { stop, port: server.port };
1890
+ }
1891
+ export {
1892
+ startDaemon
1893
+ };