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
@@ -14,6 +14,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
14
14
  "daemonLogMaxBytes",
15
15
  "eventsLogMaxBytes",
16
16
  "policy",
17
+ "webOrigins",
17
18
  "advertisedIp",
18
19
  "scheme",
19
20
  "restartDaemonOnVersionMismatch"
@@ -74,6 +75,7 @@ var defaultConfig = (paths) => {
74
75
  daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
75
76
  eventsLogMaxBytes: 10 * 1024 * 1024,
76
77
  restartDaemonOnVersionMismatch: false,
78
+ webOrigins: [],
77
79
  policy: {
78
80
  default: "allow",
79
81
  destructive: "allow"
@@ -144,6 +146,12 @@ var loadConfig = async (paths, options = {}) => {
144
146
  "restartDaemonOnVersionMismatch"
145
147
  );
146
148
  }
149
+ if (parsed.webOrigins !== void 0) {
150
+ if (!Array.isArray(parsed.webOrigins)) {
151
+ throw configError("webOrigins", "must be an array of origin strings.");
152
+ }
153
+ config.webOrigins = parsed.webOrigins.map((origin) => requireNonEmptyString(origin, "webOrigins"));
154
+ }
147
155
  if (parsed.policy !== void 0) {
148
156
  if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
149
157
  throw configError("policy", "must be an object.");
@@ -1069,7 +1077,7 @@ var EXIT_CODE_BY_ERROR_TYPE = {
1069
1077
  // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
1070
1078
  // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
1071
1079
  // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
1072
- // assertion failure (docs/tasks/08-appduct-doctor.md).
1080
+ // assertion failure.
1073
1081
  inspection_error: 66,
1074
1082
  connection_error: 70,
1075
1083
  session_error: 71,
@@ -1829,23 +1837,34 @@ var probeIosProjectYml = async (root) => {
1829
1837
  };
1830
1838
  };
1831
1839
  var PROBES = [
1832
- probeAndroidGradle,
1833
- probeAndroidManifest,
1834
- probeIosInfoPlist,
1835
- probeIosProjectYml
1840
+ ["android", probeAndroidGradle],
1841
+ ["android", probeAndroidManifest],
1842
+ ["ios", probeIosInfoPlist],
1843
+ ["ios", probeIosProjectYml]
1836
1844
  ];
1845
+ var isFlutterProject = async (root) => {
1846
+ return (await readCappedText(join5(root, "pubspec.yaml"))).ok;
1847
+ };
1837
1848
  var discoverNativeScheme = async (root) => {
1838
1849
  const tried = [];
1839
1850
  const results = [];
1840
- for (const probe of PROBES) {
1841
- const outcome = await probe(root);
1842
- tried.push(...outcome.tried);
1843
- if (outcome.result !== void 0) {
1844
- results.push(outcome.result);
1851
+ const flutter = await isFlutterProject(root);
1852
+ const androidRoot = flutter ? join5(root, "android") : root;
1853
+ const iosRoots = flutter ? [join5(root, "ios"), join5(root, "macos")] : [root];
1854
+ for (const [platform, probe] of PROBES) {
1855
+ let roots = [root];
1856
+ if (platform === "android") {
1857
+ roots = [androidRoot];
1858
+ } else if (probe === probeIosInfoPlist) {
1859
+ roots = iosRoots;
1860
+ }
1861
+ for (const probeRoot of roots) {
1862
+ const outcome = await probe(probeRoot);
1863
+ tried.push(...outcome.tried);
1864
+ if (outcome.result !== void 0) {
1865
+ results.push(outcome.result);
1866
+ }
1845
1867
  }
1846
- }
1847
- if (results.length === 0) {
1848
- return { tried };
1849
1868
  }
1850
1869
  const distinctSchemes = new Set(results.map((entry) => entry.scheme));
1851
1870
  if (distinctSchemes.size > 1) {
@@ -1853,7 +1872,7 @@ var discoverNativeScheme = async (root) => {
1853
1872
  "Conflicting deep-link schemes found in this project: " + results.map((entry) => `${entry.origin} says ${JSON.stringify(entry.scheme)}`).join(", but ") + ". Appduct will not guess which one is right \u2014 pass --scheme <scheme> to pick one."
1854
1873
  );
1855
1874
  }
1856
- return { result: results[0], tried };
1875
+ return { results, tried };
1857
1876
  };
1858
1877
 
1859
1878
  // src/scheme.ts
@@ -1916,19 +1935,21 @@ var discoverStaticProjectScheme = async (cwd) => {
1916
1935
  const tried = [`${appJsonPath} ("expo.scheme")`];
1917
1936
  const appJsonScheme = await discoverExpoScheme(cwd);
1918
1937
  if (appJsonScheme !== void 0) {
1919
- return { scheme: appJsonScheme, source: "app-json", origin: appJsonPath, tried };
1938
+ return { scheme: appJsonScheme, source: "app-json", origin: appJsonPath, nativeResults: [], tried };
1920
1939
  }
1921
1940
  const native = await discoverNativeScheme(resolve(cwd));
1922
1941
  tried.push(...native.tried);
1923
- if (native.result !== void 0) {
1942
+ const [first] = native.results;
1943
+ if (first !== void 0) {
1924
1944
  return {
1925
- scheme: native.result.scheme,
1926
- source: native.result.source,
1927
- origin: native.result.origin,
1945
+ scheme: first.scheme,
1946
+ source: first.source,
1947
+ origin: first.origin,
1948
+ nativeResults: native.results,
1928
1949
  tried
1929
1950
  };
1930
1951
  }
1931
- return { tried };
1952
+ return { nativeResults: [], tried };
1932
1953
  };
1933
1954
  var globalConfigDirs = (options = {}) => {
1934
1955
  const excluded = /* @__PURE__ */ new Set([join6(resolve(options.homeDir ?? homedir()), PROJECT_CONFIG_DIR)]);
@@ -2104,6 +2125,25 @@ ${tried.map((location, index) => ` ${index + 1}. ${location}`).join("\n")}
2104
2125
 
2105
2126
  // src/link.ts
2106
2127
  var composeDeepLink = (scheme, result) => `${scheme}:///?appduct=${result.deepLinkPayload}&pin=${encodeURIComponent(result.pin)}`;
2128
+ var assertWebPageUrl = (pageUrl) => {
2129
+ let protocol;
2130
+ try {
2131
+ protocol = new URL(pageUrl).protocol;
2132
+ } catch {
2133
+ }
2134
+ if (protocol !== "http:" && protocol !== "https:") {
2135
+ throw usageError(`"url" must be an http(s) URL (got "${pageUrl}").`);
2136
+ }
2137
+ };
2138
+ var composeWebLink = (pageUrl, result) => {
2139
+ const payload = result.deepLinkPayload;
2140
+ return {
2141
+ sessionId: result.sessionId,
2142
+ url: `${pageUrl.split("#")[0]}#appduct=${payload}`,
2143
+ script: `window.__APPDUCT__.connect("${payload}")`,
2144
+ expiresAt: result.expiresAt
2145
+ };
2146
+ };
2107
2147
 
2108
2148
  // src/qr-terminal.ts
2109
2149
  import { toQR } from "toqr";
@@ -2146,14 +2186,18 @@ var WAIT_FOR_SESSION_TOOL_NAME = "appduct_wait_for_session";
2146
2186
  var OPEN_TARGET_ADDRESS_OVERRIDE = "127.0.0.1";
2147
2187
  var DEFAULT_WAIT_TIMEOUT_MS = 12e4;
2148
2188
  var NO_TARGET = "none";
2189
+ var WEB_TARGET = "web";
2149
2190
  var QR_INSTRUCTIONS = 'This link was NOT delivered to a device \u2014 a human has to open it. Before calling appduct_wait_for_session: show the user the "qr" field verbatim inside a fenced code block (it is pre-rendered terminal art \u2014 do not redraw, summarize, or describe it), show "deepLink" underneath it for a device that cannot scan, and ask the user to scan it with the target device. Only then call appduct_wait_for_session; it blocks until the device connects and gives no output while it waits, so calling it before you have asked will look like a hang.';
2150
2191
  var CONNECT_TOOL_DESCRIPTOR = {
2151
2192
  name: CONNECT_TOOL_NAME,
2152
- description: 'Mint an Appduct session link and get it onto a device. With no "target", auto-detects a booted iOS simulator or attached Android device and delivers the link to it, returning { sessionId, delivered: true, autoDetected: true } \u2014 this is the normal agent path and needs no human. Pass target "android" or "ios-sim" (optionally with "device") to choose explicitly, or target "none" to force the human flow. Delivering to "android" (explicit or auto-detected) needs "appId" (the Android package name) \u2014 without it, more than one installed app declaring the same scheme would show an ambiguous "Open with" chooser on the device instead of failing loudly. Target "ios-device" is an experimental, never-auto-detected path that delivers to a paired physical iPhone/iPad via xcrun devicectl; it needs "appId" (the iOS bundle id), iOS 17+, Xcode 15+, a connected and trusted device with Developer Mode on, a dev-signed build installed, and the phone on the same network as this machine. An "appId" not supplied here falls back to "appId.<platform>" in the nearest .appduct/config.json (see "appduct init"); it is rejected outright with target "ios-sim" or "none", which need no app id. Pass "relaunch": true with ios-device if the app is already running and the delivery does not take. Only when no device is detected (or target is "none") does this return a QR code for a human to scan, along with an "instructions" field saying what to do with it. Follow up with appduct_wait_for_session to know when the device has connected.',
2193
+ description: `Mint an Appduct session link and get it onto a device. With no "target", auto-detects a booted iOS simulator or attached Android device and delivers the link to it, returning { sessionId, delivered: true, autoDetected: true } \u2014 this is the normal agent path and needs no human. Pass target "android" or "ios-sim" (optionally with "device") to choose explicitly, or target "none" to force the human flow. Delivering to "android" (explicit or auto-detected) needs "appId" (the Android package name) \u2014 without it, more than one installed app declaring the same scheme would show an ambiguous "Open with" chooser on the device instead of failing loudly. Target "ios-device" is an experimental, never-auto-detected path that delivers to a paired physical iPhone/iPad via xcrun devicectl; it needs "appId" (the iOS bundle id), iOS 17+, Xcode 15+, a connected and trusted device with Developer Mode on, a dev-signed build installed, and the phone on the same network as this machine. An "appId" not supplied here falls back to "appId.<platform>" in the nearest .appduct/config.json (see "appduct init"); it is rejected outright with target "ios-sim" or "none", which need no app id. Pass "relaunch": true with ios-device if the app is already running and the delivery does not take. Target "web" is for a web page: pass "url" (the page to open) and get back { url, script } \u2014 open "url" in a browser, or run "script" in a page that is already open, then call appduct_wait_for_session. It needs no scheme and no device. To have the daemon attach a page that is already open in a Chrome launched with --remote-debugging-port, also pass "browserUrl" (its debugging endpoint on this machine: 127.0.0.1, [::1] or localhost, such as http://127.0.0.1:9222): the tab whose address starts with "url" is attached and the call returns { sessionId, url, targetId, attached: true }, with no further step; "url" is the tab's own address. When no tab or several match, the error lists the open tabs with their target ids; pass one as "targetId" to pick it. A popup, or a page in a new tab, is not attached. Only when no device is detected (or target is "none") does this return a QR code for a human to scan, along with an "instructions" field saying what to do with it. Follow up with appduct_wait_for_session to know when the device has connected.`,
2153
2194
  inputSchema: {
2154
2195
  type: "object",
2155
2196
  properties: {
2156
- target: { type: "string", enum: ["android", "ios-sim", "ios-device", NO_TARGET] },
2197
+ target: { type: "string", enum: ["android", "ios-sim", "ios-device", NO_TARGET, WEB_TARGET] },
2198
+ url: { type: "string" },
2199
+ browserUrl: { type: "string" },
2200
+ targetId: { type: "string" },
2157
2201
  device: { type: "string" },
2158
2202
  appId: { type: "string" },
2159
2203
  relaunch: { type: "boolean" },
@@ -2192,10 +2236,10 @@ var asOptionalConnectTarget = (value) => {
2192
2236
  if (value === void 0) {
2193
2237
  return void 0;
2194
2238
  }
2195
- if (typeof value !== "string" || !(isOpenTarget(value) || value === NO_TARGET)) {
2239
+ if (typeof value !== "string" || !(isOpenTarget(value) || value === NO_TARGET || value === WEB_TARGET)) {
2196
2240
  throw new McpBuiltinToolError(
2197
2241
  "invalid_request",
2198
- '"target" must be "android", "ios-sim", "ios-device", or "none".'
2242
+ '"target" must be "android", "ios-sim", "ios-device", "web", or "none".'
2199
2243
  );
2200
2244
  }
2201
2245
  return value;
@@ -2273,6 +2317,42 @@ var handleConnectTool = async (rawArgs, deps) => {
2273
2317
  const appId = asOptionalNonEmptyString(args.appId, "appId");
2274
2318
  const relaunch = asOptionalBoolean(args.relaunch, "relaunch");
2275
2319
  const ttlSeconds = asOptionalPositiveNumber(args.ttlSeconds, "ttlSeconds");
2320
+ const url = asOptionalNonEmptyString(args.url, "url");
2321
+ const browserUrl = asOptionalNonEmptyString(args.browserUrl, "browserUrl");
2322
+ const targetId = asOptionalNonEmptyString(args.targetId, "targetId");
2323
+ if ((browserUrl !== void 0 || targetId !== void 0) && requestedTarget !== WEB_TARGET) {
2324
+ throw new McpBuiltinToolError("invalid_request", '"browserUrl" and "targetId" only apply with target "web".');
2325
+ }
2326
+ if (targetId !== void 0 && browserUrl === void 0) {
2327
+ throw new McpBuiltinToolError("invalid_request", '"targetId" needs "browserUrl": it names a tab of that browser.');
2328
+ }
2329
+ if (requestedTarget === WEB_TARGET) {
2330
+ if (url === void 0) {
2331
+ throw new McpBuiltinToolError("invalid_request", '"url" (the page to open) is required with target "web".');
2332
+ }
2333
+ if (device !== void 0 || appId !== void 0 || relaunch !== void 0) {
2334
+ throw new McpBuiltinToolError(
2335
+ "invalid_request",
2336
+ '"device", "appId" and "relaunch" do not apply with target "web".'
2337
+ );
2338
+ }
2339
+ try {
2340
+ assertWebPageUrl(url);
2341
+ } catch (error) {
2342
+ throw new McpBuiltinToolError("invalid_request", error.message);
2343
+ }
2344
+ if (browserUrl !== void 0) {
2345
+ const attached = await deps.call(RPC_METHODS3.webAttach, { url, browserUrl, targetId, ttlSeconds });
2346
+ return { ...attached, attached: true };
2347
+ }
2348
+ return composeWebLink(
2349
+ url,
2350
+ await deps.call(RPC_METHODS3.linkCreate, { ttlSeconds, transport: "web" })
2351
+ );
2352
+ }
2353
+ if (url !== void 0) {
2354
+ throw new McpBuiltinToolError("invalid_request", '"url" only applies with target "web".');
2355
+ }
2276
2356
  if (device !== void 0 && (requestedTarget === void 0 || requestedTarget === NO_TARGET)) {
2277
2357
  throw new McpBuiltinToolError(
2278
2358
  "invalid_request",
@@ -2657,6 +2737,7 @@ var resolveAppTool = async (call, selector, name) => {
2657
2737
  // src/mcp/events-tool.ts
2658
2738
  import {
2659
2739
  RPC_METHODS as RPC_METHODS6,
2740
+ renderEventSignature,
2660
2741
  toAppEvent as toAppEvent2
2661
2742
  } from "@appduct/shared";
2662
2743
 
@@ -2760,16 +2841,36 @@ var waitForAppEvent = async (stream, options) => {
2760
2841
  };
2761
2842
 
2762
2843
  // src/mcp/events-tool.ts
2844
+ var LIST_EVENTS_TOOL_NAME = "appduct_list_events";
2763
2845
  var EVENTS_TOOL_NAME = "appduct_events";
2764
2846
  var WAIT_FOR_EVENT_TOOL_NAME = "appduct_wait_for_event";
2765
2847
  var DEFAULT_EVENTS_LIMIT = 50;
2766
2848
  var DEFAULT_EVENTS_PAYLOAD_MAX_BYTES = 4096;
2849
+ var DEFAULT_LIST_EVENTS_LIMIT = 50;
2767
2850
  var DEFAULT_WAIT_FOR_EVENT_TIMEOUT_MS = 12e4;
2768
2851
  var MAX_WAIT_FOR_EVENT_TIMEOUT_MS = 25 * 60 * 1e3;
2769
2852
  var WAIT_FOR_EVENT_PROGRESS_INTERVAL_MS = 6e4;
2770
2853
  var clampWaitForEventTimeoutMs = (requestedMs) => {
2771
2854
  return Math.min(requestedMs ?? DEFAULT_WAIT_FOR_EVENT_TIMEOUT_MS, MAX_WAIT_FOR_EVENT_TIMEOUT_MS);
2772
2855
  };
2856
+ var LIST_EVENTS_TOOL_DESCRIPTOR = {
2857
+ name: LIST_EVENTS_TOOL_NAME,
2858
+ description: `List the events the connected app declared, as one-line signatures (\`name { field: type }\`) with each event's description. Call this before appduct_wait_for_event or appduct_events so you wait on a name the app really posts instead of guessing one. An app that declares no events returns an empty list, which does not mean it posts none. name is a whole-name, case-sensitive glob (* matches any run of characters), e.g. "cart.*"; a name without * that matches a declared event also returns that event's payload_schema. limit (default ${DEFAULT_LIST_EVENTS_LIMIT}) and offset page the name-sorted list, and total counts every match before paging. Returns { session, total, limit, events }, each event { name, signature, description }.`,
2859
+ inputSchema: {
2860
+ type: "object",
2861
+ properties: {
2862
+ selector: {
2863
+ type: "string",
2864
+ minLength: 1,
2865
+ description: "Session alias or id. Omit to target the sole active/suspended session."
2866
+ },
2867
+ name: { type: "string", minLength: 1 },
2868
+ limit: { type: "integer", exclusiveMinimum: 0 },
2869
+ offset: { type: "integer", minimum: 0 }
2870
+ },
2871
+ additionalProperties: false
2872
+ }
2873
+ };
2773
2874
  var EVENTS_TOOL_DESCRIPTOR = {
2774
2875
  name: EVENTS_TOOL_NAME,
2775
2876
  description: `Drain the events the app posted with postEvent(name, payload) that the daemon retained for a session since a cursor, for checking what the app reported after calling a tool or triggering app behavior. With no selector, targets the sole active/suspended session. name filters to events whose name matches a whole-name, case-sensitive glob (* matches any run of characters; a pattern without * is an exact name), e.g. "cart.*". Returns { events, cursor, dropped, remaining } with each event as { name, payload, ts, seq, sessionId, alias } or, once a payload's JSON exceeds payloadMaxBytes (default 4096), as { name, payloadPreview, truncated: true, payloadBytes, ts, seq, sessionId, alias } instead \u2014 check truncated before reading payload. pass cursor back as since on the next call to avoid re-reading events you've already seen; dropped counts app events after since that were evicted before this call could return them (0 once nothing has fallen off), and remaining counts events still matching this query after the returned page (0 on the last page). limit (default 50) keeps the OLDEST events in the window and advances cursor only past what was actually returned, so repeated calls page forward through everything retained rather than skipping ahead.`,
@@ -2859,6 +2960,31 @@ var rejectMatch = (args) => {
2859
2960
  );
2860
2961
  }
2861
2962
  };
2963
+ var handleListEventsTool = async (rawArgs, call) => {
2964
+ const args = asRecord3(rawArgs);
2965
+ rejectUnknownKeys(args, LIST_EVENTS_TOOL_NAME, ["selector", "name", "limit", "offset"]);
2966
+ const session = await resolveSession(call, asOptionalString(args.selector, "selector"));
2967
+ const name = asOptionalString(args.name, "name");
2968
+ const limit = args.limit ?? DEFAULT_LIST_EVENTS_LIMIT;
2969
+ const result = await call(RPC_METHODS6.eventsList, {
2970
+ selector: session.sessionId,
2971
+ ...name !== void 0 ? { name } : {},
2972
+ limit,
2973
+ ...args.offset !== void 0 && args.offset !== null ? { offset: args.offset } : {}
2974
+ });
2975
+ const lookup = name !== void 0 && !name.includes("*");
2976
+ return {
2977
+ session: session.alias,
2978
+ total: result.total,
2979
+ limit,
2980
+ events: result.events.map((event) => ({
2981
+ name: event.name,
2982
+ signature: renderEventSignature(event),
2983
+ description: event.description,
2984
+ ...lookup && event.payload_schema !== void 0 ? { payload_schema: event.payload_schema } : {}
2985
+ }))
2986
+ };
2987
+ };
2862
2988
  var handleEventsTool = async (rawArgs, deps) => {
2863
2989
  const args = asRecord3(rawArgs);
2864
2990
  rejectKinds(args);
@@ -3153,6 +3279,7 @@ var createMcpServer = async (options) => {
3153
3279
  LIST_TOOLS_TOOL_DESCRIPTOR,
3154
3280
  DESCRIBE_TOOL_TOOL_DESCRIPTOR,
3155
3281
  CALL_TOOL_TOOL_DESCRIPTOR,
3282
+ LIST_EVENTS_TOOL_DESCRIPTOR,
3156
3283
  EVENTS_TOOL_DESCRIPTOR,
3157
3284
  WAIT_FOR_EVENT_TOOL_DESCRIPTOR
3158
3285
  ]
@@ -3181,6 +3308,9 @@ var createMcpServer = async (options) => {
3181
3308
  await handleWaitForSessionTool(args, { stateDir: options.stateDir, spawn: options.spawn })
3182
3309
  );
3183
3310
  }
3311
+ if (name === LIST_EVENTS_TOOL_NAME) {
3312
+ return toolSuccessContent(await handleListEventsTool(args, stream.call));
3313
+ }
3184
3314
  if (name === EVENTS_TOOL_NAME) {
3185
3315
  return toolSuccessContent(await handleEventsTool(args, { call: stream.call }));
3186
3316
  }
@@ -3329,6 +3459,7 @@ var formatJson = (value, flags) => {
3329
3459
  import pc from "picocolors";
3330
3460
  import {
3331
3461
  formatAgentWebSocketUrl,
3462
+ renderEventSignature as renderEventSignature2,
3332
3463
  renderToolSignature as renderToolSignature2,
3333
3464
  summarizeToolDescription as summarizeToolDescription2
3334
3465
  } from "@appduct/shared";
@@ -3387,6 +3518,13 @@ var formatDevice = (device) => {
3387
3518
  const label = parts.length > 0 ? parts.join(" ") : "unknown device";
3388
3519
  return device.os ? `${label} (${device.os})` : label;
3389
3520
  };
3521
+ var SUSPEND_REASON_LABELS = {
3522
+ app_backgrounded: "app in background",
3523
+ connection_lost: "connection lost"
3524
+ };
3525
+ var formatSessionState = (session) => {
3526
+ return session.suspendReason ? `${session.state} (${SUSPEND_REASON_LABELS[session.suspendReason]})` : session.state;
3527
+ };
3390
3528
  var renderLsData = (colors, data, now) => {
3391
3529
  if (data.length === 0) {
3392
3530
  return [colors.dim("Sessions"), " No Appduct sessions are registered."];
@@ -3395,7 +3533,7 @@ var renderLsData = (colors, data, now) => {
3395
3533
  const headers = ["Alias", "State", "Device", "Tools", "Age"];
3396
3534
  const rows = data.map((session) => [
3397
3535
  session.alias,
3398
- session.state,
3536
+ formatSessionState(session),
3399
3537
  formatDevice(session.device),
3400
3538
  String(session.toolCount),
3401
3539
  formatAge(session.claimedAt ?? session.createdAt, nowIso)
@@ -3569,10 +3707,87 @@ var renderToolsListData = (colors, data, flags, full) => {
3569
3707
  }
3570
3708
  return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
3571
3709
  };
3710
+ var renderEventsTruncationLine = (data) => {
3711
+ const offset = data.offset ?? 0;
3712
+ if (data.events.length >= data.total) {
3713
+ return [];
3714
+ }
3715
+ return [
3716
+ "",
3717
+ `Showing ${data.events.length} of ${data.total} events (offset ${offset}). Narrow with --name <glob> or page with --offset <n>.`
3718
+ ];
3719
+ };
3720
+ var renderEventsListData = (colors, data, flags) => {
3721
+ const [only] = data.events;
3722
+ if (data.name !== void 0 && !data.name.includes("*") && only !== void 0) {
3723
+ return renderFields(
3724
+ colors.green(`Event: ${only.name}`),
3725
+ [
3726
+ ["Signature", renderEventSignature2(only)],
3727
+ ["Description", only.description],
3728
+ ["Payload schema", only.payload_schema]
3729
+ ],
3730
+ flags
3731
+ );
3732
+ }
3733
+ if (data.events.length === 0 && data.total > 0) {
3734
+ return [
3735
+ colors.green("Events"),
3736
+ ` No events at offset ${data.offset ?? 0}; ${data.total} matching event${data.total === 1 ? "" : "s"} in total.`
3737
+ ];
3738
+ }
3739
+ if (data.events.length === 0) {
3740
+ return [
3741
+ colors.green("Events"),
3742
+ data.name === void 0 ? " No events declared." : ` No events match ${JSON.stringify(data.name)}.`
3743
+ ];
3744
+ }
3745
+ const lines = data.events.flatMap((event) => [
3746
+ ` ${renderEventSignature2(event)}`,
3747
+ ` ${summarizeToolDescription2(event.description)}`
3748
+ ]);
3749
+ return [
3750
+ colors.green("Events"),
3751
+ ...lines,
3752
+ ...renderEventsTruncationLine(data),
3753
+ "",
3754
+ "Run `appduct events ls --name <name>` for an event's full payload schema."
3755
+ ];
3756
+ };
3572
3757
  var renderInvokeData = (colors, data, flags) => {
3573
3758
  return [colors.green("Result"), formatScalar(data, flags)];
3574
3759
  };
3575
3760
  var renderLinkData = (colors, data, flags, qr) => {
3761
+ if ("attached" in data) {
3762
+ return [
3763
+ colors.green("Page Attached"),
3764
+ ...renderFields(
3765
+ "Page",
3766
+ [
3767
+ ["Session", data.sessionId],
3768
+ ["Tab", data.targetId],
3769
+ ["URL", data.url],
3770
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
3771
+ ],
3772
+ flags
3773
+ )
3774
+ ];
3775
+ }
3776
+ if ("script" in data) {
3777
+ return [
3778
+ colors.green("Link Created"),
3779
+ ...renderFields(
3780
+ "Link",
3781
+ [
3782
+ ["Session", data.sessionId],
3783
+ ["URL", data.url],
3784
+ ["Script", data.script],
3785
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
3786
+ ],
3787
+ flags
3788
+ )
3789
+ ];
3790
+ }
3576
3791
  const lines = [
3577
3792
  colors.green("Link Created"),
3578
3793
  ...renderFields(
@@ -3693,6 +3908,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
3693
3908
  ["PID", data.daemon.pid],
3694
3909
  ["Started at", data.daemon.started_at],
3695
3910
  ["WSS port", data.daemon.wss_port],
3911
+ ["Web port", data.daemon.web_port],
3696
3912
  ["Pinned keys", data.daemon.pinned_keys.length],
3697
3913
  ["Sessions", data.daemon.session_count]
3698
3914
  ],
@@ -3760,6 +3976,8 @@ var renderSuccessData = (colors, command, data, options) => {
3760
3976
  return renderToolsListData(colors, data, flags, options.full);
3761
3977
  case "tools describe":
3762
3978
  return renderToolDetail(colors, data, flags);
3979
+ case "events ls":
3980
+ return renderEventsListData(colors, data, flags);
3763
3981
  case "tools call":
3764
3982
  return renderInvokeData(colors, data, flags);
3765
3983
  case "sessions revoke":
@@ -93,7 +93,7 @@ var EXIT_CODE_BY_ERROR_TYPE = {
93
93
  // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
94
94
  // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
95
95
  // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
96
- // assertion failure (docs/tasks/08-appduct-doctor.md).
96
+ // assertion failure.
97
97
  inspection_error: 66,
98
98
  connection_error: 70,
99
99
  session_error: 71,
@@ -213,6 +213,7 @@ var formatJson = (value, flags) => {
213
213
  import pc from "picocolors";
214
214
  import {
215
215
  formatAgentWebSocketUrl,
216
+ renderEventSignature,
216
217
  renderToolSignature,
217
218
  summarizeToolDescription
218
219
  } from "@appduct/shared";
@@ -308,6 +309,13 @@ var formatDevice = (device) => {
308
309
  const label = parts.length > 0 ? parts.join(" ") : "unknown device";
309
310
  return device.os ? `${label} (${device.os})` : label;
310
311
  };
312
+ var SUSPEND_REASON_LABELS = {
313
+ app_backgrounded: "app in background",
314
+ connection_lost: "connection lost"
315
+ };
316
+ var formatSessionState = (session) => {
317
+ return session.suspendReason ? `${session.state} (${SUSPEND_REASON_LABELS[session.suspendReason]})` : session.state;
318
+ };
311
319
  var renderLsData = (colors, data, now) => {
312
320
  if (data.length === 0) {
313
321
  return [colors.dim("Sessions"), " No Appduct sessions are registered."];
@@ -316,7 +324,7 @@ var renderLsData = (colors, data, now) => {
316
324
  const headers = ["Alias", "State", "Device", "Tools", "Age"];
317
325
  const rows = data.map((session) => [
318
326
  session.alias,
319
- session.state,
327
+ formatSessionState(session),
320
328
  formatDevice(session.device),
321
329
  String(session.toolCount),
322
330
  formatAge(session.claimedAt ?? session.createdAt, nowIso)
@@ -490,10 +498,87 @@ var renderToolsListData = (colors, data, flags, full) => {
490
498
  }
491
499
  return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
492
500
  };
501
+ var renderEventsTruncationLine = (data) => {
502
+ const offset = data.offset ?? 0;
503
+ if (data.events.length >= data.total) {
504
+ return [];
505
+ }
506
+ return [
507
+ "",
508
+ `Showing ${data.events.length} of ${data.total} events (offset ${offset}). Narrow with --name <glob> or page with --offset <n>.`
509
+ ];
510
+ };
511
+ var renderEventsListData = (colors, data, flags) => {
512
+ const [only] = data.events;
513
+ if (data.name !== void 0 && !data.name.includes("*") && only !== void 0) {
514
+ return renderFields(
515
+ colors.green(`Event: ${only.name}`),
516
+ [
517
+ ["Signature", renderEventSignature(only)],
518
+ ["Description", only.description],
519
+ ["Payload schema", only.payload_schema]
520
+ ],
521
+ flags
522
+ );
523
+ }
524
+ if (data.events.length === 0 && data.total > 0) {
525
+ return [
526
+ colors.green("Events"),
527
+ ` No events at offset ${data.offset ?? 0}; ${data.total} matching event${data.total === 1 ? "" : "s"} in total.`
528
+ ];
529
+ }
530
+ if (data.events.length === 0) {
531
+ return [
532
+ colors.green("Events"),
533
+ data.name === void 0 ? " No events declared." : ` No events match ${JSON.stringify(data.name)}.`
534
+ ];
535
+ }
536
+ const lines = data.events.flatMap((event) => [
537
+ ` ${renderEventSignature(event)}`,
538
+ ` ${summarizeToolDescription(event.description)}`
539
+ ]);
540
+ return [
541
+ colors.green("Events"),
542
+ ...lines,
543
+ ...renderEventsTruncationLine(data),
544
+ "",
545
+ "Run `appduct events ls --name <name>` for an event's full payload schema."
546
+ ];
547
+ };
493
548
  var renderInvokeData = (colors, data, flags) => {
494
549
  return [colors.green("Result"), formatScalar(data, flags)];
495
550
  };
496
551
  var renderLinkData = (colors, data, flags, qr) => {
552
+ if ("attached" in data) {
553
+ return [
554
+ colors.green("Page Attached"),
555
+ ...renderFields(
556
+ "Page",
557
+ [
558
+ ["Session", data.sessionId],
559
+ ["Tab", data.targetId],
560
+ ["URL", data.url],
561
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
562
+ ],
563
+ flags
564
+ )
565
+ ];
566
+ }
567
+ if ("script" in data) {
568
+ return [
569
+ colors.green("Link Created"),
570
+ ...renderFields(
571
+ "Link",
572
+ [
573
+ ["Session", data.sessionId],
574
+ ["URL", data.url],
575
+ ["Script", data.script],
576
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
577
+ ],
578
+ flags
579
+ )
580
+ ];
581
+ }
497
582
  const lines = [
498
583
  colors.green("Link Created"),
499
584
  ...renderFields(
@@ -614,6 +699,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
614
699
  ["PID", data.daemon.pid],
615
700
  ["Started at", data.daemon.started_at],
616
701
  ["WSS port", data.daemon.wss_port],
702
+ ["Web port", data.daemon.web_port],
617
703
  ["Pinned keys", data.daemon.pinned_keys.length],
618
704
  ["Sessions", data.daemon.session_count]
619
705
  ],
@@ -681,6 +767,8 @@ var renderSuccessData = (colors, command, data, options) => {
681
767
  return renderToolsListData(colors, data, flags, options.full);
682
768
  case "tools describe":
683
769
  return renderToolDetail(colors, data, flags);
770
+ case "events ls":
771
+ return renderEventsListData(colors, data, flags);
684
772
  case "tools call":
685
773
  return renderInvokeData(colors, data, flags);
686
774
  case "sessions revoke":