appduct 0.13.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 (36) hide show
  1. package/README.md +3 -2
  2. package/dist/bin.js +61 -4
  3. package/dist/cli/result-types.d.ts +8 -1
  4. package/dist/cli/routes/daemon/index.js +58 -1
  5. package/dist/cli/routes/daemon/run.js +152 -22
  6. package/dist/cli/routes/daemon/start.js +58 -1
  7. package/dist/cli/routes/daemon/status.js +58 -1
  8. package/dist/cli/routes/daemon/stop.js +58 -1
  9. package/dist/cli/routes/doctor.js +58 -1
  10. package/dist/cli/routes/events/index.d.ts +0 -3
  11. package/dist/cli/routes/events/index.js +60 -2
  12. package/dist/cli/routes/events/ls.d.ts +5 -0
  13. package/dist/cli/routes/events/ls.js +1798 -0
  14. package/dist/cli/routes/events/since.js +58 -1
  15. package/dist/cli/routes/events/tail.js +58 -1
  16. package/dist/cli/routes/init.js +58 -1
  17. package/dist/cli/routes/keygen.js +58 -1
  18. package/dist/cli/routes/mcp.js +108 -1
  19. package/dist/cli/routes/sessions/index.js +58 -1
  20. package/dist/cli/routes/sessions/link.js +58 -1
  21. package/dist/cli/routes/sessions/ls.js +58 -1
  22. package/dist/cli/routes/sessions/revoke.js +58 -1
  23. package/dist/cli/routes/tools/call.js +58 -1
  24. package/dist/cli/routes/tools/describe.js +58 -1
  25. package/dist/cli/routes/tools/index.js +58 -1
  26. package/dist/cli/routes/tools/ls.js +58 -1
  27. package/dist/commands/events-ls.d.ts +21 -0
  28. package/dist/daemon/calls.d.ts +3 -1
  29. package/dist/daemon/event-bus.d.ts +10 -0
  30. package/dist/daemon/registry.d.ts +20 -12
  31. package/dist/daemon/rpc-errors.d.ts +3 -0
  32. package/dist/daemon/sessions.d.ts +4 -2
  33. package/dist/index.js +61 -4
  34. package/dist/mcp/app-tools.d.ts +19 -0
  35. package/dist/mcp/events-tool.d.ts +39 -0
  36. package/package.json +2 -2
package/README.md CHANGED
@@ -46,6 +46,7 @@ Every command is `appduct <noun> <verb> [selector] [args]`, the same shape as `a
46
46
  | `appduct tools ls [selector] --groups` | list a session's tool groups with their tool counts |
47
47
  | `appduct tools describe [selector] <name>` | show one tool's full schema |
48
48
  | `appduct tools call [selector] <name> --input '<json>' [--timeout <ms>]` | call a tool |
49
+ | `appduct events ls [selector] [--name <glob>] [--limit <n>] [--offset <n>]` | list the events the app declares, one signature line each; an exact `--name` prints the full payload schema; `--limit`/`--offset` page a long list, ending with a `Showing n of total events (offset o).` line when events were left out; `--json` carries the full descriptors, with `total` counting matches before paging |
49
50
  | `appduct events tail [selector] [--follow]` | stream the events the app posts with `postEvent`; `--json` emits NDJSON |
50
51
  | `appduct events since [selector] <cursor>` | one-shot pull the events retained since `<cursor>`; `--json` emits NDJSON |
51
52
  | `appduct daemon run\|start\|stop\|status` | daemon lifecycle |
@@ -222,9 +223,9 @@ Your MCP client asks permission for `appduct_call_tool` as a single tool, so cho
222
223
 
223
224
  Set this before you connect: the daemon reads `config.json` only when it starts, so changing it later takes `appduct daemon stop`. That disconnects every device, which then has to link again, and leaves a running `appduct mcp` without a daemon, so restart the MCP server in your client as well.
224
225
 
225
- Four more built-in tools cover what the app's own tools can't. `appduct_connect` mints a link and, by default, delivers it to whichever `android`/`ios-sim` device it detects — pass `target`/`device` to choose, or `target: "none"` to force the human flow — falling back to a QR code, plus instructions to show it, only when there's nothing to deliver to. Delivering to `android` (chosen or detected) needs `appId`, resolved the same way as `--app-id` (see [Delivering the link to a device](#delivering-the-link-to-a-device)); passing it with `target: "ios-sim"` or `"none"` is an error. `appduct_wait_for_session` then waits for that session to be claimed. `target: "ios-device"` reaches a paired physical iPhone or iPad, with `appId` and the [prerequisites above](#--open-ios-device-experimental) — it's experimental and never auto-detected, so an agent has to ask for it by name.
226
+ Five more built-in tools cover what the app's own tools can't. `appduct_connect` mints a link and, by default, delivers it to whichever `android`/`ios-sim` device it detects — pass `target`/`device` to choose, or `target: "none"` to force the human flow — falling back to a QR code, plus instructions to show it, only when there's nothing to deliver to. Delivering to `android` (chosen or detected) needs `appId`, resolved the same way as `--app-id` (see [Delivering the link to a device](#delivering-the-link-to-a-device)); passing it with `target: "ios-sim"` or `"none"` is an error. `appduct_wait_for_session` then waits for that session to be claimed. `target: "ios-device"` reaches a paired physical iPhone or iPad, with `appId` and the [prerequisites above](#--open-ios-device-experimental) — it's experimental and never auto-detected, so an agent has to ask for it by name.
226
227
 
227
- The other two give an agent a pull surface over `postEvent()`-pushed app events: `appduct_events` drains everything retained since a cursor, and `appduct_wait_for_event` blocks for the next event whose name matches `name`, a whole-name glob (`*` waits for any name) — checking what's already retained before waiting live — rejecting with `tool_timeout` if none arrives in time.
228
+ The other three give an agent a pull surface over `postEvent()`-pushed app events. `appduct_list_events` lists the events the app declared (`appduct events ls`) as signatures with descriptions, plus the payload schema when `name` is an exact name, so an agent learns the names before it waits on one. `appduct_events` drains everything retained since a cursor, and `appduct_wait_for_event` blocks for the next event whose name matches `name`, a whole-name glob (`*` waits for any name) — checking what's already retained before waiting live — rejecting with `tool_timeout` if none arrives in time.
228
229
 
229
230
  ## Test runners: `appduct/client`
230
231
 
package/dist/bin.js CHANGED
@@ -140,10 +140,10 @@ var createCli = () => {
140
140
  "--timeout <ms>",
141
141
  "call: call timeout in milliseconds. Shortens the deadline; it cannot extend one past the app's own timer, which is the tool's declared timeoutMs (else 10000)."
142
142
  );
143
- cli.command("events [...args]", "Stream the app's events, or replay them since a cursor: tail or since.").usage("events <tail|since> [selector] [args]").option("--follow", "tail: accepted for script readability; the default behavior already follows.").option(
143
+ cli.command("events [...args]", "List the events an app declares, stream them, or replay them since a cursor: ls, tail, or since.").usage("events <ls|tail|since> [selector] [args]").option("--follow", "tail: accepted for script readability; the default behavior already follows.").option(
144
144
  "--name <glob>",
145
- 'Only events whose name matches this whole-name, case-sensitive glob ("*" matches any run of characters).'
146
- ).option(
145
+ `Only events whose name matches this whole-name, case-sensitive glob ("*" matches any run of characters). ls: an exact name prints that event's full payload schema.`
146
+ ).option("--limit <n>", "ls: show at most n events.").option("--offset <n>", "ls: skip the first n events of the name-sorted list.").option(
147
147
  "--payload-max-bytes <n>",
148
148
  "Cap each event's payload at this many UTF-8 bytes of its JSON; over the cap, the event carries payloadPreview, payloadBytes and truncated: true instead of payload."
149
149
  );
@@ -524,6 +524,7 @@ var getExitCodeForError = (error) => {
524
524
  import pc from "picocolors";
525
525
  import {
526
526
  formatAgentWebSocketUrl,
527
+ renderEventSignature,
527
528
  renderToolSignature,
528
529
  summarizeToolDescription
529
530
  } from "@appduct/shared";
@@ -619,6 +620,13 @@ var formatDevice = (device) => {
619
620
  const label = parts.length > 0 ? parts.join(" ") : "unknown device";
620
621
  return device.os ? `${label} (${device.os})` : label;
621
622
  };
623
+ var SUSPEND_REASON_LABELS = {
624
+ app_backgrounded: "app in background",
625
+ connection_lost: "connection lost"
626
+ };
627
+ var formatSessionState = (session) => {
628
+ return session.suspendReason ? `${session.state} (${SUSPEND_REASON_LABELS[session.suspendReason]})` : session.state;
629
+ };
622
630
  var renderLsData = (colors, data, now) => {
623
631
  if (data.length === 0) {
624
632
  return [colors.dim("Sessions"), " No Appduct sessions are registered."];
@@ -627,7 +635,7 @@ var renderLsData = (colors, data, now) => {
627
635
  const headers = ["Alias", "State", "Device", "Tools", "Age"];
628
636
  const rows = data.map((session) => [
629
637
  session.alias,
630
- session.state,
638
+ formatSessionState(session),
631
639
  formatDevice(session.device),
632
640
  String(session.toolCount),
633
641
  formatAge(session.claimedAt ?? session.createdAt, nowIso)
@@ -801,6 +809,53 @@ var renderToolsListData = (colors, data, flags, full) => {
801
809
  }
802
810
  return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
803
811
  };
812
+ var renderEventsTruncationLine = (data) => {
813
+ const offset = data.offset ?? 0;
814
+ if (data.events.length >= data.total) {
815
+ return [];
816
+ }
817
+ return [
818
+ "",
819
+ `Showing ${data.events.length} of ${data.total} events (offset ${offset}). Narrow with --name <glob> or page with --offset <n>.`
820
+ ];
821
+ };
822
+ var renderEventsListData = (colors, data, flags) => {
823
+ const [only] = data.events;
824
+ if (data.name !== void 0 && !data.name.includes("*") && only !== void 0) {
825
+ return renderFields(
826
+ colors.green(`Event: ${only.name}`),
827
+ [
828
+ ["Signature", renderEventSignature(only)],
829
+ ["Description", only.description],
830
+ ["Payload schema", only.payload_schema]
831
+ ],
832
+ flags
833
+ );
834
+ }
835
+ if (data.events.length === 0 && data.total > 0) {
836
+ return [
837
+ colors.green("Events"),
838
+ ` No events at offset ${data.offset ?? 0}; ${data.total} matching event${data.total === 1 ? "" : "s"} in total.`
839
+ ];
840
+ }
841
+ if (data.events.length === 0) {
842
+ return [
843
+ colors.green("Events"),
844
+ data.name === void 0 ? " No events declared." : ` No events match ${JSON.stringify(data.name)}.`
845
+ ];
846
+ }
847
+ const lines = data.events.flatMap((event) => [
848
+ ` ${renderEventSignature(event)}`,
849
+ ` ${summarizeToolDescription(event.description)}`
850
+ ]);
851
+ return [
852
+ colors.green("Events"),
853
+ ...lines,
854
+ ...renderEventsTruncationLine(data),
855
+ "",
856
+ "Run `appduct events ls --name <name>` for an event's full payload schema."
857
+ ];
858
+ };
804
859
  var renderInvokeData = (colors, data, flags) => {
805
860
  return [colors.green("Result"), formatScalar(data, flags)];
806
861
  };
@@ -992,6 +1047,8 @@ var renderSuccessData = (colors, command, data, options) => {
992
1047
  return renderToolsListData(colors, data, flags, options.full);
993
1048
  case "tools describe":
994
1049
  return renderToolDetail(colors, data, flags);
1050
+ case "events ls":
1051
+ return renderEventsListData(colors, data, flags);
995
1052
  case "tools call":
996
1053
  return renderInvokeData(colors, data, flags);
997
1054
  case "sessions revoke":
@@ -4,7 +4,7 @@
4
4
  * the thin-client shapes for the v2 command table.
5
5
  */
6
6
  import type { OpenTarget } from "./open-target.js";
7
- import type { AgentEndpoint, EffectivePolicyDecision, EventNotification, SessionSummary, ToolGroupSummary, ToolsListEntry, ToolsListResult } from "@appduct/shared";
7
+ import type { AgentEndpoint, EffectivePolicyDecision, EventNotification, EventsListResult, SessionSummary, ToolGroupSummary, ToolsListEntry, ToolsListResult } from "@appduct/shared";
8
8
  /**
9
9
  * CLI-domain error classes, used only to pick a sysexits-style exit code (see `errors.ts`).
10
10
  * `CliError.type` itself is *not* restricted to this union: RPC/app-originated failures preserve
@@ -135,6 +135,13 @@ export type ToolGroupsListing = {
135
135
  * listing entry, picked out of `tools.list` — not a registration, so its `group` is `null` for an
136
136
  * ungrouped tool. */
137
137
  export type ToolsCommandData = ToolsListing | ToolGroupsListing | ToolsListEntry;
138
+ /** `appduct events ls`: the daemon's `events.list` result, plus the `--name`/`--limit`/`--offset`
139
+ * inputs that produced it (only the ones actually given). */
140
+ export type EventsListing = EventsListResult & {
141
+ name?: string;
142
+ limit?: number;
143
+ offset?: number;
144
+ };
138
145
  /** `appduct tools call`: the tool's raw result payload, printed as-is. */
139
146
  export type InvokeCommandData = unknown;
140
147
  export type RevokeCommandData = {
@@ -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,6 +498,53 @@ 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
  };
@@ -681,6 +736,8 @@ var renderSuccessData = (colors, command, data, options) => {
681
736
  return renderToolsListData(colors, data, flags, options.full);
682
737
  case "tools describe":
683
738
  return renderToolDetail(colors, data, flags);
739
+ case "events ls":
740
+ return renderEventsListData(colors, data, flags);
684
741
  case "tools call":
685
742
  return renderInvokeData(colors, data, flags);
686
743
  case "sessions revoke":
@@ -484,6 +484,7 @@ var RpcApplicationError = class extends Error {
484
484
  this.name = "RpcApplicationError";
485
485
  }
486
486
  };
487
+ var appBackgroundedMessage = (alias) => `Session "${alias}" is suspended because the app is in the background. Bring the app to the foreground to resume it.`;
487
488
 
488
489
  // src/daemon/timers.ts
489
490
  var systemTimers = {
@@ -586,7 +587,7 @@ var createCallsManager = (options) => {
586
587
  const message = { type: "tool_cancel", session_id: sessionId, id: callId, reason };
587
588
  return options.sendCancel(sessionId, message);
588
589
  };
589
- const rejectSession = (sessionId, errorType) => {
590
+ const rejectSession = (sessionId, errorType, options2 = {}) => {
590
591
  const sessionMap = pendingBySession.get(sessionId);
591
592
  if (!sessionMap) {
592
593
  return;
@@ -594,13 +595,19 @@ var createCallsManager = (options) => {
594
595
  pendingBySession.delete(sessionId);
595
596
  for (const pending of sessionMap.values()) {
596
597
  timers.clearTimeout(pending.timer);
597
- pending.reject(new RpcApplicationError(errorType, `Session transitioned while the call was pending.`));
598
+ pending.reject(
599
+ new RpcApplicationError(
600
+ errorType,
601
+ options2.backgrounded ? appBackgroundedMessage(pending.alias) : "Session transitioned while the call was pending."
602
+ )
603
+ );
598
604
  }
599
605
  };
600
606
  const unsubscribeFromEventBus = options.eventBus.subscribe((event) => {
601
607
  const errorType = TERMINATING_KIND_ERROR_TYPES[event.kind];
602
608
  if (errorType && event.sessionId) {
603
- rejectSession(event.sessionId, errorType);
609
+ const backgrounded = event.kind === "session_suspended" && event.data.reason === "app_backgrounded";
610
+ rejectSession(event.sessionId, errorType, { backgrounded });
604
611
  }
605
612
  });
606
613
  const disposeAll = () => {
@@ -791,6 +798,8 @@ import {
791
798
  isEventMessage,
792
799
  isToolCallProgressMessage,
793
800
  isToolErrorMessage,
801
+ isEventRegistryDeltaMessage,
802
+ isEventRegistrySnapshotMessage,
794
803
  isToolRegistryDeltaMessage,
795
804
  isToolRegistrySnapshotMessage,
796
805
  isToolResultMessage,
@@ -888,7 +897,7 @@ var createPendingLinkRegistry = (options) => {
888
897
  };
889
898
 
890
899
  // src/daemon/registry.ts
891
- var createToolRegistry = (onChange) => {
900
+ var createRegistry = (onChange) => {
892
901
  let tools = /* @__PURE__ */ new Map();
893
902
  const notify = () => {
894
903
  onChange?.();
@@ -918,6 +927,8 @@ var RESUME_TOKEN_BYTES = 32;
918
927
  var POST_CLAIM_MESSAGE_TYPES = /* @__PURE__ */ new Set([
919
928
  "tool_registry_snapshot",
920
929
  "tool_registry_delta",
930
+ "event_registry_snapshot",
931
+ "event_registry_delta",
921
932
  "tool_result",
922
933
  "tool_error",
923
934
  "tool_call_progress",
@@ -988,6 +999,7 @@ var createSessionManager = (options) => {
988
999
  createdAt: session.createdAt.toISOString(),
989
1000
  claimedAt: session.claimedAt.toISOString(),
990
1001
  ...session.suspendedAt ? { suspendedAt: session.suspendedAt.toISOString() } : {},
1002
+ ...session.suspendReason ? { suspendReason: session.suspendReason } : {},
991
1003
  toolCount: session.registry.count()
992
1004
  };
993
1005
  };
@@ -1024,7 +1036,8 @@ var createSessionManager = (options) => {
1024
1036
  alias: session.alias,
1025
1037
  resume_token: session.resumeToken.toString("base64url"),
1026
1038
  keepalive_interval_s: options.keepaliveIntervalSeconds,
1027
- grace_s: options.graceSeconds
1039
+ grace_s: options.graceSeconds,
1040
+ event_registry: true
1028
1041
  };
1029
1042
  socket.send(JSON.stringify(ack), (error) => {
1030
1043
  if (error) {
@@ -1032,17 +1045,18 @@ var createSessionManager = (options) => {
1032
1045
  }
1033
1046
  });
1034
1047
  };
1035
- const suspend = (sessionId) => {
1048
+ const suspend = (sessionId, reason) => {
1036
1049
  const session = sessions.get(sessionId);
1037
1050
  if (!session || session.state !== "active") {
1038
1051
  return;
1039
1052
  }
1040
1053
  session.state = "suspended";
1041
1054
  session.suspendedAt = clock.now();
1055
+ session.suspendReason = reason;
1042
1056
  session.socket = void 0;
1043
1057
  session.missedPongs = 0;
1044
1058
  session.graceTimer = timers.setTimeout(() => expire(sessionId), options.graceSeconds * 1e3);
1045
- emit("session_suspended", session.sessionId, session.alias, {});
1059
+ emit("session_suspended", session.sessionId, session.alias, { reason });
1046
1060
  };
1047
1061
  const expire = (sessionId) => {
1048
1062
  const session = sessions.get(sessionId);
@@ -1059,9 +1073,10 @@ var createSessionManager = (options) => {
1059
1073
  socket.on("pong", () => {
1060
1074
  session.missedPongs = 0;
1061
1075
  });
1062
- socket.on("close", () => {
1076
+ socket.on("close", (code, reason) => {
1063
1077
  if (session.socket === socket) {
1064
- suspend(session.sessionId);
1078
+ const backgrounded = code === 1001 && reason.toString("utf8") === "app_backgrounded";
1079
+ suspend(session.sessionId, backgrounded ? "app_backgrounded" : "connection_lost");
1065
1080
  }
1066
1081
  });
1067
1082
  };
@@ -1073,7 +1088,7 @@ var createSessionManager = (options) => {
1073
1088
  if (session.missedPongs >= 2) {
1074
1089
  const staleSocket = session.socket;
1075
1090
  session.socket = void 0;
1076
- suspend(session.sessionId);
1091
+ suspend(session.sessionId, "connection_lost");
1077
1092
  staleSocket.terminate();
1078
1093
  continue;
1079
1094
  }
@@ -1138,8 +1153,11 @@ var createSessionManager = (options) => {
1138
1153
  resumeToken: randomBytes3(RESUME_TOKEN_BYTES),
1139
1154
  socket,
1140
1155
  missedPongs: 0,
1141
- registry: createToolRegistry(() => {
1156
+ registry: createRegistry(() => {
1142
1157
  emit("tools_changed", sessionId, alias, { toolCount: session.registry.count() });
1158
+ }),
1159
+ eventRegistry: createRegistry(() => {
1160
+ emit("events_changed", sessionId, alias, { eventCount: session.eventRegistry.count() });
1143
1161
  })
1144
1162
  };
1145
1163
  sessions.set(sessionId, session);
@@ -1171,6 +1189,7 @@ var createSessionManager = (options) => {
1171
1189
  session.resumeToken = randomBytes3(RESUME_TOKEN_BYTES);
1172
1190
  session.state = "active";
1173
1191
  session.suspendedAt = void 0;
1192
+ session.suspendReason = void 0;
1174
1193
  session.socket = socket;
1175
1194
  session.missedPongs = 0;
1176
1195
  attachSocketHandlers(session);
@@ -1205,6 +1224,26 @@ var createSessionManager = (options) => {
1205
1224
  }
1206
1225
  return;
1207
1226
  }
1227
+ case "event_registry_snapshot": {
1228
+ if (!isEventRegistrySnapshotMessage(message)) {
1229
+ closeSocket(socket, 1008, "invalid_registry");
1230
+ return;
1231
+ }
1232
+ session.eventRegistry.snapshot(message.events);
1233
+ return;
1234
+ }
1235
+ case "event_registry_delta": {
1236
+ if (!isEventRegistryDeltaMessage(message)) {
1237
+ closeSocket(socket, 1008, "invalid_registry");
1238
+ return;
1239
+ }
1240
+ if (message.operation === "upsert") {
1241
+ session.eventRegistry.upsert(message.event);
1242
+ } else {
1243
+ session.eventRegistry.remove(message.name);
1244
+ }
1245
+ return;
1246
+ }
1208
1247
  case "tool_result": {
1209
1248
  if (!isToolResultMessage(message)) {
1210
1249
  closeSocket(socket, 1008, "invalid_message");
@@ -1272,7 +1311,14 @@ var createSessionManager = (options) => {
1272
1311
  };
1273
1312
  const resolveForTools = (selector) => {
1274
1313
  const session = resolveSession(selector);
1275
- return { sessionId: session.sessionId, alias: session.alias, state: session.state, registry: session.registry };
1314
+ return {
1315
+ sessionId: session.sessionId,
1316
+ alias: session.alias,
1317
+ state: session.state,
1318
+ suspendReason: session.suspendReason,
1319
+ registry: session.registry,
1320
+ eventRegistry: session.eventRegistry
1321
+ };
1276
1322
  };
1277
1323
  const sendToApp = (sessionId, message) => {
1278
1324
  const session = sessions.get(sessionId);
@@ -2040,6 +2086,26 @@ var asSelectorParams = (params) => {
2040
2086
  }
2041
2087
  return { selector };
2042
2088
  };
2089
+ var asPageParams = (record) => {
2090
+ const limit = record.limit;
2091
+ if (limit !== void 0 && (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0)) {
2092
+ throw new RpcApplicationError("invalid_request", '"limit" must be a positive integer.');
2093
+ }
2094
+ const offset = record.offset;
2095
+ if (offset !== void 0 && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0)) {
2096
+ throw new RpcApplicationError("invalid_request", '"offset" must be a non-negative integer.');
2097
+ }
2098
+ return { limit, offset };
2099
+ };
2100
+ var asEventsListParams = (params) => {
2101
+ const { selector } = asSelectorParams(params);
2102
+ const record = asRecordParams(params);
2103
+ const name = record.name;
2104
+ if (name !== void 0 && typeof name !== "string") {
2105
+ throw new RpcApplicationError("invalid_request", '"name" must be a string.');
2106
+ }
2107
+ return { selector, name, ...asPageParams(record) };
2108
+ };
2043
2109
  var asToolsListParams = (params) => {
2044
2110
  const { selector } = asSelectorParams(params);
2045
2111
  const record = asRecordParams(params);
@@ -2057,14 +2123,7 @@ var asToolsListParams = (params) => {
2057
2123
  `"filter" must be a string of at most ${MAX_TOOLS_FILTER_LENGTH} characters.`
2058
2124
  );
2059
2125
  }
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
- }
2126
+ const { limit, offset } = asPageParams(record);
2068
2127
  return {
2069
2128
  selector,
2070
2129
  group,
@@ -2395,6 +2454,17 @@ var startDaemon = async (options) => {
2395
2454
  activeSessionManager.revoke(selector);
2396
2455
  return { ok: true };
2397
2456
  },
2457
+ [RPC_METHODS.eventsList]: (params) => {
2458
+ const { selector, name, limit, offset } = asEventsListParams(params);
2459
+ const registered = activeSessionManager.resolveForTools(selector).eventRegistry.list();
2460
+ const sorted = registered.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
2461
+ const matching = name === void 0 ? sorted : sorted.filter((event) => matchesNameGlob(name, event.name));
2462
+ const start = offset ?? 0;
2463
+ return {
2464
+ events: limit === void 0 ? matching.slice(start) : matching.slice(start, start + limit),
2465
+ total: matching.length
2466
+ };
2467
+ },
2398
2468
  [RPC_METHODS.toolsList]: (params) => {
2399
2469
  const { selector, group, filter, limit, offset } = asToolsListParams(params);
2400
2470
  const resolved = activeSessionManager.resolveForTools(selector);
@@ -2442,7 +2512,10 @@ var startDaemon = async (options) => {
2442
2512
  if (resolved.state !== "active") {
2443
2513
  const errorType = resolved.state === "suspended" ? "session_suspended" : "session_not_active";
2444
2514
  writeAudit("error", errorType);
2445
- throw new RpcApplicationError(errorType, `Session "${resolved.alias}" is not active.`);
2515
+ throw new RpcApplicationError(
2516
+ errorType,
2517
+ resolved.suspendReason === "app_backgrounded" ? appBackgroundedMessage(resolved.alias) : `Session "${resolved.alias}" is not active.`
2518
+ );
2446
2519
  }
2447
2520
  const tool = resolved.registry.get(name);
2448
2521
  if (!tool) {
@@ -2786,6 +2859,7 @@ var formatJson = (value, flags) => {
2786
2859
  import pc from "picocolors";
2787
2860
  import {
2788
2861
  formatAgentWebSocketUrl,
2862
+ renderEventSignature,
2789
2863
  renderToolSignature,
2790
2864
  summarizeToolDescription
2791
2865
  } from "@appduct/shared";
@@ -2881,6 +2955,13 @@ var formatDevice = (device) => {
2881
2955
  const label = parts.length > 0 ? parts.join(" ") : "unknown device";
2882
2956
  return device.os ? `${label} (${device.os})` : label;
2883
2957
  };
2958
+ var SUSPEND_REASON_LABELS = {
2959
+ app_backgrounded: "app in background",
2960
+ connection_lost: "connection lost"
2961
+ };
2962
+ var formatSessionState = (session) => {
2963
+ return session.suspendReason ? `${session.state} (${SUSPEND_REASON_LABELS[session.suspendReason]})` : session.state;
2964
+ };
2884
2965
  var renderLsData = (colors, data, now) => {
2885
2966
  if (data.length === 0) {
2886
2967
  return [colors.dim("Sessions"), " No Appduct sessions are registered."];
@@ -2889,7 +2970,7 @@ var renderLsData = (colors, data, now) => {
2889
2970
  const headers = ["Alias", "State", "Device", "Tools", "Age"];
2890
2971
  const rows = data.map((session) => [
2891
2972
  session.alias,
2892
- session.state,
2973
+ formatSessionState(session),
2893
2974
  formatDevice(session.device),
2894
2975
  String(session.toolCount),
2895
2976
  formatAge(session.claimedAt ?? session.createdAt, nowIso)
@@ -3063,6 +3144,53 @@ var renderToolsListData = (colors, data, flags, full) => {
3063
3144
  }
3064
3145
  return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
3065
3146
  };
3147
+ var renderEventsTruncationLine = (data) => {
3148
+ const offset = data.offset ?? 0;
3149
+ if (data.events.length >= data.total) {
3150
+ return [];
3151
+ }
3152
+ return [
3153
+ "",
3154
+ `Showing ${data.events.length} of ${data.total} events (offset ${offset}). Narrow with --name <glob> or page with --offset <n>.`
3155
+ ];
3156
+ };
3157
+ var renderEventsListData = (colors, data, flags) => {
3158
+ const [only] = data.events;
3159
+ if (data.name !== void 0 && !data.name.includes("*") && only !== void 0) {
3160
+ return renderFields(
3161
+ colors.green(`Event: ${only.name}`),
3162
+ [
3163
+ ["Signature", renderEventSignature(only)],
3164
+ ["Description", only.description],
3165
+ ["Payload schema", only.payload_schema]
3166
+ ],
3167
+ flags
3168
+ );
3169
+ }
3170
+ if (data.events.length === 0 && data.total > 0) {
3171
+ return [
3172
+ colors.green("Events"),
3173
+ ` No events at offset ${data.offset ?? 0}; ${data.total} matching event${data.total === 1 ? "" : "s"} in total.`
3174
+ ];
3175
+ }
3176
+ if (data.events.length === 0) {
3177
+ return [
3178
+ colors.green("Events"),
3179
+ data.name === void 0 ? " No events declared." : ` No events match ${JSON.stringify(data.name)}.`
3180
+ ];
3181
+ }
3182
+ const lines = data.events.flatMap((event) => [
3183
+ ` ${renderEventSignature(event)}`,
3184
+ ` ${summarizeToolDescription(event.description)}`
3185
+ ]);
3186
+ return [
3187
+ colors.green("Events"),
3188
+ ...lines,
3189
+ ...renderEventsTruncationLine(data),
3190
+ "",
3191
+ "Run `appduct events ls --name <name>` for an event's full payload schema."
3192
+ ];
3193
+ };
3066
3194
  var renderInvokeData = (colors, data, flags) => {
3067
3195
  return [colors.green("Result"), formatScalar(data, flags)];
3068
3196
  };
@@ -3254,6 +3382,8 @@ var renderSuccessData = (colors, command, data, options) => {
3254
3382
  return renderToolsListData(colors, data, flags, options.full);
3255
3383
  case "tools describe":
3256
3384
  return renderToolDetail(colors, data, flags);
3385
+ case "events ls":
3386
+ return renderEventsListData(colors, data, flags);
3257
3387
  case "tools call":
3258
3388
  return renderInvokeData(colors, data, flags);
3259
3389
  case "sessions revoke":