@paradow/ledge 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,18 @@
1
1
  import {
2
2
  attachEnv
3
- } from "./chunk-N3MPPCON.js";
4
- import "./chunk-564TJJZ6.js";
3
+ } from "./chunk-65XGYBHL.js";
4
+ import "./chunk-LYTRXJAQ.js";
5
5
  import {
6
6
  realTmux
7
- } from "./chunk-3TNPEAMR.js";
8
- import "./chunk-L6IGH4LF.js";
7
+ } from "./chunk-TO4XO2ML.js";
8
+ import "./chunk-YUSY3E47.js";
9
9
  import {
10
10
  connect
11
- } from "./chunk-XPWGZIYL.js";
11
+ } from "./chunk-IWJAJAPK.js";
12
12
  import {
13
13
  PROTOCOL_PORT
14
- } from "./chunk-WM4MAV46.js";
14
+ } from "./chunk-KDYAHPYN.js";
15
+ import "./chunk-FGOFRWIB.js";
15
16
 
16
17
  // src/cli/attach.ts
17
18
  import { spawn } from "child_process";
@@ -5,14 +5,20 @@ import {
5
5
  import {
6
6
  PROTOCOL_PORT,
7
7
  parseClientMessage,
8
- parseServerMessage
9
- } from "./chunk-WM4MAV46.js";
8
+ parseServerMessage,
9
+ readDaemonToken
10
+ } from "./chunk-KDYAHPYN.js";
11
+ import "./chunk-FGOFRWIB.js";
10
12
 
11
13
  // src/cli/bridge.ts
12
14
  import WebSocket from "ws";
15
+ function bearerHeaders() {
16
+ const token = readDaemonToken();
17
+ return token ? { authorization: `Bearer ${token}` } : {};
18
+ }
13
19
  function bridge(opts = {}) {
14
20
  const input = opts.input ?? process.stdin, output = opts.output ?? process.stdout;
15
- const ws = new WebSocket(`ws://127.0.0.1:${opts.port ?? PROTOCOL_PORT}`, { maxPayload: MAX_FRAME, handshakeTimeout: 1e4 });
21
+ const ws = new WebSocket(`ws://127.0.0.1:${opts.port ?? PROTOCOL_PORT}`, { maxPayload: MAX_FRAME, handshakeTimeout: 1e4, headers: bearerHeaders() });
16
22
  return new Promise((resolve, reject) => {
17
23
  let settled = false, off = () => {
18
24
  };
@@ -1,20 +1,23 @@
1
1
  import {
2
2
  exitFile,
3
3
  spawnSession
4
- } from "./chunk-564TJJZ6.js";
4
+ } from "./chunk-LYTRXJAQ.js";
5
5
  import {
6
6
  realTmux
7
- } from "./chunk-3TNPEAMR.js";
7
+ } from "./chunk-TO4XO2ML.js";
8
8
  import {
9
- ensureLedgeDir,
10
9
  log
11
- } from "./chunk-L6IGH4LF.js";
10
+ } from "./chunk-YUSY3E47.js";
12
11
  import {
12
+ DaemonRefusedError,
13
13
  connect
14
- } from "./chunk-XPWGZIYL.js";
14
+ } from "./chunk-IWJAJAPK.js";
15
15
  import {
16
16
  PROTOCOL_PORT
17
- } from "./chunk-WM4MAV46.js";
17
+ } from "./chunk-KDYAHPYN.js";
18
+ import {
19
+ ensureLedgeDir
20
+ } from "./chunk-FGOFRWIB.js";
18
21
 
19
22
  // src/cli/launch.ts
20
23
  import { spawn } from "child_process";
@@ -59,6 +62,7 @@ async function probe(port, timeoutMs) {
59
62
  try {
60
63
  client = await connect({ port, timeoutMs });
61
64
  } catch (e) {
65
+ if (e instanceof DaemonRefusedError) return { client: null, state: "unauthorized" };
62
66
  if (e.message === "connect timeout") return { client: null, state: "timeout" };
63
67
  return { client: null, state: await portOpen(port) ? "foreign" : "refused" };
64
68
  }
@@ -80,6 +84,10 @@ async function ensureDaemon(opts = {}) {
80
84
  log(`port ${port} is open but does not speak the Ledge protocol`);
81
85
  return { client: null, reason: "foreign" };
82
86
  }
87
+ if (first.state === "unauthorized") {
88
+ log(`the daemon on port ${port} refused this client's token`);
89
+ return { client: null, reason: "unauthorized" };
90
+ }
83
91
  if (first.state === "timeout") {
84
92
  log(`port ${port} accepted a connection but did not answer within ${timeoutMs} ms`);
85
93
  return { client: null, reason: "unresponsive" };
@@ -142,6 +150,7 @@ async function readExitCode(id) {
142
150
  }
143
151
  var UNTRACKED = {
144
152
  foreign: (port) => `ledge: port ${port} is used by another program, session not tracked`,
153
+ unauthorized: () => "ledge: the daemon refused this client's token (missing or changed), session not tracked; restart the daemon: systemctl --user restart ledge-daemon",
145
154
  unresponsive: () => "ledge: daemon not responding, session not tracked",
146
155
  unreachable: () => "ledge: daemon not running, session not tracked"
147
156
  };
@@ -17,7 +17,7 @@ function ledgeDir() {
17
17
  }
18
18
  function paths() {
19
19
  const dir = ledgeDir();
20
- return { dir, tmuxConf: join(dir, "tmux.conf"), journal: join(dir, "sessions.json"), log: join(dir, "daemon.log"), pid: join(dir, "daemon.pid"), history: join(dir, "history"), machines: join(dir, "machines.json"), projects: join(dir, "projects.json"), island: join(dir, "island.json") };
20
+ return { dir, tmuxConf: join(dir, "tmux.conf"), journal: join(dir, "sessions.json"), log: join(dir, "daemon.log"), pid: join(dir, "daemon.pid"), history: join(dir, "history"), machines: join(dir, "machines.json"), projects: join(dir, "projects.json"), island: join(dir, "island.json"), token: join(dir, "token") };
21
21
  }
22
22
  async function ensureLedgeDir() {
23
23
  const p = paths();
@@ -32,18 +32,7 @@ async function ensureLedgeDir() {
32
32
  ${TITLE_LINES}`);
33
33
  }
34
34
 
35
- // src/log.ts
36
- import { appendFileSync } from "fs";
37
- function log(line) {
38
- try {
39
- appendFileSync(paths().log, `${(/* @__PURE__ */ new Date()).toISOString()} | ${line}
40
- `);
41
- } catch {
42
- }
43
- }
44
-
45
35
  export {
46
36
  paths,
47
- ensureLedgeDir,
48
- log
37
+ ensureLedgeDir
49
38
  };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  cleanTopic,
3
3
  sessionTitle
4
- } from "./chunk-WM4MAV46.js";
4
+ } from "./chunk-KDYAHPYN.js";
5
5
 
6
6
  // src/daemon/crossSession.ts
7
7
  var FIRST_LINE = /^Another Claude session sent a message:\s*\n/;
@@ -101,6 +101,11 @@ function pendingTool(p) {
101
101
  if (p.kind === "plan") return "ExitPlanMode";
102
102
  return p.tool;
103
103
  }
104
+ function answeredBy(p, ev) {
105
+ if (p.source !== "hook" || ev.name !== "PostToolUse" && ev.name !== "PostToolUseFailure") return false;
106
+ const tool = pendingTool(p);
107
+ return tool !== void 0 && ev.tool === tool && ev.helperId === p.helperId;
108
+ }
104
109
  function clearedByToolRun(p, tool) {
105
110
  const t = pendingTool(p);
106
111
  return p.source === "pane" || t === void 0 || t === tool;
@@ -277,7 +282,7 @@ function reduceEvent(s, ev) {
277
282
  if (ev.name === "UserPromptSubmit" && ev.text && !s.topic && !notice2 && !crossSession(ev.text)) s = withTopic(s, ev.text, "prompt");
278
283
  const incoming = pendingFor(ev);
279
284
  if (incoming) {
280
- if (ev.name === "Notification" && s.pending?.source === "hook") return { ...s, lastHookAt: at, lastActivityAt: at };
285
+ if (ev.name === "Notification" && s.pending) return { ...s, lastHookAt: at, lastActivityAt: at };
281
286
  const pending = mergePending(s.pending, incoming);
282
287
  const state = pending.kind === "question" ? "waiting_input" : "waiting_permission";
283
288
  return { ...s, state, pending, lastHookAt: at, lastActivityAt: at };
@@ -337,6 +342,8 @@ export {
337
342
  crossSession,
338
343
  crossSessionNotices,
339
344
  QUESTION_TOOLS,
345
+ pendingTool,
346
+ answeredBy,
340
347
  reduce,
341
348
  repositoryId,
342
349
  gitInfo,
@@ -1,14 +1,23 @@
1
1
  import {
2
2
  PROTOCOL_PORT,
3
- parseServerMessage
4
- } from "./chunk-WM4MAV46.js";
3
+ parseServerMessage,
4
+ readDaemonToken
5
+ } from "./chunk-KDYAHPYN.js";
5
6
 
6
7
  // src/client.ts
7
8
  import WebSocket from "ws";
9
+ var DaemonRefusedError = class extends Error {
10
+ };
8
11
  function connect(opts = {}) {
9
12
  const port = opts.port ?? PROTOCOL_PORT;
10
13
  const host = opts.host ?? "127.0.0.1";
11
- const ws = new WebSocket(`ws://${host}:${port}`);
14
+ let token;
15
+ try {
16
+ token = opts.token === void 0 ? readDaemonToken() : opts.token;
17
+ } catch {
18
+ token = null;
19
+ }
20
+ const ws = new WebSocket(`ws://${host}:${port}`, token ? { headers: { authorization: `Bearer ${token}` } } : {});
12
21
  const queue = [];
13
22
  const MAX_QUEUED = 100;
14
23
  const waiters = [];
@@ -66,12 +75,13 @@ function connect(opts = {}) {
66
75
  if (!settled) {
67
76
  settled = true;
68
77
  clearTimeout(t);
69
- reject(e);
78
+ reject(/Unexpected server response: 401/.test(e.message) ? new DaemonRefusedError("daemon refused the token") : e);
70
79
  }
71
80
  });
72
81
  });
73
82
  }
74
83
 
75
84
  export {
85
+ DaemonRefusedError,
76
86
  connect
77
87
  };
@@ -1,3 +1,126 @@
1
+ import {
2
+ paths
3
+ } from "./chunk-FGOFRWIB.js";
4
+
5
+ // src/auth.ts
6
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
7
+ import { chmod, link, mkdir, readFile, unlink, writeFile } from "fs/promises";
8
+ import { readFileSync } from "fs";
9
+ import { dirname } from "path";
10
+ var TOKEN_RE = /^[0-9a-f]{64}$/;
11
+ var DAEMON_PROTOCOL_PREFIX = "ledge.";
12
+ function isDaemonToken(value) {
13
+ return TOKEN_RE.test(value);
14
+ }
15
+ function tokenEquals(presented, expected) {
16
+ const a = createHash("sha256").update(presented.slice(0, 256), "utf8").digest();
17
+ const b = createHash("sha256").update(expected, "utf8").digest();
18
+ return timingSafeEqual(a, b);
19
+ }
20
+ function hostAllowed(hostHeader) {
21
+ const raw = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;
22
+ if (!raw || raw.length > 255) return false;
23
+ const host = raw.trim().toLowerCase();
24
+ if (!host || host.includes(" ") || host.endsWith(".")) return false;
25
+ let name;
26
+ if (host.startsWith("[")) {
27
+ const end = host.indexOf("]");
28
+ if (end < 0) return false;
29
+ name = host.slice(1, end);
30
+ const rest = host.slice(end + 1);
31
+ if (rest !== "" && !/^:\d{1,5}$/.test(rest)) return false;
32
+ } else {
33
+ const colon = host.lastIndexOf(":");
34
+ if (colon >= 0) {
35
+ if (!/^\d{1,5}$/.test(host.slice(colon + 1))) return false;
36
+ name = host.slice(0, colon);
37
+ } else name = host;
38
+ }
39
+ return name === "localhost" || name === "127.0.0.1" || name === "::1";
40
+ }
41
+ function headerValue(value, max) {
42
+ const raw = Array.isArray(value) ? value.join(",") : value;
43
+ return !raw || raw.length > max ? "" : raw;
44
+ }
45
+ function bearerToken(authorization) {
46
+ if (!authorization || authorization.length > 512) return "";
47
+ return /^Bearer[ \t]+(\S+)$/i.exec(authorization)?.[1] ?? "";
48
+ }
49
+ function protocolToken(header) {
50
+ const raw = headerValue(header, 512);
51
+ if (!raw) return "";
52
+ for (const part of raw.split(",").slice(0, 8)) {
53
+ const protocol = part.trim();
54
+ if (protocol.startsWith(DAEMON_PROTOCOL_PREFIX)) return protocol.slice(DAEMON_PROTOCOL_PREFIX.length);
55
+ }
56
+ return "";
57
+ }
58
+ function requestAuthorized(headers, expected) {
59
+ const bearerOk = tokenEquals(bearerToken(typeof headers.authorization === "string" ? headers.authorization : void 0), expected);
60
+ const presented = protocolToken(headers["sec-websocket-protocol"]);
61
+ return bearerOk || presented !== "" && tokenEquals(presented, expected);
62
+ }
63
+ function selectDaemonProtocol(protocols, expected) {
64
+ for (const protocol of protocols) {
65
+ if (protocol.startsWith(DAEMON_PROTOCOL_PREFIX) && tokenEquals(protocol.slice(DAEMON_PROTOCOL_PREFIX.length), expected)) return protocol;
66
+ }
67
+ return false;
68
+ }
69
+ async function readTokenFile(file) {
70
+ try {
71
+ const raw = (await readFile(file, "utf8")).trim();
72
+ return isDaemonToken(raw) ? raw : void 0;
73
+ } catch (e) {
74
+ if (e.code === "ENOENT") return void 0;
75
+ throw e;
76
+ }
77
+ }
78
+ async function lockTokenFile(file) {
79
+ await chmod(file, 384);
80
+ }
81
+ async function ensureDaemonToken() {
82
+ const file = paths().token;
83
+ await mkdir(dirname(file), { recursive: true });
84
+ const current = await readTokenFile(file);
85
+ if (current) {
86
+ await lockTokenFile(file);
87
+ return current;
88
+ }
89
+ const token = randomBytes(32).toString("hex");
90
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
91
+ await writeFile(tmp, `${token}
92
+ `, { mode: 384, flag: "wx" });
93
+ await chmod(tmp, 384);
94
+ try {
95
+ await link(tmp, file);
96
+ } catch (e) {
97
+ await unlink(tmp).catch(() => {
98
+ });
99
+ if (e.code !== "EEXIST") throw e;
100
+ const raced = await readTokenFile(file);
101
+ if (raced) {
102
+ await lockTokenFile(file);
103
+ return raced;
104
+ }
105
+ await writeFile(file, `${token}
106
+ `, { mode: 384 });
107
+ }
108
+ await unlink(tmp).catch(() => {
109
+ });
110
+ await lockTokenFile(file);
111
+ const stored = await readTokenFile(file);
112
+ if (!stored) throw new Error("daemon token was not stored");
113
+ return stored;
114
+ }
115
+ function readDaemonToken() {
116
+ try {
117
+ const raw = readFileSync(paths().token, "utf8").trim();
118
+ return isDaemonToken(raw) ? raw : void 0;
119
+ } catch {
120
+ return void 0;
121
+ }
122
+ }
123
+
1
124
  // ../protocol/src/session.ts
2
125
  import { z } from "zod";
3
126
  var ToolSchema = z.enum(["claude", "codex", "cursor"]);
@@ -524,7 +647,14 @@ var ClientMessageSchema = z13.discriminatedUnion("type", [
524
647
  /** Copied into the `folder.listing` that answers; the fleet relays only that listing, from the machine asked. */
525
648
  requestId: z13.string().min(1).max(100).optional()
526
649
  }),
527
- z13.object({ type: z13.literal("folder.create"), parent: z13.string().min(1), name: z13.string().min(1) }),
650
+ z13.object({
651
+ type: z13.literal("folder.create"),
652
+ parent: z13.string().min(1),
653
+ name: z13.string().min(1),
654
+ /** As for `folder.list`: routing by the fleet, and the request its `folder.listing` answers. */
655
+ machineId: z13.string().optional(),
656
+ requestId: z13.string().min(1).max(100).optional()
657
+ }),
528
658
  z13.object({ type: z13.literal("session.reply"), id: z13.string(), text: z13.string().min(1) }),
529
659
  /** The chat card's instruction (spec 2026-10-12 relay §4): sent now if the session is free, else queued until it is. */
530
660
  z13.object({ type: z13.literal("session.relay"), qid: z13.string().min(1) }),
@@ -720,6 +850,11 @@ function sessionTitle(s) {
720
850
  }
721
851
 
722
852
  export {
853
+ hostAllowed,
854
+ requestAuthorized,
855
+ selectDaemonProtocol,
856
+ ensureDaemonToken,
857
+ readDaemonToken,
723
858
  SessionSchema,
724
859
  isArchived,
725
860
  MAX_PROPOSALS,
@@ -1,9 +1,9 @@
1
- import {
2
- paths
3
- } from "./chunk-L6IGH4LF.js";
4
1
  import {
5
2
  pickName
6
- } from "./chunk-WM4MAV46.js";
3
+ } from "./chunk-KDYAHPYN.js";
4
+ import {
5
+ paths
6
+ } from "./chunk-FGOFRWIB.js";
7
7
 
8
8
  // src/daemon/transcript.ts
9
9
  import { closeSync, fstatSync, openSync, readSync } from "fs";
@@ -95,6 +95,33 @@ function claudePlan(path) {
95
95
  }
96
96
  return plan?.text;
97
97
  }
98
+ function claudeCalls(path) {
99
+ const calls = [];
100
+ for (const line of tailLines(path)) {
101
+ let e;
102
+ try {
103
+ e = JSON.parse(line);
104
+ } catch {
105
+ continue;
106
+ }
107
+ if (e.isSidechain === true || !Array.isArray(e.message?.content)) continue;
108
+ for (const b of e.message.content) {
109
+ if (e.type === "assistant" && b?.type === "tool_use" && typeof b.id === "string" && typeof b.name === "string") calls.push({ id: b.id, name: b.name, answered: false });
110
+ else if (e.type === "user" && b?.type === "tool_result") {
111
+ const c = calls.find((c2) => c2.id === b.tool_use_id);
112
+ if (c) c.answered = true;
113
+ }
114
+ }
115
+ }
116
+ return calls;
117
+ }
118
+ function claudeOpenCall(path, tool) {
119
+ const last = claudeCalls(path).filter((c) => c.name === tool).at(-1);
120
+ return last && !last.answered ? last.id : void 0;
121
+ }
122
+ function claudeToolAnswered(path, id) {
123
+ return claudeCalls(path).some((c) => c.id === id && c.answered);
124
+ }
98
125
 
99
126
  // src/spawn.ts
100
127
  import { basename, join as join2 } from "path";
@@ -145,6 +172,8 @@ export {
145
172
  agentMessages,
146
173
  codexPlan,
147
174
  claudePlan,
175
+ claudeOpenCall,
176
+ claudeToolAnswered,
148
177
  CODEX_ENV_VAR,
149
178
  CODEX_HOOK_VAR,
150
179
  exitFile,
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  run
3
- } from "./chunk-3TNPEAMR.js";
3
+ } from "./chunk-TO4XO2ML.js";
4
4
  import {
5
5
  ensureLedgeDir,
6
6
  paths
7
- } from "./chunk-L6IGH4LF.js";
7
+ } from "./chunk-FGOFRWIB.js";
8
8
 
9
9
  // src/cli/setup.ts
10
10
  import { readFile as readFile2, writeFile, mkdir, rename, realpath, rm, stat, lstat, readlink, symlink, unlink } from "fs/promises";
@@ -1,7 +1,9 @@
1
1
  import {
2
- log,
2
+ log
3
+ } from "./chunk-YUSY3E47.js";
4
+ import {
3
5
  paths
4
- } from "./chunk-L6IGH4LF.js";
6
+ } from "./chunk-FGOFRWIB.js";
5
7
 
6
8
  // src/tmux.ts
7
9
  import { execFile } from "child_process";
@@ -0,0 +1,17 @@
1
+ import {
2
+ paths
3
+ } from "./chunk-FGOFRWIB.js";
4
+
5
+ // src/log.ts
6
+ import { appendFileSync } from "fs";
7
+ function log(line) {
8
+ try {
9
+ appendFileSync(paths().log, `${(/* @__PURE__ */ new Date()).toISOString()} | ${line}
10
+ `);
11
+ } catch {
12
+ }
13
+ }
14
+
15
+ export {
16
+ log
17
+ };
@@ -0,0 +1,6 @@
1
+ // src/version.ts
2
+ var LEDGE_VERSION = true ? "0.9.1" : "0.0.0-dev";
3
+
4
+ export {
5
+ LEDGE_VERSION
6
+ };
@@ -4,35 +4,37 @@ import {
4
4
  } from "./chunk-OLLQBK6N.js";
5
5
  import {
6
6
  QUESTION_TOOLS,
7
+ answeredBy,
7
8
  crossSession,
8
9
  crossSessionNotices,
9
10
  gitInfo,
10
11
  gitSummary,
12
+ pendingTool,
11
13
  reduce,
12
14
  repositoryId
13
- } from "./chunk-JVELEEIO.js";
15
+ } from "./chunk-I4C5U6AI.js";
14
16
  import {
15
17
  agentMessages,
18
+ claudeOpenCall,
16
19
  claudePlan,
20
+ claudeToolAnswered,
17
21
  spawnSession
18
- } from "./chunk-564TJJZ6.js";
22
+ } from "./chunk-LYTRXJAQ.js";
19
23
  import {
20
24
  LEDGE_VERSION
21
- } from "./chunk-LWPKX7SB.js";
25
+ } from "./chunk-YXXXSRQX.js";
22
26
  import {
23
27
  SERVICE,
24
28
  inDaemonService,
25
29
  tmuxServerAtRisk
26
- } from "./chunk-Q5CLZPYJ.js";
30
+ } from "./chunk-Q7RCYJ4X.js";
27
31
  import {
28
32
  realTmux,
29
33
  runFile
30
- } from "./chunk-3TNPEAMR.js";
34
+ } from "./chunk-TO4XO2ML.js";
31
35
  import {
32
- ensureLedgeDir,
33
- log,
34
- paths
35
- } from "./chunk-L6IGH4LF.js";
36
+ log
37
+ } from "./chunk-YUSY3E47.js";
36
38
  import {
37
39
  DEFAULT_PROJECTS,
38
40
  DOCUMENT_MAX_BYTES,
@@ -46,7 +48,9 @@ import {
46
48
  RouteResultSchema,
47
49
  SessionSchema,
48
50
  SshAliasSchema,
51
+ ensureDaemonToken,
49
52
  extractProposal,
53
+ hostAllowed,
50
54
  isArchived,
51
55
  offers,
52
56
  parseClientMessage,
@@ -57,8 +61,14 @@ import {
57
61
  pendingKey,
58
62
  pickName,
59
63
  projectOf,
60
- proposalRows
61
- } from "./chunk-WM4MAV46.js";
64
+ proposalRows,
65
+ requestAuthorized,
66
+ selectDaemonProtocol
67
+ } from "./chunk-KDYAHPYN.js";
68
+ import {
69
+ ensureLedgeDir,
70
+ paths
71
+ } from "./chunk-FGOFRWIB.js";
62
72
 
63
73
  // src/daemon/index.ts
64
74
  import { writeFile as writeFile6, rm as rm5 } from "fs/promises";
@@ -583,6 +593,7 @@ var History = class {
583
593
  };
584
594
 
585
595
  // src/daemon/server.ts
596
+ import { createServer } from "http";
586
597
  import { homedir as homedir5 } from "os";
587
598
  import { basename as basename3 } from "path";
588
599
  import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
@@ -637,6 +648,9 @@ function dialogMismatch(shown, pending, width) {
637
648
  if (pending.tool !== void 0 && COMMAND_TOOLS.has(pending.tool)) return shown.command ? commandMismatch(shown.command, pending.text, width) : UNCHECKED;
638
649
  return null;
639
650
  }
651
+ function bottomLines(pane, n = 5) {
652
+ return pane.split("\n").filter((l) => l.trim() !== "").slice(-n).join("\n");
653
+ }
640
654
  var flatten = (text) => text.replace(/\s*[\r\n]+\s*/g, " ").trim();
641
655
 
642
656
  // src/adapters/claude.ts
@@ -766,7 +780,7 @@ function promptStillVisible(lastLines2) {
766
780
  }
767
781
  var squash = (t) => t.replace(/\s+/g, " ").trim();
768
782
  function promptGone(pane, pending) {
769
- if (pending.kind !== "question" || !squash(pending.text)) return !promptStillVisible(pane.replace(/\s+$/, "").split("\n").slice(-5).join("\n"));
783
+ if (pending.kind !== "question" || !squash(pending.text)) return !promptStillVisible(bottomLines(pane));
770
784
  const m = detectPrompt(pane);
771
785
  if (!m || m.kind !== "question") return true;
772
786
  const shown = squash(m.text);
@@ -2859,6 +2873,25 @@ var ALLOWED_ORIGINS = [
2859
2873
  function originAllowed(origin) {
2860
2874
  return origin === void 0 || ALLOWED_ORIGINS.includes(origin);
2861
2875
  }
2876
+ function requestPath(url) {
2877
+ const raw = url ?? "/";
2878
+ const q = raw.indexOf("?");
2879
+ return q === -1 ? raw : raw.slice(0, q);
2880
+ }
2881
+ function refusal(req, token) {
2882
+ const path = requestPath(req.url);
2883
+ if (!(path === "/" || path.startsWith("/ssh/") && SshAliasSchema.safeParse(path.slice(5)).success)) return "path";
2884
+ if (!originAllowed(req.headers.origin)) return "origin";
2885
+ if (!hostAllowed(req.headers.host)) return "host";
2886
+ if (!requestAuthorized(req.headers, token)) return "auth";
2887
+ return null;
2888
+ }
2889
+ function unauthorized(req, res) {
2890
+ req.resume();
2891
+ const body = "Unauthorized\n";
2892
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", Connection: "close" });
2893
+ res.end(body);
2894
+ }
2862
2895
  function logDroppedRequest(id, ev, before, after) {
2863
2896
  if (ev.name !== "PermissionRequest" || !after || after.pending !== before?.pending) return;
2864
2897
  const held = before?.pending;
@@ -2874,22 +2907,36 @@ async function startServer(opts) {
2874
2907
  const agentEnv = opts.agentEnv ?? (async () => ({ ...process.env, ...await loginShellEnv() }));
2875
2908
  const chat = opts.chat ?? createChat({ store, tmux, home: paths().dir, userHome: opts.home, env: agentEnv, bin: opts.claudeBin, now, projects: () => projects, forge: createForge({ env: agentEnv }) });
2876
2909
  const router = createRouter({ env: agentEnv });
2910
+ const token = await ensureDaemonToken();
2911
+ const httpServer = createServer(unauthorized);
2912
+ const sockets = /* @__PURE__ */ new Set();
2913
+ httpServer.on("connection", (socket) => {
2914
+ sockets.add(socket);
2915
+ socket.on("close", () => sockets.delete(socket));
2916
+ });
2877
2917
  const wss = new WebSocketServer({
2878
- host: opts.host ?? "127.0.0.1",
2879
- port: opts.port ?? PROTOCOL_PORT,
2918
+ server: httpServer,
2880
2919
  maxPayload: MAX_FRAME,
2881
2920
  verifyClient: ({ req }) => {
2882
- const path = req.url ?? "/";
2883
- const ok = originAllowed(req.headers.origin) && (path === "/" || path.startsWith("/ssh/") && SshAliasSchema.safeParse(path.slice(5)).success);
2884
- if (!ok) log(`refused a connection from origin ${String(req.headers.origin)}`);
2885
- return ok;
2886
- }
2921
+ const why = refusal(req, token);
2922
+ if (why) {
2923
+ const origin = String(req.headers.origin ?? "none").replace(/[\r\n]/g, "").slice(0, 120);
2924
+ log(`refused a connection (${why}) from origin ${origin}`);
2925
+ }
2926
+ return why === null;
2927
+ },
2928
+ handleProtocols: (protocols) => selectDaemonProtocol(protocols, token)
2887
2929
  });
2888
2930
  await new Promise((res, rej) => {
2889
- wss.once("listening", res);
2890
- wss.once("error", rej);
2931
+ const onError = (e) => rej(e);
2932
+ wss.once("error", onError);
2933
+ httpServer.listen(opts.port ?? PROTOCOL_PORT, opts.host ?? "127.0.0.1", () => {
2934
+ wss.off("error", onError);
2935
+ res();
2936
+ });
2891
2937
  });
2892
- const port = wss.address().port;
2938
+ wss.on("error", (e) => log(`daemon server error: ${e.message}`));
2939
+ const port = httpServer.address().port;
2893
2940
  const machinesFile = opts.machinesFile ?? paths().machines;
2894
2941
  let machines = readMachines(machinesFile).map((m) => ({ ...m }));
2895
2942
  const machinesMsg = () => ({ type: "machines", machines });
@@ -2971,6 +3018,16 @@ async function startServer(opts) {
2971
3018
  }
2972
3019
  }
2973
3020
  const inFlight = /* @__PURE__ */ new Set();
3021
+ const CONFIRM_MS = opts.confirmMs ?? 3e3;
3022
+ const awaited = /* @__PURE__ */ new Map();
3023
+ function settleByHook(id, ev) {
3024
+ const a = awaited.get(id);
3025
+ const cur = store.get(id)?.pending;
3026
+ if (!a || a.settled || !answeredBy(a.pending, ev) || !cur || pendingKey(cur) !== pendingKey(a.pending)) return;
3027
+ a.settled = true;
3028
+ a.byHook = true;
3029
+ store.apply(id, { name: "Decided", at: now(), source: "daemon", ...a.decision ? { decision: a.decision } : {} });
3030
+ }
2974
3031
  async function injectAndVerify(id, planFor, requirePrompt, decision) {
2975
3032
  const s = store.get(id);
2976
3033
  if (!s) throw new Error(`unknown session ${id}`);
@@ -2986,7 +3043,7 @@ async function startServer(opts) {
2986
3043
  let before = null;
2987
3044
  if (requirePrompt) {
2988
3045
  before = s.tmuxSession ? await tmux.capture(s.tmuxSession, DETECT_LINES) : null;
2989
- if (before === null || !adapter.promptStillVisible(before.split("\n").slice(-5).join("\n"))) throw new Error(NO_PROMPT);
3046
+ if (before === null || !adapter.promptStillVisible(bottomLines(before))) throw new Error(NO_PROMPT);
2990
3047
  if (!stillTheSame()) throw new Error(STALE);
2991
3048
  if (decided) {
2992
3049
  const shown = adapter.detectPrompt(before) ?? adapter.detectPrompt(await tmux.capture(s.tmuxSession, CHECK_LINES) ?? "");
@@ -2995,21 +3052,41 @@ async function startServer(opts) {
2995
3052
  }
2996
3053
  }
2997
3054
  const plan = typeof planFor === "function" ? planFor(before ?? "") : planFor;
2998
- const promptGone3 = () => decided && adapter.promptGone ? verifyGone(() => tmux.capture(s.tmuxSession, DETECT_LINES), (p) => !adapter.promptGone(p, decided), opts.verify) : verifyGone(() => tmux.capture(s.tmuxSession, 5), (p) => adapter.promptStillVisible(p), opts.verify);
3055
+ const stillShown = (p) => decided && adapter.promptGone ? !adapter.promptGone(p, decided) : adapter.promptStillVisible(bottomLines(p));
3056
+ const tool = s.tool === "claude" && decided?.source === "hook" ? pendingTool(decided) : void 0;
3057
+ const answer = tool ? { pending: decided, ...decision ? { decision } : {}, settled: false, byHook: false } : void 0;
3058
+ const transcript = tool && !decided.helperId && s.transcriptPath ? s.transcriptPath : void 0;
3059
+ const call = transcript ? claudeOpenCall(transcript, tool) : void 0;
3060
+ const promptGone3 = async () => {
3061
+ if (!answer) return verifyGone(() => tmux.capture(s.tmuxSession, DETECT_LINES), stillShown, opts.verify);
3062
+ const interval = opts.verify?.intervalMs ?? 200;
3063
+ for (const end = Date.now() + CONFIRM_MS; ; ) {
3064
+ if (answer.byHook || call && claudeToolAnswered(transcript, call)) return true;
3065
+ const pane = await tmux.capture(s.tmuxSession, DETECT_LINES);
3066
+ if (answer.byHook || pane !== null && !stillShown(pane)) return true;
3067
+ if (Date.now() >= end) return false;
3068
+ await new Promise((r) => setTimeout(r, interval));
3069
+ }
3070
+ };
3071
+ if (answer) awaited.set(id, answer);
2999
3072
  try {
3000
3073
  await runPlan(s, plan);
3001
3074
  } catch (e) {
3002
3075
  if (e instanceof NoteNotTyped) {
3003
3076
  if (await promptGone3()) {
3004
- if (stillTheSame()) store.apply(id, { name: "Decided", at: now(), source: "daemon", ...decision ? { decision } : {} });
3077
+ if (answer) answer.settled = true;
3078
+ if (!answer?.byHook && stillTheSame()) store.apply(id, { name: "Decided", at: now(), source: "daemon", ...decision ? { decision } : {} });
3005
3079
  throw e;
3006
3080
  }
3007
3081
  e = new Error(NOT_TAKEN);
3008
3082
  }
3083
+ if (answer?.byHook) return true;
3009
3084
  store.apply(id, { name: "InjectFailed", at: now(), source: "daemon", text: e.message });
3010
3085
  throw e;
3011
3086
  }
3012
3087
  const gone = await promptGone3();
3088
+ if (answer) answer.settled = true;
3089
+ if (answer?.byHook) return true;
3013
3090
  if (!stillTheSame()) return false;
3014
3091
  if (gone) {
3015
3092
  store.apply(id, { name: "Decided", at: now(), source: "daemon", ...decision ? { decision } : {} });
@@ -3019,6 +3096,7 @@ async function startServer(opts) {
3019
3096
  throw new Error(NOT_TAKEN);
3020
3097
  } finally {
3021
3098
  inFlight.delete(id);
3099
+ awaited.delete(id);
3022
3100
  }
3023
3101
  }
3024
3102
  const READ_ONLY = "session started without ledge, read only";
@@ -3176,7 +3254,7 @@ async function startServer(opts) {
3176
3254
  case "folder.list":
3177
3255
  return send(ws, await listFolder(msg.path, opts.home, msg.requestId));
3178
3256
  case "folder.create": {
3179
- const listing = await createFolder(msg.parent, msg.name, opts.home);
3257
+ const listing = await createFolder(msg.parent, msg.name, opts.home, msg.requestId);
3180
3258
  if (!listing.error) log(`created folder ${listing.path}`);
3181
3259
  return send(ws, listing);
3182
3260
  }
@@ -3203,7 +3281,10 @@ async function startServer(opts) {
3203
3281
  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" };
3204
3282
  store.upsert(base);
3205
3283
  store.apply(msg.id, msg.event);
3206
- } else store.apply(msg.id, msg.event);
3284
+ } else {
3285
+ settleByHook(msg.id, ev);
3286
+ store.apply(msg.id, msg.event);
3287
+ }
3207
3288
  logDroppedRequest(msg.id, msg.event, before, store.get(msg.id));
3208
3289
  return;
3209
3290
  }
@@ -3336,14 +3417,15 @@ async function startServer(opts) {
3336
3417
  }
3337
3418
  wss.on("connection", (ws, req) => {
3338
3419
  ws.on("error", (e) => log(`socket error: ${e.message}`));
3339
- if (req.url?.startsWith("/ssh/")) {
3420
+ const path = requestPath(req.url);
3421
+ if (path.startsWith("/ssh/")) {
3340
3422
  if (remoteClients.size >= 20) {
3341
3423
  ws.close(1013, "Too many remote connections");
3342
3424
  return;
3343
3425
  }
3344
3426
  remoteClients.add(ws);
3345
3427
  ws.once("close", () => remoteClients.delete(ws));
3346
- const parsed = SshAliasSchema.safeParse(req.url.slice(5));
3428
+ const parsed = SshAliasSchema.safeParse(path.slice(5));
3347
3429
  if (!parsed.success) {
3348
3430
  ws.close(1008, "Invalid SSH alias");
3349
3431
  remoteClients.delete(ws);
@@ -3388,7 +3470,8 @@ async function startServer(opts) {
3388
3470
  for (const u of unsubscribeHistory) u();
3389
3471
  relay.close();
3390
3472
  for (const c of wss.clients) c.terminate();
3391
- wss.close(() => res());
3473
+ for (const socket of sockets) socket.destroy();
3474
+ wss.close(() => httpServer.close(() => res()));
3392
3475
  })
3393
3476
  };
3394
3477
  }
@@ -1,19 +1,20 @@
1
1
  import {
2
2
  QUESTION_TOOLS,
3
3
  gitInfo
4
- } from "./chunk-JVELEEIO.js";
4
+ } from "./chunk-I4C5U6AI.js";
5
5
  import {
6
6
  CODEX_ENV_VAR,
7
7
  CODEX_HOOK_VAR,
8
8
  codexPlan
9
- } from "./chunk-564TJJZ6.js";
9
+ } from "./chunk-LYTRXJAQ.js";
10
10
  import {
11
11
  log
12
- } from "./chunk-L6IGH4LF.js";
12
+ } from "./chunk-YUSY3E47.js";
13
13
  import {
14
14
  connect
15
- } from "./chunk-XPWGZIYL.js";
16
- import "./chunk-WM4MAV46.js";
15
+ } from "./chunk-IWJAJAPK.js";
16
+ import "./chunk-KDYAHPYN.js";
17
+ import "./chunk-FGOFRWIB.js";
17
18
 
18
19
  // src/cli/hook.ts
19
20
  import { readFileSync, openSync, fstatSync, readSync, closeSync } from "fs";
package/dist/cli/index.js CHANGED
@@ -17,7 +17,7 @@ async function hookMain(args) {
17
17
  try {
18
18
  const chunks = [];
19
19
  for await (const c of process.stdin) chunks.push(c);
20
- const { runHook } = await import("./hook-WQYL4YFH.js");
20
+ const { runHook } = await import("./hook-5HXY2UGM.js");
21
21
  const out = await runHook(event, Buffer.concat(chunks).toString("utf8"), { tool });
22
22
  if (out) process.stdout.write(`${out}
23
23
  `);
@@ -29,7 +29,7 @@ async function main(argv) {
29
29
  const [cmd, ...rest] = argv;
30
30
  switch (cmd) {
31
31
  case "daemon": {
32
- const { startDaemon } = await import("./daemon-SGTQRTHV.js");
32
+ const { startDaemon } = await import("./daemon-NDXT3ZDF.js");
33
33
  await startDaemon();
34
34
  await new Promise(() => {
35
35
  });
@@ -37,44 +37,44 @@ async function main(argv) {
37
37
  }
38
38
  case "--version":
39
39
  case "version": {
40
- const { LEDGE_VERSION } = await import("./version-XFBINYKQ.js");
40
+ const { LEDGE_VERSION } = await import("./version-AQN6JNH7.js");
41
41
  console.log(LEDGE_VERSION);
42
42
  return 0;
43
43
  }
44
44
  case "setup": {
45
- const { runSetup } = await import("./setup-RMPIUCPH.js");
45
+ const { runSetup } = await import("./setup-7AEH5DC3.js");
46
46
  for (const l of await runSetup()) console.log(l);
47
47
  return 0;
48
48
  }
49
49
  case "uninstall": {
50
- const { runUninstall } = await import("./uninstall-GI4WT3UQ.js");
50
+ const { runUninstall } = await import("./uninstall-7U7ENKOS.js");
51
51
  const r = await runUninstall({ purge: rest.includes("--purge") });
52
52
  for (const l of r.lines) console.log(l);
53
53
  return r.ok ? 0 : 1;
54
54
  }
55
55
  case "status": {
56
- const { printStatus } = await import("./status-GGXF4DSQ.js");
56
+ const { printStatus } = await import("./status-PDNC6IC2.js");
57
57
  console.log(await printStatus());
58
58
  return 0;
59
59
  }
60
60
  case "who": {
61
- const { printWho } = await import("./who-EJWN7HM6.js");
61
+ const { printWho } = await import("./who-LIMQJMNI.js");
62
62
  console.log(await printWho());
63
63
  return 0;
64
64
  }
65
65
  case "bridge": {
66
- const { bridge } = await import("./bridge-2CVD7BMI.js");
66
+ const { bridge } = await import("./bridge-XHPCIYE4.js");
67
67
  await bridge();
68
68
  return 0;
69
69
  }
70
70
  case "claude":
71
71
  case "codex":
72
72
  case "cursor": {
73
- const { launch } = await import("./launch-I4T5MFWF.js");
73
+ const { launch } = await import("./launch-HEJKGMYS.js");
74
74
  return launch(cmd, rest);
75
75
  }
76
76
  case "attach": {
77
- const { attach } = await import("./attach-HPGWEUKX.js");
77
+ const { attach } = await import("./attach-AZFKVVKL.js");
78
78
  return attach(rest[0] ?? "");
79
79
  }
80
80
  default:
@@ -5,15 +5,16 @@ import {
5
5
  ensureDaemon,
6
6
  launch,
7
7
  parseLaunchArgs
8
- } from "./chunk-N3MPPCON.js";
8
+ } from "./chunk-65XGYBHL.js";
9
9
  import {
10
10
  agentEnv,
11
11
  exitFile
12
- } from "./chunk-564TJJZ6.js";
13
- import "./chunk-3TNPEAMR.js";
14
- import "./chunk-L6IGH4LF.js";
15
- import "./chunk-XPWGZIYL.js";
16
- import "./chunk-WM4MAV46.js";
12
+ } from "./chunk-LYTRXJAQ.js";
13
+ import "./chunk-TO4XO2ML.js";
14
+ import "./chunk-YUSY3E47.js";
15
+ import "./chunk-IWJAJAPK.js";
16
+ import "./chunk-KDYAHPYN.js";
17
+ import "./chunk-FGOFRWIB.js";
17
18
  export {
18
19
  DaemonError,
19
20
  agentEnv,
@@ -13,9 +13,10 @@ import {
13
13
  runSetup,
14
14
  stopDetachedDaemon,
15
15
  systemdUnit
16
- } from "./chunk-Q5CLZPYJ.js";
17
- import "./chunk-3TNPEAMR.js";
18
- import "./chunk-L6IGH4LF.js";
16
+ } from "./chunk-Q7RCYJ4X.js";
17
+ import "./chunk-TO4XO2ML.js";
18
+ import "./chunk-YUSY3E47.js";
19
+ import "./chunk-FGOFRWIB.js";
19
20
  export {
20
21
  CLAUDE_EVENTS,
21
22
  CODEX_EVENTS,
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  connect
3
- } from "./chunk-XPWGZIYL.js";
4
- import "./chunk-WM4MAV46.js";
3
+ } from "./chunk-IWJAJAPK.js";
4
+ import "./chunk-KDYAHPYN.js";
5
+ import "./chunk-FGOFRWIB.js";
5
6
 
6
7
  // src/cli/status.ts
7
8
  async function printStatus(opts = {}) {
@@ -2,11 +2,12 @@ import {
2
2
  SERVICE,
3
3
  execRunner,
4
4
  stopDetachedDaemon
5
- } from "./chunk-Q5CLZPYJ.js";
6
- import "./chunk-3TNPEAMR.js";
5
+ } from "./chunk-Q7RCYJ4X.js";
6
+ import "./chunk-TO4XO2ML.js";
7
+ import "./chunk-YUSY3E47.js";
7
8
  import {
8
9
  paths
9
- } from "./chunk-L6IGH4LF.js";
10
+ } from "./chunk-FGOFRWIB.js";
10
11
 
11
12
  // src/cli/uninstall.ts
12
13
  import { readFile, writeFile, rename, rm, lstat, realpath } from "fs/promises";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  LEDGE_VERSION
3
- } from "./chunk-LWPKX7SB.js";
3
+ } from "./chunk-YXXXSRQX.js";
4
4
  export {
5
5
  LEDGE_VERSION
6
6
  };
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  connect
3
- } from "./chunk-XPWGZIYL.js";
3
+ } from "./chunk-IWJAJAPK.js";
4
4
  import {
5
5
  isArchived
6
- } from "./chunk-WM4MAV46.js";
6
+ } from "./chunk-KDYAHPYN.js";
7
+ import "./chunk-FGOFRWIB.js";
7
8
 
8
9
  // src/cli/who.ts
9
10
  import { basename } from "path";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paradow/ledge",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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" },
@@ -1,6 +0,0 @@
1
- // src/version.ts
2
- var LEDGE_VERSION = true ? "0.9.0" : "0.0.0-dev";
3
-
4
- export {
5
- LEDGE_VERSION
6
- };