appduct 0.9.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 (190) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/bin.js +2 -0
  4. package/dist/artifact-inspect.d.ts +110 -0
  5. package/dist/artifact-inspect.d.ts.map +1 -0
  6. package/dist/artifact-inspect.js +315 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +4 -0
  10. package/dist/cli/command-options.d.ts +44 -0
  11. package/dist/cli/command-options.d.ts.map +1 -0
  12. package/dist/cli/command-options.js +95 -0
  13. package/dist/cli/create-cli.d.ts +2 -0
  14. package/dist/cli/create-cli.d.ts.map +1 -0
  15. package/dist/cli/create-cli.js +56 -0
  16. package/dist/cli/dispatch.d.ts +3 -0
  17. package/dist/cli/dispatch.d.ts.map +1 -0
  18. package/dist/cli/dispatch.js +275 -0
  19. package/dist/cli/open-target.d.ts +112 -0
  20. package/dist/cli/open-target.d.ts.map +1 -0
  21. package/dist/cli/open-target.js +438 -0
  22. package/dist/cli/result-types.d.ts +194 -0
  23. package/dist/cli/result-types.d.ts.map +1 -0
  24. package/dist/cli/result-types.js +6 -0
  25. package/dist/cli/runner.d.ts +35 -0
  26. package/dist/cli/runner.d.ts.map +1 -0
  27. package/dist/cli/runner.js +111 -0
  28. package/dist/cli/types.d.ts +20 -0
  29. package/dist/cli/types.d.ts.map +1 -0
  30. package/dist/cli/types.js +10 -0
  31. package/dist/cli.d.ts +4 -0
  32. package/dist/cli.d.ts.map +1 -0
  33. package/dist/cli.js +2 -0
  34. package/dist/client/app-client.d.ts +126 -0
  35. package/dist/client/app-client.d.ts.map +1 -0
  36. package/dist/client/app-client.js +223 -0
  37. package/dist/client/bootstrap.d.ts +48 -0
  38. package/dist/client/bootstrap.d.ts.map +1 -0
  39. package/dist/client/bootstrap.js +108 -0
  40. package/dist/client/connect.d.ts +17 -0
  41. package/dist/client/connect.d.ts.map +1 -0
  42. package/dist/client/connect.js +38 -0
  43. package/dist/client/errors.d.ts +26 -0
  44. package/dist/client/errors.d.ts.map +1 -0
  45. package/dist/client/errors.js +40 -0
  46. package/dist/client/index.d.ts +19 -0
  47. package/dist/client/index.d.ts.map +1 -0
  48. package/dist/client/index.js +16 -0
  49. package/dist/commands/daemon.d.ts +22 -0
  50. package/dist/commands/daemon.d.ts.map +1 -0
  51. package/dist/commands/daemon.js +128 -0
  52. package/dist/commands/doctor.d.ts +23 -0
  53. package/dist/commands/doctor.d.ts.map +1 -0
  54. package/dist/commands/doctor.js +42 -0
  55. package/dist/commands/events.d.ts +37 -0
  56. package/dist/commands/events.d.ts.map +1 -0
  57. package/dist/commands/events.js +76 -0
  58. package/dist/commands/init.d.ts +51 -0
  59. package/dist/commands/init.d.ts.map +1 -0
  60. package/dist/commands/init.js +231 -0
  61. package/dist/commands/invoke.d.ts +22 -0
  62. package/dist/commands/invoke.d.ts.map +1 -0
  63. package/dist/commands/invoke.js +54 -0
  64. package/dist/commands/keygen.d.ts +23 -0
  65. package/dist/commands/keygen.d.ts.map +1 -0
  66. package/dist/commands/keygen.js +46 -0
  67. package/dist/commands/link.d.ts +30 -0
  68. package/dist/commands/link.d.ts.map +1 -0
  69. package/dist/commands/link.js +43 -0
  70. package/dist/commands/ls.d.ts +9 -0
  71. package/dist/commands/ls.d.ts.map +1 -0
  72. package/dist/commands/ls.js +7 -0
  73. package/dist/commands/mcp.d.ts +49 -0
  74. package/dist/commands/mcp.d.ts.map +1 -0
  75. package/dist/commands/mcp.js +98 -0
  76. package/dist/commands/revoke.d.ts +12 -0
  77. package/dist/commands/revoke.d.ts.map +1 -0
  78. package/dist/commands/revoke.js +7 -0
  79. package/dist/commands/tools.d.ts +23 -0
  80. package/dist/commands/tools.d.ts.map +1 -0
  81. package/dist/commands/tools.js +52 -0
  82. package/dist/daemon/address.d.ts +30 -0
  83. package/dist/daemon/address.d.ts.map +1 -0
  84. package/dist/daemon/address.js +71 -0
  85. package/dist/daemon/audit.d.ts +107 -0
  86. package/dist/daemon/audit.d.ts.map +1 -0
  87. package/dist/daemon/audit.js +237 -0
  88. package/dist/daemon/calls.d.ts +84 -0
  89. package/dist/daemon/calls.d.ts.map +1 -0
  90. package/dist/daemon/calls.js +158 -0
  91. package/dist/daemon/config.d.ts +79 -0
  92. package/dist/daemon/config.d.ts.map +1 -0
  93. package/dist/daemon/config.js +170 -0
  94. package/dist/daemon/daemon.d.ts +43 -0
  95. package/dist/daemon/daemon.d.ts.map +1 -0
  96. package/dist/daemon/daemon.js +560 -0
  97. package/dist/daemon/event-bus.d.ts +49 -0
  98. package/dist/daemon/event-bus.d.ts.map +1 -0
  99. package/dist/daemon/event-bus.js +111 -0
  100. package/dist/daemon/links.d.ts +54 -0
  101. package/dist/daemon/links.d.ts.map +1 -0
  102. package/dist/daemon/links.js +118 -0
  103. package/dist/daemon/listener.d.ts +34 -0
  104. package/dist/daemon/listener.d.ts.map +1 -0
  105. package/dist/daemon/listener.js +132 -0
  106. package/dist/daemon/log-rotation.d.ts +82 -0
  107. package/dist/daemon/log-rotation.d.ts.map +1 -0
  108. package/dist/daemon/log-rotation.js +136 -0
  109. package/dist/daemon/pidfile.d.ts +38 -0
  110. package/dist/daemon/pidfile.d.ts.map +1 -0
  111. package/dist/daemon/pidfile.js +90 -0
  112. package/dist/daemon/policy.d.ts +22 -0
  113. package/dist/daemon/policy.d.ts.map +1 -0
  114. package/dist/daemon/policy.js +24 -0
  115. package/dist/daemon/registry.d.ts +19 -0
  116. package/dist/daemon/registry.d.ts.map +1 -0
  117. package/dist/daemon/registry.js +31 -0
  118. package/dist/daemon/rpc-server.d.ts +54 -0
  119. package/dist/daemon/rpc-server.d.ts.map +1 -0
  120. package/dist/daemon/rpc-server.js +203 -0
  121. package/dist/daemon/sessions.d.ts +86 -0
  122. package/dist/daemon/sessions.d.ts.map +1 -0
  123. package/dist/daemon/sessions.js +429 -0
  124. package/dist/daemon/socket-probe.d.ts +16 -0
  125. package/dist/daemon/socket-probe.d.ts.map +1 -0
  126. package/dist/daemon/socket-probe.js +28 -0
  127. package/dist/daemon/state-dir.d.ts +31 -0
  128. package/dist/daemon/state-dir.d.ts.map +1 -0
  129. package/dist/daemon/state-dir.js +43 -0
  130. package/dist/daemon/timers.d.ts +16 -0
  131. package/dist/daemon/timers.d.ts.map +1 -0
  132. package/dist/daemon/timers.js +12 -0
  133. package/dist/daemon/tls.d.ts +58 -0
  134. package/dist/daemon/tls.d.ts.map +1 -0
  135. package/dist/daemon/tls.js +79 -0
  136. package/dist/errors.d.ts +28 -0
  137. package/dist/errors.d.ts.map +1 -0
  138. package/dist/errors.js +162 -0
  139. package/dist/host-certificate.d.ts +8 -0
  140. package/dist/host-certificate.d.ts.map +1 -0
  141. package/dist/host-certificate.js +111 -0
  142. package/dist/index.d.ts +4 -0
  143. package/dist/index.d.ts.map +1 -0
  144. package/dist/index.js +3 -0
  145. package/dist/key-material.d.ts +14 -0
  146. package/dist/key-material.d.ts.map +1 -0
  147. package/dist/key-material.js +42 -0
  148. package/dist/link.d.ts +76 -0
  149. package/dist/link.d.ts.map +1 -0
  150. package/dist/link.js +117 -0
  151. package/dist/mcp/connect-tool.d.ts +115 -0
  152. package/dist/mcp/connect-tool.d.ts.map +1 -0
  153. package/dist/mcp/connect-tool.js +396 -0
  154. package/dist/mcp/daemon-tools.d.ts +13 -0
  155. package/dist/mcp/daemon-tools.d.ts.map +1 -0
  156. package/dist/mcp/daemon-tools.js +29 -0
  157. package/dist/mcp/events-tool.d.ts +111 -0
  158. package/dist/mcp/events-tool.d.ts.map +1 -0
  159. package/dist/mcp/events-tool.js +322 -0
  160. package/dist/mcp/server.d.ts +58 -0
  161. package/dist/mcp/server.d.ts.map +1 -0
  162. package/dist/mcp/server.js +538 -0
  163. package/dist/mcp/tool-mapping.d.ts +58 -0
  164. package/dist/mcp/tool-mapping.d.ts.map +1 -0
  165. package/dist/mcp/tool-mapping.js +143 -0
  166. package/dist/mcp/tool-namespace.d.ts +40 -0
  167. package/dist/mcp/tool-namespace.d.ts.map +1 -0
  168. package/dist/mcp/tool-namespace.js +65 -0
  169. package/dist/native-scheme.d.ts +59 -0
  170. package/dist/native-scheme.d.ts.map +1 -0
  171. package/dist/native-scheme.js +465 -0
  172. package/dist/output.d.ts +28 -0
  173. package/dist/output.d.ts.map +1 -0
  174. package/dist/output.js +342 -0
  175. package/dist/package-version.d.ts +30 -0
  176. package/dist/package-version.d.ts.map +1 -0
  177. package/dist/package-version.js +47 -0
  178. package/dist/qr-terminal.d.ts +13 -0
  179. package/dist/qr-terminal.d.ts.map +1 -0
  180. package/dist/qr-terminal.js +45 -0
  181. package/dist/rpc/client.d.ts +137 -0
  182. package/dist/rpc/client.d.ts.map +1 -0
  183. package/dist/rpc/client.js +819 -0
  184. package/dist/scheme.d.ts +181 -0
  185. package/dist/scheme.d.ts.map +1 -0
  186. package/dist/scheme.js +321 -0
  187. package/dist/spki-pin.d.ts +3 -0
  188. package/dist/spki-pin.d.ts.map +1 -0
  189. package/dist/spki-pin.js +14 -0
  190. package/package.json +65 -0
package/dist/link.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shared core of `link.create` + deep-link composition + optional emulator/simulator delivery
3
+ * (ARCHITECTURE.md §8, §10): mints a pending session via `link.create`, then composes
4
+ * `<scheme>:///?appduct=<payload>&pin=<spki-pin>` and optionally delivers it to a booted
5
+ * Android emulator/device or iOS simulator. Used by `commands/link.ts` (`appduct link`) and
6
+ * `client/bootstrap.ts` (`appduct/client`'s `link()`) so this shape — scheme resolution, the
7
+ * `pin` query param, the `127.0.0.1` emulator/simulator address override (which the experimental
8
+ * `ios-device` target deliberately opts out of) — can't drift between the CLI and the programmatic
9
+ * client.
10
+ *
11
+ * The `pin` param is separate from the `appduct` bootstrap blob (opt-in hardening dev-mode) — it
12
+ * is never part of that binary v2 payload, so old apps that don't know about it simply ignore it.
13
+ * It carries the daemon's SPKI pin (same value as `daemon.status`'s `pinnedKeys[0]` / `appduct
14
+ * keygen`'s output) so a debug build with no build-time `cliPins` configured can trust it for this
15
+ * one connection instead of requiring a native rebuild just to test locally. The pin is standard
16
+ * (non-URL-safe) base64 (`sha256/<44-char-base64>`, may contain `+`/`/`/`=`), so it is percent-
17
+ * encoded here; native/JS parsers use `URLSearchParams`, which decodes it back.
18
+ */
19
+ import { RPC_METHODS } from "@appduct/shared";
20
+ import { deliverToOpenTarget, isOpenTarget, isValidBundleId, invalidBundleIdMessage, isLoopbackAddress, loopbackAddressMessage, usesLoopbackAddress, MISSING_BUNDLE_ID_MESSAGE, OPEN_TARGETS, } from "./cli/open-target.js";
21
+ import { loadConfig } from "./daemon/config.js";
22
+ import { getStateDirPaths } from "./daemon/state-dir.js";
23
+ import { usageError } from "./errors.js";
24
+ import { callDaemon } from "./rpc/client.js";
25
+ import { resolveSchemeOrThrow } from "./scheme.js";
26
+ /** The emulator/simulator fast path forces `127.0.0.1`: the daemon's wss listener already binds
27
+ * all interfaces, and both delivery mechanisms (adb reverse, the iOS simulator's shared host
28
+ * network) make the daemon reachable there regardless of the machine's real LAN address. A
29
+ * physical iOS device (`ios-device`) has no such tunnel, so it keeps the detected LAN address —
30
+ * see `usesLoopbackAddress`. */
31
+ const OPEN_TARGET_ADDRESS_OVERRIDE = "127.0.0.1";
32
+ /**
33
+ * The deep-link shape, in one place: `<scheme>:///?appduct=<payload>&pin=<spki-pin>`. Exported
34
+ * so `mcp/connect-tool.ts` composes byte-identical links rather than a second, drifting copy — the
35
+ * `pin` param went missing from the MCP side exactly because there were two of these.
36
+ *
37
+ * The pin is standard (non-URL-safe) base64 (`sha256/<44 chars>`, possibly containing `+`/`/`/`=`),
38
+ * so it is percent-encoded; parsers use `URLSearchParams`, which decodes it back. It is appended
39
+ * *after* the bootstrap blob, so anything reading `appduct=` must stop at the `&`.
40
+ */
41
+ export const composeDeepLink = (scheme, result) => `${scheme}:///?appduct=${result.deepLinkPayload}&pin=${encodeURIComponent(result.pin)}`;
42
+ /** Throws `usageError`/`AppductCliError` on validation failure — `commands/link.ts` renders
43
+ * that straight through the CLI's own error path; `client/bootstrap.ts` converts it to a
44
+ * `AppductError` via `toAppductError` (which already understands `AppductCliError`). */
45
+ export const mintLink = async (options) => {
46
+ if (options.target !== undefined && !isOpenTarget(options.target)) {
47
+ throw usageError(`"target" must be one of ${OPEN_TARGETS.map((target) => `"${target}"`).join(", ")} (got "${options.target}").`);
48
+ }
49
+ if (options.device !== undefined && options.target === undefined) {
50
+ throw usageError('"device" only applies alongside a target.');
51
+ }
52
+ if (options.bundleId !== undefined && options.target !== "ios-device") {
53
+ throw usageError('"bundleId" only applies alongside target "ios-device".');
54
+ }
55
+ if (options.relaunch !== undefined && options.target !== "ios-device") {
56
+ throw usageError('"relaunch" only applies alongside target "ios-device".');
57
+ }
58
+ const paths = getStateDirPaths(options.stateDir);
59
+ const config = await loadConfig(paths);
60
+ const scheme = await resolveSchemeOrThrow({
61
+ flagScheme: options.scheme,
62
+ env: options.schemeEnv,
63
+ cwd: options.cwd,
64
+ configScheme: config.scheme,
65
+ stateConfigPath: paths.configPath,
66
+ stateDirRoot: paths.root,
67
+ });
68
+ // `ios-device` needs a bundle id `devicectl` can launch; resolved (and required) *before* the
69
+ // link is minted so a missing one is a plain usage error rather than a stranded pending session.
70
+ const bundleId = options.target === "ios-device" ? (options.bundleId ?? config.iosBundleId) : undefined;
71
+ if (options.target === "ios-device" && !bundleId) {
72
+ throw usageError(MISSING_BUNDLE_ID_MESSAGE);
73
+ }
74
+ if (bundleId !== undefined && !isValidBundleId(bundleId)) {
75
+ throw usageError(invalidBundleIdMessage(bundleId));
76
+ }
77
+ const result = await callDaemon(RPC_METHODS.linkCreate, {
78
+ ttlSeconds: options.ttlSeconds,
79
+ addressOverride: options.target && usesLoopbackAddress(options.target) ? OPEN_TARGET_ADDRESS_OVERRIDE : undefined,
80
+ }, { stateDir: options.stateDir, spawn: options.spawn, autoSpawn: options.autoSpawn });
81
+ // A physical device is the one target that cannot reach a loopback address, and
82
+ // `detectAdvertisedAddress` falls back to `127.0.0.1` when it finds no routable interface. Left
83
+ // unchecked, the link would be delivered happily and then never claimed — a silent hang in
84
+ // `wait_for_session` with nothing pointing at the cause.
85
+ if (options.target === "ios-device" && isLoopbackAddress(result.endpoint.address)) {
86
+ throw usageError(loopbackAddressMessage(result.endpoint.address));
87
+ }
88
+ const deepLink = composeDeepLink(scheme, result);
89
+ if (options.target) {
90
+ await deliverToOpenTarget({
91
+ target: options.target,
92
+ deepLink,
93
+ wssPort: result.endpoint.port,
94
+ device: options.device,
95
+ bundleId,
96
+ relaunch: options.relaunch,
97
+ exec: options.exec,
98
+ env: options.env,
99
+ });
100
+ return {
101
+ sessionId: result.sessionId,
102
+ deepLink,
103
+ endpoint: result.endpoint,
104
+ expiresAt: result.expiresAt,
105
+ pin: result.pin,
106
+ delivered: true,
107
+ target: options.target,
108
+ };
109
+ }
110
+ return {
111
+ sessionId: result.sessionId,
112
+ deepLink,
113
+ endpoint: result.endpoint,
114
+ expiresAt: result.expiresAt,
115
+ pin: result.pin,
116
+ };
117
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The built-in `appduct_connect` / `appduct_wait_for_session` management tools
3
+ * (ARCHITECTURE.md §9 scope item 4): let an MCP agent bootstrap a device session without shell
4
+ * access — mint a link (and, whenever a device can be found, deliver it via the task-07
5
+ * emulator/simulator fast path), then wait for it to be claimed.
6
+ *
7
+ * Delivery is attempted by *default*, not only on request: an agent that omitted `target` used to
8
+ * get a QR code, which is a flow that cannot complete without a human noticing and scanning it.
9
+ * The overwhelmingly common case — one booted simulator or emulator, no human watching — now just
10
+ * works, and the QR path is reserved for when there is genuinely nothing to deliver to (or the
11
+ * caller asked for it with `target: "none"`), where it carries explicit instructions telling the
12
+ * agent to show the code and ask.
13
+ */
14
+ import { type ExecFn, type OpenTarget } from "../cli/open-target.js";
15
+ import { type SpawnFn } from "../rpc/client.js";
16
+ import type { DaemonCall } from "./daemon-tools.js";
17
+ export declare const CONNECT_TOOL_NAME = "appduct_connect";
18
+ export declare const WAIT_FOR_SESSION_TOOL_NAME = "appduct_wait_for_session";
19
+ export declare const CONNECT_TOOL_DESCRIPTOR: {
20
+ readonly name: "appduct_connect";
21
+ readonly description: string;
22
+ readonly inputSchema: {
23
+ readonly type: "object";
24
+ readonly properties: {
25
+ readonly target: {
26
+ readonly type: "string";
27
+ readonly enum: readonly ["android", "ios-sim", "ios-device", "none"];
28
+ };
29
+ readonly device: {
30
+ readonly type: "string";
31
+ };
32
+ readonly bundleId: {
33
+ readonly type: "string";
34
+ };
35
+ readonly relaunch: {
36
+ readonly type: "boolean";
37
+ };
38
+ readonly ttlSeconds: {
39
+ readonly type: "number";
40
+ readonly exclusiveMinimum: 0;
41
+ };
42
+ };
43
+ readonly additionalProperties: false;
44
+ };
45
+ };
46
+ export declare const WAIT_FOR_SESSION_TOOL_DESCRIPTOR: {
47
+ readonly name: "appduct_wait_for_session";
48
+ readonly description: string;
49
+ readonly inputSchema: {
50
+ readonly type: "object";
51
+ readonly properties: {
52
+ readonly sessionId: {
53
+ readonly type: "string";
54
+ };
55
+ readonly timeoutMs: {
56
+ readonly type: "number";
57
+ readonly exclusiveMinimum: 0;
58
+ };
59
+ };
60
+ readonly required: readonly ["sessionId"];
61
+ readonly additionalProperties: false;
62
+ };
63
+ };
64
+ /** Errors from the built-in management tools reuse the daemon's wire `ErrorType` union
65
+ * (`RpcApplicationError`-style: `{ type, message }`) so the MCP server's generic error-content
66
+ * mapping (see `server.ts`) handles them the same way it handles a proxied device tool's error. */
67
+ export declare class McpBuiltinToolError extends Error {
68
+ readonly type: "invalid_request" | "tool_timeout" | "tool_execution_error";
69
+ constructor(type: "invalid_request" | "tool_timeout" | "tool_execution_error", message: string);
70
+ }
71
+ export type ConnectToolDeps = {
72
+ call: DaemonCall;
73
+ /** The scheme composing the deep link (`<scheme>:///?appduct=<payload>`), resolved once at
74
+ * server startup against the shared order in `scheme.ts` — the same one `appduct link` uses,
75
+ * so an app root with an `app.json` needs no configuration at all. */
76
+ scheme?: string;
77
+ /** Every location `scheme.ts` consulted, named in the failure below so an agent can tell its
78
+ * human exactly where to put a scheme instead of guessing at one global file. */
79
+ schemeTried?: string[];
80
+ /** `config.json`'s `iosBundleId`, the default for the experimental `ios-device` target when the
81
+ * call did not pass `bundleId`. Same source order as `scheme`. */
82
+ iosBundleId?: string;
83
+ exec?: ExecFn;
84
+ env?: NodeJS.ProcessEnv;
85
+ };
86
+ export type ConnectToolResult = {
87
+ sessionId: string;
88
+ deepLink: string;
89
+ expiresAt: number;
90
+ delivered?: true;
91
+ target?: OpenTarget;
92
+ /** The adb serial / simulator udid the link was delivered to, when one was resolved. */
93
+ device?: string;
94
+ /** Set when no `target` was given and exactly one device was found and used. */
95
+ autoDetected?: true;
96
+ /** Why this call did or did not deliver — the deciding detail an agent needs to explain itself
97
+ * (or to re-call with an explicit target). */
98
+ note?: string;
99
+ /** Present only on the QR path: a scannable QR of the deep link, rendered as text. */
100
+ qr?: string;
101
+ /** Present only on the QR path: what the agent must do before waiting. See {@link QR_INSTRUCTIONS}. */
102
+ instructions?: string;
103
+ };
104
+ export declare const handleConnectTool: (rawArgs: unknown, deps: ConnectToolDeps) => Promise<ConnectToolResult>;
105
+ export type WaitForSessionToolDeps = {
106
+ stateDir: string;
107
+ spawn?: SpawnFn;
108
+ };
109
+ export type WaitForSessionToolResult = {
110
+ sessionId: string;
111
+ claimed: true;
112
+ alias: string;
113
+ };
114
+ export declare const handleWaitForSessionTool: (rawArgs: unknown, deps: WaitForSessionToolDeps) => Promise<WaitForSessionToolResult>;
115
+ //# sourceMappingURL=connect-tool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connect-tool.d.ts","sourceRoot":"","sources":["../../src/mcp/connect-tool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,OAAO,EAUL,KAAK,MAAM,EACX,KAAK,UAAU,EAChB,MAAM,uBAAuB,CAAC;AAI/B,OAAO,EAKL,KAAK,OAAO,EACb,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,eAAO,MAAM,iBAAiB,oBAAoB,CAAC;AACnD,eAAO,MAAM,0BAA0B,6BAA6B,CAAC;AA6BrE,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;CA2B1B,CAAC;AAEX,eAAO,MAAM,gCAAgC;;;;;;;;;;;;;;;;;CAiBnC,CAAC;AAEX;;mGAEmG;AACnG,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,IAAI,EAAE,iBAAiB,GAAG,cAAc,GAAG,sBAAsB;gBAAjE,IAAI,EAAE,iBAAiB,GAAG,cAAc,GAAG,sBAAsB,EAC1E,OAAO,EAAE,MAAM;CAKlB;AA6DD,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB;;0EAEsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;qFACiF;IACjF,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;sEACkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,IAAI,CAAC;IACjB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,wFAAwF;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,YAAY,CAAC,EAAE,IAAI,CAAC;IACpB;kDAC8C;IAC9C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sFAAsF;IACtF,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,uGAAuG;IACvG,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAsEF,eAAO,MAAM,iBAAiB,GAC5B,SAAS,OAAO,EAChB,MAAM,eAAe,KACpB,OAAO,CAAC,iBAAiB,CA4I3B,CAAC;AA6BF,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,IAAI,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,eAAO,MAAM,wBAAwB,GACnC,SAAS,OAAO,EAChB,MAAM,sBAAsB,KAC3B,OAAO,CAAC,wBAAwB,CAiHlC,CAAC"}
@@ -0,0 +1,396 @@
1
+ /**
2
+ * The built-in `appduct_connect` / `appduct_wait_for_session` management tools
3
+ * (ARCHITECTURE.md §9 scope item 4): let an MCP agent bootstrap a device session without shell
4
+ * access — mint a link (and, whenever a device can be found, deliver it via the task-07
5
+ * emulator/simulator fast path), then wait for it to be claimed.
6
+ *
7
+ * Delivery is attempted by *default*, not only on request: an agent that omitted `target` used to
8
+ * get a QR code, which is a flow that cannot complete without a human noticing and scanning it.
9
+ * The overwhelmingly common case — one booted simulator or emulator, no human watching — now just
10
+ * works, and the QR path is reserved for when there is genuinely nothing to deliver to (or the
11
+ * caller asked for it with `target: "none"`), where it carries explicit instructions telling the
12
+ * agent to show the code and ask.
13
+ */
14
+ import { RPC_METHODS, } from "@appduct/shared";
15
+ import { deliverToOpenTarget, detectBootedTargets, isOpenTarget, isValidBundleId, invalidBundleIdMessage, isLoopbackAddress, loopbackAddressMessage, usesLoopbackAddress, MISSING_BUNDLE_ID_MESSAGE, } from "../cli/open-target.js";
16
+ import { composeDeepLink } from "../link.js";
17
+ import { renderQrToTerminal } from "../qr-terminal.js";
18
+ import { describeMissingScheme } from "../scheme.js";
19
+ import { openDaemonStream, DaemonRpcError, isDaemonUnreachableError, } from "../rpc/client.js";
20
+ export const CONNECT_TOOL_NAME = "appduct_connect";
21
+ export const WAIT_FOR_SESSION_TOOL_NAME = "appduct_wait_for_session";
22
+ /** The emulator/simulator fast path forces `127.0.0.1` — same reasoning as `cli/link.ts`. The
23
+ * experimental `ios-device` target is excluded (`usesLoopbackAddress`): a physical iPhone reaches
24
+ * the daemon only over the LAN. */
25
+ const OPEN_TARGET_ADDRESS_OVERRIDE = "127.0.0.1";
26
+ const DEFAULT_WAIT_TIMEOUT_MS = 120_000;
27
+ /** `target: "none"` — an explicit opt out of delivery, for when the human/QR flow is what is
28
+ * actually wanted (a physical device on the same network, say). Distinct from omitting `target`,
29
+ * which now means "figure it out". */
30
+ const NO_TARGET = "none";
31
+ /**
32
+ * Attached to every QR-path result. Without this an agent reliably minted a link, said nothing
33
+ * about it, and went straight to `appduct_wait_for_session` — which then blocked for its full
34
+ * timeout waiting on a scan nobody had been asked to perform.
35
+ */
36
+ const QR_INSTRUCTIONS = "This link was NOT delivered to a device — a human has to open it. Before calling " +
37
+ "appduct_wait_for_session: show the user the \"qr\" field verbatim inside a fenced code block " +
38
+ "(it is pre-rendered terminal art — do not redraw, summarize, or describe it), show \"deepLink\" " +
39
+ "underneath it for a device that cannot scan, and ask the user to scan it with the target " +
40
+ "device. Only then call appduct_wait_for_session; it blocks until the device connects and " +
41
+ "gives no output while it waits, so calling it before you have asked will look like a hang.";
42
+ export const CONNECT_TOOL_DESCRIPTOR = {
43
+ name: CONNECT_TOOL_NAME,
44
+ description: "Mint a Appduct session link and get it onto a device. With no \"target\", auto-detects a " +
45
+ "booted iOS simulator or attached Android device and delivers the link to it, returning " +
46
+ "{ sessionId, delivered: true, autoDetected: true } — this is the normal agent path and needs " +
47
+ "no human. Pass target \"android\" or \"ios-sim\" (optionally with \"device\") to choose " +
48
+ "explicitly, or target \"none\" to force the human flow. Target \"ios-device\" is an " +
49
+ "experimental, never-auto-detected path that delivers to a paired physical iPhone/iPad via " +
50
+ "xcrun devicectl; it needs \"bundleId\" (or \"iosBundleId\" in config.json), iOS 17+, Xcode " +
51
+ "15+, a connected and trusted device with Developer Mode on, a dev-signed build installed, and " +
52
+ "the phone on the same network as this machine. Pass \"relaunch\": true with it if the app is " +
53
+ "already running and the delivery does not take. Only when no device is " +
54
+ "detected (or target is \"none\") does this return a QR code for a human to scan, along with " +
55
+ "an \"instructions\" field saying what to do with it. Follow up with " +
56
+ "appduct_wait_for_session to know when the device has connected.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ target: { type: "string", enum: ["android", "ios-sim", "ios-device", NO_TARGET] },
61
+ device: { type: "string" },
62
+ bundleId: { type: "string" },
63
+ relaunch: { type: "boolean" },
64
+ ttlSeconds: { type: "number", exclusiveMinimum: 0 },
65
+ },
66
+ additionalProperties: false,
67
+ },
68
+ };
69
+ export const WAIT_FOR_SESSION_TOOL_DESCRIPTOR = {
70
+ name: WAIT_FOR_SESSION_TOOL_NAME,
71
+ description: "Wait for a session minted by appduct_connect to be claimed by a device. Resolves as soon " +
72
+ "as the device connects (or immediately if it already has); rejects with tool_timeout if " +
73
+ "timeoutMs elapses first. If appduct_connect returned a QR instead of delivering the link, " +
74
+ "show that QR to the user and ask them to scan it before calling this — it produces no output " +
75
+ "while it waits.",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ sessionId: { type: "string" },
80
+ timeoutMs: { type: "number", exclusiveMinimum: 0 },
81
+ },
82
+ required: ["sessionId"],
83
+ additionalProperties: false,
84
+ },
85
+ };
86
+ /** Errors from the built-in management tools reuse the daemon's wire `ErrorType` union
87
+ * (`RpcApplicationError`-style: `{ type, message }`) so the MCP server's generic error-content
88
+ * mapping (see `server.ts`) handles them the same way it handles a proxied device tool's error. */
89
+ export class McpBuiltinToolError extends Error {
90
+ type;
91
+ constructor(type, message) {
92
+ super(message);
93
+ this.type = type;
94
+ this.name = "McpBuiltinToolError";
95
+ }
96
+ }
97
+ const asRecord = (value) => {
98
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
99
+ return {};
100
+ }
101
+ return value;
102
+ };
103
+ const asOptionalConnectTarget = (value) => {
104
+ if (value === undefined) {
105
+ return undefined;
106
+ }
107
+ if (typeof value !== "string" || !(isOpenTarget(value) || value === NO_TARGET)) {
108
+ throw new McpBuiltinToolError("invalid_request", '"target" must be "android", "ios-sim", "ios-device", or "none".');
109
+ }
110
+ return value;
111
+ };
112
+ const asOptionalNonEmptyString = (value, field) => {
113
+ if (value === undefined) {
114
+ return undefined;
115
+ }
116
+ if (typeof value !== "string" || value.length === 0) {
117
+ throw new McpBuiltinToolError("invalid_request", `"${field}" must be a non-empty string.`);
118
+ }
119
+ return value;
120
+ };
121
+ const asOptionalBoolean = (value, field) => {
122
+ if (value === undefined) {
123
+ return undefined;
124
+ }
125
+ if (typeof value !== "boolean") {
126
+ throw new McpBuiltinToolError("invalid_request", `"${field}" must be a boolean.`);
127
+ }
128
+ return value;
129
+ };
130
+ const asOptionalPositiveNumber = (value, field) => {
131
+ if (value === undefined) {
132
+ return undefined;
133
+ }
134
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
135
+ throw new McpBuiltinToolError("invalid_request", `"${field}" must be a positive number.`);
136
+ }
137
+ return value;
138
+ };
139
+ /**
140
+ * Decides where (if anywhere) this link is going, *before* it is minted: the `127.0.0.1` address
141
+ * override baked into a link depends on whether it is being delivered locally, and that cannot be
142
+ * revised after `link.create`. A link minted for loopback and then fallen back to a QR code would
143
+ * be unscannable-in-effect — the address would be wrong for the phone that scanned it.
144
+ */
145
+ const resolveDelivery = async (requested, device, bundleId, deps) => {
146
+ if (requested !== undefined && requested !== NO_TARGET) {
147
+ // `ios-device` is reachable only through this branch: `detectBootedTargets` never returns it,
148
+ // so a paired iPhone can never be picked up by the auto-detection path below.
149
+ return {
150
+ delivery: {
151
+ target: requested,
152
+ device,
153
+ ...(requested === "ios-device" ? { bundleId: bundleId ?? deps.iosBundleId } : {}),
154
+ label: device ?? requested,
155
+ autoDetected: false,
156
+ },
157
+ };
158
+ }
159
+ if (requested === NO_TARGET) {
160
+ return {
161
+ note: 'Called with target: "none", so no device delivery was attempted.',
162
+ };
163
+ }
164
+ const detection = await detectBootedTargets({ exec: deps.exec, env: deps.env });
165
+ if (detection.kind === "single") {
166
+ return { delivery: { ...detection.detected, autoDetected: true } };
167
+ }
168
+ if (detection.kind === "ambiguous") {
169
+ return {
170
+ note: 'No "target" was given and several devices are available (' +
171
+ detection.candidates.map((candidate) => `${candidate.target}: ${candidate.label}`).join("; ") +
172
+ '), so none was picked automatically. Re-call with "target" and "device" to deliver to one ' +
173
+ "of them, or use the QR below.",
174
+ };
175
+ }
176
+ return {
177
+ note: 'No "target" was given and no booted iOS simulator or attached Android device was detected, ' +
178
+ "so the link could not be delivered automatically. A physical iPhone/iPad is never " +
179
+ 'auto-detected: to deliver to one, re-call with target "ios-device" plus "bundleId" (needs ' +
180
+ "iOS 17+, Xcode 15+ and a paired, trusted device with Developer Mode on) — otherwise use " +
181
+ "the QR flow below.",
182
+ };
183
+ };
184
+ export const handleConnectTool = async (rawArgs, deps) => {
185
+ const args = asRecord(rawArgs);
186
+ const requestedTarget = asOptionalConnectTarget(args.target);
187
+ const device = asOptionalNonEmptyString(args.device, "device");
188
+ const bundleId = asOptionalNonEmptyString(args.bundleId, "bundleId");
189
+ const relaunch = asOptionalBoolean(args.relaunch, "relaunch");
190
+ const ttlSeconds = asOptionalPositiveNumber(args.ttlSeconds, "ttlSeconds");
191
+ if (device !== undefined && (requestedTarget === undefined || requestedTarget === NO_TARGET)) {
192
+ throw new McpBuiltinToolError("invalid_request", '"device" requires an explicit "target" of "android", "ios-sim" or "ios-device" — a bare ' +
193
+ "serial/udid does not say which platform it belongs to.");
194
+ }
195
+ if (bundleId !== undefined && requestedTarget !== "ios-device") {
196
+ throw new McpBuiltinToolError("invalid_request", '"bundleId" only applies with target "ios-device".');
197
+ }
198
+ if (relaunch !== undefined && requestedTarget !== "ios-device") {
199
+ throw new McpBuiltinToolError("invalid_request", '"relaunch" only applies with target "ios-device".');
200
+ }
201
+ if (!deps.scheme) {
202
+ throw new McpBuiltinToolError("invalid_request", describeMissingScheme(deps.schemeTried ?? []));
203
+ }
204
+ // Captured after the check so the closures below carry the narrowing (a mutable property does
205
+ // not stay narrowed across one).
206
+ const scheme = deps.scheme;
207
+ const { delivery, note } = await resolveDelivery(requestedTarget, device, bundleId, deps);
208
+ // Rejected before minting, so a call that cannot possibly deliver does not strand a pending
209
+ // session behind it.
210
+ if (delivery?.target === "ios-device" && !delivery.bundleId) {
211
+ throw new McpBuiltinToolError("invalid_request", MISSING_BUNDLE_ID_MESSAGE);
212
+ }
213
+ if (delivery?.bundleId !== undefined && !isValidBundleId(delivery.bundleId)) {
214
+ throw new McpBuiltinToolError("invalid_request", invalidBundleIdMessage(delivery.bundleId));
215
+ }
216
+ // A link's advertised address is fixed at mint time: `127.0.0.1` only when it is being handed to
217
+ // something that reaches this machine over loopback (an emulator or simulator — not a physical
218
+ // iPhone, which needs the LAN address). That is why delivery is decided first, and why the QR
219
+ // fallback below has to mint a *fresh* link rather than reuse a loopback one no phone could reach.
220
+ const mint = (deliverLocally) => {
221
+ return deps.call(RPC_METHODS.linkCreate, {
222
+ ttlSeconds,
223
+ addressOverride: deliverLocally ? OPEN_TARGET_ADDRESS_OVERRIDE : undefined,
224
+ });
225
+ };
226
+ const asQrResult = (result, qrNote) => {
227
+ const deepLink = composeDeepLink(scheme, result);
228
+ return {
229
+ sessionId: result.sessionId,
230
+ deepLink,
231
+ expiresAt: result.expiresAt,
232
+ ...(qrNote === undefined ? {} : { note: qrNote }),
233
+ qr: renderQrToTerminal(deepLink),
234
+ instructions: QR_INSTRUCTIONS,
235
+ };
236
+ };
237
+ if (!delivery) {
238
+ return asQrResult(await mint(false), note);
239
+ }
240
+ const result = await mint(usesLoopbackAddress(delivery.target));
241
+ // Same guard as `link.ts`: `detectAdvertisedAddress` falls back to `127.0.0.1` when it finds no
242
+ // routable interface, and such a link points a physical phone at itself. Delivering it would
243
+ // leave `appduct_wait_for_session` blocking for its whole timeout with nothing to explain why.
244
+ if (delivery.target === "ios-device" && isLoopbackAddress(result.endpoint.address)) {
245
+ throw new McpBuiltinToolError("invalid_request", loopbackAddressMessage(result.endpoint.address));
246
+ }
247
+ const deepLink = composeDeepLink(scheme, result);
248
+ try {
249
+ await deliverToOpenTarget({
250
+ target: delivery.target,
251
+ device: delivery.device,
252
+ bundleId: delivery.bundleId,
253
+ relaunch,
254
+ deepLink,
255
+ wssPort: result.endpoint.port,
256
+ exec: deps.exec,
257
+ env: deps.env,
258
+ });
259
+ }
260
+ catch (error) {
261
+ const detail = error instanceof Error ? error.message : "Failed to deliver the deep link.";
262
+ // An explicitly named target that cannot be reached is the caller's problem to see: falling
263
+ // back would quietly ignore what they asked for.
264
+ if (!delivery.autoDetected) {
265
+ throw new McpBuiltinToolError("tool_execution_error", detail);
266
+ }
267
+ // An auto-detected one is not. Nobody asked for this device — a simulator that happens to be
268
+ // booted without the app installed, say — so a failure to reach it must not turn a call that
269
+ // would previously have returned a usable QR into a hard error. The loopback link just minted
270
+ // is abandoned (it expires on its own TTL) and a fresh, correctly-addressed one takes its
271
+ // place.
272
+ return asQrResult(await mint(false), `Auto-detected ${delivery.target} target ${delivery.label}, but delivering the link to it ` +
273
+ `failed: ${detail}. Falling back to a QR code — pass an explicit "target" (and "device") ` +
274
+ "if a different device is the right one.");
275
+ }
276
+ return {
277
+ sessionId: result.sessionId,
278
+ deepLink,
279
+ expiresAt: result.expiresAt,
280
+ delivered: true,
281
+ target: delivery.target,
282
+ ...(delivery.device === undefined ? {} : { device: delivery.device }),
283
+ ...(delivery.autoDetected
284
+ ? {
285
+ autoDetected: true,
286
+ note: `No "target" was given; delivered to the only device found: ${delivery.label}.`,
287
+ }
288
+ : {}),
289
+ };
290
+ };
291
+ /** Socket-level failures that mean the daemon went away mid-wait. `isDaemonUnreachableError`
292
+ * covers the "never got there" cases (`ENOENT`/`ECONNREFUSED`) and a timed-out request; these are
293
+ * the "was there, then wasn't" ones, which surface raw from a call that was already in flight. */
294
+ const CONNECTION_LOST_CODES = new Set(["ECONNRESET", "EPIPE"]);
295
+ /** Appends the underlying cause, so an operator reading CI output still gets the syscall detail
296
+ * even though the sentence in front of it is what the agent acts on. */
297
+ const describeCause = (error) => {
298
+ return error instanceof Error && error.message.length > 0 ? ` (${error.message})` : "";
299
+ };
300
+ const daemonClosedMessage = (sessionId) => `The connection to the Appduct daemon closed while waiting for session "${sessionId}".`;
301
+ const daemonUnreachableMessage = (sessionId, error) => `The Appduct daemon is unreachable, so session "${sessionId}" cannot be waited on${describeCause(error)}.`;
302
+ const isConnectionLost = (error) => {
303
+ if (isDaemonUnreachableError(error)) {
304
+ return true;
305
+ }
306
+ const code = error?.code;
307
+ return typeof code === "string" && CONNECTION_LOST_CODES.has(code);
308
+ };
309
+ export const handleWaitForSessionTool = async (rawArgs, deps) => {
310
+ const args = asRecord(rawArgs);
311
+ if (typeof args.sessionId !== "string" || args.sessionId.length === 0) {
312
+ throw new McpBuiltinToolError("invalid_request", '"sessionId" must be a non-empty string.');
313
+ }
314
+ const sessionId = args.sessionId;
315
+ const timeoutMs = asOptionalPositiveNumber(args.timeoutMs, "timeoutMs") ?? DEFAULT_WAIT_TIMEOUT_MS;
316
+ let stream;
317
+ try {
318
+ stream = await openDaemonStream({ stateDir: deps.stateDir, spawn: deps.spawn });
319
+ }
320
+ catch (error) {
321
+ // The daemon can be on its way out at the exact moment this tool opens its stream, in which
322
+ // case `connect` fails with ECONNRESET — not the ENOENT/ECONNREFUSED that would mean "not
323
+ // running" and trigger an auto-spawn. That is the same situation as losing it mid-wait, and
324
+ // has to read the same way rather than as a bare syscall error against a socket path.
325
+ if (isConnectionLost(error)) {
326
+ throw new McpBuiltinToolError("tool_execution_error", daemonUnreachableMessage(sessionId, error));
327
+ }
328
+ throw error;
329
+ }
330
+ try {
331
+ return await new Promise((resolve, reject) => {
332
+ let settled = false;
333
+ const settle = (fn) => {
334
+ if (settled) {
335
+ return;
336
+ }
337
+ settled = true;
338
+ clearTimeout(timer);
339
+ unsubscribeNotification();
340
+ unsubscribeClose();
341
+ fn();
342
+ };
343
+ const timer = setTimeout(() => {
344
+ settle(() => reject(new McpBuiltinToolError("tool_timeout", `Timed out after ${timeoutMs}ms waiting for session "${sessionId}" to be claimed.`)));
345
+ }, timeoutMs);
346
+ const connectionLost = () => new McpBuiltinToolError("tool_execution_error", daemonClosedMessage(sessionId));
347
+ // A daemon that goes away mid-wait must not read as "still waiting" for the rest of the
348
+ // timeout — the caller has to know the connection is gone, not sit in silence.
349
+ const unsubscribeClose = stream.onClose(() => {
350
+ settle(() => reject(connectionLost()));
351
+ });
352
+ // Registered *before* `events.subscribe` is sent (not after awaiting it) — a
353
+ // `session_claimed` notification that arrives in the same TCP chunk as the subscribe
354
+ // response is dispatched synchronously within that chunk's processing, before an `await`
355
+ // continuation would get a chance to run; registering first means it is always in place in
356
+ // time to see it. Same reasoning as `client/bootstrap.ts`.
357
+ const unsubscribeNotification = stream.onNotification((payload) => {
358
+ const event = payload;
359
+ if (event.kind === "session_claimed" && event.sessionId === sessionId) {
360
+ settle(() => resolve({ sessionId, claimed: true, alias: event.alias ?? "" }));
361
+ }
362
+ });
363
+ stream
364
+ .call(RPC_METHODS.eventsSubscribe, { sessionSelector: sessionId, kinds: ["session_claimed"] })
365
+ .then(async () => {
366
+ if (settled) {
367
+ return;
368
+ }
369
+ // Catch-up, once the subscription is definitely in place: the session may have been
370
+ // claimed before this tool ran at all (an agent polling, or a long gap after
371
+ // appduct_connect), or in the round trip the subscribe itself took. Either way the
372
+ // event is already in the past and no amount of waiting will replay it.
373
+ const existing = await stream.call(RPC_METHODS.sessionsDescribe, {
374
+ selector: sessionId,
375
+ });
376
+ settle(() => resolve({ sessionId, claimed: true, alias: existing.alias }));
377
+ })
378
+ .catch((error) => {
379
+ // `unknown_session` from the catch-up is the expected "not claimed yet" answer — keep
380
+ // waiting for the live event. Anything else is a real failure.
381
+ if (error instanceof DaemonRpcError && error.data?.type === "unknown_session") {
382
+ return;
383
+ }
384
+ // A daemon dying mid-wait races two routes to get here: `onClose` above, or whichever
385
+ // call was in flight rejecting with a raw socket error. Same condition, so report it the
386
+ // same way — which route wins is a platform detail (Linux tends to reset where macOS
387
+ // closes), and nobody should have to read `connect ECONNRESET <socket path>` to find out
388
+ // the daemon stopped.
389
+ settle(() => reject(isConnectionLost(error) ? connectionLost() : error));
390
+ });
391
+ });
392
+ }
393
+ finally {
394
+ stream.close();
395
+ }
396
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Fetches the effective (possibly namespaced) MCP tool list live from the daemon
3
+ * (ARCHITECTURE.md §9): `sessions.list` for the live sessions, then `tools.list` per session.
4
+ * There is no caching here — every `tools/list` request and every list-changed check re-fetches,
5
+ * since the daemon's session/registry state is the single source of truth and can change between
6
+ * any two calls.
7
+ */
8
+ import { type NamespacedTool } from "./tool-namespace.js";
9
+ /** A method call against the daemon RPC — satisfied by both `callDaemon` (bound to a method+params)
10
+ * and `DaemonStream.call` from `rpc/client.ts`. */
11
+ export type DaemonCall = <TResult>(method: string, params?: unknown) => Promise<TResult>;
12
+ export declare const fetchEffectiveTools: (call: DaemonCall) => Promise<NamespacedTool[]>;
13
+ //# sourceMappingURL=daemon-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"daemon-tools.d.ts","sourceRoot":"","sources":["../../src/mcp/daemon-tools.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAUH,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAEhF;mDACmD;AACnD,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAEzF,eAAO,MAAM,mBAAmB,GAAU,MAAM,UAAU,KAAG,OAAO,CAAC,cAAc,EAAE,CAuBpF,CAAC"}