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
@@ -28,6 +28,16 @@ export type ProjectAppEventOptions = {
28
28
  /** Caps the payload's JSON to this many UTF-8 bytes (issue #113); see {@link truncateToUtf8Bytes}. */
29
29
  payloadMaxBytes?: number;
30
30
  };
31
+ /** Whether `name` matches the whole-name glob `pattern` (`*` matches any run of characters,
32
+ * everything else literally), in time linear in the two strings' lengths.
33
+ *
34
+ * No regex and no backtracking: the daemon is single-threaded, so a match that blows up blocks
35
+ * every session and RPC client (issue #112 review). Compiling `*` to `.*` was exponential in the
36
+ * star count, and a two-pointer matcher that backtracks to the last `*` was still quadratic.
37
+ * Instead the pattern is split on `*`: the first piece must be a prefix, the last a suffix, and
38
+ * each middle piece is found in order with `indexOf`. Taking the leftmost occurrence of each
39
+ * middle piece is always safe, because it leaves the most room for the pieces after it. */
40
+ export declare const matchesNameGlob: (pattern: string, name: string) => boolean;
31
41
  /**
32
42
  * Whether `event` survives `options.name`'s glob, and truncates its payload to `options
33
43
  * .payloadMaxBytes` when given — the one implementation `since()`'s drain and `daemon.ts`'s
@@ -1,18 +1,26 @@
1
1
  /**
2
- * Per-session tool registry (ARCHITECTURE.md §7). Callers (sessions.ts/listener.ts) must validate
3
- * incoming `tool_registry_snapshot`/`tool_registry_delta` wire messages with the shared guards
4
- * *before* calling into this module — v1 crashed by indexing into an unvalidated `tools: [null]`
5
- * snapshot; this module only ever stores already-validated {@link ToolDescriptor}s.
2
+ * Per-session registry of what an app declared, by name (ARCHITECTURE.md §7): one instance holds a
3
+ * session's tools, another its events (issue #124). Callers (sessions.ts) must validate incoming
4
+ * `tool_registry_*`/`event_registry_*` wire messages with the shared guards *before* calling into
5
+ * this module — v1 crashed by indexing into an unvalidated `tools: [null]` snapshot; this module
6
+ * only ever stores already-validated descriptors.
6
7
  */
7
- import type { ToolDescriptor } from "@appduct/shared";
8
- export type ToolRegistry = {
8
+ import type { EventDescriptor, ToolDescriptor } from "@appduct/shared";
9
+ export type Registry<T extends {
10
+ name: string;
11
+ }> = {
9
12
  /** Authoritative replace: used for the initial snapshot and every post-resume re-sync. */
10
- snapshot: (tools: ToolDescriptor[]) => void;
11
- upsert: (tool: ToolDescriptor) => void;
13
+ snapshot: (items: T[]) => void;
14
+ upsert: (item: T) => void;
12
15
  remove: (name: string) => void;
13
- list: () => ToolDescriptor[];
14
- get: (name: string) => ToolDescriptor | undefined;
16
+ list: () => T[];
17
+ get: (name: string) => T | undefined;
15
18
  count: () => number;
16
19
  };
17
- /** `onChange` fires after every mutation (snapshot/upsert/remove) — used to emit `tools_changed`. */
18
- export declare const createToolRegistry: (onChange?: () => void) => ToolRegistry;
20
+ export type ToolRegistry = Registry<ToolDescriptor>;
21
+ export type EventRegistry = Registry<EventDescriptor>;
22
+ /** `onChange` fires after every mutation (snapshot/upsert/remove) — used to emit `tools_changed`
23
+ * and `events_changed`. */
24
+ export declare const createRegistry: <T extends {
25
+ name: string;
26
+ }>(onChange?: () => void) => Registry<T>;
@@ -14,3 +14,6 @@ export declare class RpcApplicationError extends Error {
14
14
  /** JSON-RPC error code; defaults to the shared "server error" range. */
15
15
  code?: number, details?: unknown | undefined);
16
16
  }
17
+ /** The `session_suspended` message for an app that closed its socket because it went to the
18
+ * background: says why and how to recover. */
19
+ export declare const appBackgroundedMessage: (alias: string) => string;
@@ -10,12 +10,12 @@
10
10
  * §6: "terminal states free the alias" — there is no persisted history, matching §3/§13's "no
11
11
  * per-session files, all state in-daemon-memory").
12
12
  */
13
- import { type EventMessage, type SessionClaimMessage, type SessionResumeMessage, type SessionSummary, type ToolCallMessage, type ToolCallProgressMessage, type ToolCancelMessage, type ToolErrorMessage, type ToolResultMessage } from "@appduct/shared";
13
+ import { type EventMessage, type SessionClaimMessage, type SessionResumeMessage, type SessionSummary, type SessionSuspendReason, type ToolCallMessage, type ToolCallProgressMessage, type ToolCancelMessage, type ToolErrorMessage, type ToolResultMessage } from "@appduct/shared";
14
14
  import type { WebSocket } from "ws";
15
15
  import type { Clock } from "../cli/types.js";
16
16
  import type { EventBus } from "./event-bus.js";
17
17
  import { type CreatedLink } from "./links.js";
18
- import { type ToolRegistry } from "./registry.js";
18
+ import { type EventRegistry, type ToolRegistry } from "./registry.js";
19
19
  import { type TimerFns } from "./timers.js";
20
20
  /** Wire message types allowed *after* claim (session_claim/session_resume are pre-claim only). */
21
21
  export declare const POST_CLAIM_MESSAGE_TYPES: Set<string>;
@@ -69,7 +69,9 @@ export type SessionManager = {
69
69
  sessionId: string;
70
70
  alias: string;
71
71
  state: "active" | "suspended";
72
+ suspendReason?: SessionSuspendReason;
72
73
  registry: ToolRegistry;
74
+ eventRegistry: EventRegistry;
73
75
  };
74
76
  /** Sends a `tool_call` frame to the session's active socket; `false` if the session has no
75
77
  * active socket right now (caller has typically already checked ACTIVE state via
package/dist/index.js CHANGED
@@ -138,10 +138,10 @@ var createCli = () => {
138
138
  "--timeout <ms>",
139
139
  "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)."
140
140
  );
141
- 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(
141
+ 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(
142
142
  "--name <glob>",
143
- 'Only events whose name matches this whole-name, case-sensitive glob ("*" matches any run of characters).'
144
- ).option(
143
+ `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.`
144
+ ).option("--limit <n>", "ls: show at most n events.").option("--offset <n>", "ls: skip the first n events of the name-sorted list.").option(
145
145
  "--payload-max-bytes <n>",
146
146
  "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."
147
147
  );
@@ -546,6 +546,7 @@ var getExitCodeForError = (error) => {
546
546
  import pc from "picocolors";
547
547
  import {
548
548
  formatAgentWebSocketUrl,
549
+ renderEventSignature,
549
550
  renderToolSignature,
550
551
  summarizeToolDescription
551
552
  } from "@appduct/shared";
@@ -641,6 +642,13 @@ var formatDevice = (device) => {
641
642
  const label = parts.length > 0 ? parts.join(" ") : "unknown device";
642
643
  return device.os ? `${label} (${device.os})` : label;
643
644
  };
645
+ var SUSPEND_REASON_LABELS = {
646
+ app_backgrounded: "app in background",
647
+ connection_lost: "connection lost"
648
+ };
649
+ var formatSessionState = (session) => {
650
+ return session.suspendReason ? `${session.state} (${SUSPEND_REASON_LABELS[session.suspendReason]})` : session.state;
651
+ };
644
652
  var renderLsData = (colors, data, now) => {
645
653
  if (data.length === 0) {
646
654
  return [colors.dim("Sessions"), " No Appduct sessions are registered."];
@@ -649,7 +657,7 @@ var renderLsData = (colors, data, now) => {
649
657
  const headers = ["Alias", "State", "Device", "Tools", "Age"];
650
658
  const rows = data.map((session) => [
651
659
  session.alias,
652
- session.state,
660
+ formatSessionState(session),
653
661
  formatDevice(session.device),
654
662
  String(session.toolCount),
655
663
  formatAge(session.claimedAt ?? session.createdAt, nowIso)
@@ -823,6 +831,53 @@ var renderToolsListData = (colors, data, flags, full) => {
823
831
  }
824
832
  return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
825
833
  };
834
+ var renderEventsTruncationLine = (data) => {
835
+ const offset = data.offset ?? 0;
836
+ if (data.events.length >= data.total) {
837
+ return [];
838
+ }
839
+ return [
840
+ "",
841
+ `Showing ${data.events.length} of ${data.total} events (offset ${offset}). Narrow with --name <glob> or page with --offset <n>.`
842
+ ];
843
+ };
844
+ var renderEventsListData = (colors, data, flags) => {
845
+ const [only] = data.events;
846
+ if (data.name !== void 0 && !data.name.includes("*") && only !== void 0) {
847
+ return renderFields(
848
+ colors.green(`Event: ${only.name}`),
849
+ [
850
+ ["Signature", renderEventSignature(only)],
851
+ ["Description", only.description],
852
+ ["Payload schema", only.payload_schema]
853
+ ],
854
+ flags
855
+ );
856
+ }
857
+ if (data.events.length === 0 && data.total > 0) {
858
+ return [
859
+ colors.green("Events"),
860
+ ` No events at offset ${data.offset ?? 0}; ${data.total} matching event${data.total === 1 ? "" : "s"} in total.`
861
+ ];
862
+ }
863
+ if (data.events.length === 0) {
864
+ return [
865
+ colors.green("Events"),
866
+ data.name === void 0 ? " No events declared." : ` No events match ${JSON.stringify(data.name)}.`
867
+ ];
868
+ }
869
+ const lines = data.events.flatMap((event) => [
870
+ ` ${renderEventSignature(event)}`,
871
+ ` ${summarizeToolDescription(event.description)}`
872
+ ]);
873
+ return [
874
+ colors.green("Events"),
875
+ ...lines,
876
+ ...renderEventsTruncationLine(data),
877
+ "",
878
+ "Run `appduct events ls --name <name>` for an event's full payload schema."
879
+ ];
880
+ };
826
881
  var renderInvokeData = (colors, data, flags) => {
827
882
  return [colors.green("Result"), formatScalar(data, flags)];
828
883
  };
@@ -1014,6 +1069,8 @@ var renderSuccessData = (colors, command, data, options) => {
1014
1069
  return renderToolsListData(colors, data, flags, options.full);
1015
1070
  case "tools describe":
1016
1071
  return renderToolDetail(colors, data, flags);
1072
+ case "events ls":
1073
+ return renderEventsListData(colors, data, flags);
1017
1074
  case "tools call":
1018
1075
  return renderInvokeData(colors, data, flags);
1019
1076
  case "sessions revoke":
@@ -107,12 +107,30 @@ export type ResolvedAppTool = {
107
107
  descriptor: ListedToolDescriptor;
108
108
  policy: EffectivePolicyDecision;
109
109
  };
110
+ type ResolvedSession = {
111
+ sessionId: string;
112
+ alias: string;
113
+ };
110
114
  export type CallToolArgs = {
111
115
  selector?: string;
112
116
  name: string;
113
117
  args: Record<string, unknown>;
114
118
  timeoutMs?: number;
115
119
  };
120
+ /** Every key an agent sends must be one the tool declares. A misspelled key (`arguments` for
121
+ * `args`, `timeout_ms` for `timeoutMs`) would otherwise be dropped silently, and the tool would run
122
+ * without what the agent meant to pass. */
123
+ export declare const rejectUnknownKeys: (args: Record<string, unknown>, tool: string, allowed: readonly string[]) => void;
124
+ /** `null` counts as absent for every optional field: some clients fill unset optional
125
+ * parameters with `null` rather than leaving them out. */
126
+ export declare const asOptionalString: (value: unknown, field: string) => string | undefined;
127
+ /** Resolves `selector` (an alias, a session id, or nothing) to one concrete session first, with
128
+ * the daemon's own rules and errors (`no_session`, `ambiguous_session`, `unknown_session`).
129
+ * Everything after that is routed by the **session id**, never the alias: the daemon frees an
130
+ * alias when a session ends and gives it to the next device of the same model, so routing by alias
131
+ * could land a call — one the user may already have approved — on a different device. If the
132
+ * session goes away mid-call, the next daemon call fails with `unknown_session` instead. */
133
+ export declare const resolveSession: (call: DaemonCall, selector: string | undefined) => Promise<ResolvedSession>;
116
134
  export declare const handleListToolsTool: (rawArgs: unknown, call: DaemonCall) => Promise<{
117
135
  groups?: import("@appduct/shared").ToolGroupSummary[] | undefined;
118
136
  tools: {
@@ -152,3 +170,4 @@ export declare const parseCallToolArgs: (rawArgs: unknown) => CallToolArgs;
152
170
  */
153
171
  export declare const resolveCallDeadline: (tool: ResolvedAppTool, requestedTimeoutMs: number | undefined) => number;
154
172
  export declare const resolveAppTool: (call: DaemonCall, selector: string | undefined, name: string) => Promise<ResolvedAppTool>;
173
+ export {};
@@ -12,12 +12,40 @@
12
12
  import { type AppEvent } from "@appduct/shared";
13
13
  import { type SpawnFn } from "../rpc/client.js";
14
14
  import type { DaemonCall } from "./daemon-tools.js";
15
+ export declare const LIST_EVENTS_TOOL_NAME = "appduct_list_events";
15
16
  export declare const EVENTS_TOOL_NAME = "appduct_events";
16
17
  export declare const WAIT_FOR_EVENT_TOOL_NAME = "appduct_wait_for_event";
17
18
  /** Server-side cap on `timeoutMs` (issue #6's "Cap timeoutMs server-side below the idle window
18
19
  * rather than trusting the caller") — a pure function so the cap's boundary behavior is
19
20
  * unit-testable without spinning up a daemon or actually waiting out either bound. */
20
21
  export declare const clampWaitForEventTimeoutMs: (requestedMs: number | undefined) => number;
22
+ export declare const LIST_EVENTS_TOOL_DESCRIPTOR: {
23
+ readonly name: "appduct_list_events";
24
+ readonly description: string;
25
+ readonly inputSchema: {
26
+ readonly type: "object";
27
+ readonly properties: {
28
+ readonly selector: {
29
+ readonly type: "string";
30
+ readonly minLength: 1;
31
+ readonly description: "Session alias or id. Omit to target the sole active/suspended session.";
32
+ };
33
+ readonly name: {
34
+ readonly type: "string";
35
+ readonly minLength: 1;
36
+ };
37
+ readonly limit: {
38
+ readonly type: "integer";
39
+ readonly exclusiveMinimum: 0;
40
+ };
41
+ readonly offset: {
42
+ readonly type: "integer";
43
+ readonly minimum: 0;
44
+ };
45
+ };
46
+ readonly additionalProperties: false;
47
+ };
48
+ };
21
49
  export declare const EVENTS_TOOL_DESCRIPTOR: {
22
50
  readonly name: "appduct_events";
23
51
  readonly description: string;
@@ -78,6 +106,17 @@ export declare const WAIT_FOR_EVENT_TOOL_DESCRIPTOR: {
78
106
  export type EventsToolDeps = {
79
107
  call: DaemonCall;
80
108
  };
109
+ export declare const handleListEventsTool: (rawArgs: unknown, call: DaemonCall) => Promise<{
110
+ session: string;
111
+ total: number;
112
+ limit: {};
113
+ events: {
114
+ payload_schema?: import("@appduct/shared").EventSchemaDescriptor | undefined;
115
+ name: string;
116
+ signature: string;
117
+ description: string;
118
+ }[];
119
+ }>;
81
120
  export type EventsToolResult = {
82
121
  events: AppEvent[];
83
122
  cursor: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appduct",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Driving React Native tools without in-app debug UI.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -45,7 +45,7 @@
45
45
  "reflect-metadata": "^0.2.2",
46
46
  "toqr": "^0.1.1",
47
47
  "ws": "^8.18.3",
48
- "@appduct/shared": "0.13.0"
48
+ "@appduct/shared": "0.14.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "^22.10.0",