appduct 0.12.0 → 0.14.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 (75) hide show
  1. package/README.md +43 -37
  2. package/dist/bin.js +102 -37
  3. package/dist/cli/command-options.d.ts +7 -17
  4. package/dist/cli/result-types.d.ts +19 -9
  5. package/dist/cli/routes/daemon/index.js +70 -17
  6. package/dist/cli/routes/daemon/run.js +288 -50
  7. package/dist/cli/routes/daemon/start.js +70 -17
  8. package/dist/cli/routes/daemon/status.js +70 -17
  9. package/dist/cli/routes/daemon/stop.js +70 -17
  10. package/dist/cli/routes/doctor.js +70 -17
  11. package/dist/cli/routes/events/index.d.ts +6 -0
  12. package/dist/cli/routes/events/index.js +900 -0
  13. package/dist/cli/routes/events/ls.d.ts +5 -0
  14. package/dist/cli/routes/events/ls.js +1798 -0
  15. package/dist/cli/routes/events/shared-options.d.ts +12 -0
  16. package/dist/cli/routes/events/shared-options.js +102 -0
  17. package/dist/cli/routes/events/since.d.ts +6 -0
  18. package/dist/cli/routes/events/since.js +2034 -0
  19. package/dist/cli/routes/events/tail.d.ts +6 -0
  20. package/dist/cli/routes/{events.js → events/tail.js} +235 -163
  21. package/dist/cli/routes/init.js +72 -19
  22. package/dist/cli/routes/keygen.js +70 -17
  23. package/dist/cli/routes/mcp.js +289 -166
  24. package/dist/cli/routes/sessions/index.d.ts +7 -0
  25. package/dist/cli/routes/sessions/index.js +900 -0
  26. package/dist/cli/routes/sessions/link.d.ts +4 -0
  27. package/dist/cli/routes/{link.js → sessions/link.js} +71 -18
  28. package/dist/cli/routes/sessions/ls.d.ts +4 -0
  29. package/dist/cli/routes/{ls.js → sessions/ls.js} +71 -18
  30. package/dist/cli/routes/sessions/revoke.d.ts +4 -0
  31. package/dist/cli/routes/{revoke.js → sessions/revoke.js} +72 -19
  32. package/dist/cli/routes/tools/call.d.ts +4 -0
  33. package/dist/cli/routes/{invoke.js → tools/call.js} +73 -20
  34. package/dist/cli/routes/tools/describe.d.ts +6 -0
  35. package/dist/cli/routes/tools/describe.js +1894 -0
  36. package/dist/cli/routes/tools/index.d.ts +6 -0
  37. package/dist/cli/routes/tools/index.js +900 -0
  38. package/dist/cli/routes/tools/ls.d.ts +4 -0
  39. package/dist/cli/routes/{tools.js → tools/ls.js} +98 -74
  40. package/dist/cli/routes/tools/shared-options.d.ts +16 -0
  41. package/dist/cli/routes/tools/shared-options.js +122 -0
  42. package/dist/client/app-client.d.ts +96 -37
  43. package/dist/client/index.d.ts +3 -2
  44. package/dist/client/index.js +178 -138
  45. package/dist/commands/events-ls.d.ts +21 -0
  46. package/dist/commands/events.d.ts +31 -14
  47. package/dist/commands/invoke.d.ts +1 -1
  48. package/dist/commands/link.d.ts +1 -1
  49. package/dist/commands/ls.d.ts +1 -1
  50. package/dist/commands/revoke.d.ts +1 -1
  51. package/dist/commands/tools.d.ts +12 -13
  52. package/dist/daemon/calls.d.ts +3 -1
  53. package/dist/daemon/config.d.ts +1 -1
  54. package/dist/daemon/event-bus.d.ts +42 -0
  55. package/dist/daemon/registry.d.ts +20 -12
  56. package/dist/daemon/rpc-errors.d.ts +4 -1
  57. package/dist/daemon/sessions.d.ts +4 -2
  58. package/dist/errors.d.ts +1 -1
  59. package/dist/events/index.d.ts +60 -0
  60. package/dist/index.js +114 -40
  61. package/dist/link.d.ts +1 -1
  62. package/dist/mcp/app-tools.d.ts +22 -2
  63. package/dist/mcp/connect-tool.d.ts +1 -1
  64. package/dist/mcp/events-tool.d.ts +67 -27
  65. package/dist/native-scheme.d.ts +1 -1
  66. package/dist/output.d.ts +29 -6
  67. package/dist/rpc/client.d.ts +1 -1
  68. package/dist/scheme.d.ts +4 -4
  69. package/package.json +2 -2
  70. package/dist/cli/routes/events.d.ts +0 -3
  71. package/dist/cli/routes/invoke.d.ts +0 -3
  72. package/dist/cli/routes/link.d.ts +0 -3
  73. package/dist/cli/routes/ls.d.ts +0 -3
  74. package/dist/cli/routes/revoke.d.ts +0 -3
  75. package/dist/cli/routes/tools.d.ts +0 -3
@@ -0,0 +1,4 @@
1
+ /** Route for `appduct tools ls` — loaded by `routes/tools/index.ts`'s router only when it runs
2
+ * (issue #96; replaces the removed `appduct tools [selector]` listing form). */
3
+ import type { Route } from "../../router.js";
4
+ export declare const route: Route;
@@ -1,6 +1,3 @@
1
- // src/cli/routes/tools.ts
2
- import { isValidToolGroup } from "@appduct/shared";
3
-
4
1
  // src/commands/tools.ts
5
2
  import { RPC_METHODS as RPC_METHODS2 } from "@appduct/shared";
6
3
 
@@ -1105,38 +1102,18 @@ var handleToolsCommand = async (options, context) => {
1105
1102
  if (options.name !== void 0 && hasListingOnlyOptions(options)) {
1106
1103
  throw listingOnlyError();
1107
1104
  }
1108
- if (options.selector !== void 0 && options.name !== void 0) {
1105
+ if (options.name !== void 0) {
1109
1106
  const result = await listTools(options.selector, context);
1110
1107
  const tool = findTool(result.tools, options.name);
1111
1108
  if (!tool) {
1112
1109
  throw usageError(
1113
- `Tool "${options.name}" is not registered on session "${options.selector}".`,
1110
+ options.selector !== void 0 ? `Tool "${options.name}" is not registered on session "${options.selector}".` : `Tool "${options.name}" is not registered.`,
1114
1111
  { available: result.tools.map((entry) => entry.name) }
1115
1112
  );
1116
1113
  }
1117
1114
  return { ok: true, data: tool };
1118
1115
  }
1119
- if (options.selector !== void 0) {
1120
- let implicitTools;
1121
- try {
1122
- implicitTools = await listTools(void 0, context);
1123
- } catch (error) {
1124
- if (!(error instanceof DaemonRpcError)) {
1125
- throw error;
1126
- }
1127
- }
1128
- if (implicitTools) {
1129
- const tool = findTool(implicitTools.tools, options.selector);
1130
- if (tool) {
1131
- if (hasListingOnlyOptions(options)) {
1132
- throw listingOnlyError();
1133
- }
1134
- return { ok: true, data: tool };
1135
- }
1136
- }
1137
- return { ok: true, data: await listOrGroups(options.selector, options, context) };
1138
- }
1139
- return { ok: true, data: await listOrGroups(void 0, options, context) };
1116
+ return { ok: true, data: await listOrGroups(options.selector, options, context) };
1140
1117
  };
1141
1118
 
1142
1119
  // src/cli/command-options.ts
@@ -1182,17 +1159,11 @@ var readTextOption = (argv, value, flagName) => {
1182
1159
  }
1183
1160
  return raw ?? String(Array.isArray(value) ? value.at(-1) : value);
1184
1161
  };
1185
- var splitOptionalSelectorAndTarget = (args, commandUsage) => {
1186
- if (args.length > 2) {
1162
+ var splitOptionalSelector = (args, commandUsage) => {
1163
+ if (args.length > 1) {
1187
1164
  throw usageError(`Usage: ${commandUsage} (too many arguments).`);
1188
1165
  }
1189
- if (args.length === 2) {
1190
- return { selector: args[0], target: args[1] };
1191
- }
1192
- if (args.length === 1) {
1193
- return { selectorOrTarget: args[0] };
1194
- }
1195
- return {};
1166
+ return { selector: args[0] };
1196
1167
  };
1197
1168
 
1198
1169
  // src/cli/global-flags.ts
@@ -1204,6 +1175,7 @@ var formatJson = (value, flags) => {
1204
1175
  import pc from "picocolors";
1205
1176
  import {
1206
1177
  formatAgentWebSocketUrl,
1178
+ renderEventSignature,
1207
1179
  renderToolSignature,
1208
1180
  summarizeToolDescription
1209
1181
  } from "@appduct/shared";
@@ -1299,6 +1271,13 @@ var formatDevice = (device) => {
1299
1271
  const label = parts.length > 0 ? parts.join(" ") : "unknown device";
1300
1272
  return device.os ? `${label} (${device.os})` : label;
1301
1273
  };
1274
+ var SUSPEND_REASON_LABELS = {
1275
+ app_backgrounded: "app in background",
1276
+ connection_lost: "connection lost"
1277
+ };
1278
+ var formatSessionState = (session) => {
1279
+ return session.suspendReason ? `${session.state} (${SUSPEND_REASON_LABELS[session.suspendReason]})` : session.state;
1280
+ };
1302
1281
  var renderLsData = (colors, data, now) => {
1303
1282
  if (data.length === 0) {
1304
1283
  return [colors.dim("Sessions"), " No Appduct sessions are registered."];
@@ -1307,7 +1286,7 @@ var renderLsData = (colors, data, now) => {
1307
1286
  const headers = ["Alias", "State", "Device", "Tools", "Age"];
1308
1287
  const rows = data.map((session) => [
1309
1288
  session.alias,
1310
- session.state,
1289
+ formatSessionState(session),
1311
1290
  formatDevice(session.device),
1312
1291
  String(session.toolCount),
1313
1292
  formatAge(session.claimedAt ?? session.createdAt, nowIso)
@@ -1321,9 +1300,6 @@ var renderLsData = (colors, data, now) => {
1321
1300
  ...rows.map((row) => ` ${row.map((cell, index) => cell.padEnd(widths[index])).join(" ")}`)
1322
1301
  ];
1323
1302
  };
1324
- var isToolsListing = (data) => {
1325
- return typeof data === "object" && data !== null && Array.isArray(data.tools);
1326
- };
1327
1303
  var isToolGroupsListing = (data) => {
1328
1304
  return typeof data === "object" && data !== null && Array.isArray(data.groups) && !Array.isArray(data.tools);
1329
1305
  };
@@ -1341,7 +1317,7 @@ var renderEmptyToolsLine = (data) => {
1341
1317
  }
1342
1318
  if (data.group !== void 0) {
1343
1319
  const match = data.filter === void 0 ? "" : ` match ${JSON.stringify(data.filter)}`;
1344
- return ` No tools in group ${JSON.stringify(data.group)}${match}. Run \`appduct tools --groups\` to see the session's groups.`;
1320
+ return ` No tools in group ${JSON.stringify(data.group)}${match}. Run \`appduct tools ls --groups\` to see the session's groups.`;
1345
1321
  }
1346
1322
  return data.filter === void 0 ? " No tools registered." : ` No tools match ${JSON.stringify(data.filter)}.`;
1347
1323
  };
@@ -1423,7 +1399,7 @@ var renderToolSummaryTable = (colors, data) => {
1423
1399
  ...grouped ? renderGroupedToolLines(colors, data.tools) : data.tools.flatMap((tool) => renderToolSummaryLines(tool, " ")),
1424
1400
  ...renderTruncationLine(data),
1425
1401
  "",
1426
- "Run `appduct tools <name>` for a tool's full schema."
1402
+ "Run `appduct tools describe <name>` for a tool's full schema."
1427
1403
  ];
1428
1404
  };
1429
1405
  var renderToolDetail = (colors, tool, flags) => {
@@ -1475,18 +1451,62 @@ var renderToolGroups = (colors, data) => {
1475
1451
  colors.green("Groups"),
1476
1452
  ...data.groups.map((entry) => ` ${label(entry).padEnd(width)} ${String(entry.total).padStart(countWidth)}`),
1477
1453
  "",
1478
- `${data.total} tool${data.total === 1 ? "" : "s"} in total. ` + (hasAnyGroup(data.groups) ? "Run `appduct tools --group <name>` to list one group's tools." : "No tool declares a group.")
1454
+ `${data.total} tool${data.total === 1 ? "" : "s"} in total. ` + (hasAnyGroup(data.groups) ? "Run `appduct tools ls --group <name>` to list one group's tools." : "No tool declares a group.")
1479
1455
  ];
1480
1456
  };
1481
- var renderToolsData = (colors, data, flags, full) => {
1457
+ var renderToolsListData = (colors, data, flags, full) => {
1482
1458
  if (isToolGroupsListing(data)) {
1483
1459
  return renderToolGroups(colors, data);
1484
1460
  }
1485
- if (!isToolsListing(data)) {
1486
- return renderToolDetail(colors, data, flags);
1487
- }
1488
1461
  return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
1489
1462
  };
1463
+ var renderEventsTruncationLine = (data) => {
1464
+ const offset = data.offset ?? 0;
1465
+ if (data.events.length >= data.total) {
1466
+ return [];
1467
+ }
1468
+ return [
1469
+ "",
1470
+ `Showing ${data.events.length} of ${data.total} events (offset ${offset}). Narrow with --name <glob> or page with --offset <n>.`
1471
+ ];
1472
+ };
1473
+ var renderEventsListData = (colors, data, flags) => {
1474
+ const [only] = data.events;
1475
+ if (data.name !== void 0 && !data.name.includes("*") && only !== void 0) {
1476
+ return renderFields(
1477
+ colors.green(`Event: ${only.name}`),
1478
+ [
1479
+ ["Signature", renderEventSignature(only)],
1480
+ ["Description", only.description],
1481
+ ["Payload schema", only.payload_schema]
1482
+ ],
1483
+ flags
1484
+ );
1485
+ }
1486
+ if (data.events.length === 0 && data.total > 0) {
1487
+ return [
1488
+ colors.green("Events"),
1489
+ ` No events at offset ${data.offset ?? 0}; ${data.total} matching event${data.total === 1 ? "" : "s"} in total.`
1490
+ ];
1491
+ }
1492
+ if (data.events.length === 0) {
1493
+ return [
1494
+ colors.green("Events"),
1495
+ data.name === void 0 ? " No events declared." : ` No events match ${JSON.stringify(data.name)}.`
1496
+ ];
1497
+ }
1498
+ const lines = data.events.flatMap((event) => [
1499
+ ` ${renderEventSignature(event)}`,
1500
+ ` ${summarizeToolDescription(event.description)}`
1501
+ ]);
1502
+ return [
1503
+ colors.green("Events"),
1504
+ ...lines,
1505
+ ...renderEventsTruncationLine(data),
1506
+ "",
1507
+ "Run `appduct events ls --name <name>` for an event's full payload schema."
1508
+ ];
1509
+ };
1490
1510
  var renderInvokeData = (colors, data, flags) => {
1491
1511
  return [colors.green("Result"), formatScalar(data, flags)];
1492
1512
  };
@@ -1670,15 +1690,19 @@ var renderSuccessData = (colors, command, data, options) => {
1670
1690
  return renderInitData(colors, data, flags);
1671
1691
  case "keygen":
1672
1692
  return renderKeygenData(colors, data, flags);
1673
- case "link":
1693
+ case "sessions link":
1674
1694
  return renderLinkData(colors, data, flags, options.qr);
1675
- case "ls":
1695
+ case "sessions ls":
1676
1696
  return renderLsData(colors, data, options.now ?? /* @__PURE__ */ new Date());
1677
- case "tools":
1678
- return renderToolsData(colors, data, flags, options.full);
1679
- case "invoke":
1697
+ case "tools ls":
1698
+ return renderToolsListData(colors, data, flags, options.full);
1699
+ case "tools describe":
1700
+ return renderToolDetail(colors, data, flags);
1701
+ case "events ls":
1702
+ return renderEventsListData(colors, data, flags);
1703
+ case "tools call":
1680
1704
  return renderInvokeData(colors, data, flags);
1681
- case "revoke":
1705
+ case "sessions revoke":
1682
1706
  return renderRevokeData(colors, data);
1683
1707
  case "daemon run":
1684
1708
  return renderDaemonRunData(colors, data, flags);
@@ -1824,7 +1848,24 @@ var guarded = (context) => {
1824
1848
  };
1825
1849
  };
1826
1850
 
1827
- // src/cli/routes/tools.ts
1851
+ // src/cli/routes/tools/shared-options.ts
1852
+ import { isValidToolGroup } from "@appduct/shared";
1853
+ var parseToolsListingFlags = (context) => {
1854
+ const { options } = context;
1855
+ const limit = parsePositiveIntegerOption(options.limit, "--limit");
1856
+ const offset = parseNonNegativeIntegerOption(options.offset, "--offset");
1857
+ const filter = readTextOption(context.argv, options.filter, "--filter");
1858
+ const group = readTextOption(context.argv, options.group, "--group");
1859
+ if (group !== void 0 && !isValidToolGroup(group)) {
1860
+ throw usageError(
1861
+ `"--group" must be a group name like "checkout" or "checkout/payment" (one or two "/"-separated segments of [a-zA-Z0-9_-], at most 64 characters each); got ${JSON.stringify(group)}.`
1862
+ );
1863
+ }
1864
+ const groups = options.groups === void 0 ? void 0 : Boolean(options.groups);
1865
+ return { group, groups, filter, limit, offset };
1866
+ };
1867
+
1868
+ // src/cli/routes/tools/ls.ts
1828
1869
  var route = async (context) => {
1829
1870
  const { options, stateDir } = context;
1830
1871
  return executeCommand(
@@ -1833,29 +1874,12 @@ var route = async (context) => {
1833
1874
  // 64, `--json` envelope) instead of escaping the route as an uncaught rejection, and before
1834
1875
  // the version check so a typo never waits on (or restarts) the daemon.
1835
1876
  () => {
1836
- const { selector, target, selectorOrTarget } = splitOptionalSelectorAndTarget(
1837
- context.args,
1838
- "tools [selector] [name]"
1839
- );
1840
- const limit = parsePositiveIntegerOption(options.limit, "--limit");
1841
- const offset = parseNonNegativeIntegerOption(options.offset, "--offset");
1842
- const filter = readTextOption(context.argv, options.filter, "--filter");
1843
- const group = readTextOption(context.argv, options.group, "--group");
1844
- if (group !== void 0 && !isValidToolGroup(group)) {
1845
- throw usageError(
1846
- `"--group" must be a group name like "checkout" or "checkout/payment" (one or two "/"-separated segments of [a-zA-Z0-9_-], at most 64 characters each); got ${JSON.stringify(group)}.`
1847
- );
1848
- }
1849
- const groups = options.groups === void 0 ? void 0 : Boolean(options.groups);
1850
- if (groups && options.full) {
1877
+ const { selector } = splitOptionalSelector(context.args, "tools ls [selector]");
1878
+ const listing = parseToolsListingFlags(context);
1879
+ if (listing.groups && options.full) {
1851
1880
  throw usageError('"--groups" lists groups only and cannot be combined with "--full".');
1852
1881
  }
1853
- return guarded(context)(
1854
- () => handleToolsCommand(
1855
- { selector: selector ?? selectorOrTarget, name: target, group, groups, filter, limit, offset },
1856
- { stateDir }
1857
- )
1858
- )();
1882
+ return guarded(context)(() => handleToolsCommand({ selector, ...listing }, { stateDir }))();
1859
1883
  },
1860
1884
  context.env,
1861
1885
  { full: Boolean(options.full) }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The listing-only flags (`--group`/`--groups`/`--filter`/`--limit`/`--offset`) shared by
3
+ * `tools ls`, which acts on them, and `tools describe`, which only needs them parsed so it can
4
+ * reject one next to a `<name>` with the same usage error it has always gotten
5
+ * (`commands/tools.ts`'s `hasListingOnlyOptions`). Kept out of `index.ts` — nothing outside this
6
+ * directory imports it — since a route module, not the noun's router, is the caller.
7
+ */
8
+ import type { RouteContext } from "../../router.js";
9
+ export type ToolsListingFlags = {
10
+ group?: string;
11
+ groups?: boolean;
12
+ filter?: string;
13
+ limit?: number;
14
+ offset?: number;
15
+ };
16
+ export declare const parseToolsListingFlags: (context: RouteContext) => ToolsListingFlags;
@@ -0,0 +1,122 @@
1
+ // src/cli/routes/tools/shared-options.ts
2
+ import { isValidToolGroup } from "@appduct/shared";
3
+
4
+ // src/rpc/client.ts
5
+ import { join as join2 } from "node:path";
6
+ import { RPC_METHODS } from "@appduct/shared";
7
+
8
+ // src/daemon/config.ts
9
+ var DEFAULT_DAEMON_LOG_MAX_BYTES = 10 * 1024 * 1024;
10
+
11
+ // src/package-root.ts
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ var PACKAGE_NAME = "appduct";
16
+ var cached;
17
+ var getPackageRoot = () => {
18
+ if (cached !== void 0) {
19
+ return cached;
20
+ }
21
+ let dir = dirname(fileURLToPath(import.meta.url));
22
+ for (; ; ) {
23
+ const candidate = join(dir, "package.json");
24
+ if (existsSync(candidate)) {
25
+ let name;
26
+ try {
27
+ name = JSON.parse(readFileSync(candidate, "utf8")).name;
28
+ } catch {
29
+ }
30
+ if (name === PACKAGE_NAME) {
31
+ cached = dir;
32
+ return dir;
33
+ }
34
+ }
35
+ const parent = dirname(dir);
36
+ if (parent === dir) {
37
+ throw new Error(`Could not locate the "${PACKAGE_NAME}" package root above ${import.meta.url}.`);
38
+ }
39
+ dir = parent;
40
+ }
41
+ };
42
+
43
+ // src/rpc/client.ts
44
+ var defaultBinPath = join2(getPackageRoot(), "bin.js");
45
+
46
+ // src/errors.ts
47
+ var AppductCliError = class extends Error {
48
+ type;
49
+ details;
50
+ constructor(type, message, details) {
51
+ super(message);
52
+ this.name = "AppductCliError";
53
+ this.type = type;
54
+ this.details = details;
55
+ }
56
+ };
57
+ var usageError = (message, details) => {
58
+ return new AppductCliError("usage_error", message, details);
59
+ };
60
+
61
+ // src/cli/command-options.ts
62
+ var parsePositiveIntegerOption = (value, flagName) => {
63
+ if (value === void 0) {
64
+ return void 0;
65
+ }
66
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
67
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) {
68
+ throw usageError(`"${flagName}" must be a positive integer.`);
69
+ }
70
+ return parsed;
71
+ };
72
+ var parseNonNegativeIntegerOption = (value, flagName) => {
73
+ if (value === void 0) {
74
+ return void 0;
75
+ }
76
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
77
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
78
+ throw usageError(`"${flagName}" must be a non-negative integer.`);
79
+ }
80
+ return parsed;
81
+ };
82
+ var readTextOption = (argv, value, flagName) => {
83
+ if (value === void 0) {
84
+ return void 0;
85
+ }
86
+ if (typeof value === "boolean") {
87
+ throw usageError(`"${flagName}" requires a value.`);
88
+ }
89
+ let raw;
90
+ for (let index = 0; index < argv.length; index += 1) {
91
+ const token = argv[index];
92
+ if (token === "--") {
93
+ break;
94
+ }
95
+ if (token === flagName && index + 1 < argv.length) {
96
+ raw = argv[index + 1];
97
+ index += 1;
98
+ } else if (token.startsWith(`${flagName}=`)) {
99
+ raw = token.slice(flagName.length + 1);
100
+ }
101
+ }
102
+ return raw ?? String(Array.isArray(value) ? value.at(-1) : value);
103
+ };
104
+
105
+ // src/cli/routes/tools/shared-options.ts
106
+ var parseToolsListingFlags = (context) => {
107
+ const { options } = context;
108
+ const limit = parsePositiveIntegerOption(options.limit, "--limit");
109
+ const offset = parseNonNegativeIntegerOption(options.offset, "--offset");
110
+ const filter = readTextOption(context.argv, options.filter, "--filter");
111
+ const group = readTextOption(context.argv, options.group, "--group");
112
+ if (group !== void 0 && !isValidToolGroup(group)) {
113
+ throw usageError(
114
+ `"--group" must be a group name like "checkout" or "checkout/payment" (one or two "/"-separated segments of [a-zA-Z0-9_-], at most 64 characters each); got ${JSON.stringify(group)}.`
115
+ );
116
+ }
117
+ const groups = options.groups === void 0 ? void 0 : Boolean(options.groups);
118
+ return { group, groups, filter, limit, offset };
119
+ };
120
+ export {
121
+ parseToolsListingFlags
122
+ };
@@ -3,11 +3,12 @@
3
3
  * wrapper over the same `tools.list`/`tools.call`/`events.subscribe`/`events.since` RPC the CLI and
4
4
  * MCP server use (`rpc/client.ts`'s `DaemonStream`), bound to one resolved session. No new privilege
5
5
  * or transport — every call is attributed `caller: "client"` for the audit log (ARCHITECTURE.md
6
- * §12). {@link AppClient.waitForEvent}'s drain-then-live pattern mirrors the built-in
7
- * `appduct_wait_for_event` MCP tool (`mcp/events-tool.ts`) — see that module's doc comment for
8
- * why the notification listener has to be registered before the `events.since` drain call is sent.
6
+ * §12). {@link AppClient.waitForEvent} is `events/index.ts`'s `waitForAppEvent` (issue #114) — the
7
+ * same drain-then-live implementation the built-in `appduct_wait_for_event` MCP tool
8
+ * (`mcp/events-tool.ts`) uses — called on a fresh stream opened for that one wait, so concurrent
9
+ * waits on the same `AppClient` never share (and can't clobber) one connection's subscription.
9
10
  */
10
- import { type ListedToolDescriptor } from "@appduct/shared";
11
+ import { type AppEvent, type FullAppEvent, type ListedToolDescriptor } from "@appduct/shared";
11
12
  import type { DaemonStream } from "../rpc/client.js";
12
13
  /** A project's own tool map — declared once, e.g. `type Tools = { sum: { args: { a: number; b:
13
14
  * number }; result: { total: number } } }` — to get typed {@link AppClient.call} throughout a test
@@ -39,9 +40,6 @@ export type CallOptions = {
39
40
  export type WaitForEventOptions = {
40
41
  /** Defaults to 30s. */
41
42
  timeoutMs?: number;
42
- /** Extra filter over the event's payload; the event still must match `name` first. A predicate
43
- * that throws rejects the wait with that error rather than crashing the connection. */
44
- match?: (payload: unknown) => boolean;
45
43
  /**
46
44
  * Exclusive lower bound on `AppEvent.seq` (a cursor from a previous {@link AppClient.events} or
47
45
  * {@link AppClient.waitForEvent} call) — skips already-retained events at/before it instead of
@@ -49,6 +47,12 @@ export type WaitForEventOptions = {
49
47
  * so a matching event that already fired before this call still resolves immediately.
50
48
  */
51
49
  since?: number;
50
+ /**
51
+ * Caps the resolved event's payload to this many UTF-8 bytes of its JSON, daemon-side (issue
52
+ * #114, same cap {@link AppClient.events} takes). Omitted, the payload is delivered whole; passed,
53
+ * the resolved event may come back truncated — see {@link AppClient.waitForEvent}'s overloads.
54
+ */
55
+ payloadMaxBytes?: number;
52
56
  };
53
57
  export type EventsOptions = {
54
58
  /** Exclusive lower bound on `AppEvent.seq`; omitted returns the whole retained buffer (oldest
@@ -58,26 +62,35 @@ export type EventsOptions = {
58
62
  * matching `since` up to the buffer's own retention limit. */
59
63
  limit?: number;
60
64
  };
61
- export type EventsResult = {
62
- events: AppEvent[];
65
+ /** `events.since`'s `dropped`/`remaining`, shared by both of {@link AppClient.events}'s overloaded
66
+ * results (issue #113). */
67
+ export type EventsCounts = {
68
+ /** App events after `since` that were evicted before this call could return them (`0` once
69
+ * nothing has fallen off — including across a gap of only Appduct's own kinds, which never
70
+ * advance `seq`). */
71
+ dropped: number;
72
+ /** Events still matching this query after the returned (possibly `limit`-truncated) page; `0`
73
+ * on the last page. */
74
+ remaining: number;
75
+ };
76
+ /** {@link AppClient.events}'s result with no `payloadMaxBytes` — every event's `payload` is
77
+ * readable unnarrowed. */
78
+ export type FullEventsResult = EventsCounts & {
79
+ events: FullAppEvent[];
63
80
  /** The highest `seq` currently retained for this session (not just among the returned events) —
64
- * pass it back as `since`/`since` on the next {@link AppClient.events}/{@link
65
- * AppClient.waitForEvent} call to resume after it. */
81
+ * pass it back as `since` on the next {@link AppClient.events}/{@link AppClient.waitForEvent}
82
+ * call to resume after it. */
66
83
  cursor: number;
67
84
  };
68
- /** An app-pushed event (`postEvent(name, payload)`), narrowed from the daemon's generic
69
- * `EventNotification` envelope to the shape a `waitForEvent`/`events` caller actually wants. */
70
- export type AppEvent<TPayload = unknown> = {
71
- name: string;
72
- payload: TPayload;
73
- /** Unix ms. */
74
- ts: number;
75
- sessionId: string;
76
- alias?: string;
77
- /** Monotonically increasing per-session cursor (ARCHITECTURE.md §5) assigned by the daemon's
78
- * retention buffer at emit time — pass back into `since` to resume after this event. */
79
- seq: number;
85
+ /** {@link AppClient.events}'s result once a `payloadMaxBytes` cap is given — narrow each event on
86
+ * `truncated` before reading `payload`. */
87
+ export type EventsResult = EventsCounts & {
88
+ events: AppEvent[];
89
+ cursor: number;
80
90
  };
91
+ /** Re-exported from `@appduct/shared` (issue #112) so `appduct/client` keeps its own public
92
+ * `AppEvent` name and doc comment for a `waitForEvent`/`events` caller. */
93
+ export type { AppEvent, FullAppEvent } from "@appduct/shared";
81
94
  export type AppClient<TTools = ToolMap> = {
82
95
  readonly sessionId: string;
83
96
  /** `tools.list` for this session. An entry spells an ungrouped tool's `group` as `null`,
@@ -86,26 +99,70 @@ export type AppClient<TTools = ToolMap> = {
86
99
  /** `tools.call`; rejects with a {@link AppductError} whose `type` preserves the wire error
87
100
  * type verbatim (e.g. `"tool_timeout"`, `"policy_denied"`, `"session_suspended"`). */
88
101
  call<K extends keyof TTools & string>(name: K, args: ToolArgs<TTools, K>, options?: CallOptions): Promise<ToolResult<TTools, K>>;
89
- /** Drains `app_event`s retained in the daemon's per-session ring buffer since a cursor — the pull
102
+ /**
103
+ * Drains `app_event`s retained in the daemon's per-session ring buffer since a cursor — the pull
90
104
  * counterpart to {@link AppClient.waitForEvent}, for checking what already happened instead of
91
105
  * waiting for the next one. Pass `cursor` from the result back as `since` on the next call to
92
- * avoid re-reading events already seen. */
93
- events(options?: EventsOptions): Promise<EventsResult>;
106
+ * avoid re-reading events already seen. `dropped`/`remaining` (issue #113) report, respectively,
107
+ * how many app events after `since` were evicted before this call could return them, and how
108
+ * much more still matches after this page.
109
+ *
110
+ * Overloaded on `payloadMaxBytes` (issue #113): omit it and every event's `payload` is readable
111
+ * unnarrowed ({@link FullEventsResult}); pass it to cap each payload's JSON to that many UTF-8
112
+ * bytes, and narrow each event on `truncated` before reading `payload` ({@link EventsResult}) —
113
+ * an event whose payload was over the cap has `payloadPreview`/`payloadBytes` instead.
114
+ *
115
+ * The uncapped overload is listed first, but excludes `payloadMaxBytes` from its type
116
+ * (`payloadMaxBytes?: undefined`) rather than simply omitting the key from `EventsOptions`:
117
+ * TypeScript's excess-property check only rejects an extra `payloadMaxBytes` on an object
118
+ * *literal* passed directly as the argument, so an options bag held in a variable would
119
+ * otherwise still structurally match plain `EventsOptions` and resolve to `FullEventsResult`
120
+ * — letting `event.payload` compile unnarrowed even though the daemon truncated it (issue
121
+ * #113's review). Requiring the key to be `undefined` when present means a variable whose
122
+ * `payloadMaxBytes` is typed `number` (required or optional) fails to match this overload —
123
+ * by excess-property check for a literal, by property-type mismatch for a variable — and
124
+ * falls through to the capped overload below, which accepts any `payloadMaxBytes` (including
125
+ * `number | undefined`) and returns the narrowed `EventsResult` whenever the type cannot
126
+ * prove the payload is uncapped.
127
+ */
128
+ events(options?: EventsOptions & {
129
+ payloadMaxBytes?: undefined;
130
+ }): Promise<FullEventsResult>;
131
+ events(options: EventsOptions & {
132
+ payloadMaxBytes?: number;
133
+ }): Promise<EventsResult>;
94
134
  /**
95
135
  * Waits for the next `app_event` (as pushed by the connected app's `postEvent(name, payload)`)
96
- * matching `name`. Checks the daemon's retained buffer for an already-arrived match first, then
97
- * falls back to a live wait — so an event emitted between "the caller decides to wait" and "the
98
- * subscription lands" is never missed; pass `since` (a cursor from a previous {@link
136
+ * whose name matches `name`, a whole-name, case-sensitive glob (issue #114: `*` matches any run
137
+ * of characters, a pattern with no `*` is an exact name — e.g. `"cart.*"`, or `"*"` for the next
138
+ * event of any name). Checks the daemon's retained buffer for an already-arrived match first,
139
+ * then falls back to a live wait — so an event emitted between "the caller decides to wait" and
140
+ * "the subscription lands" is never missed; pass `since` (a cursor from a previous {@link
99
141
  * AppClient.events}/`waitForEvent` call) to skip events already handled and wait only for a new
100
142
  * one, since omitting it can return an old match instantly on every call.
101
143
  *
102
- * This shares the connection's single `events.subscribe` filter (the daemon keeps one per
103
- * connection, replaced — not merged — on each call); concurrent `waitForEvent` calls on the same
104
- * `AppClient` all see every `app_event`, so this is safe today, but it means the connection stays
105
- * subscribed to `app_event` for its lifetime once any `waitForEvent` has run.
144
+ * Opens its own daemon stream for this one wait (issue #114) rather than sharing this
145
+ * `AppClient`'s connection, so concurrent `waitForEvent` calls with different `name`s each get
146
+ * their own `events.subscribe` filter and resolve on their own event.
147
+ *
148
+ * Overloaded on `payloadMaxBytes` the same way {@link AppClient.events} is (issue #114): omit it
149
+ * and the resolved event's `payload` is readable unnarrowed ({@link FullAppEvent}); pass it to
150
+ * cap the payload's JSON to that many UTF-8 bytes daemon-side, and narrow on `truncated` before
151
+ * reading `payload` ({@link AppEvent}) — an event whose payload was over the cap has
152
+ * `payloadPreview`/`payloadBytes` instead. See {@link AppClient.events}'s doc comment for why the
153
+ * uncapped overload spells its `payloadMaxBytes` as `undefined` rather than omitting the key.
106
154
  */
107
- waitForEvent<TPayload = unknown>(name: string, options?: WaitForEventOptions): Promise<AppEvent<TPayload>>;
108
- /** Closes the underlying connection. Safe to call more than once. */
155
+ waitForEvent<TPayload = unknown>(name: string, options?: WaitForEventOptions & {
156
+ payloadMaxBytes?: undefined;
157
+ }): Promise<FullAppEvent<TPayload>>;
158
+ waitForEvent<TPayload = unknown>(name: string, options: WaitForEventOptions & {
159
+ payloadMaxBytes?: number;
160
+ }): Promise<AppEvent<TPayload>>;
161
+ /** Closes the underlying connection, and every stream a still-pending {@link
162
+ * AppClient.waitForEvent} call opened for itself — each such call rejects with a `"connection_error"`
163
+ * {@link AppductError}, the same way it did before `waitForEvent` moved onto its own stream (issue
164
+ * #114). A `waitForEvent` call made after `close()` rejects immediately with the same error and
165
+ * never opens a stream (so it never auto-spawns a daemon). Safe to call more than once. */
109
166
  close(): void;
110
167
  };
111
168
  /**
@@ -122,5 +179,7 @@ export type AppClient<TTools = ToolMap> = {
122
179
  * generic transport error in place of the daemon's real one.
123
180
  */
124
181
  export declare const transportTimeoutForToolCall: (timeoutMs: number | undefined) => number;
125
- export declare const makeAppClient: <TTools = ToolMap>(stream: DaemonStream, sessionId: string) => AppClient<TTools>;
126
- export {};
182
+ export declare const makeAppClient: <TTools = ToolMap>(stream: DaemonStream, sessionId: string,
183
+ /** Opens a fresh `DaemonStream` for one {@link AppClient.waitForEvent} call (issue #114) — every
184
+ * other method keeps using `stream` above. */
185
+ openStream: () => Promise<DaemonStream>) => AppClient<TTools>;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `appduct/client` (issue #8): a first-class programmatic client for test runners — a thin typed
3
3
  * wrapper over the same daemon RPC the CLI and MCP server use, so a Jest/Vitest/Detox spec can
4
- * `connect()`/`call()` instead of spawning `appduct invoke ... --json` and parsing stdout.
4
+ * `connect()`/`call()` instead of spawning `appduct tools call ... --json` and parsing stdout.
5
5
  *
6
6
  * ```ts
7
7
  * import { connect } from "appduct/client";
@@ -14,5 +14,6 @@
14
14
  export { connect, type ConnectOptions } from "./connect.js";
15
15
  export { link, waitForSession, type LinkOptions, type LinkResult, type WaitForSessionOptions } from "./bootstrap.js";
16
16
  export { AppductError, type AppductErrorType } from "./errors.js";
17
- export type { AppClient, AppEvent, CallOptions, EventsOptions, EventsResult, ToolMap, WaitForEventOptions, } from "./app-client.js";
17
+ export type { AppClient, AppEvent, CallOptions, EventsOptions, EventsResult, FullAppEvent, FullEventsResult, ToolMap, WaitForEventOptions, } from "./app-client.js";
18
+ export type { TruncatedAppEvent } from "@appduct/shared";
18
19
  export type { AgentEndpoint, ErrorType, ListedToolDescriptor, ToolDescriptor } from "@appduct/shared";