appduct 0.13.0 → 0.15.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.
Files changed (62) hide show
  1. package/README.md +55 -24
  2. package/dist/artifact-inspect.d.ts +38 -39
  3. package/dist/bin.js +106 -7
  4. package/dist/cli/result-types.d.ts +35 -2
  5. package/dist/cli/routes/daemon/index.js +90 -2
  6. package/dist/cli/routes/daemon/run.js +1088 -517
  7. package/dist/cli/routes/daemon/start.js +98 -2
  8. package/dist/cli/routes/daemon/status.js +99 -2
  9. package/dist/cli/routes/daemon/stop.js +98 -2
  10. package/dist/cli/routes/doctor.js +98 -3
  11. package/dist/cli/routes/events/index.d.ts +0 -3
  12. package/dist/cli/routes/events/index.js +92 -3
  13. package/dist/cli/routes/events/ls.d.ts +5 -0
  14. package/dist/cli/routes/events/ls.js +1837 -0
  15. package/dist/cli/routes/events/since.js +98 -2
  16. package/dist/cli/routes/events/tail.js +98 -2
  17. package/dist/cli/routes/init.js +265 -108
  18. package/dist/cli/routes/keygen.js +90 -2
  19. package/dist/cli/routes/mcp.js +243 -25
  20. package/dist/cli/routes/sessions/index.js +90 -2
  21. package/dist/cli/routes/sessions/link.js +199 -22
  22. package/dist/cli/routes/sessions/ls.js +98 -2
  23. package/dist/cli/routes/sessions/revoke.js +98 -2
  24. package/dist/cli/routes/tools/call.js +98 -2
  25. package/dist/cli/routes/tools/describe.js +98 -2
  26. package/dist/cli/routes/tools/index.js +90 -2
  27. package/dist/cli/routes/tools/ls.js +98 -2
  28. package/dist/client/attach-page.d.ts +39 -0
  29. package/dist/client/bootstrap.d.ts +17 -3
  30. package/dist/client/index.d.ts +2 -1
  31. package/dist/client/index.js +286 -112
  32. package/dist/commands/doctor.d.ts +3 -4
  33. package/dist/commands/events-ls.d.ts +21 -0
  34. package/dist/commands/init.d.ts +8 -2
  35. package/dist/commands/link.d.ts +10 -1
  36. package/dist/daemon/calls.d.ts +3 -1
  37. package/dist/daemon/config.d.ts +6 -0
  38. package/dist/daemon/daemon.d.ts +6 -1
  39. package/dist/daemon/event-bus.d.ts +10 -0
  40. package/dist/daemon/links.d.ts +9 -5
  41. package/dist/daemon/listener.d.ts +28 -1
  42. package/dist/daemon/registry.d.ts +20 -12
  43. package/dist/daemon/rpc-errors.d.ts +6 -0
  44. package/dist/daemon/sessions.d.ts +14 -8
  45. package/dist/daemon/tls.d.ts +0 -3
  46. package/dist/devtools-relay/attach-tab.d.ts +33 -0
  47. package/dist/devtools-relay/index.d.ts +8 -0
  48. package/dist/devtools-relay/memory-daemon-sockets.d.ts +20 -0
  49. package/dist/devtools-relay/memory-devtools-browser.d.ts +10 -0
  50. package/dist/devtools-relay/memory-page-channel.d.ts +16 -0
  51. package/dist/devtools-relay/node-daemon-socket.d.ts +3 -0
  52. package/dist/devtools-relay/node-devtools-browser.d.ts +3 -0
  53. package/dist/devtools-relay/ports.d.ts +48 -0
  54. package/dist/devtools-relay/relay.d.ts +16 -0
  55. package/dist/index.js +106 -7
  56. package/dist/link.d.ts +35 -1
  57. package/dist/mcp/app-tools.d.ts +19 -0
  58. package/dist/mcp/connect-tool.d.ts +15 -2
  59. package/dist/mcp/events-tool.d.ts +39 -0
  60. package/dist/native-scheme.d.ts +11 -7
  61. package/dist/scheme.d.ts +5 -1
  62. package/package.json +15 -5
@@ -2,131 +2,209 @@
2
2
  import { rm as rm5 } from "node:fs/promises";
3
3
  import {
4
4
  MAX_TOOLS_FILTER_LENGTH,
5
- RPC_METHODS,
5
+ RPC_METHODS as RPC_METHODS3,
6
6
  isValidToolGroup,
7
7
  summarizeToolGroups,
8
8
  toolGroupMatches,
9
9
  EVENT_KINDS
10
10
  } from "@appduct/shared";
11
11
 
12
- // src/package-version.ts
13
- import { readFileSync as readFileSync2 } from "node:fs";
14
- import { join as join2 } from "node:path";
15
-
16
- // src/package-root.ts
17
- import { existsSync, readFileSync } from "node:fs";
18
- import { dirname, join } from "node:path";
19
- import { fileURLToPath } from "node:url";
20
- var PACKAGE_NAME = "appduct";
21
- var cached;
22
- var getPackageRoot = () => {
23
- if (cached !== void 0) {
24
- return cached;
25
- }
26
- let dir = dirname(fileURLToPath(import.meta.url));
27
- for (; ; ) {
28
- const candidate = join(dir, "package.json");
29
- if (existsSync(candidate)) {
30
- let name;
31
- try {
32
- name = JSON.parse(readFileSync(candidate, "utf8")).name;
33
- } catch {
34
- }
35
- if (name === PACKAGE_NAME) {
36
- cached = dir;
37
- return dir;
38
- }
39
- }
40
- const parent = dirname(dir);
41
- if (parent === dir) {
42
- throw new Error(`Could not locate the "${PACKAGE_NAME}" package root above ${import.meta.url}.`);
12
+ // src/devtools-relay/relay.ts
13
+ import { decodeBootstrap } from "@appduct/shared";
14
+ var linkPayload = (link) => {
15
+ const payload = new URLSearchParams(new URL(link.url).hash.slice(1)).get("appduct");
16
+ if (payload === null) throw new Error('The link has no #appduct= payload: pass the result of link({ target: "web", url }).');
17
+ return payload;
18
+ };
19
+ var parseLink = (link) => {
20
+ const bootstrap = decodeBootstrap(linkPayload(link));
21
+ if (!bootstrap) throw new Error("The link's #appduct= payload is not a valid Appduct link.");
22
+ return bootstrap;
23
+ };
24
+ var GOING_AWAY = 1001;
25
+ var relayPage = (page, link, openDaemonSocket) => {
26
+ const bootstrap = parseLink(link);
27
+ const host = bootstrap.address.includes(":") ? `[${bootstrap.address}]` : bootstrap.address;
28
+ const url = `ws://${host}:${bootstrap.port}`;
29
+ let current;
30
+ const tellPage = (message) => {
31
+ page.evaluate(`window.__APPDUCT__.receive(${JSON.stringify(JSON.stringify(message))})`).catch(() => void 0);
32
+ };
33
+ const closeCurrent = (code, reason) => {
34
+ const socket = current;
35
+ current = void 0;
36
+ socket?.close(code, reason);
37
+ };
38
+ page.onBindingCall((json) => {
39
+ const message = JSON.parse(json);
40
+ if (message.kind === "open") {
41
+ closeCurrent(GOING_AWAY, "superseded");
42
+ const socket = openDaemonSocket(url, {
43
+ open: () => current === socket && tellPage({ kind: "open" }),
44
+ message: (text) => current === socket && tellPage({ kind: "message", text }),
45
+ close: (code, reason) => {
46
+ if (current !== socket) return;
47
+ current = void 0;
48
+ tellPage({ kind: "close", code, reason });
49
+ }
50
+ });
51
+ current = socket;
52
+ } else if (message.kind === "send") {
53
+ current?.send(message.text ?? "");
54
+ } else if (message.kind === "close") {
55
+ closeCurrent(message.code ?? 1e3, message.reason ?? "");
43
56
  }
44
- dir = parent;
45
- }
57
+ });
58
+ page.onContextReset(() => closeCurrent(GOING_AWAY, "page_gone"));
46
59
  };
47
60
 
48
- // src/package-version.ts
49
- var DAEMON_VERSION_OVERRIDE_ENV = "APPDUCT_DAEMON_VERSION_OVERRIDE";
50
- var isTestRuntime = (env) => {
51
- return Boolean(env.VITEST) || env.NODE_ENV === "test";
52
- };
53
- var cached2;
54
- var getPackageVersion = () => {
55
- cached2 ??= JSON.parse(readFileSync2(join2(getPackageRoot(), "package.json"), "utf8")).version;
56
- return cached2;
57
- };
58
- var getDaemonReportedVersion = (env = process.env) => {
59
- if (!isTestRuntime(env)) {
60
- return getPackageVersion();
61
+ // src/devtools-relay/attach-tab.ts
62
+ var listTabs = (tabs) => tabs.length === 0 ? " (none)" : tabs.map((tab) => ` ${tab.id} ${tab.url}`).join("\n");
63
+ var pickTab = (tabs, url, targetId) => {
64
+ if (targetId !== void 0) {
65
+ const byId = tabs.find((tab) => tab.id === targetId);
66
+ if (byId) return byId;
67
+ throw new Error(`No open tab has target id "${targetId}". Open tabs:
68
+ ${listTabs(tabs)}`);
69
+ }
70
+ const matching = tabs.filter((tab) => tab.url.startsWith(url));
71
+ if (matching.length === 1) return matching[0];
72
+ if (matching.length === 0) throw new Error(`No open tab starts with "${url}". Open tabs:
73
+ ${listTabs(tabs)}`);
74
+ throw new Error(`${matching.length} open tabs start with "${url}"; pass the target id of the one you want. Matching tabs:
75
+ ${listTabs(matching)}`);
76
+ };
77
+ var WAIT_FOR_WEB = `new Promise((resolve, reject) => {
78
+ const ready = () => typeof window.__APPDUCT__?.connect === 'function';
79
+ if (ready()) return resolve(true);
80
+ const timer = setInterval(() => { if (ready()) { clearInterval(timer); clearTimeout(giveUp); resolve(true); } }, 50);
81
+ const giveUp = setTimeout(() => { clearInterval(timer); reject(new Error('@appduct/web did not load')); }, 5000);
82
+ })`;
83
+ var attachBrowserTab = async (options) => {
84
+ const tab = pickTab(await options.browser.targets(), options.url, options.targetId);
85
+ const link = await options.mintLink();
86
+ const page = await options.browser.openPage(tab.id);
87
+ options.relayedTabs.get(tab.id)?.close();
88
+ options.relayedTabs.set(tab.id, page);
89
+ try {
90
+ relayPage(page, link, options.openDaemonSocket);
91
+ await page.evaluate(WAIT_FOR_WEB).catch(() => {
92
+ throw new Error(`The tab ${tab.id} (${tab.url}) does not load @appduct/web. Add it to the page, then try again.`);
93
+ });
94
+ await page.evaluate(`window.__APPDUCT__.connect(${JSON.stringify(linkPayload(link))}, { transport: "devtools" })`);
95
+ } catch (error) {
96
+ page.close();
97
+ if (options.relayedTabs.get(tab.id) === page) options.relayedTabs.delete(tab.id);
98
+ throw error;
61
99
  }
62
- const override = env[DAEMON_VERSION_OVERRIDE_ENV];
63
- return override !== void 0 && override.length > 0 ? override : getPackageVersion();
100
+ return { sessionId: link.sessionId, url: tab.url, targetId: tab.id, expiresAt: link.expiresAt };
64
101
  };
65
102
 
66
- // src/daemon/address.ts
67
- import { networkInterfaces } from "node:os";
68
- import {
69
- isLinkLocalIpv6Address,
70
- isPrivateIpv4Address,
71
- isUniqueLocalIpv6Address
72
- } from "@appduct/shared";
73
- var isDockerDefaultBridgeAddress = (address) => {
74
- return address.startsWith("172.17.");
75
- };
76
- var isCgnatIpv4Address = (address) => {
77
- const parts = address.split(".");
78
- if (parts.length !== 4) {
79
- return false;
80
- }
81
- const first = Number(parts[0]);
82
- const second = Number(parts[1]);
83
- return first === 100 && Number.isInteger(second) && second >= 64 && second <= 127;
103
+ // src/devtools-relay/node-devtools-browser.ts
104
+ import WebSocket from "ws";
105
+ var BINDING = "__appductBinding";
106
+ var listTargets = async (browserUrl) => {
107
+ let listed;
108
+ try {
109
+ listed = await (await fetch(new URL("/json/list", browserUrl))).json();
110
+ } catch (error) {
111
+ throw new Error(`Could not reach a browser at ${browserUrl}: ${error.message}. Launch Chrome with --remote-debugging-port and its own --user-data-dir.`);
112
+ }
113
+ return listed.filter((target) => target.type === "page");
114
+ };
115
+ var openTab = async (target, browserUrl) => {
116
+ const endpoint = new URL(browserUrl);
117
+ const socketUrl = new URL(target.webSocketDebuggerUrl);
118
+ socketUrl.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:";
119
+ socketUrl.host = endpoint.host;
120
+ const socket = new WebSocket(socketUrl);
121
+ await new Promise((resolve2, reject) => {
122
+ socket.once("open", () => resolve2());
123
+ socket.once("error", reject);
124
+ });
125
+ const pending = /* @__PURE__ */ new Map();
126
+ const bindingHandlers = [];
127
+ const resetHandlers = [];
128
+ let nextId = 0;
129
+ const send = (method, params) => new Promise((resolve2, reject) => {
130
+ nextId += 1;
131
+ pending.set(nextId, { resolve: resolve2, reject });
132
+ socket.send(JSON.stringify({ id: nextId, method, params }));
133
+ });
134
+ socket.on("message", (data) => {
135
+ const message = JSON.parse(data.toString());
136
+ if (message.id !== void 0) {
137
+ const call = pending.get(message.id);
138
+ pending.delete(message.id);
139
+ if (message.error) call?.reject(new Error(message.error.message));
140
+ else call?.resolve(message.result);
141
+ } else if (message.method === "Runtime.bindingCalled" && message.params?.name === BINDING) {
142
+ bindingHandlers.forEach((handler) => handler(message.params.payload));
143
+ } else if (message.method === "Runtime.executionContextsCleared") {
144
+ resetHandlers.forEach((handler) => handler());
145
+ }
146
+ });
147
+ socket.on("error", () => void 0);
148
+ socket.on("close", () => {
149
+ pending.forEach((call) => call.reject(new Error("The tab's debugging connection closed.")));
150
+ pending.clear();
151
+ resetHandlers.forEach((handler) => handler());
152
+ });
153
+ await send("Runtime.enable", {});
154
+ await send("Runtime.addBinding", { name: BINDING });
155
+ return {
156
+ onBindingCall: (handler) => void bindingHandlers.push(handler),
157
+ onContextReset: (handler) => void resetHandlers.push(handler),
158
+ evaluate: async (expression) => {
159
+ const result = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
160
+ const failure = result?.exceptionDetails;
161
+ if (failure) throw new Error(failure.exception?.description ?? failure.text);
162
+ },
163
+ close: () => socket.close()
164
+ };
84
165
  };
85
- var isExcludedIpv4Address = (address) => {
86
- return isDockerDefaultBridgeAddress(address) || isCgnatIpv4Address(address);
166
+ var connectNodeDevtoolsBrowser = (browserUrl) => {
167
+ const open4 = /* @__PURE__ */ new Map();
168
+ return {
169
+ targets: async () => (await listTargets(browserUrl)).map(({ id, url, title }) => ({ id, url, title })),
170
+ openPage: async (targetId) => {
171
+ const target = (await listTargets(browserUrl)).find((candidate) => candidate.id === targetId);
172
+ if (!target?.webSocketDebuggerUrl) throw new Error(`The browser at ${browserUrl} has no tab ${targetId}.`);
173
+ const page = await openTab(target, browserUrl);
174
+ open4.get(targetId)?.close();
175
+ open4.set(targetId, page);
176
+ return page;
177
+ },
178
+ close: () => {
179
+ open4.forEach((page) => page.close());
180
+ open4.clear();
181
+ }
182
+ };
87
183
  };
88
- var detectAdvertisedAddress = (options = {}) => {
89
- if (options.override) {
90
- return {
91
- family: options.override.includes(":") ? 6 : 4,
92
- address: options.override
93
- };
94
- }
95
- const ipv4Candidates = [];
96
- const ipv6Candidates = [];
97
- for (const entries of Object.values(networkInterfaces())) {
98
- for (const entry of entries ?? []) {
99
- if (entry.internal) {
100
- continue;
101
- }
102
- if (entry.family === "IPv4") {
103
- if (isExcludedIpv4Address(entry.address)) {
104
- continue;
105
- }
106
- ipv4Candidates.push(entry.address);
107
- } else if (entry.family === "IPv6") {
108
- if (isLinkLocalIpv6Address(entry.address)) {
109
- continue;
110
- }
111
- ipv6Candidates.push(entry.address);
112
- }
184
+
185
+ // src/devtools-relay/node-daemon-socket.ts
186
+ import WebSocket2 from "ws";
187
+ var openNodeDaemonSocket = (url, events) => {
188
+ const socket = new WebSocket2(url);
189
+ socket.on("open", () => events.open());
190
+ socket.on("message", (data, isBinary) => {
191
+ if (isBinary) {
192
+ socket.close(4008, "binary_frame_not_supported");
193
+ return;
113
194
  }
114
- }
115
- const preferredIpv4 = ipv4Candidates.find((address) => isPrivateIpv4Address(address)) ?? ipv4Candidates[0];
116
- if (preferredIpv4) {
117
- return { family: 4, address: preferredIpv4 };
118
- }
119
- const preferredIpv6 = ipv6Candidates.find((address) => isUniqueLocalIpv6Address(address)) ?? ipv6Candidates[0];
120
- if (preferredIpv6) {
121
- return { family: 6, address: preferredIpv6 };
122
- }
123
- return { family: 4, address: "127.0.0.1" };
195
+ events.message(data.toString());
196
+ });
197
+ socket.on("error", () => void 0);
198
+ socket.on("close", (code, reason) => events.close(code, reason.toString()));
199
+ return { send: (text) => socket.send(text), close: (code, reason) => socket.close(code, reason) };
124
200
  };
125
201
 
126
- // src/daemon/audit.ts
127
- import { createHash } from "node:crypto";
128
- import { chmod, mkdir, open, readdir, rm, stat } from "node:fs/promises";
129
- import { join as join3 } from "node:path";
202
+ // src/link.ts
203
+ import { RPC_METHODS as RPC_METHODS2 } from "@appduct/shared";
204
+
205
+ // src/rpc/client.ts
206
+ import { join as join4 } from "node:path";
207
+ import { RPC_METHODS } from "@appduct/shared";
130
208
 
131
209
  // src/daemon/config.ts
132
210
  import { readFile } from "node:fs/promises";
@@ -141,6 +219,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
141
219
  "daemonLogMaxBytes",
142
220
  "eventsLogMaxBytes",
143
221
  "policy",
222
+ "webOrigins",
144
223
  "advertisedIp",
145
224
  "scheme",
146
225
  "restartDaemonOnVersionMismatch"
@@ -201,6 +280,7 @@ var defaultConfig = (paths) => {
201
280
  daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
202
281
  eventsLogMaxBytes: 10 * 1024 * 1024,
203
282
  restartDaemonOnVersionMismatch: false,
283
+ webOrigins: [],
204
284
  policy: {
205
285
  default: "allow",
206
286
  destructive: "allow"
@@ -265,43 +345,461 @@ var loadConfig = async (paths, options = {}) => {
265
345
  if (parsed.eventsLogMaxBytes !== void 0) {
266
346
  config.eventsLogMaxBytes = requirePositiveInteger(parsed.eventsLogMaxBytes, "eventsLogMaxBytes");
267
347
  }
268
- if (parsed.restartDaemonOnVersionMismatch !== void 0) {
269
- config.restartDaemonOnVersionMismatch = requireBoolean(
270
- parsed.restartDaemonOnVersionMismatch,
271
- "restartDaemonOnVersionMismatch"
272
- );
348
+ if (parsed.restartDaemonOnVersionMismatch !== void 0) {
349
+ config.restartDaemonOnVersionMismatch = requireBoolean(
350
+ parsed.restartDaemonOnVersionMismatch,
351
+ "restartDaemonOnVersionMismatch"
352
+ );
353
+ }
354
+ if (parsed.webOrigins !== void 0) {
355
+ if (!Array.isArray(parsed.webOrigins)) {
356
+ throw configError("webOrigins", "must be an array of origin strings.");
357
+ }
358
+ config.webOrigins = parsed.webOrigins.map((origin) => requireNonEmptyString(origin, "webOrigins"));
359
+ }
360
+ if (parsed.policy !== void 0) {
361
+ if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
362
+ throw configError("policy", "must be an object.");
363
+ }
364
+ const policy = parsed.policy;
365
+ for (const key of Object.keys(policy)) {
366
+ if (!KNOWN_POLICY_KEYS.has(key)) {
367
+ warn(`Unknown Appduct config key "policy.${key}" is ignored.`);
368
+ }
369
+ }
370
+ if (policy.default !== void 0) {
371
+ config.policy.default = requirePolicyDecision(policy.default, "policy.default");
372
+ }
373
+ if (policy.destructive !== void 0) {
374
+ config.policy.destructive = requirePolicyDecision(policy.destructive, "policy.destructive");
375
+ }
376
+ if (policy.tools !== void 0) {
377
+ if (typeof policy.tools !== "object" || policy.tools === null || Array.isArray(policy.tools)) {
378
+ throw configError("policy.tools", "must be an object.");
379
+ }
380
+ const tools = {};
381
+ for (const [toolKey, toolValue] of Object.entries(policy.tools)) {
382
+ tools[toolKey] = requirePolicyDecision(toolValue, `policy.tools["${toolKey}"]`);
383
+ }
384
+ config.policy.tools = tools;
385
+ }
386
+ }
387
+ return config;
388
+ };
389
+
390
+ // src/daemon/pidfile.ts
391
+ import { readFileSync } from "node:fs";
392
+ import { open, readFile as readFile2, rm } from "node:fs/promises";
393
+ var DaemonAlreadyRunningError = class extends Error {
394
+ constructor(pid) {
395
+ super(`Appduct daemon is already running (pid ${pid}).`);
396
+ this.pid = pid;
397
+ this.name = "DaemonAlreadyRunningError";
398
+ }
399
+ };
400
+ var readProcStatus = (pid) => {
401
+ try {
402
+ return readFileSync(`/proc/${pid}/status`, "utf8");
403
+ } catch {
404
+ return void 0;
405
+ }
406
+ };
407
+ var isZombie = (pid, readStatus) => {
408
+ const status = readStatus(pid);
409
+ if (status === void 0) {
410
+ return false;
411
+ }
412
+ return /^State:\s*Z\b/mu.test(status);
413
+ };
414
+ var isProcessAlive = (pid, readStatus = readProcStatus) => {
415
+ try {
416
+ process.kill(pid, 0);
417
+ } catch (error) {
418
+ return error.code === "EPERM";
419
+ }
420
+ return !isZombie(pid, readStatus);
421
+ };
422
+ var readPidFromFile = async (pidFilePath) => {
423
+ let contents;
424
+ try {
425
+ contents = await readFile2(pidFilePath, "utf8");
426
+ } catch (error) {
427
+ if (error.code === "ENOENT") {
428
+ return void 0;
429
+ }
430
+ throw error;
431
+ }
432
+ const pid = Number.parseInt(contents.trim(), 10);
433
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
434
+ };
435
+ var acquirePidfile = async (pidFilePath, options = {}) => {
436
+ const pid = options.pid ?? process.pid;
437
+ for (let attempt = 0; attempt < 5; attempt += 1) {
438
+ try {
439
+ const handle = await open(pidFilePath, "wx", 384);
440
+ try {
441
+ await handle.writeFile(String(pid), "utf8");
442
+ } finally {
443
+ await handle.close();
444
+ }
445
+ let released = false;
446
+ return {
447
+ pid,
448
+ release: async () => {
449
+ if (released) {
450
+ return;
451
+ }
452
+ released = true;
453
+ await rm(pidFilePath, { force: true });
454
+ }
455
+ };
456
+ } catch (error) {
457
+ if (error.code !== "EEXIST") {
458
+ throw error;
459
+ }
460
+ const existingPid = await readPidFromFile(pidFilePath);
461
+ if (existingPid !== void 0 && isProcessAlive(existingPid)) {
462
+ throw new DaemonAlreadyRunningError(existingPid);
463
+ }
464
+ await rm(pidFilePath, { force: true });
465
+ await options.onStaleTakeover?.();
466
+ }
467
+ }
468
+ throw new Error(`Failed to acquire the Appduct daemon pidfile at "${pidFilePath}".`);
469
+ };
470
+
471
+ // src/daemon/state-dir.ts
472
+ import { join } from "node:path";
473
+ import { mkdir, chmod } from "node:fs/promises";
474
+ var getStateDirPaths = (stateDir) => {
475
+ return {
476
+ root: stateDir,
477
+ socketPath: join(stateDir, "daemon.sock"),
478
+ pidFilePath: join(stateDir, "daemon.pid"),
479
+ logFilePath: join(stateDir, "daemon.log"),
480
+ eventsLogPath: join(stateDir, "events.log"),
481
+ keyPath: join(stateDir, "key.pem"),
482
+ configPath: join(stateDir, "config.json"),
483
+ auditDir: join(stateDir, "audit"),
484
+ spawnLockPath: join(stateDir, "daemon.spawn.lock")
485
+ };
486
+ };
487
+ var getSocketPath = (paths) => {
488
+ return paths.socketPath;
489
+ };
490
+ var ensureStateDir = async (stateDir) => {
491
+ const paths = getStateDirPaths(stateDir);
492
+ await mkdir(paths.auditDir, { recursive: true });
493
+ await chmod(paths.root, 448);
494
+ await chmod(paths.auditDir, 448);
495
+ return paths;
496
+ };
497
+
498
+ // src/package-root.ts
499
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
500
+ import { dirname, join as join2 } from "node:path";
501
+ import { fileURLToPath } from "node:url";
502
+ var PACKAGE_NAME = "appduct";
503
+ var cached;
504
+ var getPackageRoot = () => {
505
+ if (cached !== void 0) {
506
+ return cached;
507
+ }
508
+ let dir = dirname(fileURLToPath(import.meta.url));
509
+ for (; ; ) {
510
+ const candidate = join2(dir, "package.json");
511
+ if (existsSync(candidate)) {
512
+ let name;
513
+ try {
514
+ name = JSON.parse(readFileSync2(candidate, "utf8")).name;
515
+ } catch {
516
+ }
517
+ if (name === PACKAGE_NAME) {
518
+ cached = dir;
519
+ return dir;
520
+ }
521
+ }
522
+ const parent = dirname(dir);
523
+ if (parent === dir) {
524
+ throw new Error(`Could not locate the "${PACKAGE_NAME}" package root above ${import.meta.url}.`);
525
+ }
526
+ dir = parent;
527
+ }
528
+ };
529
+
530
+ // src/package-version.ts
531
+ import { readFileSync as readFileSync3 } from "node:fs";
532
+ import { join as join3 } from "node:path";
533
+ var DAEMON_VERSION_OVERRIDE_ENV = "APPDUCT_DAEMON_VERSION_OVERRIDE";
534
+ var isTestRuntime = (env) => {
535
+ return Boolean(env.VITEST) || env.NODE_ENV === "test";
536
+ };
537
+ var cached2;
538
+ var getPackageVersion = () => {
539
+ cached2 ??= JSON.parse(readFileSync3(join3(getPackageRoot(), "package.json"), "utf8")).version;
540
+ return cached2;
541
+ };
542
+ var getDaemonReportedVersion = (env = process.env) => {
543
+ if (!isTestRuntime(env)) {
544
+ return getPackageVersion();
545
+ }
546
+ const override = env[DAEMON_VERSION_OVERRIDE_ENV];
547
+ return override !== void 0 && override.length > 0 ? override : getPackageVersion();
548
+ };
549
+
550
+ // src/rpc/client.ts
551
+ var defaultBinPath = join4(getPackageRoot(), "bin.js");
552
+ var DaemonRpcError = class extends Error {
553
+ constructor(code, message, data) {
554
+ super(message);
555
+ this.code = code;
556
+ this.data = data;
557
+ this.name = "DaemonRpcError";
558
+ }
559
+ };
560
+ var DaemonUnavailableError = class extends Error {
561
+ constructor(message, reason = "unreachable") {
562
+ super(message);
563
+ this.reason = reason;
564
+ this.name = "DaemonUnavailableError";
565
+ }
566
+ };
567
+ var describeLiveState = (state) => {
568
+ const parts = [];
569
+ if (state.sessions > 0) {
570
+ parts.push(`${state.sessions} connected session(s)`);
571
+ }
572
+ if (state.pendingLinks > 0) {
573
+ parts.push(`${state.pendingLinks} unclaimed link(s)`);
574
+ }
575
+ return parts.join(" and ");
576
+ };
577
+ var DaemonVersionMismatchError = class extends Error {
578
+ constructor(daemonVersion, clientVersion, live, message) {
579
+ super(
580
+ message ?? `The running Appduct daemon is version ${daemonVersion}, but this client is version ${clientVersion}. It was not restarted automatically because ${describeLiveState(live)} would be lost. Run "appduct daemon stop" once that state is expendable, or force it with "--daemon-restart".`
581
+ );
582
+ this.daemonVersion = daemonVersion;
583
+ this.clientVersion = clientVersion;
584
+ this.name = "DaemonVersionMismatchError";
585
+ this.sessionCount = live.sessions;
586
+ this.pendingLinkCount = live.pendingLinks;
587
+ }
588
+ sessionCount;
589
+ pendingLinkCount;
590
+ };
591
+ var isConnectionMissingError = (error) => {
592
+ const code = error.code;
593
+ return code === "ENOENT" || code === "ECONNREFUSED";
594
+ };
595
+ var isDaemonUnreachableError = (error) => {
596
+ return error instanceof DaemonUnavailableError || isConnectionMissingError(error);
597
+ };
598
+
599
+ // src/errors.ts
600
+ var EXIT_CODE_BY_ERROR_TYPE = {
601
+ usage_error: 64,
602
+ validation_error: 65,
603
+ // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
604
+ // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
605
+ // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
606
+ // assertion failure.
607
+ inspection_error: 66,
608
+ connection_error: 70,
609
+ session_error: 71,
610
+ tool_error: 72,
611
+ permission_error: 77,
612
+ internal_error: 1,
613
+ // `doctor --assert-present`/`--assert-absent`: the artifact was inspected successfully and the
614
+ // assertion did not hold. Distinct from `inspection_error` (66) — this means the answer is known
615
+ // and it's "no", not "couldn't tell" — and distinct from `internal_error` (1) so a caller can't
616
+ // conflate a normal assertion failure with an unexpected crash.
617
+ assertion_error: 3
618
+ };
619
+ var RPC_ERROR_EXIT_CLASS = {
620
+ no_session: "session_error",
621
+ ambiguous_session: "session_error",
622
+ unknown_session: "session_error",
623
+ session_not_active: "session_error",
624
+ session_suspended: "session_error",
625
+ tool_not_found: "tool_error",
626
+ tool_input_validation_error: "tool_error",
627
+ tool_output_validation_error: "tool_error",
628
+ tool_execution_error: "tool_error",
629
+ tool_serialization_error: "tool_error",
630
+ tool_timeout: "tool_error",
631
+ tool_cancelled: "tool_error",
632
+ policy_denied: "permission_error",
633
+ invalid_request: "validation_error"
634
+ };
635
+ var AppductCliError = class extends Error {
636
+ type;
637
+ details;
638
+ constructor(type, message, details) {
639
+ super(message);
640
+ this.name = "AppductCliError";
641
+ this.type = type;
642
+ this.details = details;
643
+ }
644
+ };
645
+ var connectionError = (message, details) => {
646
+ return new AppductCliError("connection_error", message, details);
647
+ };
648
+ var isAppductCliError = (value) => {
649
+ return value instanceof AppductCliError;
650
+ };
651
+ var rpcErrorClass = (error) => {
652
+ const type = error.data?.type;
653
+ return type ? RPC_ERROR_EXIT_CLASS[type] : void 0;
654
+ };
655
+ var toCliError = (error) => {
656
+ if (isAppductCliError(error)) {
657
+ return {
658
+ type: error.type,
659
+ message: error.message,
660
+ details: error.details
661
+ };
662
+ }
663
+ if (error instanceof DaemonVersionMismatchError) {
664
+ return {
665
+ type: "connection_error",
666
+ message: error.message,
667
+ details: {
668
+ daemon_version: error.daemonVersion,
669
+ client_version: error.clientVersion,
670
+ session_count: error.sessionCount,
671
+ pending_link_count: error.pendingLinkCount
672
+ }
673
+ };
674
+ }
675
+ if (error instanceof DaemonRpcError && error.data?.type) {
676
+ return {
677
+ type: error.data.type,
678
+ message: error.message,
679
+ details: error.data.details
680
+ };
681
+ }
682
+ if (isDaemonUnreachableError(error)) {
683
+ return {
684
+ type: "connection_error",
685
+ message: error instanceof Error ? error.message : "The Appduct daemon is unreachable."
686
+ };
687
+ }
688
+ if (error instanceof Error) {
689
+ return {
690
+ type: "internal_error",
691
+ message: error.message
692
+ };
693
+ }
694
+ return {
695
+ type: "internal_error",
696
+ message: "An unexpected error occurred.",
697
+ details: error
698
+ };
699
+ };
700
+ var getExitCodeForError = (error) => {
701
+ if (isAppductCliError(error)) {
702
+ return EXIT_CODE_BY_ERROR_TYPE[error.type];
703
+ }
704
+ if (error instanceof DaemonVersionMismatchError) {
705
+ return EXIT_CODE_BY_ERROR_TYPE.connection_error;
706
+ }
707
+ if (error instanceof DaemonRpcError) {
708
+ const cliErrorClass = rpcErrorClass(error);
709
+ return EXIT_CODE_BY_ERROR_TYPE[cliErrorClass ?? "internal_error"];
710
+ }
711
+ if (isDaemonUnreachableError(error)) {
712
+ return EXIT_CODE_BY_ERROR_TYPE.connection_error;
713
+ }
714
+ return EXIT_CODE_BY_ERROR_TYPE.internal_error;
715
+ };
716
+
717
+ // src/scheme.ts
718
+ import { dirname as dirname2, join as join5, resolve } from "node:path";
719
+
720
+ // src/native-scheme.ts
721
+ var MAX_PROBE_FILE_BYTES = 2 * 1024 * 1024;
722
+
723
+ // src/scheme.ts
724
+ var PROJECT_CONFIG_DIR = ".appduct";
725
+ var PROJECT_CONFIG_FILENAME = "config.json";
726
+ var PROJECT_CONFIG_RELATIVE_PATH = join5(PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
727
+
728
+ // src/link.ts
729
+ var composeWebLink = (pageUrl, result) => {
730
+ const payload = result.deepLinkPayload;
731
+ return {
732
+ sessionId: result.sessionId,
733
+ url: `${pageUrl.split("#")[0]}#appduct=${payload}`,
734
+ script: `window.__APPDUCT__.connect("${payload}")`,
735
+ expiresAt: result.expiresAt
736
+ };
737
+ };
738
+
739
+ // src/daemon/address.ts
740
+ import { networkInterfaces } from "node:os";
741
+ import {
742
+ isLinkLocalIpv6Address,
743
+ isPrivateIpv4Address,
744
+ isUniqueLocalIpv6Address
745
+ } from "@appduct/shared";
746
+ var isDockerDefaultBridgeAddress = (address) => {
747
+ return address.startsWith("172.17.");
748
+ };
749
+ var isCgnatIpv4Address = (address) => {
750
+ const parts = address.split(".");
751
+ if (parts.length !== 4) {
752
+ return false;
753
+ }
754
+ const first = Number(parts[0]);
755
+ const second = Number(parts[1]);
756
+ return first === 100 && Number.isInteger(second) && second >= 64 && second <= 127;
757
+ };
758
+ var isExcludedIpv4Address = (address) => {
759
+ return isDockerDefaultBridgeAddress(address) || isCgnatIpv4Address(address);
760
+ };
761
+ var detectAdvertisedAddress = (options = {}) => {
762
+ if (options.override) {
763
+ return {
764
+ family: options.override.includes(":") ? 6 : 4,
765
+ address: options.override
766
+ };
273
767
  }
274
- if (parsed.policy !== void 0) {
275
- if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
276
- throw configError("policy", "must be an object.");
277
- }
278
- const policy = parsed.policy;
279
- for (const key of Object.keys(policy)) {
280
- if (!KNOWN_POLICY_KEYS.has(key)) {
281
- warn(`Unknown Appduct config key "policy.${key}" is ignored.`);
282
- }
283
- }
284
- if (policy.default !== void 0) {
285
- config.policy.default = requirePolicyDecision(policy.default, "policy.default");
286
- }
287
- if (policy.destructive !== void 0) {
288
- config.policy.destructive = requirePolicyDecision(policy.destructive, "policy.destructive");
289
- }
290
- if (policy.tools !== void 0) {
291
- if (typeof policy.tools !== "object" || policy.tools === null || Array.isArray(policy.tools)) {
292
- throw configError("policy.tools", "must be an object.");
768
+ const ipv4Candidates = [];
769
+ const ipv6Candidates = [];
770
+ for (const entries of Object.values(networkInterfaces())) {
771
+ for (const entry of entries ?? []) {
772
+ if (entry.internal) {
773
+ continue;
293
774
  }
294
- const tools = {};
295
- for (const [toolKey, toolValue] of Object.entries(policy.tools)) {
296
- tools[toolKey] = requirePolicyDecision(toolValue, `policy.tools["${toolKey}"]`);
775
+ if (entry.family === "IPv4") {
776
+ if (isExcludedIpv4Address(entry.address)) {
777
+ continue;
778
+ }
779
+ ipv4Candidates.push(entry.address);
780
+ } else if (entry.family === "IPv6") {
781
+ if (isLinkLocalIpv6Address(entry.address)) {
782
+ continue;
783
+ }
784
+ ipv6Candidates.push(entry.address);
297
785
  }
298
- config.policy.tools = tools;
299
786
  }
300
787
  }
301
- return config;
788
+ const preferredIpv4 = ipv4Candidates.find((address) => isPrivateIpv4Address(address)) ?? ipv4Candidates[0];
789
+ if (preferredIpv4) {
790
+ return { family: 4, address: preferredIpv4 };
791
+ }
792
+ const preferredIpv6 = ipv6Candidates.find((address) => isUniqueLocalIpv6Address(address)) ?? ipv6Candidates[0];
793
+ if (preferredIpv6) {
794
+ return { family: 6, address: preferredIpv6 };
795
+ }
796
+ return { family: 4, address: "127.0.0.1" };
302
797
  };
303
798
 
304
799
  // src/daemon/audit.ts
800
+ import { createHash } from "node:crypto";
801
+ import { chmod as chmod2, mkdir as mkdir2, open as open2, readdir, rm as rm2, stat } from "node:fs/promises";
802
+ import { join as join6 } from "node:path";
305
803
  var canonicalize = (value) => {
306
804
  if (Array.isArray(value)) {
307
805
  return value.map(canonicalize);
@@ -338,13 +836,13 @@ var oldestRetainedStamp = (now, retentionDays) => {
338
836
  return Number.isNaN(cutoff.getTime()) ? "0000-00-00" : dayStamp(cutoff);
339
837
  };
340
838
  var auditFilePathForDate = (auditDir, date) => {
341
- return join3(auditDir, `${dayStamp(date)}.jsonl`);
839
+ return join6(auditDir, `${dayStamp(date)}.jsonl`);
342
840
  };
343
841
  var appendLine = async (filePath, line) => {
344
- const handle = await open(filePath, "a", 384);
842
+ const handle = await open2(filePath, "a", 384);
345
843
  try {
346
844
  await handle.appendFile(line);
347
- await chmod(filePath, 384);
845
+ await chmod2(filePath, 384);
348
846
  } finally {
349
847
  await handle.close();
350
848
  }
@@ -397,7 +895,7 @@ var createAuditLogger = (options) => {
397
895
  continue;
398
896
  }
399
897
  try {
400
- await rm(join3(options.auditDir, file.name), { force: true });
898
+ await rm2(join6(options.auditDir, file.name), { force: true });
401
899
  deleted += 1;
402
900
  } catch (error) {
403
901
  failed += 1;
@@ -415,7 +913,7 @@ var createAuditLogger = (options) => {
415
913
  const filePath = auditFilePathForDate(options.auditDir, now);
416
914
  queue = queue.then(async () => {
417
915
  try {
418
- await mkdir(options.auditDir, { recursive: true });
916
+ await mkdir2(options.auditDir, { recursive: true });
419
917
  await appendLine(filePath, line);
420
918
  } catch (error) {
421
919
  failedWrites += 1;
@@ -425,8 +923,8 @@ var createAuditLogger = (options) => {
425
923
  };
426
924
  const prune = () => {
427
925
  let settle;
428
- const result = new Promise((resolve) => {
429
- settle = resolve;
926
+ const result = new Promise((resolve2) => {
927
+ settle = resolve2;
430
928
  });
431
929
  queue = queue.then(async () => {
432
930
  try {
@@ -448,7 +946,7 @@ var createAuditLogger = (options) => {
448
946
  let bytes = 0;
449
947
  for (const file of listing.files) {
450
948
  try {
451
- bytes += (await stat(join3(options.auditDir, file.name))).size;
949
+ bytes += (await stat(join6(options.auditDir, file.name))).size;
452
950
  files += 1;
453
951
  } catch {
454
952
  }
@@ -484,6 +982,8 @@ var RpcApplicationError = class extends Error {
484
982
  this.name = "RpcApplicationError";
485
983
  }
486
984
  };
985
+ var appBackgroundedMessage = (alias) => `Session "${alias}" is suspended because the app is in the background. Bring the app to the foreground to resume it.`;
986
+ var appReconnectedMessage = (alias) => `Session "${alias}" reconnected while the call was pending, so its result was lost. The call can be retried.`;
487
987
 
488
988
  // src/daemon/timers.ts
489
989
  var systemTimers = {
@@ -504,6 +1004,7 @@ var clampTimeout = (timeoutMs) => {
504
1004
  };
505
1005
  var TERMINATING_KIND_ERROR_TYPES = {
506
1006
  session_suspended: "session_suspended",
1007
+ session_resumed: "session_suspended",
507
1008
  session_revoked: "unknown_session",
508
1009
  session_expired: "unknown_session"
509
1010
  };
@@ -525,7 +1026,7 @@ var createCallsManager = (options) => {
525
1026
  };
526
1027
  const call = (session, name, args, timeoutMs) => {
527
1028
  const callId = generateCallId();
528
- const result = new Promise((resolve, reject) => {
1029
+ const result = new Promise((resolve2, reject) => {
529
1030
  const effectiveTimeoutMs = clampTimeout(timeoutMs);
530
1031
  const timer = timers.setTimeout(() => {
531
1032
  const pending = takePending(session.sessionId, callId);
@@ -538,7 +1039,7 @@ var createCallsManager = (options) => {
538
1039
  sessionMap = /* @__PURE__ */ new Map();
539
1040
  pendingBySession.set(session.sessionId, sessionMap);
540
1041
  }
541
- sessionMap.set(callId, { alias: session.alias, resolve, reject, timer });
1042
+ sessionMap.set(callId, { alias: session.alias, resolve: resolve2, reject, timer });
542
1043
  const message = {
543
1044
  type: "tool_call",
544
1045
  session_id: session.sessionId,
@@ -586,7 +1087,7 @@ var createCallsManager = (options) => {
586
1087
  const message = { type: "tool_cancel", session_id: sessionId, id: callId, reason };
587
1088
  return options.sendCancel(sessionId, message);
588
1089
  };
589
- const rejectSession = (sessionId, errorType) => {
1090
+ const rejectSession = (sessionId, errorType, options2 = {}) => {
590
1091
  const sessionMap = pendingBySession.get(sessionId);
591
1092
  if (!sessionMap) {
592
1093
  return;
@@ -594,13 +1095,20 @@ var createCallsManager = (options) => {
594
1095
  pendingBySession.delete(sessionId);
595
1096
  for (const pending of sessionMap.values()) {
596
1097
  timers.clearTimeout(pending.timer);
597
- pending.reject(new RpcApplicationError(errorType, `Session transitioned while the call was pending.`));
1098
+ pending.reject(
1099
+ new RpcApplicationError(
1100
+ errorType,
1101
+ options2.message?.(pending.alias) ?? "Session transitioned while the call was pending."
1102
+ )
1103
+ );
598
1104
  }
599
1105
  };
600
1106
  const unsubscribeFromEventBus = options.eventBus.subscribe((event) => {
601
1107
  const errorType = TERMINATING_KIND_ERROR_TYPES[event.kind];
602
1108
  if (errorType && event.sessionId) {
603
- rejectSession(event.sessionId, errorType);
1109
+ const backgrounded = event.kind === "session_suspended" && event.data.reason === "app_backgrounded";
1110
+ const message = event.kind === "session_resumed" ? appReconnectedMessage : backgrounded ? appBackgroundedMessage : void 0;
1111
+ rejectSession(event.sessionId, errorType, { message });
604
1112
  }
605
1113
  });
606
1114
  const disposeAll = () => {
@@ -781,8 +1289,14 @@ var createEventsLog = (options) => {
781
1289
  };
782
1290
 
783
1291
  // src/daemon/listener.ts
1292
+ import { createServer as createHttpServer } from "node:http";
784
1293
  import { createServer as createHttpsServer } from "node:https";
785
- import { isSessionClaimMessage, isSessionResumeMessage, isSessionBoundMessage } from "@appduct/shared";
1294
+ import { createServer as createNetServer } from "node:net";
1295
+ import {
1296
+ isSessionClaimMessage,
1297
+ isSessionResumeMessage,
1298
+ isSessionBoundMessage
1299
+ } from "@appduct/shared";
786
1300
  import { WebSocketServer } from "ws";
787
1301
 
788
1302
  // src/daemon/sessions.ts
@@ -791,6 +1305,8 @@ import {
791
1305
  isEventMessage,
792
1306
  isToolCallProgressMessage,
793
1307
  isToolErrorMessage,
1308
+ isEventRegistryDeltaMessage,
1309
+ isEventRegistrySnapshotMessage,
794
1310
  isToolRegistryDeltaMessage,
795
1311
  isToolRegistrySnapshotMessage,
796
1312
  isToolResultMessage,
@@ -821,13 +1337,13 @@ var createPendingLinkRegistry = (options) => {
821
1337
  options.eventBus.drop(sessionId);
822
1338
  };
823
1339
  return {
824
- create: (ttlSeconds, addressOverride) => {
1340
+ create: (ttlSeconds, addressOverride, transport = "native") => {
825
1341
  const sessionId = generateSessionId();
826
1342
  const token = randomBytes2(TOKEN_BYTES);
827
1343
  const createdAt = options.clock.now();
828
1344
  const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1e3);
829
- const detectedEndpoint = options.getEndpoint();
830
- const endpoint = addressOverride ? {
1345
+ const detectedEndpoint = options.getEndpoint(transport);
1346
+ const endpoint = addressOverride && transport === "native" ? {
831
1347
  family: addressOverride.includes(":") ? 6 : 4,
832
1348
  address: addressOverride,
833
1349
  port: detectedEndpoint.port
@@ -839,7 +1355,7 @@ var createPendingLinkRegistry = (options) => {
839
1355
  record.timer = options.timers.setTimeout(() => discard(sessionId), ttlSeconds * 1e3);
840
1356
  }
841
1357
  }, ttlSeconds * 1e3);
842
- const link = { sessionId, token, createdAt, expiresAt };
1358
+ const link = { sessionId, token, createdAt, expiresAt, transport };
843
1359
  records.set(sessionId, { link, attempts: 0, timer });
844
1360
  const deepLinkPayload = encodeBootstrap({
845
1361
  ...endpoint,
@@ -888,7 +1404,7 @@ var createPendingLinkRegistry = (options) => {
888
1404
  };
889
1405
 
890
1406
  // src/daemon/registry.ts
891
- var createToolRegistry = (onChange) => {
1407
+ var createRegistry = (onChange) => {
892
1408
  let tools = /* @__PURE__ */ new Map();
893
1409
  const notify = () => {
894
1410
  onChange?.();
@@ -918,6 +1434,8 @@ var RESUME_TOKEN_BYTES = 32;
918
1434
  var POST_CLAIM_MESSAGE_TYPES = /* @__PURE__ */ new Set([
919
1435
  "tool_registry_snapshot",
920
1436
  "tool_registry_delta",
1437
+ "event_registry_snapshot",
1438
+ "event_registry_delta",
921
1439
  "tool_result",
922
1440
  "tool_error",
923
1441
  "tool_call_progress",
@@ -988,6 +1506,7 @@ var createSessionManager = (options) => {
988
1506
  createdAt: session.createdAt.toISOString(),
989
1507
  claimedAt: session.claimedAt.toISOString(),
990
1508
  ...session.suspendedAt ? { suspendedAt: session.suspendedAt.toISOString() } : {},
1509
+ ...session.suspendReason ? { suspendReason: session.suspendReason } : {},
991
1510
  toolCount: session.registry.count()
992
1511
  };
993
1512
  };
@@ -1024,7 +1543,8 @@ var createSessionManager = (options) => {
1024
1543
  alias: session.alias,
1025
1544
  resume_token: session.resumeToken.toString("base64url"),
1026
1545
  keepalive_interval_s: options.keepaliveIntervalSeconds,
1027
- grace_s: options.graceSeconds
1546
+ grace_s: options.graceSeconds,
1547
+ event_registry: true
1028
1548
  };
1029
1549
  socket.send(JSON.stringify(ack), (error) => {
1030
1550
  if (error) {
@@ -1032,17 +1552,18 @@ var createSessionManager = (options) => {
1032
1552
  }
1033
1553
  });
1034
1554
  };
1035
- const suspend = (sessionId) => {
1555
+ const suspend = (sessionId, reason) => {
1036
1556
  const session = sessions.get(sessionId);
1037
1557
  if (!session || session.state !== "active") {
1038
1558
  return;
1039
1559
  }
1040
1560
  session.state = "suspended";
1041
1561
  session.suspendedAt = clock.now();
1562
+ session.suspendReason = reason;
1042
1563
  session.socket = void 0;
1043
1564
  session.missedPongs = 0;
1044
1565
  session.graceTimer = timers.setTimeout(() => expire(sessionId), options.graceSeconds * 1e3);
1045
- emit("session_suspended", session.sessionId, session.alias, {});
1566
+ emit("session_suspended", session.sessionId, session.alias, { reason });
1046
1567
  };
1047
1568
  const expire = (sessionId) => {
1048
1569
  const session = sessions.get(sessionId);
@@ -1059,9 +1580,10 @@ var createSessionManager = (options) => {
1059
1580
  socket.on("pong", () => {
1060
1581
  session.missedPongs = 0;
1061
1582
  });
1062
- socket.on("close", () => {
1583
+ socket.on("close", (code, reason) => {
1063
1584
  if (session.socket === socket) {
1064
- suspend(session.sessionId);
1585
+ const backgrounded = code === 1001 && reason.toString("utf8") === "app_backgrounded";
1586
+ suspend(session.sessionId, backgrounded ? "app_backgrounded" : "connection_lost");
1065
1587
  }
1066
1588
  });
1067
1589
  };
@@ -1073,7 +1595,7 @@ var createSessionManager = (options) => {
1073
1595
  if (session.missedPongs >= 2) {
1074
1596
  const staleSocket = session.socket;
1075
1597
  session.socket = void 0;
1076
- suspend(session.sessionId);
1598
+ suspend(session.sessionId, "connection_lost");
1077
1599
  staleSocket.terminate();
1078
1600
  continue;
1079
1601
  }
@@ -1084,10 +1606,11 @@ var createSessionManager = (options) => {
1084
1606
  }
1085
1607
  }
1086
1608
  }, options.keepaliveIntervalSeconds * 1e3);
1087
- const createLink = (ttlSeconds, addressOverride) => {
1609
+ const createLink = (ttlSeconds, addressOverride, transport) => {
1088
1610
  const { link, deepLinkPayload, endpoint } = pendingLinks.create(
1089
1611
  ttlSeconds ?? options.linkTtlSeconds,
1090
- addressOverride
1612
+ addressOverride,
1613
+ transport
1091
1614
  );
1092
1615
  return {
1093
1616
  sessionId: link.sessionId,
@@ -1096,7 +1619,7 @@ var createSessionManager = (options) => {
1096
1619
  expiresAt: Math.floor(link.expiresAt.getTime() / 1e3)
1097
1620
  };
1098
1621
  };
1099
- const handleClaim = (socket, message) => {
1622
+ const handleClaim = (socket, message, transport) => {
1100
1623
  const pending = pendingLinks.get(message.session_id);
1101
1624
  if (!pending) {
1102
1625
  if (sessions.has(message.session_id)) {
@@ -1106,6 +1629,10 @@ var createSessionManager = (options) => {
1106
1629
  }
1107
1630
  return null;
1108
1631
  }
1632
+ if (pending.transport !== transport) {
1633
+ closeSocket(socket, 1008, "wrong_transport");
1634
+ return null;
1635
+ }
1109
1636
  const nowMs = clock.now().getTime();
1110
1637
  if (pending.expiresAt.getTime() <= nowMs) {
1111
1638
  pendingLinks.discard(pending.sessionId);
@@ -1135,11 +1662,15 @@ var createSessionManager = (options) => {
1135
1662
  device,
1136
1663
  createdAt: pending.createdAt,
1137
1664
  claimedAt: clock.now(),
1665
+ transport,
1138
1666
  resumeToken: randomBytes3(RESUME_TOKEN_BYTES),
1139
1667
  socket,
1140
1668
  missedPongs: 0,
1141
- registry: createToolRegistry(() => {
1669
+ registry: createRegistry(() => {
1142
1670
  emit("tools_changed", sessionId, alias, { toolCount: session.registry.count() });
1671
+ }),
1672
+ eventRegistry: createRegistry(() => {
1673
+ emit("events_changed", sessionId, alias, { eventCount: session.eventRegistry.count() });
1143
1674
  })
1144
1675
  };
1145
1676
  sessions.set(sessionId, session);
@@ -1148,12 +1679,16 @@ var createSessionManager = (options) => {
1148
1679
  emit("session_claimed", sessionId, alias, { device });
1149
1680
  return sessionId;
1150
1681
  };
1151
- const handleResume = (socket, message) => {
1682
+ const handleResume = (socket, message, transport) => {
1152
1683
  const session = sessions.get(message.session_id);
1153
1684
  if (!session) {
1154
1685
  closeSocket(socket, 1008, "unknown_session");
1155
1686
  return null;
1156
1687
  }
1688
+ if (session.transport !== transport) {
1689
+ closeSocket(socket, 1008, "wrong_transport");
1690
+ return null;
1691
+ }
1157
1692
  const candidateToken = decodeFixedLengthToken(message.resume_token, session.resumeToken.length);
1158
1693
  if (!tokensMatch(candidateToken, session.resumeToken)) {
1159
1694
  closeSocket(socket, 1008, "invalid_resume_token");
@@ -1171,6 +1706,7 @@ var createSessionManager = (options) => {
1171
1706
  session.resumeToken = randomBytes3(RESUME_TOKEN_BYTES);
1172
1707
  session.state = "active";
1173
1708
  session.suspendedAt = void 0;
1709
+ session.suspendReason = void 0;
1174
1710
  session.socket = socket;
1175
1711
  session.missedPongs = 0;
1176
1712
  attachSocketHandlers(session);
@@ -1184,6 +1720,9 @@ var createSessionManager = (options) => {
1184
1720
  closeSocket(socket, 1008, "unknown_session");
1185
1721
  return;
1186
1722
  }
1723
+ if (socket !== session.socket) {
1724
+ return;
1725
+ }
1187
1726
  switch (message.type) {
1188
1727
  case "tool_registry_snapshot": {
1189
1728
  if (!isToolRegistrySnapshotMessage(message)) {
@@ -1205,6 +1744,26 @@ var createSessionManager = (options) => {
1205
1744
  }
1206
1745
  return;
1207
1746
  }
1747
+ case "event_registry_snapshot": {
1748
+ if (!isEventRegistrySnapshotMessage(message)) {
1749
+ closeSocket(socket, 1008, "invalid_registry");
1750
+ return;
1751
+ }
1752
+ session.eventRegistry.snapshot(message.events);
1753
+ return;
1754
+ }
1755
+ case "event_registry_delta": {
1756
+ if (!isEventRegistryDeltaMessage(message)) {
1757
+ closeSocket(socket, 1008, "invalid_registry");
1758
+ return;
1759
+ }
1760
+ if (message.operation === "upsert") {
1761
+ session.eventRegistry.upsert(message.event);
1762
+ } else {
1763
+ session.eventRegistry.remove(message.name);
1764
+ }
1765
+ return;
1766
+ }
1208
1767
  case "tool_result": {
1209
1768
  if (!isToolResultMessage(message)) {
1210
1769
  closeSocket(socket, 1008, "invalid_message");
@@ -1272,7 +1831,14 @@ var createSessionManager = (options) => {
1272
1831
  };
1273
1832
  const resolveForTools = (selector) => {
1274
1833
  const session = resolveSession(selector);
1275
- return { sessionId: session.sessionId, alias: session.alias, state: session.state, registry: session.registry };
1834
+ return {
1835
+ sessionId: session.sessionId,
1836
+ alias: session.alias,
1837
+ state: session.state,
1838
+ suspendReason: session.suspendReason,
1839
+ registry: session.registry,
1840
+ eventRegistry: session.eventRegistry
1841
+ };
1276
1842
  };
1277
1843
  const sendToApp = (sessionId, message) => {
1278
1844
  const session = sessions.get(sessionId);
@@ -1306,19 +1872,12 @@ var createSessionManager = (options) => {
1306
1872
  // src/daemon/listener.ts
1307
1873
  var DEFAULT_PRE_CLAIM_TIMEOUT_MS = 1e4;
1308
1874
  var MAX_PAYLOAD_BYTES = 256 * 1024;
1875
+ var MAX_PORT_ATTEMPTS = 5;
1309
1876
  var isRecord = (value) => {
1310
1877
  return typeof value === "object" && value !== null && !Array.isArray(value);
1311
1878
  };
1312
- var startListener = async (options) => {
1313
- const timers = options.timers ?? systemTimers;
1314
- const preClaimTimeoutMs = options.preClaimTimeoutMs ?? DEFAULT_PRE_CLAIM_TIMEOUT_MS;
1315
- const material = options.tls.current();
1316
- const httpsServer = createHttpsServer({ key: material.keyPem, cert: material.certPem });
1317
- httpsServer.on("error", () => {
1318
- });
1319
- const wss = new WebSocketServer({ server: httpsServer, maxPayload: MAX_PAYLOAD_BYTES });
1320
- wss.on("error", () => {
1321
- });
1879
+ var attachFrameGate = (wss, options) => {
1880
+ const { timers, preClaimTimeoutMs } = options;
1322
1881
  wss.on("connection", (socket) => {
1323
1882
  socket.on("error", () => {
1324
1883
  });
@@ -1349,7 +1908,7 @@ var startListener = async (options) => {
1349
1908
  }
1350
1909
  if (claimedSessionId === null) {
1351
1910
  if (isSessionClaimMessage(parsed)) {
1352
- const result = options.sessionManager.handleClaim(socket, parsed);
1911
+ const result = options.sessionManager.handleClaim(socket, parsed, options.transport);
1353
1912
  if (result) {
1354
1913
  claimedSessionId = result;
1355
1914
  timers.clearTimeout(preClaimTimer);
@@ -1357,7 +1916,7 @@ var startListener = async (options) => {
1357
1916
  return;
1358
1917
  }
1359
1918
  if (isSessionResumeMessage(parsed)) {
1360
- const result = options.sessionManager.handleResume(socket, parsed);
1919
+ const result = options.sessionManager.handleResume(socket, parsed, options.transport);
1361
1920
  if (result) {
1362
1921
  claimedSessionId = result;
1363
1922
  timers.clearTimeout(preClaimTimer);
@@ -1378,39 +1937,138 @@ var startListener = async (options) => {
1378
1937
  options.sessionManager.handlePostClaimMessage(claimedSessionId, socket, parsed);
1379
1938
  });
1380
1939
  });
1381
- await new Promise((resolve, reject) => {
1940
+ };
1941
+ var listen = (server, port, host) => {
1942
+ return new Promise((resolve2, reject) => {
1382
1943
  const onError = (error) => {
1383
- httpsServer.off("listening", onListening);
1944
+ server.off("listening", onListening);
1384
1945
  reject(error);
1385
1946
  };
1386
1947
  const onListening = () => {
1387
- httpsServer.off("error", onError);
1388
- resolve();
1948
+ server.off("error", onError);
1949
+ resolve2();
1389
1950
  };
1390
- httpsServer.once("error", onError);
1391
- httpsServer.once("listening", onListening);
1392
- httpsServer.listen(options.port);
1951
+ server.once("error", onError);
1952
+ server.once("listening", onListening);
1953
+ server.listen(port, host);
1954
+ });
1955
+ };
1956
+ var closeServer = (wss, server) => {
1957
+ return new Promise((resolve2) => {
1958
+ for (const client of wss.clients) {
1959
+ client.terminate();
1960
+ }
1961
+ wss.close(() => {
1962
+ server.closeAllConnections();
1963
+ server.close(() => resolve2());
1964
+ });
1965
+ });
1966
+ };
1967
+ var boundPort = (server) => {
1968
+ const address = server.address();
1969
+ return address && typeof address !== "string" ? address.port : void 0;
1970
+ };
1971
+ var isAddressInUse = (error) => {
1972
+ return error.code === "EADDRINUSE";
1973
+ };
1974
+ var portInUseError = (port) => {
1975
+ return new Error(
1976
+ `Port ${port} is already in use by another program. Stop that program, or set a different wssPort in config.json.`
1977
+ );
1978
+ };
1979
+ var startListener = async (options) => {
1980
+ const timers = options.timers ?? systemTimers;
1981
+ const preClaimTimeoutMs = options.preClaimTimeoutMs ?? DEFAULT_PRE_CLAIM_TIMEOUT_MS;
1982
+ const material = options.tls.current();
1983
+ const httpsServer = createHttpsServer({ key: material.keyPem, cert: material.certPem });
1984
+ httpsServer.on("error", () => {
1985
+ });
1986
+ const wss = new WebSocketServer({ server: httpsServer, maxPayload: MAX_PAYLOAD_BYTES });
1987
+ wss.on("error", () => {
1393
1988
  });
1989
+ attachFrameGate(wss, { sessionManager: options.sessionManager, transport: "native", preClaimTimeoutMs, timers });
1990
+ let loopbackServer;
1991
+ for (let attempt = 1; ; attempt += 1) {
1992
+ try {
1993
+ await listen(httpsServer, options.port);
1994
+ } catch (error) {
1995
+ throw isAddressInUse(error) ? portInUseError(options.port) : error;
1996
+ }
1997
+ if (!options.bindLoopback) {
1998
+ break;
1999
+ }
2000
+ const port = boundPort(httpsServer);
2001
+ const candidate = createNetServer((socket) => httpsServer.emit("connection", socket));
2002
+ candidate.on("error", () => {
2003
+ });
2004
+ try {
2005
+ await listen(candidate, port, "127.0.0.1");
2006
+ loopbackServer = candidate;
2007
+ break;
2008
+ } catch (error) {
2009
+ await new Promise((resolve2) => httpsServer.close(() => resolve2()));
2010
+ if (!isAddressInUse(error)) {
2011
+ throw error;
2012
+ }
2013
+ if (options.port !== 0) {
2014
+ throw portInUseError(port);
2015
+ }
2016
+ if (attempt === MAX_PORT_ATTEMPTS) {
2017
+ throw new Error(`Could not find a free port: ${MAX_PORT_ATTEMPTS} ports in a row were in use on 127.0.0.1.`);
2018
+ }
2019
+ }
2020
+ }
1394
2021
  return {
1395
2022
  httpsServer,
1396
2023
  wss,
1397
- port: () => {
1398
- const address = httpsServer.address();
1399
- return address && typeof address !== "string" ? address.port : void 0;
1400
- },
2024
+ port: () => boundPort(httpsServer),
1401
2025
  applyTls: (nextMaterial) => {
1402
2026
  const server = httpsServer;
1403
2027
  server.setSecureContext?.({ key: nextMaterial.keyPem, cert: nextMaterial.certPem });
1404
2028
  },
1405
- close: () => new Promise((resolve) => {
1406
- for (const client of wss.clients) {
1407
- client.terminate();
2029
+ close: () => {
2030
+ loopbackServer?.close();
2031
+ return closeServer(wss, httpsServer);
2032
+ }
2033
+ };
2034
+ };
2035
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
2036
+ var isLoopbackOrigin = (origin) => {
2037
+ try {
2038
+ const url = new URL(origin);
2039
+ return (url.protocol === "http:" || url.protocol === "https:") && LOOPBACK_HOSTNAMES.has(url.hostname);
2040
+ } catch {
2041
+ return false;
2042
+ }
2043
+ };
2044
+ var startWebListener = async (options) => {
2045
+ const timers = options.timers ?? systemTimers;
2046
+ const preClaimTimeoutMs = options.preClaimTimeoutMs ?? DEFAULT_PRE_CLAIM_TIMEOUT_MS;
2047
+ const allowed = new Set(options.origins);
2048
+ const httpServer = createHttpServer((_request, response) => {
2049
+ response.writeHead(426, { "content-type": "text/plain" });
2050
+ response.end("Appduct web listener: WebSocket upgrades only.");
2051
+ });
2052
+ httpServer.on("error", () => {
2053
+ });
2054
+ const wss = new WebSocketServer({
2055
+ server: httpServer,
2056
+ maxPayload: MAX_PAYLOAD_BYTES,
2057
+ verifyClient: ({ origin }, callback) => {
2058
+ if (origin === void 0 || isLoopbackOrigin(origin) || allowed.has(origin)) {
2059
+ callback(true);
2060
+ } else {
2061
+ callback(false, 403, "Forbidden");
1408
2062
  }
1409
- wss.close(() => {
1410
- httpsServer.closeAllConnections();
1411
- httpsServer.close(() => resolve());
1412
- });
1413
- })
2063
+ }
2064
+ });
2065
+ wss.on("error", () => {
2066
+ });
2067
+ attachFrameGate(wss, { sessionManager: options.sessionManager, transport: "web", preClaimTimeoutMs, timers });
2068
+ await listen(httpServer, 0, "127.0.0.1");
2069
+ return {
2070
+ port: () => boundPort(httpServer),
2071
+ close: () => closeServer(wss, httpServer)
1414
2072
  };
1415
2073
  };
1416
2074
 
@@ -1424,88 +2082,7 @@ var evaluate = (descriptor, session, policy) => {
1424
2082
  if (descriptor.annotations?.destructiveHint === true) {
1425
2083
  return policy.destructive;
1426
2084
  }
1427
- return policy.default;
1428
- };
1429
-
1430
- // src/daemon/pidfile.ts
1431
- import { readFileSync as readFileSync3 } from "node:fs";
1432
- import { open as open2, readFile as readFile2, rm as rm2 } from "node:fs/promises";
1433
- var DaemonAlreadyRunningError = class extends Error {
1434
- constructor(pid) {
1435
- super(`Appduct daemon is already running (pid ${pid}).`);
1436
- this.pid = pid;
1437
- this.name = "DaemonAlreadyRunningError";
1438
- }
1439
- };
1440
- var readProcStatus = (pid) => {
1441
- try {
1442
- return readFileSync3(`/proc/${pid}/status`, "utf8");
1443
- } catch {
1444
- return void 0;
1445
- }
1446
- };
1447
- var isZombie = (pid, readStatus) => {
1448
- const status = readStatus(pid);
1449
- if (status === void 0) {
1450
- return false;
1451
- }
1452
- return /^State:\s*Z\b/mu.test(status);
1453
- };
1454
- var isProcessAlive = (pid, readStatus = readProcStatus) => {
1455
- try {
1456
- process.kill(pid, 0);
1457
- } catch (error) {
1458
- return error.code === "EPERM";
1459
- }
1460
- return !isZombie(pid, readStatus);
1461
- };
1462
- var readPidFromFile = async (pidFilePath) => {
1463
- let contents;
1464
- try {
1465
- contents = await readFile2(pidFilePath, "utf8");
1466
- } catch (error) {
1467
- if (error.code === "ENOENT") {
1468
- return void 0;
1469
- }
1470
- throw error;
1471
- }
1472
- const pid = Number.parseInt(contents.trim(), 10);
1473
- return Number.isInteger(pid) && pid > 0 ? pid : void 0;
1474
- };
1475
- var acquirePidfile = async (pidFilePath, options = {}) => {
1476
- const pid = options.pid ?? process.pid;
1477
- for (let attempt = 0; attempt < 5; attempt += 1) {
1478
- try {
1479
- const handle = await open2(pidFilePath, "wx", 384);
1480
- try {
1481
- await handle.writeFile(String(pid), "utf8");
1482
- } finally {
1483
- await handle.close();
1484
- }
1485
- let released = false;
1486
- return {
1487
- pid,
1488
- release: async () => {
1489
- if (released) {
1490
- return;
1491
- }
1492
- released = true;
1493
- await rm2(pidFilePath, { force: true });
1494
- }
1495
- };
1496
- } catch (error) {
1497
- if (error.code !== "EEXIST") {
1498
- throw error;
1499
- }
1500
- const existingPid = await readPidFromFile(pidFilePath);
1501
- if (existingPid !== void 0 && isProcessAlive(existingPid)) {
1502
- throw new DaemonAlreadyRunningError(existingPid);
1503
- }
1504
- await rm2(pidFilePath, { force: true });
1505
- await options.onStaleTakeover?.();
1506
- }
1507
- }
1508
- throw new Error(`Failed to acquire the Appduct daemon pidfile at "${pidFilePath}".`);
2085
+ return policy.default;
1509
2086
  };
1510
2087
 
1511
2088
  // src/daemon/node-append-only-file.ts
@@ -1541,7 +2118,7 @@ var NodeAppendOnlyFile = class {
1541
2118
 
1542
2119
  // src/daemon/rpc-server.ts
1543
2120
  import { createServer } from "node:net";
1544
- import { chmod as chmod2, rm as rm3 } from "node:fs/promises";
2121
+ import { chmod as chmod3, rm as rm3 } from "node:fs/promises";
1545
2122
  var MAX_LINE_BYTES = 1024 * 1024;
1546
2123
  var MAX_NOTIFY_BUFFERED_BYTES = 4 * 1024 * 1024;
1547
2124
  var isPlainObject = (value) => {
@@ -1679,20 +2256,20 @@ var startRpcServer = async (options) => {
1679
2256
  });
1680
2257
  server.on("error", () => {
1681
2258
  });
1682
- await new Promise((resolve, reject) => {
2259
+ await new Promise((resolve2, reject) => {
1683
2260
  const onError = (error) => {
1684
2261
  server.off("listening", onListening);
1685
2262
  reject(error);
1686
2263
  };
1687
2264
  const onListening = () => {
1688
2265
  server.off("error", onError);
1689
- resolve();
2266
+ resolve2();
1690
2267
  };
1691
2268
  server.once("error", onError);
1692
2269
  server.once("listening", onListening);
1693
2270
  server.listen(options.socketPath);
1694
2271
  });
1695
- await chmod2(options.socketPath, 384);
2272
+ await chmod3(options.socketPath, 384);
1696
2273
  return {
1697
2274
  server,
1698
2275
  notify: (connection, payload) => {
@@ -1706,42 +2283,15 @@ var startRpcServer = async (options) => {
1706
2283
  writeLine(connection.socket, { jsonrpc: "2.0", method: "event", params: payload });
1707
2284
  },
1708
2285
  connections: () => Array.from(connections.values()),
1709
- close: () => new Promise((resolve) => {
2286
+ close: () => new Promise((resolve2) => {
1710
2287
  for (const connection of connections.values()) {
1711
2288
  connection.socket.destroy();
1712
2289
  }
1713
- server.close(() => resolve());
2290
+ server.close(() => resolve2());
1714
2291
  })
1715
2292
  };
1716
2293
  };
1717
2294
 
1718
- // src/daemon/state-dir.ts
1719
- import { join as join4 } from "node:path";
1720
- import { mkdir as mkdir2, chmod as chmod3 } from "node:fs/promises";
1721
- var getStateDirPaths = (stateDir) => {
1722
- return {
1723
- root: stateDir,
1724
- socketPath: join4(stateDir, "daemon.sock"),
1725
- pidFilePath: join4(stateDir, "daemon.pid"),
1726
- logFilePath: join4(stateDir, "daemon.log"),
1727
- eventsLogPath: join4(stateDir, "events.log"),
1728
- keyPath: join4(stateDir, "key.pem"),
1729
- configPath: join4(stateDir, "config.json"),
1730
- auditDir: join4(stateDir, "audit"),
1731
- spawnLockPath: join4(stateDir, "daemon.spawn.lock")
1732
- };
1733
- };
1734
- var getSocketPath = (paths) => {
1735
- return paths.socketPath;
1736
- };
1737
- var ensureStateDir = async (stateDir) => {
1738
- const paths = getStateDirPaths(stateDir);
1739
- await mkdir2(paths.auditDir, { recursive: true });
1740
- await chmod3(paths.root, 448);
1741
- await chmod3(paths.auditDir, 448);
1742
- return paths;
1743
- };
1744
-
1745
2295
  // src/daemon/tls.ts
1746
2296
  import { stat as stat3, readFile as readFile3 } from "node:fs/promises";
1747
2297
 
@@ -1919,7 +2469,7 @@ var generateHostCertificate = async (keyPem, advertisedIp) => {
1919
2469
 
1920
2470
  // src/key-material.ts
1921
2471
  import { generateKeyPairSync } from "node:crypto";
1922
- import { dirname as dirname2 } from "node:path";
2472
+ import { dirname as dirname3 } from "node:path";
1923
2473
  import { mkdir as mkdir3, rename as rename2, rm as rm4, writeFile } from "node:fs/promises";
1924
2474
  var generatePrivateKeyPem = () => {
1925
2475
  const { privateKey } = generateKeyPairSync("rsa", {
@@ -1933,7 +2483,7 @@ var generatePrivateKeyPem = () => {
1933
2483
  };
1934
2484
  var writePrivateKeyAtomically = async (path, keyPem) => {
1935
2485
  const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
1936
- await mkdir3(dirname2(path), { recursive: true });
2486
+ await mkdir3(dirname3(path), { recursive: true });
1937
2487
  try {
1938
2488
  await writeFile(temporaryPath, keyPem, {
1939
2489
  encoding: "utf8",
@@ -2006,13 +2556,14 @@ var toAgentEndpoint = (address, port) => {
2006
2556
  var KNOWN_EVENT_KINDS = new Set(EVENT_KINDS);
2007
2557
  var packageVersion = () => getDaemonReportedVersion();
2008
2558
  var AUDIT_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2009
- var buildStatusResult = async (config, boundWssPort, startedAt, tls, sessionManager, auditLogger, auditDir) => {
2559
+ var buildStatusResult = async (config, boundWssPort, boundWebPort, startedAt, tls, sessionManager, auditLogger, auditDir) => {
2010
2560
  const stats = await auditLogger.stats();
2011
2561
  return {
2012
2562
  version: packageVersion(),
2013
2563
  pid: process.pid,
2014
2564
  startedAt: startedAt.toISOString(),
2015
2565
  wssPort: boundWssPort,
2566
+ webPort: boundWebPort,
2016
2567
  pinnedKeys: tls.pinnedKeys(),
2017
2568
  sessions: sessionManager.list(),
2018
2569
  pendingLinks: sessionManager.pendingLinkCount(),
@@ -2040,6 +2591,26 @@ var asSelectorParams = (params) => {
2040
2591
  }
2041
2592
  return { selector };
2042
2593
  };
2594
+ var asPageParams = (record) => {
2595
+ const limit = record.limit;
2596
+ if (limit !== void 0 && (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0)) {
2597
+ throw new RpcApplicationError("invalid_request", '"limit" must be a positive integer.');
2598
+ }
2599
+ const offset = record.offset;
2600
+ if (offset !== void 0 && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0)) {
2601
+ throw new RpcApplicationError("invalid_request", '"offset" must be a non-negative integer.');
2602
+ }
2603
+ return { limit, offset };
2604
+ };
2605
+ var asEventsListParams = (params) => {
2606
+ const { selector } = asSelectorParams(params);
2607
+ const record = asRecordParams(params);
2608
+ const name = record.name;
2609
+ if (name !== void 0 && typeof name !== "string") {
2610
+ throw new RpcApplicationError("invalid_request", '"name" must be a string.');
2611
+ }
2612
+ return { selector, name, ...asPageParams(record) };
2613
+ };
2043
2614
  var asToolsListParams = (params) => {
2044
2615
  const { selector } = asSelectorParams(params);
2045
2616
  const record = asRecordParams(params);
@@ -2057,14 +2628,7 @@ var asToolsListParams = (params) => {
2057
2628
  `"filter" must be a string of at most ${MAX_TOOLS_FILTER_LENGTH} characters.`
2058
2629
  );
2059
2630
  }
2060
- const limit = record.limit;
2061
- if (limit !== void 0 && (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0)) {
2062
- throw new RpcApplicationError("invalid_request", '"limit" must be a positive integer.');
2063
- }
2064
- const offset = record.offset;
2065
- if (offset !== void 0 && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0)) {
2066
- throw new RpcApplicationError("invalid_request", '"offset" must be a non-negative integer.');
2067
- }
2631
+ const { limit, offset } = asPageParams(record);
2068
2632
  return {
2069
2633
  selector,
2070
2634
  group,
@@ -2213,11 +2777,51 @@ var asLinkCreateParams = (params) => {
2213
2777
  if (addressOverride !== void 0 && (typeof addressOverride !== "string" || addressOverride.length === 0)) {
2214
2778
  throw new RpcApplicationError("invalid_request", '"addressOverride" must be a non-empty string.');
2215
2779
  }
2216
- return { ttlSeconds, addressOverride };
2780
+ const transport = record.transport;
2781
+ if (transport !== void 0 && transport !== "native" && transport !== "web") {
2782
+ throw new RpcApplicationError("invalid_request", '"transport" must be "native" or "web".');
2783
+ }
2784
+ return { ttlSeconds, addressOverride, transport };
2785
+ };
2786
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "[::1]", "localhost"]);
2787
+ var asWebAttachParams = (params) => {
2788
+ if (typeof params !== "object" || params === null || Array.isArray(params)) {
2789
+ throw new RpcApplicationError("invalid_request", "Params must be an object.");
2790
+ }
2791
+ const { url, browserUrl, targetId, ttlSeconds } = params;
2792
+ for (const [field, value] of [["url", url], ["browserUrl", browserUrl], ["targetId", targetId]]) {
2793
+ if (value !== void 0 && (typeof value !== "string" || value.length === 0)) {
2794
+ throw new RpcApplicationError("invalid_request", `"${field}" must be a non-empty string.`);
2795
+ }
2796
+ }
2797
+ if (url === void 0 || browserUrl === void 0) {
2798
+ throw new RpcApplicationError("invalid_request", '"url" and "browserUrl" are required.');
2799
+ }
2800
+ let parsedBrowserUrl;
2801
+ try {
2802
+ parsedBrowserUrl = new URL(browserUrl);
2803
+ } catch {
2804
+ }
2805
+ if (parsedBrowserUrl === void 0 || parsedBrowserUrl.protocol !== "http:" && parsedBrowserUrl.protocol !== "https:") {
2806
+ throw new RpcApplicationError("invalid_request", `"browserUrl" must be an http(s) URL such as http://127.0.0.1:9222 (got "${browserUrl}").`);
2807
+ }
2808
+ if (!LOOPBACK_HOSTS.has(parsedBrowserUrl.hostname)) {
2809
+ throw new RpcApplicationError(
2810
+ "invalid_request",
2811
+ `"browserUrl" must be a loopback address (127.0.0.1, [::1] or localhost), because a debugging port gives full control of the browser (got "${browserUrl}").`
2812
+ );
2813
+ }
2814
+ if (ttlSeconds !== void 0 && (typeof ttlSeconds !== "number" || !Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
2815
+ throw new RpcApplicationError("invalid_request", '"ttlSeconds" must be a positive integer.');
2816
+ }
2817
+ return { url, browserUrl, targetId, ttlSeconds };
2217
2818
  };
2218
2819
  var startDaemon = async (options) => {
2219
2820
  const clock = options.clock ?? { now: () => /* @__PURE__ */ new Date() };
2220
2821
  const timers = options.timers ?? systemTimers;
2822
+ const connectDevtoolsBrowser = options.connectDevtoolsBrowser ?? connectNodeDevtoolsBrowser;
2823
+ const devtoolsBrowsers = /* @__PURE__ */ new Map();
2824
+ const relayedTabs = /* @__PURE__ */ new Map();
2221
2825
  const paths = getStateDirPaths(options.stateDir);
2222
2826
  await ensureStateDir(options.stateDir);
2223
2827
  const config = await loadConfig(paths, { warn: options.warn });
@@ -2240,15 +2844,17 @@ var startDaemon = async (options) => {
2240
2844
  let pidfile;
2241
2845
  let server;
2242
2846
  let listener;
2847
+ let webListener;
2243
2848
  let tlsManager;
2244
2849
  let sessionManager;
2245
2850
  let callsManager;
2246
2851
  let eventBus;
2247
2852
  let boundWssPort = config.wssPort;
2853
+ let boundWebPort = 0;
2248
2854
  let shuttingDown = false;
2249
2855
  let resolveExited;
2250
- const exited = new Promise((resolve) => {
2251
- resolveExited = resolve;
2856
+ const exited = new Promise((resolve2) => {
2857
+ resolveExited = resolve2;
2252
2858
  });
2253
2859
  const shutdown = async () => {
2254
2860
  if (shuttingDown) {
@@ -2261,8 +2867,10 @@ var startDaemon = async (options) => {
2261
2867
  auditPruneInterval = void 0;
2262
2868
  }
2263
2869
  callsManager?.disposeAll();
2870
+ devtoolsBrowsers.forEach((browser) => browser.close());
2264
2871
  sessionManager?.disposeAll(1001, "daemon_shutdown");
2265
2872
  await listener?.close();
2873
+ await webListener?.close();
2266
2874
  await server?.close();
2267
2875
  await pidfile?.release();
2268
2876
  await rm5(getSocketPath(paths), { force: true });
@@ -2294,7 +2902,8 @@ var startDaemon = async (options) => {
2294
2902
  linkTtlSeconds: config.linkTtlSeconds,
2295
2903
  // `boundWssPort`, not `config.wssPort`: a link minted by a daemon on an OS-assigned port
2296
2904
  // must advertise the port the app can actually reach, not the `0` that asked for one.
2297
- getEndpoint: () => toAgentEndpoint(tls.current().advertisedAddress, boundWssPort),
2905
+ // A web link points at the loopback web listener, never the advertised LAN address.
2906
+ getEndpoint: (transport) => transport === "web" ? { family: 4, address: "127.0.0.1", port: boundWebPort } : toAgentEndpoint(tls.current().advertisedAddress, boundWssPort),
2298
2907
  eventBus: activeEventBus,
2299
2908
  clock,
2300
2909
  onToolFrame: (message) => {
@@ -2327,11 +2936,14 @@ var startDaemon = async (options) => {
2327
2936
  const activeCallsManager = callsManager;
2328
2937
  listener = await startListener({
2329
2938
  port: config.wssPort,
2939
+ bindLoopback: process.platform === "darwin",
2330
2940
  tls,
2331
2941
  sessionManager
2332
2942
  });
2333
2943
  const activeListener = listener;
2334
2944
  boundWssPort = activeListener.port() ?? config.wssPort;
2945
+ webListener = await startWebListener({ sessionManager, origins: config.webOrigins, timers });
2946
+ boundWebPort = webListener.port() ?? 0;
2335
2947
  activeEventBus.subscribe((event) => {
2336
2948
  for (const connection of server?.connections() ?? []) {
2337
2949
  const subscription = connection.state.eventSubscription;
@@ -2354,13 +2966,22 @@ var startDaemon = async (options) => {
2354
2966
  server?.notify(connection, projected);
2355
2967
  }
2356
2968
  });
2969
+ const mintLink = async (ttlSeconds, addressOverride, transport) => {
2970
+ const previousMaterial = tls.current();
2971
+ const nextMaterial = await tls.refresh();
2972
+ if (nextMaterial !== previousMaterial) {
2973
+ activeListener.applyTls(nextMaterial);
2974
+ }
2975
+ return { ...activeSessionManager.createLink(ttlSeconds, addressOverride, transport), pin: tls.pinnedKeys()[0] };
2976
+ };
2357
2977
  server = await startRpcServer({
2358
2978
  socketPath: getSocketPath(paths),
2359
2979
  dispatch: {
2360
- [RPC_METHODS.daemonStatus]: async () => {
2980
+ [RPC_METHODS3.daemonStatus]: async () => {
2361
2981
  return buildStatusResult(
2362
2982
  config,
2363
2983
  boundWssPort,
2984
+ boundWebPort,
2364
2985
  startedAt,
2365
2986
  tls,
2366
2987
  activeSessionManager,
@@ -2368,34 +2989,60 @@ var startDaemon = async (options) => {
2368
2989
  paths.auditDir
2369
2990
  );
2370
2991
  },
2371
- [RPC_METHODS.daemonShutdown]: (_params, context) => {
2992
+ [RPC_METHODS3.daemonShutdown]: (_params, context) => {
2372
2993
  context.afterSend(() => {
2373
2994
  void shutdown();
2374
2995
  });
2375
2996
  return { ok: true };
2376
2997
  },
2377
- [RPC_METHODS.linkCreate]: async (params) => {
2378
- const { ttlSeconds, addressOverride } = asLinkCreateParams(params);
2379
- const previousMaterial = tls.current();
2380
- const nextMaterial = await tls.refresh();
2381
- if (nextMaterial !== previousMaterial) {
2382
- activeListener.applyTls(nextMaterial);
2998
+ [RPC_METHODS3.linkCreate]: async (params) => {
2999
+ const { ttlSeconds, addressOverride, transport } = asLinkCreateParams(params);
3000
+ return mintLink(ttlSeconds, addressOverride, transport);
3001
+ },
3002
+ [RPC_METHODS3.webAttach]: async (params) => {
3003
+ const { url, browserUrl, targetId, ttlSeconds } = asWebAttachParams(params);
3004
+ let browser = devtoolsBrowsers.get(browserUrl);
3005
+ if (browser === void 0) {
3006
+ browser = connectDevtoolsBrowser(browserUrl);
3007
+ devtoolsBrowsers.set(browserUrl, browser);
3008
+ }
3009
+ try {
3010
+ return await attachBrowserTab({
3011
+ browser,
3012
+ relayedTabs,
3013
+ url,
3014
+ targetId,
3015
+ openDaemonSocket: openNodeDaemonSocket,
3016
+ mintLink: async () => composeWebLink(url, await mintLink(ttlSeconds, void 0, "web"))
3017
+ });
3018
+ } catch (error) {
3019
+ throw new RpcApplicationError("invalid_request", error instanceof Error ? error.message : String(error));
2383
3020
  }
2384
- return { ...activeSessionManager.createLink(ttlSeconds, addressOverride), pin: tls.pinnedKeys()[0] };
2385
3021
  },
2386
- [RPC_METHODS.sessionsList]: () => {
3022
+ [RPC_METHODS3.sessionsList]: () => {
2387
3023
  return activeSessionManager.list();
2388
3024
  },
2389
- [RPC_METHODS.sessionsDescribe]: (params) => {
3025
+ [RPC_METHODS3.sessionsDescribe]: (params) => {
2390
3026
  const { selector } = asSelectorParams(params);
2391
3027
  return activeSessionManager.describe(selector);
2392
3028
  },
2393
- [RPC_METHODS.sessionsRevoke]: (params) => {
3029
+ [RPC_METHODS3.sessionsRevoke]: (params) => {
2394
3030
  const { selector } = asSelectorParams(params);
2395
3031
  activeSessionManager.revoke(selector);
2396
3032
  return { ok: true };
2397
3033
  },
2398
- [RPC_METHODS.toolsList]: (params) => {
3034
+ [RPC_METHODS3.eventsList]: (params) => {
3035
+ const { selector, name, limit, offset } = asEventsListParams(params);
3036
+ const registered = activeSessionManager.resolveForTools(selector).eventRegistry.list();
3037
+ const sorted = registered.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
3038
+ const matching = name === void 0 ? sorted : sorted.filter((event) => matchesNameGlob(name, event.name));
3039
+ const start = offset ?? 0;
3040
+ return {
3041
+ events: limit === void 0 ? matching.slice(start) : matching.slice(start, start + limit),
3042
+ total: matching.length
3043
+ };
3044
+ },
3045
+ [RPC_METHODS3.toolsList]: (params) => {
2399
3046
  const { selector, group, filter, limit, offset } = asToolsListParams(params);
2400
3047
  const resolved = activeSessionManager.resolveForTools(selector);
2401
3048
  const entries = resolved.registry.list().map((descriptor) => ({
@@ -2420,7 +3067,7 @@ var startDaemon = async (options) => {
2420
3067
  // This handler is the single seam every `tools.call` passes through: policy (ARCHITECTURE.md
2421
3068
  // §12) is evaluated once the target tool descriptor is known, and one audit record is
2422
3069
  // written for every attempt that reaches a resolved session, across ok/error/denied.
2423
- [RPC_METHODS.toolsCall]: async (params, context) => {
3070
+ [RPC_METHODS3.toolsCall]: async (params, context) => {
2424
3071
  const { selector, name, args, timeoutMs, caller, consent } = asToolsCallParams(params);
2425
3072
  const effectiveCaller = caller ?? "cli";
2426
3073
  const resolved = activeSessionManager.resolveForTools(selector);
@@ -2442,7 +3089,10 @@ var startDaemon = async (options) => {
2442
3089
  if (resolved.state !== "active") {
2443
3090
  const errorType = resolved.state === "suspended" ? "session_suspended" : "session_not_active";
2444
3091
  writeAudit("error", errorType);
2445
- throw new RpcApplicationError(errorType, `Session "${resolved.alias}" is not active.`);
3092
+ throw new RpcApplicationError(
3093
+ errorType,
3094
+ resolved.suspendReason === "app_backgrounded" ? appBackgroundedMessage(resolved.alias) : `Session "${resolved.alias}" is not active.`
3095
+ );
2446
3096
  }
2447
3097
  const tool = resolved.registry.get(name);
2448
3098
  if (!tool) {
@@ -2523,13 +3173,13 @@ var startDaemon = async (options) => {
2523
3173
  unregisterOnClose();
2524
3174
  }
2525
3175
  },
2526
- [RPC_METHODS.toolsCancel]: (params) => {
3176
+ [RPC_METHODS3.toolsCancel]: (params) => {
2527
3177
  const { selector, callId, reason } = asToolsCancelParams(params);
2528
3178
  const resolved = activeSessionManager.resolveForTools(selector);
2529
3179
  const cancelled = activeCallsManager.cancel(resolved.sessionId, callId, reason ?? "client_cancelled");
2530
3180
  return { cancelled };
2531
3181
  },
2532
- [RPC_METHODS.eventsSubscribe]: (params, context) => {
3182
+ [RPC_METHODS3.eventsSubscribe]: (params, context) => {
2533
3183
  const { sessionSelector, kinds, name, payloadMaxBytes } = asEventsSubscribeParams(params);
2534
3184
  context.connection.state.eventSubscription = {
2535
3185
  sessionSelector,
@@ -2539,7 +3189,7 @@ var startDaemon = async (options) => {
2539
3189
  };
2540
3190
  return { ok: true };
2541
3191
  },
2542
- [RPC_METHODS.eventsSince]: (params) => {
3192
+ [RPC_METHODS3.eventsSince]: (params) => {
2543
3193
  const { selector, since, limit, name, payloadMaxBytes } = asEventsSinceParams(params);
2544
3194
  const resolved = activeSessionManager.describe(selector);
2545
3195
  return activeEventBus.since(resolved.sessionId, { since, limit, name, payloadMaxBytes });
@@ -2558,6 +3208,7 @@ var startDaemon = async (options) => {
2558
3208
  callsManager?.disposeAll();
2559
3209
  sessionManager?.disposeAll(1001, "daemon_startup_failed");
2560
3210
  await listener?.close();
3211
+ await webListener?.close();
2561
3212
  await pidfile?.release();
2562
3213
  throw error;
2563
3214
  }
@@ -2568,6 +3219,7 @@ var startDaemon = async (options) => {
2568
3219
  startedAt,
2569
3220
  server,
2570
3221
  listener,
3222
+ webListener,
2571
3223
  tls: tlsManager,
2572
3224
  eventBus,
2573
3225
  exited,
@@ -2575,175 +3227,6 @@ var startDaemon = async (options) => {
2575
3227
  };
2576
3228
  };
2577
3229
 
2578
- // src/rpc/client.ts
2579
- import { join as join5 } from "node:path";
2580
- import { RPC_METHODS as RPC_METHODS2 } from "@appduct/shared";
2581
- var defaultBinPath = join5(getPackageRoot(), "bin.js");
2582
- var DaemonRpcError = class extends Error {
2583
- constructor(code, message, data) {
2584
- super(message);
2585
- this.code = code;
2586
- this.data = data;
2587
- this.name = "DaemonRpcError";
2588
- }
2589
- };
2590
- var DaemonUnavailableError = class extends Error {
2591
- constructor(message, reason = "unreachable") {
2592
- super(message);
2593
- this.reason = reason;
2594
- this.name = "DaemonUnavailableError";
2595
- }
2596
- };
2597
- var describeLiveState = (state) => {
2598
- const parts = [];
2599
- if (state.sessions > 0) {
2600
- parts.push(`${state.sessions} connected session(s)`);
2601
- }
2602
- if (state.pendingLinks > 0) {
2603
- parts.push(`${state.pendingLinks} unclaimed link(s)`);
2604
- }
2605
- return parts.join(" and ");
2606
- };
2607
- var DaemonVersionMismatchError = class extends Error {
2608
- constructor(daemonVersion, clientVersion, live, message) {
2609
- super(
2610
- message ?? `The running Appduct daemon is version ${daemonVersion}, but this client is version ${clientVersion}. It was not restarted automatically because ${describeLiveState(live)} would be lost. Run "appduct daemon stop" once that state is expendable, or force it with "--daemon-restart".`
2611
- );
2612
- this.daemonVersion = daemonVersion;
2613
- this.clientVersion = clientVersion;
2614
- this.name = "DaemonVersionMismatchError";
2615
- this.sessionCount = live.sessions;
2616
- this.pendingLinkCount = live.pendingLinks;
2617
- }
2618
- sessionCount;
2619
- pendingLinkCount;
2620
- };
2621
- var isConnectionMissingError = (error) => {
2622
- const code = error.code;
2623
- return code === "ENOENT" || code === "ECONNREFUSED";
2624
- };
2625
- var isDaemonUnreachableError = (error) => {
2626
- return error instanceof DaemonUnavailableError || isConnectionMissingError(error);
2627
- };
2628
-
2629
- // src/errors.ts
2630
- var EXIT_CODE_BY_ERROR_TYPE = {
2631
- usage_error: 64,
2632
- validation_error: 65,
2633
- // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
2634
- // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
2635
- // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
2636
- // assertion failure (docs/tasks/08-appduct-doctor.md).
2637
- inspection_error: 66,
2638
- connection_error: 70,
2639
- session_error: 71,
2640
- tool_error: 72,
2641
- permission_error: 77,
2642
- internal_error: 1,
2643
- // `doctor --assert-present`/`--assert-absent`: the artifact was inspected successfully and the
2644
- // assertion did not hold. Distinct from `inspection_error` (66) — this means the answer is known
2645
- // and it's "no", not "couldn't tell" — and distinct from `internal_error` (1) so a caller can't
2646
- // conflate a normal assertion failure with an unexpected crash.
2647
- assertion_error: 3
2648
- };
2649
- var RPC_ERROR_EXIT_CLASS = {
2650
- no_session: "session_error",
2651
- ambiguous_session: "session_error",
2652
- unknown_session: "session_error",
2653
- session_not_active: "session_error",
2654
- session_suspended: "session_error",
2655
- tool_not_found: "tool_error",
2656
- tool_input_validation_error: "tool_error",
2657
- tool_output_validation_error: "tool_error",
2658
- tool_execution_error: "tool_error",
2659
- tool_serialization_error: "tool_error",
2660
- tool_timeout: "tool_error",
2661
- tool_cancelled: "tool_error",
2662
- policy_denied: "permission_error",
2663
- invalid_request: "validation_error"
2664
- };
2665
- var AppductCliError = class extends Error {
2666
- type;
2667
- details;
2668
- constructor(type, message, details) {
2669
- super(message);
2670
- this.name = "AppductCliError";
2671
- this.type = type;
2672
- this.details = details;
2673
- }
2674
- };
2675
- var connectionError = (message, details) => {
2676
- return new AppductCliError("connection_error", message, details);
2677
- };
2678
- var isAppductCliError = (value) => {
2679
- return value instanceof AppductCliError;
2680
- };
2681
- var rpcErrorClass = (error) => {
2682
- const type = error.data?.type;
2683
- return type ? RPC_ERROR_EXIT_CLASS[type] : void 0;
2684
- };
2685
- var toCliError = (error) => {
2686
- if (isAppductCliError(error)) {
2687
- return {
2688
- type: error.type,
2689
- message: error.message,
2690
- details: error.details
2691
- };
2692
- }
2693
- if (error instanceof DaemonVersionMismatchError) {
2694
- return {
2695
- type: "connection_error",
2696
- message: error.message,
2697
- details: {
2698
- daemon_version: error.daemonVersion,
2699
- client_version: error.clientVersion,
2700
- session_count: error.sessionCount,
2701
- pending_link_count: error.pendingLinkCount
2702
- }
2703
- };
2704
- }
2705
- if (error instanceof DaemonRpcError && error.data?.type) {
2706
- return {
2707
- type: error.data.type,
2708
- message: error.message,
2709
- details: error.data.details
2710
- };
2711
- }
2712
- if (isDaemonUnreachableError(error)) {
2713
- return {
2714
- type: "connection_error",
2715
- message: error instanceof Error ? error.message : "The Appduct daemon is unreachable."
2716
- };
2717
- }
2718
- if (error instanceof Error) {
2719
- return {
2720
- type: "internal_error",
2721
- message: error.message
2722
- };
2723
- }
2724
- return {
2725
- type: "internal_error",
2726
- message: "An unexpected error occurred.",
2727
- details: error
2728
- };
2729
- };
2730
- var getExitCodeForError = (error) => {
2731
- if (isAppductCliError(error)) {
2732
- return EXIT_CODE_BY_ERROR_TYPE[error.type];
2733
- }
2734
- if (error instanceof DaemonVersionMismatchError) {
2735
- return EXIT_CODE_BY_ERROR_TYPE.connection_error;
2736
- }
2737
- if (error instanceof DaemonRpcError) {
2738
- const cliErrorClass = rpcErrorClass(error);
2739
- return EXIT_CODE_BY_ERROR_TYPE[cliErrorClass ?? "internal_error"];
2740
- }
2741
- if (isDaemonUnreachableError(error)) {
2742
- return EXIT_CODE_BY_ERROR_TYPE.connection_error;
2743
- }
2744
- return EXIT_CODE_BY_ERROR_TYPE.internal_error;
2745
- };
2746
-
2747
3230
  // src/commands/daemon/run.ts
2748
3231
  var handleDaemonRunCommand = async (context) => {
2749
3232
  let daemon;
@@ -2786,6 +3269,7 @@ var formatJson = (value, flags) => {
2786
3269
  import pc from "picocolors";
2787
3270
  import {
2788
3271
  formatAgentWebSocketUrl,
3272
+ renderEventSignature,
2789
3273
  renderToolSignature,
2790
3274
  summarizeToolDescription
2791
3275
  } from "@appduct/shared";
@@ -2881,6 +3365,13 @@ var formatDevice = (device) => {
2881
3365
  const label = parts.length > 0 ? parts.join(" ") : "unknown device";
2882
3366
  return device.os ? `${label} (${device.os})` : label;
2883
3367
  };
3368
+ var SUSPEND_REASON_LABELS = {
3369
+ app_backgrounded: "app in background",
3370
+ connection_lost: "connection lost"
3371
+ };
3372
+ var formatSessionState = (session) => {
3373
+ return session.suspendReason ? `${session.state} (${SUSPEND_REASON_LABELS[session.suspendReason]})` : session.state;
3374
+ };
2884
3375
  var renderLsData = (colors, data, now) => {
2885
3376
  if (data.length === 0) {
2886
3377
  return [colors.dim("Sessions"), " No Appduct sessions are registered."];
@@ -2889,7 +3380,7 @@ var renderLsData = (colors, data, now) => {
2889
3380
  const headers = ["Alias", "State", "Device", "Tools", "Age"];
2890
3381
  const rows = data.map((session) => [
2891
3382
  session.alias,
2892
- session.state,
3383
+ formatSessionState(session),
2893
3384
  formatDevice(session.device),
2894
3385
  String(session.toolCount),
2895
3386
  formatAge(session.claimedAt ?? session.createdAt, nowIso)
@@ -3063,10 +3554,87 @@ var renderToolsListData = (colors, data, flags, full) => {
3063
3554
  }
3064
3555
  return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
3065
3556
  };
3557
+ var renderEventsTruncationLine = (data) => {
3558
+ const offset = data.offset ?? 0;
3559
+ if (data.events.length >= data.total) {
3560
+ return [];
3561
+ }
3562
+ return [
3563
+ "",
3564
+ `Showing ${data.events.length} of ${data.total} events (offset ${offset}). Narrow with --name <glob> or page with --offset <n>.`
3565
+ ];
3566
+ };
3567
+ var renderEventsListData = (colors, data, flags) => {
3568
+ const [only] = data.events;
3569
+ if (data.name !== void 0 && !data.name.includes("*") && only !== void 0) {
3570
+ return renderFields(
3571
+ colors.green(`Event: ${only.name}`),
3572
+ [
3573
+ ["Signature", renderEventSignature(only)],
3574
+ ["Description", only.description],
3575
+ ["Payload schema", only.payload_schema]
3576
+ ],
3577
+ flags
3578
+ );
3579
+ }
3580
+ if (data.events.length === 0 && data.total > 0) {
3581
+ return [
3582
+ colors.green("Events"),
3583
+ ` No events at offset ${data.offset ?? 0}; ${data.total} matching event${data.total === 1 ? "" : "s"} in total.`
3584
+ ];
3585
+ }
3586
+ if (data.events.length === 0) {
3587
+ return [
3588
+ colors.green("Events"),
3589
+ data.name === void 0 ? " No events declared." : ` No events match ${JSON.stringify(data.name)}.`
3590
+ ];
3591
+ }
3592
+ const lines = data.events.flatMap((event) => [
3593
+ ` ${renderEventSignature(event)}`,
3594
+ ` ${summarizeToolDescription(event.description)}`
3595
+ ]);
3596
+ return [
3597
+ colors.green("Events"),
3598
+ ...lines,
3599
+ ...renderEventsTruncationLine(data),
3600
+ "",
3601
+ "Run `appduct events ls --name <name>` for an event's full payload schema."
3602
+ ];
3603
+ };
3066
3604
  var renderInvokeData = (colors, data, flags) => {
3067
3605
  return [colors.green("Result"), formatScalar(data, flags)];
3068
3606
  };
3069
3607
  var renderLinkData = (colors, data, flags, qr) => {
3608
+ if ("attached" in data) {
3609
+ return [
3610
+ colors.green("Page Attached"),
3611
+ ...renderFields(
3612
+ "Page",
3613
+ [
3614
+ ["Session", data.sessionId],
3615
+ ["Tab", data.targetId],
3616
+ ["URL", data.url],
3617
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
3618
+ ],
3619
+ flags
3620
+ )
3621
+ ];
3622
+ }
3623
+ if ("script" in data) {
3624
+ return [
3625
+ colors.green("Link Created"),
3626
+ ...renderFields(
3627
+ "Link",
3628
+ [
3629
+ ["Session", data.sessionId],
3630
+ ["URL", data.url],
3631
+ ["Script", data.script],
3632
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
3633
+ ],
3634
+ flags
3635
+ )
3636
+ ];
3637
+ }
3070
3638
  const lines = [
3071
3639
  colors.green("Link Created"),
3072
3640
  ...renderFields(
@@ -3187,6 +3755,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
3187
3755
  ["PID", data.daemon.pid],
3188
3756
  ["Started at", data.daemon.started_at],
3189
3757
  ["WSS port", data.daemon.wss_port],
3758
+ ["Web port", data.daemon.web_port],
3190
3759
  ["Pinned keys", data.daemon.pinned_keys.length],
3191
3760
  ["Sessions", data.daemon.session_count]
3192
3761
  ],
@@ -3254,6 +3823,8 @@ var renderSuccessData = (colors, command, data, options) => {
3254
3823
  return renderToolsListData(colors, data, flags, options.full);
3255
3824
  case "tools describe":
3256
3825
  return renderToolDetail(colors, data, flags);
3826
+ case "events ls":
3827
+ return renderEventsListData(colors, data, flags);
3257
3828
  case "tools call":
3258
3829
  return renderInvokeData(colors, data, flags);
3259
3830
  case "sessions revoke":