agent-embassy 1.9.5 → 3.0.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 (150) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/CONTRIBUTING.md +19 -34
  3. package/README.md +110 -221
  4. package/SECURITY.md +55 -89
  5. package/dist/src/errors.d.ts +10 -1
  6. package/dist/src/errors.js +3 -1
  7. package/dist/src/errors.js.map +1 -1
  8. package/dist/src/gateway/claude-helper-protocol.d.ts +9 -12
  9. package/dist/src/gateway/claude-helper-protocol.js +15 -13
  10. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  11. package/dist/src/gateway/claude-helper-supervisor.d.ts +2 -6
  12. package/dist/src/gateway/claude-helper-supervisor.js +10 -9
  13. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  14. package/dist/src/gateway/claude-helper.js +18 -10
  15. package/dist/src/gateway/claude-helper.js.map +1 -1
  16. package/dist/src/gateway/claude-peer.d.ts +0 -3
  17. package/dist/src/gateway/claude-peer.js +6 -18
  18. package/dist/src/gateway/claude-peer.js.map +1 -1
  19. package/dist/src/gateway/cli.d.ts +18 -13
  20. package/dist/src/gateway/cli.js +942 -298
  21. package/dist/src/gateway/cli.js.map +1 -1
  22. package/dist/src/gateway/codex-local-transport.d.ts +1 -1
  23. package/dist/src/gateway/codex-local-transport.js +2 -2
  24. package/dist/src/gateway/codex-local-transport.js.map +1 -1
  25. package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
  26. package/dist/src/gateway/codex-socket-holder.js +76 -0
  27. package/dist/src/gateway/codex-socket-holder.js.map +1 -0
  28. package/dist/src/gateway/codex-stateless-transport.js +1 -1
  29. package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
  30. package/dist/src/gateway/config.d.ts +5 -13
  31. package/dist/src/gateway/config.js +7 -19
  32. package/dist/src/gateway/config.js.map +1 -1
  33. package/dist/src/gateway/control.d.ts +55 -108
  34. package/dist/src/gateway/control.js +95 -222
  35. package/dist/src/gateway/control.js.map +1 -1
  36. package/dist/src/gateway/federation-nodes.d.ts +29 -3
  37. package/dist/src/gateway/federation-nodes.js +200 -15
  38. package/dist/src/gateway/federation-nodes.js.map +1 -1
  39. package/dist/src/gateway/peer-client.d.ts +4 -3
  40. package/dist/src/gateway/peer-client.js +22 -13
  41. package/dist/src/gateway/peer-client.js.map +1 -1
  42. package/dist/src/gateway/peer-protocol.d.ts +8 -11
  43. package/dist/src/gateway/peer-protocol.js +5 -12
  44. package/dist/src/gateway/peer-protocol.js.map +1 -1
  45. package/dist/src/gateway/provenance-envelope.d.ts +0 -1
  46. package/dist/src/gateway/provenance-envelope.js +4 -19
  47. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  48. package/dist/src/gateway/providers.d.ts +10 -7
  49. package/dist/src/gateway/providers.js +21 -28
  50. package/dist/src/gateway/providers.js.map +1 -1
  51. package/dist/src/gateway/server.d.ts +4 -14
  52. package/dist/src/gateway/server.js +33 -59
  53. package/dist/src/gateway/server.js.map +1 -1
  54. package/dist/src/gateway/service-agent.d.ts +187 -0
  55. package/dist/src/gateway/service-agent.js +758 -0
  56. package/dist/src/gateway/service-agent.js.map +1 -0
  57. package/dist/src/gateway/service.d.ts +117 -32
  58. package/dist/src/gateway/service.js +525 -576
  59. package/dist/src/gateway/service.js.map +1 -1
  60. package/dist/src/gateway/status-view.d.ts +167 -0
  61. package/dist/src/gateway/status-view.js +488 -0
  62. package/dist/src/gateway/status-view.js.map +1 -0
  63. package/dist/src/gateway/store.d.ts +103 -21
  64. package/dist/src/gateway/store.js +473 -547
  65. package/dist/src/gateway/store.js.map +1 -1
  66. package/dist/src/gateway/types.d.ts +51 -102
  67. package/dist/src/gateway/types.js +15 -52
  68. package/dist/src/gateway/types.js.map +1 -1
  69. package/docs/CONFIGURATION.md +194 -59
  70. package/docs/DELIVERY.md +11 -11
  71. package/docs/GATEWAY-ARCHITECTURE.md +286 -394
  72. package/package.json +4 -12
  73. package/skills/embassy-peer/SKILL.md +72 -92
  74. package/skills/embassy-peer/agents/openai.yaml +1 -1
  75. package/README.zh-CN.md +0 -269
  76. package/assets/live-dashboard/app.css +0 -1619
  77. package/assets/vendor/react/LICENSE +0 -21
  78. package/assets/vendor/react/react-dom.production.min.js +0 -267
  79. package/assets/vendor/react/react.production.min.js +0 -31
  80. package/dist/src/gateway/acp-client.d.ts +0 -110
  81. package/dist/src/gateway/acp-client.js +0 -407
  82. package/dist/src/gateway/acp-client.js.map +0 -1
  83. package/dist/src/gateway/acp-provider.d.ts +0 -66
  84. package/dist/src/gateway/acp-provider.js +0 -275
  85. package/dist/src/gateway/acp-provider.js.map +0 -1
  86. package/dist/src/gateway/cli-copy.d.ts +0 -8
  87. package/dist/src/gateway/cli-copy.en.d.ts +0 -17
  88. package/dist/src/gateway/cli-copy.en.js +0 -58
  89. package/dist/src/gateway/cli-copy.en.js.map +0 -1
  90. package/dist/src/gateway/cli-copy.js +0 -22
  91. package/dist/src/gateway/cli-copy.js.map +0 -1
  92. package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -17
  93. package/dist/src/gateway/cli-copy.zh-CN.js +0 -58
  94. package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
  95. package/dist/src/gateway/codex-doctor.d.ts +0 -36
  96. package/dist/src/gateway/codex-doctor.js +0 -127
  97. package/dist/src/gateway/codex-doctor.js.map +0 -1
  98. package/dist/src/gateway/dashboard-copy.d.ts +0 -7
  99. package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
  100. package/dist/src/gateway/dashboard-copy.en.js +0 -505
  101. package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
  102. package/dist/src/gateway/dashboard-copy.js +0 -514
  103. package/dist/src/gateway/dashboard-copy.js.map +0 -1
  104. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
  105. package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
  106. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
  107. package/dist/src/gateway/dashboard-model.d.ts +0 -343
  108. package/dist/src/gateway/dashboard-model.js +0 -1061
  109. package/dist/src/gateway/dashboard-model.js.map +0 -1
  110. package/dist/src/gateway/dashboard.d.ts +0 -20
  111. package/dist/src/gateway/dashboard.js +0 -874
  112. package/dist/src/gateway/dashboard.js.map +0 -1
  113. package/dist/src/gateway/deepseek-detect.d.ts +0 -14
  114. package/dist/src/gateway/deepseek-detect.js +0 -41
  115. package/dist/src/gateway/deepseek-detect.js.map +0 -1
  116. package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
  117. package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
  118. package/dist/src/gateway/live-dashboard-assets.js +0 -74
  119. package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
  120. package/dist/src/gateway/live-dashboard-command.d.ts +0 -58
  121. package/dist/src/gateway/live-dashboard-command.js +0 -334
  122. package/dist/src/gateway/live-dashboard-command.js.map +0 -1
  123. package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
  124. package/dist/src/gateway/live-dashboard-http.js +0 -383
  125. package/dist/src/gateway/live-dashboard-http.js.map +0 -1
  126. package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
  127. package/dist/src/gateway/live-dashboard-protocol.js +0 -114
  128. package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
  129. package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
  130. package/dist/src/gateway/live-dashboard-server.js +0 -144
  131. package/dist/src/gateway/live-dashboard-server.js.map +0 -1
  132. package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
  133. package/dist/src/gateway/live-dashboard-stream.js +0 -234
  134. package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
  135. package/dist/src/gateway/live-dashboard.d.ts +0 -28
  136. package/dist/src/gateway/live-dashboard.js +0 -154
  137. package/dist/src/gateway/live-dashboard.js.map +0 -1
  138. package/dist/src/gateway/locale.d.ts +0 -4
  139. package/dist/src/gateway/locale.js +0 -10
  140. package/dist/src/gateway/locale.js.map +0 -1
  141. package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
  142. package/dist/src/gateway/progress-watch-machine.js +0 -70
  143. package/dist/src/gateway/progress-watch-machine.js.map +0 -1
  144. package/dist/src/gateway/state-v2-to-v3.d.ts +0 -24
  145. package/dist/src/gateway/state-v2-to-v3.js +0 -1034
  146. package/dist/src/gateway/state-v2-to-v3.js.map +0 -1
  147. package/docs/CONFIGURATION.zh-CN.md +0 -93
  148. package/docs/DASHBOARD.md +0 -98
  149. package/docs/DASHBOARD.zh-CN.md +0 -49
  150. package/docs/DELIVERY.zh-CN.md +0 -55
@@ -1,38 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  /** Foreground broker plus bounded metadata-only control client. */
3
+ import { randomBytes } from "node:crypto";
3
4
  import { realpathSync } from "node:fs";
4
5
  import { lstat, realpath } from "node:fs/promises";
6
+ import { userInfo } from "node:os";
5
7
  import path from "node:path";
6
8
  import { fileURLToPath } from "node:url";
7
9
  import { BridgeError } from "../errors.js";
8
- import { getCliCopy } from "./cli-copy.js";
9
- import { callerIdentityConflictHintEn } from "./cli-copy.en.js";
10
- import { callerIdentityConflictHintZhCn } from "./cli-copy.zh-CN.js";
11
- import { GATEWAY_CONTROL_DEFAULT_TIMEOUT_MS, GATEWAY_CONTROL_MAX_MESSAGE_BYTES, GATEWAY_CONTROL_MAX_RESPONSE_BYTES, GATEWAY_CONTROL_PROTOCOL_VERSION, GatewayControlTransportError, isClaudeSessionSelector, isGatewayAlias, isGatewayConversationId, isGatewayDeliveryToken, isGatewayHostId, isGatewayReplyAddress, sendGatewayControlRequest } from "./control.js";
12
- import { loadGatewayConfig } from "./config.js";
13
- import { loadGatewayNodeInventory } from "./federation-nodes.js";
14
- import { isDashboardLocale } from "./locale.js";
15
- import { DEFAULT_LIVE_DASHBOARD_PORT, runLiveDashboardCommand } from "./live-dashboard-command.js";
10
+ import { GATEWAY_CONTROL_DEFAULT_TIMEOUT_MS, GATEWAY_CONTROL_MAX_MESSAGE_BYTES, GATEWAY_CONTROL_MAX_RESPONSE_BYTES, GATEWAY_CONTROL_PROTOCOL_VERSION, GatewayControlTransportError, isClaudeSessionSelector, isGatewayAlias, isGatewayConversationId, isGatewayDeliveryToken, isGatewayReplyAddress, sendGatewayControlRequest } from "./control.js";
11
+ import { defaultGatewayStateDir, loadGatewayConfig } from "./config.js";
12
+ import { isDefaultedGatewayNodeInventory, loadGatewayNodeInventory } from "./federation-nodes.js";
16
13
  import { runGatewayServer } from "./server.js";
17
- import { PROGRESS_WATCH_DEFAULT_IDLE_MS } from "./progress-watch-machine.js";
18
14
  import { PeerHandlerError, runPeerStdio } from "./peer-stdio.js";
19
- import { convertGatewayStateV2ToV3 } from "./state-v2-to-v3.js";
15
+ import { boundedServiceDetail, defaultProbeHostLease, defaultRunLaunchctl, installServiceAgent, readOwnedSmallFile, recordedPid, serviceAgentStatus, uninstallServiceAgent } from "./service-agent.js";
16
+ import { GATEWAY_CONTROLLER_LOCK_FILE } from "./store.js";
17
+ import { diffWatch, emptyWatchState, renderStatus, renderWatchEvent, STATUS_RECENT, STATUS_ROUTE_STALE_AFTER_MS, terminalPainter } from "./status-view.js";
20
18
  const THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
21
- const DEFAULT_HOST_ID = "this-mac";
22
19
  const CLI_MAX_OUTPUT_BYTES = GATEWAY_CONTROL_MAX_RESPONSE_BYTES;
23
20
  const DELIVERY_POLL_INTERVAL_MS = 250;
24
21
  const DELIVERY_POLL_MIN_REQUEST_TIMEOUT_MS = 50;
25
22
  const PEER_AWAIT_REQUEST_TIMEOUT_MS = 35_000;
26
- export const EMBASSY_VERSION = "1.9.5";
27
- // RELEASE VERSION SWEEP — every place the version lives: package.json,
28
- // npm-shrinkwrap.json (x2), this constant,
29
- // test/gateway-cli.test.ts package-metadata assertion.
30
- const DEFAULT_CLI_LOCALE = "en";
23
+ export const EMBASSY_VERSION = "3.0.0";
24
+ // RELEASE VERSION SWEEP — every place the version lives, seven sites:
25
+ // package.json; npm-shrinkwrap.json (two: the root and packages[""]); this
26
+ // constant; test/gateway-cli.test.ts's literal package-version assertion; and
27
+ // README.md's two `embassy status` renders. test/status-view.test.ts pins
28
+ // those renders to this constant and derives its own fixture and regexes from
29
+ // it, so it is not a sweep site.
31
30
  export const gatewayCliCommands = [
32
- "serve", "health", "status", "doctor", "convert-state-v2-to-v3", "delivery-status",
33
- "wait-delivery", "untrack", "refresh-dashboard", "dashboard", "register-codex",
34
- "unregister-codex", "select-claude", "unselect-claude", "pair", "unpair",
35
- "send-to-claude", "send-to-codex", "reply",
31
+ "serve", "service", "health", "status", "watch", "check", "delivery-status",
32
+ "wait-delivery", "refresh", "register-codex",
33
+ "unregister-codex",
34
+ "send", "reply",
36
35
  "register-peer", "unregister-peer", "await",
37
36
  "peer-stdio",
38
37
  ];
@@ -44,17 +43,140 @@ export const gatewayCliExitCodes = Object.freeze({
44
43
  ambiguous: 5,
45
44
  failure: 6,
46
45
  });
46
+ const HELP_USAGE = `Embassy — local messaging for Claude Code and Codex
47
+
48
+ Usage:
49
+ embassy <command> [options]
50
+
51
+ Commands:
52
+ serve Run the socket-only broker
53
+ service install|uninstall|status
54
+ Run the broker as a macOS launchd agent
55
+ health Check broker health
56
+ status [--json] [--recent <n>]
57
+ Show the broker, connectors, routes, and recent
58
+ messages; JSON when piped or with --json
59
+ watch [--json] Tail messages and route activity until Ctrl-C
60
+ check [--to <alias>] [--timeout <s>]
61
+ Round-trip self-test against a registered peer
62
+ refresh Rescan for Claude sessions
63
+ register-codex Register or succeed a Codex task
64
+ unregister-codex Unregister the current Codex task
65
+ register-peer --alias <peer-alias> [--token-stdin|--emit-env]
66
+ Register a universal shell peer
67
+ unregister-peer --alias <peer-alias> [--token-stdin]
68
+ Unregister a universal shell peer
69
+ await --alias <peer-alias> [--token-stdin]
70
+ Wait for one peer message and acknowledge stdout
71
+ peer-stdio Serve the bounded federation protocol on stdin/stdout
72
+ send Send stdin to a route with --to, or to a conversation
73
+ you belong to with --conversation; a discovered Claude
74
+ session's route installs on its first send
75
+ reply Deprecated alias for send --conversation
76
+ delivery-status Read a delivery token
77
+ wait-delivery Wait for terminal delivery status
78
+
79
+ Options:
80
+ --token-stdin Read the peer token as the first LF-terminated stdin line
81
+ --emit-env Print the first registration token as an export command
82
+ --version, -v Print the version
83
+ --help, -h Show this help
84
+ `;
85
+ /**
86
+ * Fixed one-line stderr summaries; stdout carries the protocol and stderr
87
+ * never carries private detail. The `service` subtree is the one deliberate
88
+ * exception, and only for its own local files: it reports the plist path, the
89
+ * log path, a missing program path, and launchctl's bounded stderr, because
90
+ * managing those files is the whole command. launchctl's *stdout* is never
91
+ * quoted — a `print` dump carries the agent's environment values.
92
+ */
93
+ const CLI_STDERR = {
94
+ input: "request rejected.",
95
+ decision: "gateway rejected the request.",
96
+ unavailable: "gateway unavailable.",
97
+ ambiguous: "outcome ambiguous; do not retry automatically.",
98
+ failure: "command failed.",
99
+ unsafe: "gateway state directory or socket has unexpected permissions or ownership. Verify the exact path, owner, and modes before retrying.",
100
+ tokenUnknown: "delivery token not recognized; it may have expired or left bounded retention.",
101
+ deliveryTimeout: "the delivery has not settled yet; the gateway is still running. Check again later with embassy delivery-status.",
102
+ };
103
+ /** Exact next-step remedies appended after the summary for the faults that have one. */
104
+ const CLI_HINT = {
105
+ noBrokerRunning: "No broker is running (state dir {stateDir}). Run `embassy service install` once, or `embassy serve` in a terminal — or verify EMBASSY_STATE_DIR is not scrubbed or misdirected (for example by a sandboxed task's HOME).",
106
+ controlConnectDenied: "the broker may be running, but this process cannot connect; grant this task write access to the gateway state directory, then retry. Do not start a second broker. If access should already work, verify EMBASSY_STATE_DIR names this user's own state directory.",
107
+ controlInvalidResponse: "if either Embassy installation changed recently, rebuild or repoint this client to the broker's installation; otherwise restart the broker, then retry.",
108
+ controlVersionMismatch: "rebuild or repoint this client to the broker's Embassy installation, then retry.",
109
+ stateAccessDenied: "local policy denied access to the gateway state directory; grant this process access, then retry starting the broker. If access should already work, verify EMBASSY_STATE_DIR names this user's own state directory.",
110
+ messageTooLarge: "message exceeds the 16 KiB acceptance cap; shorten or split it. For long prose, pipe the body from a file.",
111
+ stateResetRequired: "state reset required; follow docs/CONFIGURATION.md#private-state-reset. Resetting abandons unsettled work. To check for unsettled work after upgrading, temporarily use Embassy 2.0.x before resetting.",
112
+ callerIdentityConflict: "both agent identities were inherited; rerun this Codex-side call with env -u CLAUDE_CODE_MESSAGING_SOCKET, or this Claude-side call with env -u CODEX_THREAD_ID",
113
+ aliasHostMismatch: "aliases on this machine end with @{localHost} (from {stateDir}/nodes.json); found @{given}",
114
+ aliasHostDefaulted: "no nodes.json has been written at {stateDir} yet — a broker writes it on first start; until then this machine defaults to @{localHost}; found @{given}",
115
+ stateInUse: "another broker may own {stateDir}: if `embassy serve` is not running anywhere, the lock {stateDir}/.gateway-controller.lock is stale (recorded host {host}, pid {pid}) — remove it and start again.",
116
+ stateInUseUnrecorded: "another broker may own {stateDir}: if `embassy serve` is not running anywhere, the lock {stateDir}/.gateway-controller.lock is stale — remove it and start again.",
117
+ stateLockUnverified: "the lock {stateDir}/.gateway-controller.lock cannot be read as a controller record; if `embassy serve` is not running anywhere, remove that file and start again.",
118
+ nodeInventoryChanged: "nodes.json at {stateDir} changed while the broker was starting; start again.",
119
+ stateWriteFailed: "nodes.json could not be written at {stateDir} (disk full, read-only, or quota?)",
120
+ stateSyncFailed: "nodes.json was written at {stateDir} but the directory could not be synced; start again and check the volume",
121
+ unknownTarget: "no current route answers to that name. A Claude session is addressed by its live name: run embassy refresh, then read embassy status for the name it has now.",
122
+ aliasCollision: "the alias names more than one live session; rename one, or address the session by UUID with --to <session-uuid>.",
123
+ workspaceOverlap: "that session's workspace contains the gateway state directory; move one so they no longer overlap, then retry.",
124
+ callerAliasMismatch: "--from must be the sending session's own alias; read the name embassy status shows for this session.",
125
+ targetChanged: "the session you addressed renamed or exited while the send was being set up; run embassy refresh and address it by its current name.",
126
+ checkNoTarget: "no Codex task is registered, so there is nothing to check. Run `embassy register-codex --alias codex-<name>@{localHost}` from inside the task, or name any current route with `embassy check --to <alias>`.",
127
+ checkAllStale: "every registered Codex task is unobserved or was last observed more than ten minutes ago ({aliases}), so nothing was sent. Read `embassy status` for each one's remedy, or check one anyway with `embassy check --to <alias>`.",
128
+ peerAliasRequired: "a peer token is present (EMBASSY_PEER_TOKEN or --token-stdin) but --from is not a peer-* alias; unset the token or name the peer route.",
129
+ codexCallerRequired: "--from names a Codex task ({alias}) but no CODEX_THREAD_ID was inherited; run this inside that Codex task, as a shell step of its current turn.",
130
+ peerCallerRequired: "--from names a shell peer ({alias}) but no peer token was given; pass the peer_ token on the first stdin line with --token-stdin (or EMBASSY_PEER_TOKEN from a stable shell).",
131
+ codexRouteUnregistered: "no registration for {alias} on this broker; run `embassy register-codex --alias {alias}` from inside the Codex task (after a private state reset, every Codex task registers again).",
132
+ codexThreadMismatch: "{alias} is not registered to this task's inherited CODEX_THREAD_ID; --from must name the alias this task registered (read it from `embassy status`), or register this task with `embassy register-codex`.",
133
+ callerIdentityRequired: "no caller credential was inherited, and a conversation names no route to infer one from: run this inside the Codex task (CODEX_THREAD_ID), inside the Claude Code session (CLAUDE_CODE_MESSAGING_SOCKET), or as a registered shell peer with --token-stdin and the peer_ token on the first stdin line.",
134
+ unknownMethod: "the broker does not implement that control method, so this client and the broker are from different Embassy builds; rebuild or update this client to the broker's Embassy installation, then retry.",
135
+ };
136
+ /**
137
+ * The three connect-stage codes that all mean the same thing to the person
138
+ * reading them: nothing is serving this state directory. A missing socket
139
+ * never reaches the transport (validatePrivateGatewayControlSocket maps it to
140
+ * CONTROL_SOCKET_UNAVAILABLE first), and a broker that died leaving its socket
141
+ * behind reports CONTROL_LISTENER_UNAVAILABLE — so the hint has to cover all
142
+ * three or it is unreachable in practice.
143
+ */
144
+ const isNoBrokerCode = (code) => code === "CONTROL_SOCKET_UNAVAILABLE" || code === "CONTROL_SOCKET_MISSING" ||
145
+ code === "CONTROL_LISTENER_UNAVAILABLE";
146
+ /** Best effort, and never throws: a hint must not replace the fault it explains. */
147
+ function resolvedStateDirForHint(env) {
148
+ try {
149
+ return path.resolve(defaultGatewayStateDir(env));
150
+ }
151
+ catch {
152
+ return env.EMBASSY_STATE_DIR ?? env.XDG_STATE_HOME ?? "unresolvable";
153
+ }
154
+ }
155
+ /**
156
+ * Renders a CLI_HINT entry, substituting {name} placeholders from `vars` in a
157
+ * single pass: a substituted value is never rescanned, so a state directory
158
+ * literally named `/tmp/{host}` cannot expand into anything else.
159
+ */
160
+ function renderHint(hint, vars) {
161
+ const text = CLI_HINT[hint];
162
+ if (vars === undefined)
163
+ return text;
164
+ return text.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (token, name) => Object.hasOwn(vars, name) ? vars[name] : token);
165
+ }
166
+ const hintLine = (hint, env) => `[embassy] ${renderHint(hint, { stateDir: resolvedStateDirForHint(env) })}\n`;
47
167
  class CliFault extends Error {
48
168
  code;
49
169
  retryable;
50
170
  hint;
51
171
  kind;
52
- constructor(code, retryable = false, hint, kind) {
172
+ hintVars;
173
+ constructor(code, retryable = false, hint, kind, hintVars) {
53
174
  super("The gateway client rejected the request.");
54
175
  this.code = code;
55
176
  this.retryable = retryable;
56
177
  this.hint = hint;
57
178
  this.kind = kind;
179
+ this.hintVars = hintVars;
58
180
  this.name = "CliFault";
59
181
  }
60
182
  }
@@ -84,39 +206,7 @@ function parseOptions(args, valueNames, flagNames = []) {
84
206
  }
85
207
  return parsed;
86
208
  }
87
- function commonOptions(args, env) {
88
- const stripped = [];
89
- let locale;
90
- for (let index = 0; index < args.length; index += 1) {
91
- const token = args[index];
92
- if (token !== "--lang") {
93
- if (token !== undefined)
94
- stripped.push(token);
95
- continue;
96
- }
97
- if (locale !== undefined)
98
- fault();
99
- const value = args[index + 1];
100
- if (!isDashboardLocale(value))
101
- fault();
102
- locale = value;
103
- index += 1;
104
- }
105
- const inherited = env.EMBASSY_LOCALE;
106
- if (locale === undefined && inherited !== undefined && inherited.length > 0 && !isDashboardLocale(inherited))
107
- fault();
108
- return {
109
- args: stripped,
110
- locale: locale ?? (isDashboardLocale(inherited) ? inherited : DEFAULT_CLI_LOCALE),
111
- };
112
- }
113
- function fallbackCliLocale(args, env) {
114
- const indices = args.flatMap((value, index) => value === "--lang" ? [index] : []);
115
- const flagged = indices.length === 1 ? args[indices[0] + 1] : undefined;
116
- return isDashboardLocale(flagged) ? flagged
117
- : isDashboardLocale(env.EMBASSY_LOCALE) ? env.EMBASSY_LOCALE : DEFAULT_CLI_LOCALE;
118
- }
119
- const fixedStderr = (locale, kind) => `[embassy] ${getCliCopy(locale)[`error.${kind}`]}\n`;
209
+ const fixedStderr = (kind) => `[embassy] ${CLI_STDERR[kind]}\n`;
120
210
  function requireString(options, name) {
121
211
  const value = options[name];
122
212
  if (typeof value !== "string")
@@ -141,12 +231,20 @@ function requireCodexAlias(options, name) {
141
231
  return alias;
142
232
  }
143
233
  const gatewayAliasHost = (alias) => alias.slice(alias.lastIndexOf("@") + 1);
144
- function requirePairAliases(options) {
145
- const from = requireAlias(options, "from");
146
- const to = requireAlias(options, "to");
147
- if (from === to)
234
+ /**
235
+ * An alias naming another host is rejected before any broker call. The hint
236
+ * says where this machine's own host came from, because the two cases have
237
+ * different remedies: a durable nodes.json is the answer, while a defaulted
238
+ * identity is still provisional until the first `embassy serve` records it.
239
+ */
240
+ function aliasHostFault(local, given) {
241
+ return new CliFault("INVALID_ARGUMENTS", false, local.defaulted ? "aliasHostDefaulted" : "aliasHostMismatch", undefined, { localHost: local.host, given, stateDir: local.stateDir });
242
+ }
243
+ function requireConversationId(options, name) {
244
+ const conversationId = requireString(options, name);
245
+ if (!isGatewayConversationId(conversationId))
148
246
  fault();
149
- return [from, to];
247
+ return conversationId;
150
248
  }
151
249
  function requireClaudeSelector(options, name) {
152
250
  const selector = requireString(options, name);
@@ -154,22 +252,6 @@ function requireClaudeSelector(options, name) {
154
252
  fault();
155
253
  return selector;
156
254
  }
157
- function trackIdleMinutes(options) {
158
- const tracking = options.track === true;
159
- const raw = options["idle-minutes"];
160
- if (raw !== undefined && !tracking)
161
- fault();
162
- if (!tracking)
163
- return undefined;
164
- if (raw === undefined)
165
- return PROGRESS_WATCH_DEFAULT_IDLE_MS / 60_000;
166
- if (typeof raw !== "string" || !/^[1-9][0-9]{0,3}$/.test(raw))
167
- fault();
168
- const minutes = Number(raw);
169
- if (!Number.isSafeInteger(minutes) || minutes > 24 * 60)
170
- fault();
171
- return minutes;
172
- }
173
255
  function requireDeliveryToken(options, name) {
174
256
  const token = requireString(options, name);
175
257
  if (!isGatewayDeliveryToken(token))
@@ -210,14 +292,6 @@ function requireExclusiveCodexThreadId(env) {
210
292
  throw callerIdentityConflictFault(env);
211
293
  return requireCodexThreadId(env);
212
294
  }
213
- function optionalCodexThreadId(env) {
214
- const threadId = env.CODEX_THREAD_ID;
215
- if (threadId === undefined || threadId.length === 0)
216
- return undefined;
217
- if (!THREAD_ID_PATTERN.test(threadId))
218
- fault("CODEX_IDENTITY_REQUIRED");
219
- return threadId.toLowerCase();
220
- }
221
295
  function optionalClaudeReplyAddress(env) {
222
296
  const socketPath = env.CLAUDE_CODE_MESSAGING_SOCKET;
223
297
  if (socketPath === undefined || socketPath.length === 0)
@@ -249,7 +323,7 @@ async function readMessageBody(stdin) {
249
323
  const buffer = Buffer.from(chunk);
250
324
  length += buffer.length;
251
325
  if (length > GATEWAY_CONTROL_MAX_MESSAGE_BYTES)
252
- throw new CliFault("MESSAGE_TOO_LARGE", false, "hint.messageTooLarge");
326
+ throw new CliFault("MESSAGE_TOO_LARGE", false, "messageTooLarge");
253
327
  chunks.push(buffer);
254
328
  }
255
329
  let text;
@@ -274,7 +348,7 @@ async function readPeerInput(stdin, source, body) {
274
348
  const buffer = Buffer.from(chunk);
275
349
  length += buffer.length;
276
350
  if (length > GATEWAY_CONTROL_MAX_MESSAGE_BYTES + 38)
277
- throw new CliFault("MESSAGE_TOO_LARGE", false, "hint.messageTooLarge");
351
+ throw new CliFault("MESSAGE_TOO_LARGE", false, "messageTooLarge");
278
352
  chunks.push(buffer);
279
353
  }
280
354
  const value = Buffer.concat(chunks, length), newline = value.indexOf(0x0a);
@@ -295,89 +369,58 @@ async function readPeerInput(stdin, source, body) {
295
369
  return { token, text };
296
370
  }
297
371
  const emptyParams = (args) => args.length === 0 ? {} : fault();
298
- function parseServeInboundMode(args) {
299
- const options = parseOptions(args, ["inbound"]);
300
- if (Object.keys(options).length === 0)
301
- return "paired";
302
- count(options, 1);
303
- if (options.inbound !== "open")
304
- fault();
305
- return "open";
306
- }
307
- function parseLiveDashboardPort(value) {
308
- if (value === undefined)
309
- return DEFAULT_LIVE_DASHBOARD_PORT;
310
- if (typeof value !== "string" || !/^[0-9]+$/.test(value))
311
- fault();
312
- const port = Number(value);
313
- if (!Number.isSafeInteger(port) || port < 1_024 || port > 65_535)
314
- fault();
315
- return port;
316
- }
317
- function parseLiveDashboardArgs(args) {
318
- if (args.length === 0)
319
- throw new CliFault("INVALID_ARGUMENTS", false, "hint.dashboardLiveRequired");
320
- const options = parseOptions(args, ["port"], ["live"]);
321
- count(options, 1, 2);
322
- if (options.live !== true)
323
- fault();
324
- return parseLiveDashboardPort(options.port);
325
- }
326
372
  const envelope = (method, params) => ({ protocolVersion: GATEWAY_CONTROL_PROTOCOL_VERSION, method, params });
327
- async function buildRequest(command, args, env, stdin) {
373
+ async function buildRequest(command, args, env, stdin, loadLocalHost) {
328
374
  const simple = {
329
- health: "health", status: "list_snapshot", doctor: "list_snapshot",
330
- "refresh-dashboard": "refresh_dashboard",
375
+ health: "health", refresh: "refresh_discovery",
331
376
  };
332
377
  const simpleMethod = simple[command];
333
378
  if (simpleMethod !== undefined)
334
379
  return envelope(simpleMethod, emptyParams(args));
335
380
  switch (command) {
336
381
  case "serve":
337
- case "dashboard":
338
- case "convert-state-v2-to-v3":
382
+ case "service":
339
383
  case "peer-stdio":
384
+ case "status":
385
+ case "watch":
386
+ case "check":
340
387
  return fault();
341
388
  case "health":
342
- case "status":
343
- case "doctor":
344
- case "refresh-dashboard": return fault();
389
+ case "refresh": return fault();
345
390
  case "delivery-status":
346
391
  case "wait-delivery": {
347
392
  const options = parseOptions(args, ["token"]);
348
393
  count(options, 1);
349
394
  return envelope("delivery_status", { token: requireDeliveryToken(options, "token") });
350
395
  }
351
- case "untrack": {
352
- const options = parseOptions(args, ["conversation"]);
353
- count(options, 1);
354
- const conversationId = requireString(options, "conversation");
355
- if (!isGatewayConversationId(conversationId))
356
- fault();
357
- return envelope("untrack", { conversationId });
358
- }
359
396
  case "register-codex": {
360
- const options = parseOptions(args, ["alias", "host", "succeeds"]);
397
+ const options = parseOptions(args, ["alias", "succeeds"]);
361
398
  const alias = requireCodexAlias(options, "alias");
362
399
  const succeedsAlias = options.succeeds === undefined ? undefined : requireCodexAlias(options, "succeeds");
363
400
  count(options, succeedsAlias === undefined ? 1 : 2, 2);
364
- if (succeedsAlias !== undefined && options.host !== undefined)
365
- fault();
366
- const host = succeedsAlias === undefined ? (options.host ?? DEFAULT_HOST_ID) : gatewayAliasHost(alias);
367
- if (typeof host !== "string" ||
368
- !isGatewayHostId(host) ||
369
- !alias.endsWith(`@${host}`) ||
370
- (succeedsAlias !== undefined && (succeedsAlias === alias || gatewayAliasHost(succeedsAlias) !== host)))
401
+ const threadId = requireExclusiveCodexThreadId(env);
402
+ if (succeedsAlias === alias)
371
403
  fault();
404
+ const local = await loadLocalHost();
405
+ if (gatewayAliasHost(alias) !== local.host)
406
+ throw aliasHostFault(local, gatewayAliasHost(alias));
407
+ if (succeedsAlias !== undefined && gatewayAliasHost(succeedsAlias) !== local.host) {
408
+ throw aliasHostFault(local, gatewayAliasHost(succeedsAlias));
409
+ }
372
410
  return envelope("register_codex", {
373
- alias, threadId: requireExclusiveCodexThreadId(env), hostId: host, busyPolicy: "queue",
411
+ alias, threadId, hostId: local.host, busyPolicy: "queue",
374
412
  ...(succeedsAlias === undefined ? {} : { succeedsAlias }),
375
413
  });
376
414
  }
377
415
  case "unregister-codex": {
378
416
  const options = parseOptions(args, ["alias"]);
379
417
  count(options, 1);
380
- return envelope("unregister_codex", { alias: requireCodexAlias(options, "alias"), threadId: requireExclusiveCodexThreadId(env) });
418
+ const alias = requireCodexAlias(options, "alias");
419
+ const threadId = requireExclusiveCodexThreadId(env);
420
+ const local = await loadLocalHost();
421
+ if (gatewayAliasHost(alias) !== local.host)
422
+ throw aliasHostFault(local, gatewayAliasHost(alias));
423
+ return envelope("unregister_codex", { alias, threadId });
381
424
  }
382
425
  case "register-peer":
383
426
  case "unregister-peer":
@@ -391,85 +434,73 @@ async function buildRequest(command, args, env, stdin) {
391
434
  fault();
392
435
  if (options["emit-env"] === true && source !== undefined)
393
436
  fault();
437
+ const requireLocalAlias = async () => {
438
+ const local = await loadLocalHost();
439
+ if (gatewayAliasHost(alias) !== local.host)
440
+ throw aliasHostFault(local, gatewayAliasHost(alias));
441
+ };
394
442
  if (source === undefined) {
395
443
  if (command !== "register-peer")
396
444
  fault("CALLER_IDENTITY_REQUIRED");
445
+ await requireLocalAlias();
397
446
  return envelope("register_peer", { alias });
398
447
  }
399
448
  const { token } = await readPeerInput(stdin, source, false);
449
+ await requireLocalAlias();
400
450
  return envelope(command === "register-peer" ? "register_peer" : command === "unregister-peer" ? "unregister_peer" : "await_peer", { alias, token });
401
451
  }
402
- case "select-claude":
403
- case "unselect-claude": {
404
- const options = parseOptions(args, ["alias", "session"]);
405
- count(options, 1);
406
- const selector = requireClaudeSelector(options, options.alias === undefined ? "session" : "alias");
407
- const codexThreadId = optionalCodexThreadId(env);
408
- return envelope(command === "select-claude" ? "select_claude" : "unselect_claude", {
409
- alias: selector, ...(codexThreadId === undefined ? {} : { codexThreadId }),
410
- });
411
- }
412
- case "pair":
413
- case "unpair": {
414
- const options = parseOptions(args, ["claude", "codex", "from", "to"]);
415
- count(options, 2);
416
- const legacyArm = options.claude !== undefined || options.codex !== undefined;
417
- if (legacyArm) {
418
- if (options.from !== undefined || options.to !== undefined)
419
- fault();
420
- return envelope(command, {
421
- claudeAlias: requireClaudeSelector(options, "claude"), codexAlias: requireCodexAlias(options, "codex"),
422
- codexThreadId: requireExclusiveCodexThreadId(env),
423
- });
424
- }
425
- return envelope(command, { aliases: requirePairAliases(options) });
426
- }
427
- case "send-to-claude":
428
- case "send-to-codex": {
429
- const options = parseOptions(args, ["from", "to", "idle-minutes"], ["expects-reply", "track", "token-stdin"]);
430
- count(options, 2, 6);
431
- const source = peerTokenSource(options, env);
432
- const principals = Number(hasIdentity(env.CODEX_THREAD_ID)) + Number(hasIdentity(env.CLAUDE_CODE_MESSAGING_SOCKET)) + Number(source !== undefined);
433
- if (principals > 1)
434
- throw callerIdentityConflictFault(env);
435
- const fromAlias = requireAlias(options, "from");
436
- const toAlias = command === "send-to-claude" ? requireClaudeSelector(options, "to") : requireAlias(options, "to");
437
- const idleMinutes = trackIdleMinutes(options);
438
- const peer = source === undefined ? undefined : await readPeerInput(stdin, source, true);
439
- const authority = peer === undefined ? command === "send-to-claude"
440
- ? requireExclusiveCodexThreadId(env) : requireExclusiveClaudeReplyAddress(env) : undefined;
441
- const common = {
442
- fromAlias, toAlias, text: peer?.text ?? await readMessageBody(stdin),
443
- expectsReply: options["expects-reply"] === true,
444
- ...(idleMinutes === undefined ? {} : { trackIdleMinutes: idleMinutes }),
445
- };
446
- return command === "send-to-claude"
447
- ? envelope("send_to_claude", { ...common, ...(peer === undefined ? { threadId: authority } : { peerToken: peer.token }) })
448
- : envelope("send_to_codex", { ...common, ...(peer === undefined ? { replyAddress: authority } : { peerToken: peer.token }) });
449
- }
452
+ // `reply` is the deprecated spelling of `send --conversation`: it names the
453
+ // caller's own alias `--alias` instead of `--from`, and both verbs build
454
+ // the one `send` request. Keep it until the reply hints already delivered
455
+ // in older envelopes have aged out.
456
+ case "send":
450
457
  case "reply": {
451
- const options = parseOptions(args, ["conversation", "alias", "idle-minutes"], ["track", "token-stdin"]);
452
- count(options, 2, 5);
453
- const conversationId = requireString(options, "conversation");
454
- if (!isGatewayConversationId(conversationId))
458
+ const options = command === "reply"
459
+ ? parseOptions(args, ["conversation", "alias"], ["token-stdin"])
460
+ : parseOptions(args, ["from", "to", "conversation"], ["expects-reply", "token-stdin"]);
461
+ count(options, 2, command === "reply" ? 3 : 4);
462
+ const fromAlias = requireAlias(options, command === "reply" ? "alias" : "from");
463
+ const conversationId = options.conversation === undefined
464
+ ? undefined : requireConversationId(options, "conversation");
465
+ const toAlias = options.to === undefined
466
+ ? undefined : requireClaudeSelector(options, "to");
467
+ // One target, and a conversation is always answered expecting a reply.
468
+ if ((toAlias === undefined) === (conversationId === undefined))
469
+ fault();
470
+ if (conversationId !== undefined && options["expects-reply"] === true)
455
471
  fault();
456
- const alias = requireAlias(options, "alias");
457
- const idleMinutes = trackIdleMinutes(options);
458
- const threadId = env.CODEX_THREAD_ID, source = peerTokenSource(options, env);
459
- const principals = Number(hasIdentity(threadId)) + Number(hasIdentity(env.CLAUDE_CODE_MESSAGING_SOCKET)) + Number(source !== undefined);
472
+ const source = peerTokenSource(options, env);
473
+ const principals = Number(hasIdentity(env.CODEX_THREAD_ID)) + Number(hasIdentity(env.CLAUDE_CODE_MESSAGING_SOCKET)) + Number(source !== undefined);
460
474
  if (principals > 1)
461
475
  throw callerIdentityConflictFault(env);
462
- const replyAddress = optionalClaudeReplyAddress(env);
463
- const codex = hasIdentity(threadId);
464
- if (!codex && replyAddress === undefined && source === undefined)
465
- fault("CALLER_IDENTITY_REQUIRED");
476
+ // A conversation names no route to infer a provider from, so a caller
477
+ // with nothing inherited gets the provider-neutral code and every
478
+ // credential it could present; a route send keeps its Claude-shaped
479
+ // default. A peer credential authenticates a peer-* alias and nothing
480
+ // else, in either addressing form.
481
+ if (principals === 0) {
482
+ if (conversationId !== undefined)
483
+ throw new CliFault("CALLER_IDENTITY_REQUIRED", false, "callerIdentityRequired");
484
+ // A route send names its sender's shape, so the hint can name the
485
+ // one credential that shape needs; only a Claude-shaped alias keeps
486
+ // the Claude-specific code.
487
+ if (fromAlias.startsWith("codex-"))
488
+ throw new CliFault("CALLER_IDENTITY_REQUIRED", false, "codexCallerRequired", undefined, { alias: fromAlias });
489
+ if (fromAlias.startsWith("peer-"))
490
+ throw new CliFault("CALLER_IDENTITY_REQUIRED", false, "peerCallerRequired", undefined, { alias: fromAlias });
491
+ }
492
+ if (source !== undefined && !fromAlias.startsWith("peer-"))
493
+ throw new CliFault("INVALID_ARGUMENTS", false, "peerAliasRequired");
466
494
  const peer = source === undefined ? undefined : await readPeerInput(stdin, source, true);
467
- return envelope("reply", {
468
- conversationId, text: peer?.text ?? await readMessageBody(stdin),
469
- ...(idleMinutes === undefined ? {} : { trackIdleMinutes: idleMinutes }),
470
- caller: peer !== undefined ? { kind: "peer", alias, token: peer.token } : codex
471
- ? { kind: "codex", alias, threadId: requireCodexThreadId(env) }
472
- : { kind: "claude", alias, replyAddress: requireClaudeReplyAddress(env) },
495
+ const authority = peer === undefined ? hasIdentity(env.CODEX_THREAD_ID)
496
+ ? { threadId: requireExclusiveCodexThreadId(env) }
497
+ : { replyAddress: requireExclusiveClaudeReplyAddress(env) }
498
+ : { peerToken: peer.token };
499
+ const target = toAlias === undefined
500
+ ? { conversationId: conversationId }
501
+ : { toAlias, expectsReply: options["expects-reply"] === true };
502
+ return envelope("send", {
503
+ fromAlias, text: peer?.text ?? await readMessageBody(stdin), ...target, ...authority,
473
504
  });
474
505
  }
475
506
  }
@@ -481,7 +512,11 @@ export async function validatePrivateGatewayControlSocket(stateDir, socketPath)
481
512
  try {
482
513
  [state, socket] = await Promise.all([lstat(stateDir), lstat(socketPath)]);
483
514
  }
484
- catch {
515
+ catch (error) {
516
+ const code = error !== null && typeof error === "object" && "code" in error
517
+ ? error.code : undefined;
518
+ if (code === "EPERM" || code === "EACCES")
519
+ throw new CliFault("CONTROL_CONNECT_DENIED", true, "controlConnectDenied");
485
520
  throw new CliFault("CONTROL_SOCKET_UNAVAILABLE", true);
486
521
  }
487
522
  const uid = process.getuid?.();
@@ -522,41 +557,114 @@ async function writeComplete(output, frame) {
522
557
  }
523
558
  });
524
559
  }
525
- function writeFailure(stdout, stderr, locale, command, code, options) {
560
+ function writeFailure(stdout, stderr, command, code, options) {
526
561
  stdout.write(serializedOutput({ ok: false, command: command ?? "unknown", error: {
527
562
  code, ambiguous: options.ambiguous ?? false, retryable: options.retryable ?? false,
563
+ ...(options.detail ?? {}),
528
564
  } }));
529
- stderr.write(fixedStderr(locale, options.kind));
565
+ stderr.write(fixedStderr(options.kind));
566
+ }
567
+ function writeStateResetHint(stderr, code) {
568
+ if (code === "GATEWAY_STATE_SCHEMA_UNSUPPORTED" || code === "CORRUPT_GATEWAY_STATE") {
569
+ stderr.write(`[embassy] ${CLI_HINT.stateResetRequired}\n`);
570
+ }
571
+ }
572
+ /**
573
+ * A BridgeError's own message never reaches a terminal — stderr carries only
574
+ * fixed lines and these hints, so nothing private can escape through a
575
+ * message. Any remedy an operator must actually read therefore lives here,
576
+ * keyed by code and interpolating only the resolved state directory and the
577
+ * bounded values the error carried in `detail`.
578
+ */
579
+ const BRIDGE_ERROR_HINTS = {
580
+ GATEWAY_STATE_IN_USE: "stateInUse",
581
+ GATEWAY_STATE_LOCK_UNVERIFIED: "stateLockUnverified",
582
+ GATEWAY_NODE_INVENTORY_CHANGED: "nodeInventoryChanged",
583
+ GATEWAY_STATE_WRITE_FAILED: "stateWriteFailed",
584
+ };
585
+ /** The state directory a hint should name, resolved the same way every command resolves it. */
586
+ function hintStateDir(env) {
587
+ try {
588
+ return path.resolve(defaultGatewayStateDir(env));
589
+ }
590
+ catch {
591
+ return "the Embassy state directory";
592
+ }
593
+ }
594
+ function writeBridgeErrorHint(stderr, error, env) {
595
+ const hint = BRIDGE_ERROR_HINTS[error.code];
596
+ if (hint === undefined)
597
+ return;
598
+ const detail = error.detail;
599
+ // Two codes render differently depending on what the error could establish:
600
+ // a lock whose recorded machine name is unrepresentable names no host at
601
+ // all, and a write that reached the file but not the directory entry says so.
602
+ const named = hint === "stateInUse" && (detail?.host === undefined || detail.pid === undefined)
603
+ ? "stateInUseUnrecorded"
604
+ : hint === "stateWriteFailed" && detail?.stage === "sync" ? "stateSyncFailed" : hint;
605
+ stderr.write(`[embassy] ${renderHint(named, {
606
+ stateDir: hintStateDir(env),
607
+ ...(detail?.host === undefined ? {} : { host: detail.host }),
608
+ ...(detail?.pid === undefined ? {} : { pid: detail.pid }),
609
+ })}\n`);
530
610
  }
531
611
  function isRejectedResult(result) {
532
612
  return result !== null && typeof result === "object" && result.accepted === false;
533
613
  }
534
- function isProgressWatchOwnerConflict(result) {
535
- return isRejectedResult(result) && result.code === "watch_owner_conflict";
614
+ function refusalHint(request, result) {
615
+ if (!isRejectedResult(result))
616
+ return undefined;
617
+ const reason = result.reason;
618
+ const from = request.method === "send" ? request.params.fromAlias : undefined;
619
+ if (reason === "PEER_ALIAS_COLLISION")
620
+ return { hint: "aliasCollision" };
621
+ if (typeof reason === "string" && reason.startsWith("CLAUDE_PEER_WORKSPACE_"))
622
+ return { hint: "workspaceOverlap" };
623
+ // A Codex task with no registration under its alias — every task, after a
624
+ // private state reset — is told to register; a registration held by another
625
+ // task is a genuine mismatch with its own remedy; a conversation answered
626
+ // from the wrong end gets the own-alias remedy.
627
+ if (reason === "ROUTE_UNREGISTERED" && from !== undefined && from.startsWith("codex-")) {
628
+ return { hint: "codexRouteUnregistered", vars: { alias: from } };
629
+ }
630
+ if (reason === "CODEX_THREAD_MISMATCH" && from !== undefined)
631
+ return { hint: "codexThreadMismatch", vars: { alias: from } };
632
+ if (reason === "CLAUDE_ROUTE_MISMATCH" || reason === "CONVERSATION_CALLER_MISMATCH")
633
+ return { hint: "callerAliasMismatch" };
634
+ if (reason === "CLAUDE_TARGET_CHANGED")
635
+ return { hint: "targetChanged" };
636
+ if (result.code !== "not_found" || request.method !== "send")
637
+ return undefined;
638
+ const target = request.params.toAlias;
639
+ if (target === undefined)
640
+ return undefined;
641
+ return target.startsWith("codex-") || target.startsWith("peer-") ? undefined : { hint: "unknownTarget" };
642
+ }
643
+ /** UNKNOWN_METHOD is build skew, not an argument error: say which side to move. */
644
+ function writeUnknownMethodHint(stderr, code) {
645
+ if (code === "UNKNOWN_METHOD")
646
+ stderr.write(`[embassy] ${CLI_HINT.unknownMethod}\n`);
536
647
  }
537
648
  function responseExitCode(response) {
538
649
  return !response.ok ? gatewayCliExitCodes.failure
539
650
  : isRejectedResult(response.result) ? gatewayCliExitCodes.rejected : gatewayCliExitCodes.ok;
540
651
  }
541
- function codexDoctorConditions(result) {
542
- const connectors = result !== null && typeof result === "object"
543
- ? result.connectors : undefined;
544
- const connector = Array.isArray(connectors) ? connectors.find((row) => row !== null && typeof row === "object" && row.provider === "codex") : undefined;
545
- const doctor = connector !== null && typeof connector === "object"
546
- ? connector.codexDoctor : undefined;
547
- const conditions = doctor !== null && typeof doctor === "object"
548
- ? doctor.conditions : undefined;
549
- return Array.isArray(conditions)
550
- ? conditions.filter((value) => typeof value === "string") : ["unknown"];
551
- }
552
652
  function waitDeliveryExitCode(response) {
553
653
  return response.ok && response.result.found && response.result.terminal && response.result.state === "delivered"
554
654
  ? gatewayCliExitCodes.ok : gatewayCliExitCodes.failure;
555
655
  }
556
656
  const defaultDelay = async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds));
557
- async function waitForDelivery(socketPath, request, sendRequest, now, delay) {
558
- let deadline;
657
+ async function waitForDelivery(socketPath, request, sendRequest, now, delay,
658
+ // `check` bounds this wait by its own budget; `wait-delivery` keeps waiting
659
+ // to the broker's delivery deadline, which is hours by default.
660
+ maximumWaitMs,
661
+ // `check` also passes its Ctrl-C signal, so an operator who gives up during
662
+ // this wait reaches the cleanup hop within one poll instead of one budget.
663
+ signal) {
664
+ let deadline = maximumWaitMs === undefined ? undefined : now() + maximumWaitMs;
559
665
  while (true) {
666
+ if (signal?.aborted === true)
667
+ return { kind: "interrupted" };
560
668
  const remaining = deadline === undefined ? undefined : deadline - now();
561
669
  if (remaining !== undefined && remaining <= 0)
562
670
  return { kind: "timeout" };
@@ -596,6 +704,343 @@ async function waitForDelivery(socketPath, request, sendRequest, now, delay) {
596
704
  await delay(Math.min(DELIVERY_POLL_INTERVAL_MS, after));
597
705
  }
598
706
  }
707
+ const SERVICE_HEALTH_DEADLINE_MS = 10_000;
708
+ const SERVICE_HEALTH_POLL_INTERVAL_MS = 200;
709
+ /**
710
+ * Per-attempt cap. Without one, each attempt inherits the 3-second control
711
+ * timeout and a stalled socket stretches the "10 s" window past two minutes.
712
+ */
713
+ const SERVICE_HEALTH_REQUEST_TIMEOUT_MS = 1_000;
714
+ /**
715
+ * control.ts rejects any timeout below 50 ms with CONTROL_INVALID_RESPONSE.
716
+ * An attempt squeezed into the tail of the window would therefore fabricate a
717
+ * fault and become the "last observed" code, so the poll treats less than
718
+ * this much remaining as the deadline already reached.
719
+ */
720
+ const SERVICE_HEALTH_MIN_REQUEST_TIMEOUT_MS = 50;
721
+ /** A second, independent bound: a clock that never advances cannot loop forever. */
722
+ const SERVICE_HEALTH_MAX_ATTEMPTS = SERVICE_HEALTH_DEADLINE_MS / SERVICE_HEALTH_POLL_INTERVAL_MS;
723
+ /** Elapsed time is measured monotonically; a wall-clock step must not move it. */
724
+ const monotonicNow = () => performance.now();
725
+ /**
726
+ * Codes that answer the question rather than postpone it. Polling still runs
727
+ * to the deadline — a mode or ownership check can be momentarily unlucky
728
+ * while the broker is publishing its socket — but if the *last* thing
729
+ * observed was one of these refusals rather than silence, install reports it
730
+ * with that code's own class and points at `embassy health`, the command that
731
+ * explains it, instead of a retryable timeout.
732
+ */
733
+ const SERVICE_HEALTH_DECISIVE = new Map([
734
+ ["CONTROL_STATE_UNSAFE", { kind: "unsafe", retryable: false, exitCode: gatewayCliExitCodes.invalidInput }],
735
+ ["CONTROL_SOCKET_UNSAFE", { kind: "unsafe", retryable: false, exitCode: gatewayCliExitCodes.invalidInput }],
736
+ ["CONTROL_CONNECT_DENIED", { kind: "unavailable", hint: "controlConnectDenied", retryable: true, exitCode: gatewayCliExitCodes.unavailable }],
737
+ ["CONTROL_VERSION_MISMATCH", { kind: "unavailable", hint: "controlVersionMismatch", retryable: true, exitCode: gatewayCliExitCodes.unavailable }],
738
+ ]);
739
+ /**
740
+ * The install command's own probe, bounded by wall clock rather than by an
741
+ * attempt count: a freshly bootstrapped launchd agent has to load its state
742
+ * and publish a control socket, which on a cold cache is seconds. This never
743
+ * throws, but silence at the deadline is not success — install reports the
744
+ * last code it observed and exits non-zero, because an agent that never
745
+ * answered is exactly the case the operator has to hear about.
746
+ */
747
+ async function pollServiceHealth(config, sendRequest, validateSocket, delay, now) {
748
+ const started = now();
749
+ const elapsed = () => Math.max(0, now() - started);
750
+ const remainingMs = () => SERVICE_HEALTH_DEADLINE_MS - elapsed();
751
+ let lastObserved = "SERVICE_HEALTH_NO_RESPONSE";
752
+ for (let attempt = 0; attempt < SERVICE_HEALTH_MAX_ATTEMPTS; attempt += 1) {
753
+ const remaining = remainingMs();
754
+ if (remaining < SERVICE_HEALTH_MIN_REQUEST_TIMEOUT_MS)
755
+ break;
756
+ try {
757
+ await validateSocket(config.stateDir, config.controlSocketPath);
758
+ const response = await sendRequest({
759
+ socketPath: config.controlSocketPath,
760
+ request: envelope("health", {}),
761
+ timeoutMs: Math.floor(Math.min(remaining, SERVICE_HEALTH_REQUEST_TIMEOUT_MS)),
762
+ });
763
+ if (response.ok)
764
+ return { ok: true, result: response.result, elapsedMs: elapsed() };
765
+ lastObserved = response.error.code;
766
+ }
767
+ catch (error) {
768
+ lastObserved = error instanceof GatewayControlTransportError ? error.code
769
+ : error instanceof CliFault ? error.code
770
+ : error instanceof BridgeError ? error.code
771
+ : "SERVICE_HEALTH_NO_RESPONSE";
772
+ }
773
+ const left = remainingMs();
774
+ if (left < SERVICE_HEALTH_MIN_REQUEST_TIMEOUT_MS)
775
+ break;
776
+ await delay(Math.min(SERVICE_HEALTH_POLL_INTERVAL_MS, left));
777
+ }
778
+ return { ok: false, lastObserved, elapsedMs: elapsed() };
779
+ }
780
+ const WATCH_POLL_INTERVAL_MS = 1_000;
781
+ const CHECK_TIMEOUT_DEFAULT_SECONDS = 60;
782
+ const CHECK_TIMEOUT_MAX_SECONDS = 600;
783
+ /** control.ts refuses a request timeout below this, so it is also the floor here. */
784
+ const CONTROL_MIN_REQUEST_TIMEOUT_MS = 50;
785
+ /**
786
+ * The unit the attempt bound is counted in while `check` awaits a reply: one
787
+ * attempt per second of budget, plus one. A broker answering `timeout`
788
+ * instantly therefore cannot spin the loop even if the clock never moves.
789
+ */
790
+ const CHECK_REPLY_ATTEMPT_MS = 1_000;
791
+ const MAX_CONTROLLER_LOCK_BYTES = 4_096;
792
+ /**
793
+ * Whether stdout is a terminal. Reading the stream's own `isTTY` rather than
794
+ * `process.stdout` keeps this injectable: a test writes to a capture that
795
+ * declares `isTTY`, and nothing consults the real process.
796
+ */
797
+ const isTerminal = (output) => output.isTTY === true;
798
+ /** Color is opt-out through NO_COLOR, and never carries meaning by itself. */
799
+ const useColor = (output, env) => isTerminal(output) && (env.NO_COLOR === undefined || env.NO_COLOR.length === 0);
800
+ /** A bounded integer option; anything outside the range is an argument fault. */
801
+ function boundedOption(options, name, fallback, minimum, maximum) {
802
+ const raw = options[name];
803
+ if (raw === undefined)
804
+ return fallback;
805
+ if (typeof raw !== "string" || !/^[0-9]{1,9}$/.test(raw))
806
+ fault();
807
+ const value = Number(raw);
808
+ if (value < minimum || value > maximum)
809
+ fault();
810
+ return value;
811
+ }
812
+ /**
813
+ * The broker's pid, from the controller lock its store writes in the state
814
+ * directory. Best effort and never throws: `status` prints with or without it,
815
+ * and that file's path is already named in the CLI's own hints, so this
816
+ * discloses nothing new.
817
+ */
818
+ export async function readGatewayControllerPid(stateDir) {
819
+ return recordedPid(await readOwnedSmallFile(path.join(stateDir, GATEWAY_CONTROLLER_LOCK_FILE), process.getuid?.(), MAX_CONTROLLER_LOCK_BYTES));
820
+ }
821
+ /**
822
+ * `watch` and `check` both run until the operator stops them, and `check` has
823
+ * a temporary registration to take back down when that happens. Tests inject
824
+ * the signal; a real terminal gets SIGINT/SIGTERM handlers that are removed
825
+ * again on the way out, so Ctrl-C runs the cleanup path instead of killing
826
+ * node with 130 and leaving the registration to its own expiry.
827
+ */
828
+ function interruptSignal(signal) {
829
+ if (signal !== undefined)
830
+ return { signal, dispose: () => undefined };
831
+ const controller = new AbortController();
832
+ const stop = () => controller.abort();
833
+ process.on("SIGINT", stop);
834
+ process.on("SIGTERM", stop);
835
+ return { signal: controller.signal, dispose: () => {
836
+ process.off("SIGINT", stop);
837
+ process.off("SIGTERM", stop);
838
+ } };
839
+ }
840
+ /**
841
+ * How long the throwaway registration is allowed to outlive the check: the
842
+ * whole budget plus a minute, so a broker that is merely slow still finds a
843
+ * live mailbox, and an operator who kills the process at the worst possible
844
+ * moment waits at most that long for the broker to reclaim it.
845
+ */
846
+ const CHECK_IDENTITY_GRACE_MS = 60_000;
847
+ /**
848
+ * Set when a real Ctrl-C interrupted `check`. The abandoned `await_peer`
849
+ * request may still hold its socket — and with it the event loop — until the
850
+ * broker answers it. The operator already has the summary, so the entry point
851
+ * exits once stdio has drained instead of lingering on that socket.
852
+ */
853
+ let interruptedCheckExit = false;
854
+ /**
855
+ * The round-trip self-test. It mints its own principal — a throwaway `peer-*`
856
+ * registration — rather than borrowing an inherited Codex or Claude identity,
857
+ * because the operator running it after a Claude Code or Codex upgrade is
858
+ * usually in a plain terminal with neither. That also gives the reply
859
+ * somewhere to arrive: the peer mailbox this process is the only waiter on.
860
+ * The conversation token returned by the send is the correlation seam; the
861
+ * echoed id is confirmation printed beside it, never a second identity.
862
+ */
863
+ async function runGatewayCheck(options) {
864
+ const { sendRequest, socketPath, stdout, stderr } = options;
865
+ const paint = terminalPainter(options.color);
866
+ const call = async (method, params, timeoutMs) => {
867
+ const response = await sendRequest({
868
+ socketPath, request: envelope(method, params),
869
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
870
+ });
871
+ if (!response.ok)
872
+ writeUnknownMethodHint(stderr, response.error.code);
873
+ return response;
874
+ };
875
+ const hops = [];
876
+ const emit = (hop) => {
877
+ hops.push(hop);
878
+ const mark = hop.ok ? paint("ok ", "green") : paint("FAIL", "red");
879
+ const timing = hop.elapsedMs === undefined ? "" : ` ${String(hop.elapsedMs)} ms`;
880
+ stdout.write(` ${mark} ${hop.name.padEnd(9)} ${hop.detail}${timing}\n`);
881
+ return hop;
882
+ };
883
+ const alias = `peer-check-${randomBytes(4).toString("hex")}@${options.hostId}`;
884
+ const id = randomBytes(4).toString("hex");
885
+ stdout.write(`embassy check ${id} → ${options.target}\n\n`);
886
+ let stage = options.now();
887
+ const lap = () => {
888
+ const at = options.now();
889
+ const elapsed = Math.max(0, Math.round(at - stage));
890
+ stage = at;
891
+ return elapsed;
892
+ };
893
+ const ttlMs = options.timeoutMs + CHECK_IDENTITY_GRACE_MS;
894
+ // Ctrl-C. The signal is raced against the two long waits below, so the
895
+ // cleanup hop runs the moment the operator gives up, not when the broker's
896
+ // long-poll happens to return.
897
+ let interrupted = false;
898
+ const interruption = new Promise((resolve) => {
899
+ if (options.interrupt.aborted) {
900
+ resolve("interrupted");
901
+ return;
902
+ }
903
+ options.interrupt.addEventListener("abort", () => resolve("interrupted"), { once: true });
904
+ });
905
+ const summarize = (exitCode) => {
906
+ const failed = hops.find((hop) => !hop.ok && hop.name !== "cleanup");
907
+ const cleanup = hops.find((hop) => !hop.ok && hop.name === "cleanup");
908
+ stdout.write(`\n${failed !== undefined
909
+ ? `check ${interrupted ? "interrupted" : "failed"} at the ${failed.name} hop`
910
+ : cleanup === undefined ? "check passed"
911
+ : `check passed; cleanup failed (${cleanup.code ?? "no safe code"})`}\n`);
912
+ return failed === undefined && cleanup !== undefined ? gatewayCliExitCodes.failure : exitCode;
913
+ };
914
+ const registered = await call("register_peer", { alias, ephemeral: true, ttlMs });
915
+ if (!registered.ok || !("token" in registered.result)) {
916
+ const refusal = !registered.ok ? registered.error.code
917
+ : `the broker refused a temporary check identity (${registered.result.code}${"reason" in registered.result && registered.result.reason !== undefined
918
+ ? ` ${registered.result.reason}` : ""})`;
919
+ emit({ ok: false, name: "register", detail: refusal, elapsedMs: lap() });
920
+ return summarize(gatewayCliExitCodes.failure);
921
+ }
922
+ const token = registered.result.token;
923
+ emit({ ok: true, name: "register", detail: `${alias} (ephemeral, ${String(Math.round(ttlMs / 60_000))} min)`, elapsedMs: lap() });
924
+ // Every later hop runs inside this closure so that one `finally` releases
925
+ // the temporary identity and the summary line is printed exactly once,
926
+ // whichever hop gave up first.
927
+ const roundTrip = async () => {
928
+ // The body names no reply verb: the broker's own reply hint, delivered
929
+ // inside the provenance envelope, always carries the current one.
930
+ const text = `[embassy check ${id}] Embassy round-trip self-test. Reply with one line repeating ${id}, using the command in this message's reply hint.`;
931
+ const sent = await call("send", {
932
+ fromAlias: alias, toAlias: options.target, text, expectsReply: true, peerToken: token
933
+ });
934
+ if (!sent.ok) {
935
+ emit({ ok: false, name: "send", detail: sent.error.code, elapsedMs: lap() });
936
+ return gatewayCliExitCodes.failure;
937
+ }
938
+ if (!sent.result.accepted) {
939
+ const { code, reason } = sent.result;
940
+ emit({ ok: false, name: "send",
941
+ detail: `${code}${reason === undefined ? "" : ` ${reason}`}`, elapsedMs: lap() });
942
+ return gatewayCliExitCodes.rejected;
943
+ }
944
+ const { conversationId, deliveryToken } = sent.result;
945
+ emit({ ok: true, name: "send", detail: `accepted, conversation …${conversationId.slice(-8)}`, elapsedMs: lap() });
946
+ const delivery = await waitForDelivery(socketPath, envelope("delivery_status", { token: deliveryToken }), sendRequest, options.now, options.delay, options.timeoutMs, options.interrupt);
947
+ if (delivery.kind === "interrupted") {
948
+ interrupted = true;
949
+ emit({ ok: false, name: "delivered", detail: "interrupted before it settled", elapsedMs: lap() });
950
+ return gatewayCliExitCodes.failure;
951
+ }
952
+ if (delivery.kind !== "response" || !delivery.response.ok || !delivery.response.result.found ||
953
+ delivery.response.result.state !== "delivered") {
954
+ const detail = delivery.kind === "timeout"
955
+ ? `still unsettled after ${String(Math.round(options.timeoutMs / 1_000))} s`
956
+ : delivery.kind === "unknown" ? "the delivery token left bounded retention"
957
+ : !delivery.response.ok ? delivery.response.error.code
958
+ : delivery.response.result.found
959
+ ? `${delivery.response.result.state}${delivery.response.result.safeErrorCode === undefined
960
+ ? "" : ` ${delivery.response.result.safeErrorCode}`}`
961
+ : "the delivery token left bounded retention";
962
+ emit({ ok: false, name: "delivered", detail, elapsedMs: lap() });
963
+ return gatewayCliExitCodes.failure;
964
+ }
965
+ emit({ ok: true, name: "delivered", detail: "the peer's transport accepted it", elapsedMs: lap() });
966
+ // Two independent bounds, as everywhere else that polls here: the wall
967
+ // clock, and an attempt count, so a broker that answers "timeout"
968
+ // instantly cannot spin this loop forever.
969
+ const replyDeadline = options.now() + options.timeoutMs;
970
+ const attempts = Math.ceil(options.timeoutMs / CHECK_REPLY_ATTEMPT_MS) + 1;
971
+ let answered = false;
972
+ let uncorrelated = 0;
973
+ for (let attempt = 0; attempt < attempts && !answered; attempt += 1) {
974
+ const remaining = replyDeadline - options.now();
975
+ if (remaining < CONTROL_MIN_REQUEST_TIMEOUT_MS)
976
+ break;
977
+ // The long-poll is raced against Ctrl-C rather than merely checked
978
+ // between polls: the broker holds `await_peer` open for up to 35 s, and
979
+ // the cleanup below must not wait for that. The abandoned request ends
980
+ // on its own once the cleanup retires the mailbox it was waiting on.
981
+ const waited = options.interrupt.aborted ? "interrupted" : await Promise.race([
982
+ call("await_peer", { alias, token }, Math.min(PEER_AWAIT_REQUEST_TIMEOUT_MS, Math.floor(remaining))),
983
+ interruption,
984
+ ]);
985
+ if (waited === "interrupted") {
986
+ interrupted = true;
987
+ emit({ ok: false, name: "reply", detail: "interrupted before a reply arrived", elapsedMs: lap() });
988
+ return gatewayCliExitCodes.failure;
989
+ }
990
+ if (!waited.ok) {
991
+ if (waited.error.code === "REQUEST_TIMEOUT")
992
+ continue;
993
+ emit({ ok: false, name: "reply", detail: waited.error.code, elapsedMs: lap() });
994
+ return gatewayCliExitCodes.failure;
995
+ }
996
+ if (waited.result.state === "timeout")
997
+ continue;
998
+ // The receipt is part of the round trip: a message the broker would not
999
+ // let this mailbox acknowledge is a failed reply hop, not a passed one.
1000
+ const receipt = await call("peer_receipt", { alias, token, receipt: waited.result.receipt });
1001
+ if (!receipt.ok || !receipt.result.accepted) {
1002
+ emit({ ok: false, name: "reply", detail: !receipt.ok ? receipt.error.code
1003
+ : `receipt refused (${receipt.result.code}${"reason" in receipt.result && receipt.result.reason !== undefined
1004
+ ? ` ${String(receipt.result.reason)}` : ""})`, elapsedMs: lap() });
1005
+ return gatewayCliExitCodes.failure;
1006
+ }
1007
+ const frame = JSON.parse(waited.result.frame);
1008
+ if (frame.result.conversationId !== conversationId) {
1009
+ // Consumed, because this mailbox has exactly one waiter and the
1010
+ // message would otherwise sit unacknowledged — but never silently:
1011
+ // an answer that opened its own conversation is the single most
1012
+ // likely way a peer's reply rule has drifted.
1013
+ uncorrelated += 1;
1014
+ stdout.write(paint(` note received an uncorrelated message from ${frame.result.fromAlias} (new conversation) — replies must use \`embassy send --conversation <token>\`\n`, "yellow"));
1015
+ continue;
1016
+ }
1017
+ emit({ ok: true, name: "reply", detail: `${frame.result.fromAlias}${frame.result.text.includes(id) ? ` echoed ${id}` : ` answered without repeating ${id}`}`,
1018
+ elapsedMs: lap() });
1019
+ answered = true;
1020
+ }
1021
+ if (!answered) {
1022
+ emit({ ok: false, name: "reply", detail: `no reply within ${String(Math.round(options.timeoutMs / 1_000))} s — the peer received the message but did not answer${uncorrelated === 0 ? "" : ` (${String(uncorrelated)} uncorrelated message(s) received)`}`,
1023
+ elapsedMs: lap() });
1024
+ return gatewayCliExitCodes.failure;
1025
+ }
1026
+ return gatewayCliExitCodes.ok;
1027
+ };
1028
+ let exitCode;
1029
+ try {
1030
+ exitCode = await roundTrip();
1031
+ }
1032
+ finally {
1033
+ const released = await call("unregister_peer", { alias, token }).catch(() => undefined);
1034
+ const failure = released === undefined ? "CONTROL_REQUEST_FAILED"
1035
+ : !released.ok ? released.error.code
1036
+ : released.result.accepted ? undefined : released.result.code;
1037
+ emit({ ok: failure === undefined, name: "cleanup",
1038
+ ...(failure === undefined ? {} : { code: failure }),
1039
+ detail: failure === undefined ? "temporary check identity removed"
1040
+ : `the temporary check identity could not be removed; it expires on its own within ${String(Math.round(ttlMs / 60_000))} min` });
1041
+ }
1042
+ return summarize(exitCode);
1043
+ }
599
1044
  /** Run one command; foreground runners own and release their signal handlers. */
600
1045
  export async function runGatewayCli(argv = process.argv.slice(2), dependencies = {}) {
601
1046
  const env = dependencies.env ?? process.env;
@@ -608,32 +1053,33 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
608
1053
  return gatewayCliExitCodes.ok;
609
1054
  }
610
1055
  const command = isCommand(argv[0]) ? argv[0] : undefined;
611
- let locale = fallbackCliLocale(argv.slice(1), env);
612
- let serverReady = false, dashboardReady = false;
613
- let liveDashboardPort;
1056
+ const args = argv.slice(1);
1057
+ let serverReady = false;
1058
+ let identity;
1059
+ const loadIdentity = () => identity ??= (async () => {
1060
+ const inventory = await (dependencies.loadNodeInventory ?? loadGatewayNodeInventory)(path.resolve(defaultGatewayStateDir(env)));
1061
+ return { inventory, config: loadConfig(env, inventory), defaulted: isDefaultedGatewayNodeInventory(inventory) };
1062
+ })();
614
1063
  const success = (result) => {
615
1064
  stdout.write(serializedOutput({ ok: true, command: command, result }));
616
1065
  };
617
1066
  try {
618
- const common = commonOptions(argv.slice(1), env);
619
- locale = common.locale;
620
1067
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
621
- emptyParams(common.args);
622
- stdout.write(getCliCopy(locale)["help.usage"]);
1068
+ emptyParams(args);
1069
+ stdout.write(HELP_USAGE);
623
1070
  return gatewayCliExitCodes.ok;
624
1071
  }
625
1072
  if (command === undefined)
626
1073
  fault("UNKNOWN_COMMAND");
627
1074
  if (command === "peer-stdio") {
628
- emptyParams(common.args);
1075
+ emptyParams(args);
629
1076
  try {
630
- const config = loadConfig(env);
631
- const inventory = await (dependencies.loadNodeInventory ?? loadGatewayNodeInventory)(config.stateDir);
1077
+ const { config, inventory } = await loadIdentity();
632
1078
  await validateSocket(config.stateDir, config.controlSocketPath);
633
1079
  let peerHost, firstCatalog;
634
1080
  const request = async (method, params) => {
635
1081
  const response = await sendRequest({ socketPath: config.controlSocketPath,
636
- request: { protocolVersion: 1, method, params } });
1082
+ request: { protocolVersion: GATEWAY_CONTROL_PROTOCOL_VERSION, method, params } });
637
1083
  if (!response.ok)
638
1084
  throw new PeerHandlerError({ code: -32000, message: "Local broker refused peer authority" });
639
1085
  return response.result;
@@ -666,9 +1112,215 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
666
1112
  return gatewayCliExitCodes.unavailable;
667
1113
  }
668
1114
  }
1115
+ if (command === "service") {
1116
+ const subcommand = args[0];
1117
+ if (args.length !== 1)
1118
+ fault();
1119
+ if (subcommand !== "install" && subcommand !== "uninstall" && subcommand !== "status")
1120
+ fault();
1121
+ // Identity and the state directory are validated before the first
1122
+ // launchd side effect. Loading them afterwards meant a missing
1123
+ // inventory or an unusable state root exited 2 "request rejected"
1124
+ // while the agent was already bootstrapped and looping.
1125
+ const config = subcommand === "install" ? (await loadIdentity()).config : undefined;
1126
+ const serviceDeps = {
1127
+ homeDir: (dependencies.serviceHomeDir ?? (() => userInfo().homedir))(),
1128
+ runLaunchctl: dependencies.runLaunchctl ?? defaultRunLaunchctl,
1129
+ env, execPath: process.execPath, cliPath: fileURLToPath(import.meta.url),
1130
+ uid: process.getuid(),
1131
+ delay: dependencies.delay ?? defaultDelay, now: dependencies.now ?? monotonicNow,
1132
+ probeHostLease: dependencies.probeHostLease ?? defaultProbeHostLease,
1133
+ };
1134
+ try {
1135
+ if (subcommand === "install") {
1136
+ const installed = await installServiceAgent(serviceDeps);
1137
+ const health = await pollServiceHealth(config, sendRequest, validateSocket, dependencies.delay ?? defaultDelay, dependencies.now ?? monotonicNow);
1138
+ if (!health.ok) {
1139
+ // The agent stays installed either way: this is a report about
1140
+ // the broker, not an install failure, so nothing is rolled back.
1141
+ const elapsed = (health.elapsedMs / 1000).toFixed(1);
1142
+ const decisive = SERVICE_HEALTH_DECISIVE.get(health.lastObserved);
1143
+ writeFailure(stdout, stderr, command, "SERVICE_HEALTH_UNAVAILABLE", {
1144
+ retryable: decisive?.retryable ?? true, kind: decisive?.kind ?? "unavailable",
1145
+ detail: { lastObserved: health.lastObserved },
1146
+ });
1147
+ stderr.write(decisive === undefined
1148
+ ? `[embassy] Installed, but the broker did not answer within ${elapsed} s; last observed ${health.lastObserved}. Run \`embassy service status\` or \`embassy health\`; log: ${installed.logPath}.\n`
1149
+ : `[embassy] Installed, but the broker answered ${health.lastObserved} after ${elapsed} s. Run \`embassy health\` to diagnose it; log: ${installed.logPath}.\n`);
1150
+ if (decisive?.hint !== undefined)
1151
+ stderr.write(hintLine(decisive.hint, env));
1152
+ return decisive?.exitCode ?? gatewayCliExitCodes.unavailable;
1153
+ }
1154
+ success({ subcommand, ...installed, health });
1155
+ return gatewayCliExitCodes.ok;
1156
+ }
1157
+ if (subcommand === "uninstall") {
1158
+ success({ subcommand, ...(await uninstallServiceAgent(serviceDeps)) });
1159
+ return gatewayCliExitCodes.ok;
1160
+ }
1161
+ const status = await serviceAgentStatus(serviceDeps);
1162
+ success({ subcommand, ...status });
1163
+ if (status.state !== "unknown")
1164
+ return gatewayCliExitCodes.ok;
1165
+ stderr.write(fixedStderr("unavailable"));
1166
+ stderr.write(`[embassy] ${status.note}${status.launchctlStderr === undefined ? "" : ` launchctl: ${status.launchctlStderr}`}\n`);
1167
+ return gatewayCliExitCodes.unavailable;
1168
+ }
1169
+ catch (error) {
1170
+ // launchctl's own stderr and the instance lease's own message are the
1171
+ // whole value of these failures; the generic handler below discards
1172
+ // the message and reports only the code. A genuine filesystem failure
1173
+ // on this path (an unreadable plist, an undeletable one) is a real,
1174
+ // recoverable service failure, not an INTERNAL_ERROR — but only an
1175
+ // errno-shaped one. A string `code` alone would also match CliFault
1176
+ // and the lease's own spawn failures, relabelling faults that already
1177
+ // carry a truer code of their own.
1178
+ const errno = error !== null && typeof error === "object" &&
1179
+ (typeof error.errno === "number" ||
1180
+ typeof error.syscall === "string");
1181
+ if (!(error instanceof BridgeError) && !(errno && error instanceof Error))
1182
+ throw error;
1183
+ const failure = error instanceof BridgeError ? error : new BridgeError("SERVICE_AGENT_FILESYSTEM_FAILED", `The service command could not complete: ${boundedServiceDetail(error.message)}`, true);
1184
+ writeFailure(stdout, stderr, command, failure.code, {
1185
+ retryable: failure.recoverable,
1186
+ kind: failure.code === "SERVICE_AGENT_PATH_UNSAFE" ? "unsafe"
1187
+ : failure.recoverable ? "unavailable" : "input",
1188
+ });
1189
+ stderr.write(`[embassy] ${failure.message}\n`);
1190
+ return failure.recoverable ? gatewayCliExitCodes.unavailable : gatewayCliExitCodes.invalidInput;
1191
+ }
1192
+ }
1193
+ if (command === "status" || command === "watch" || command === "check") {
1194
+ // Options are parsed before anything is loaded or contacted, so an
1195
+ // unknown flag is a flat argument fault with no side effect at all.
1196
+ const options = command === "status" ? parseOptions(args, ["recent"], ["json"])
1197
+ : command === "watch" ? parseOptions(args, [], ["json"])
1198
+ : parseOptions(args, ["to", "timeout"]);
1199
+ count(options, 0, 2);
1200
+ const recent = boundedOption(options, "recent", STATUS_RECENT.default, STATUS_RECENT.minimum, STATUS_RECENT.maximum);
1201
+ const timeoutSeconds = boundedOption(options, "timeout", CHECK_TIMEOUT_DEFAULT_SECONDS, 1, CHECK_TIMEOUT_MAX_SECONDS);
1202
+ const target = options.to === undefined ? undefined : requireClaudeSelector(options, "to");
1203
+ const { config } = await loadIdentity();
1204
+ await validateSocket(config.stateDir, config.controlSocketPath);
1205
+ const ask = async (method, params) => await sendRequest({
1206
+ socketPath: config.controlSocketPath,
1207
+ request: envelope(method, params),
1208
+ });
1209
+ if (command === "status") {
1210
+ // `status` is read-only. It deliberately does NOT rescan: a rescan
1211
+ // performs the passive-discovery scan SECURITY.md reserves for an
1212
+ // explicit request, and journals a `discovery_refreshed` row into the
1213
+ // same bounded activity ring this pane exists to show — 256 status
1214
+ // calls would evict every route retirement it was meant to surface.
1215
+ // The header reports how old the scan is and offers `embassy refresh`.
1216
+ const response = await ask("list_snapshot", {});
1217
+ if (!response.ok) {
1218
+ writeFailure(stdout, stderr, command, response.error.code, { kind: "failure" });
1219
+ writeUnknownMethodHint(stderr, response.error.code);
1220
+ return gatewayCliExitCodes.failure;
1221
+ }
1222
+ if (options.json === true || !isTerminal(stdout)) {
1223
+ success(response.result);
1224
+ return gatewayCliExitCodes.ok;
1225
+ }
1226
+ const pid = await (dependencies.readControllerPid ?? readGatewayControllerPid)(config.stateDir);
1227
+ stdout.write(renderStatus(response.result, {
1228
+ stateDir: config.stateDir, version: EMBASSY_VERSION, recent,
1229
+ color: useColor(stdout, env), now: (dependencies.now ?? Date.now)(),
1230
+ ...(pid === undefined ? {} : { pid }),
1231
+ }));
1232
+ return gatewayCliExitCodes.ok;
1233
+ }
1234
+ if (command === "watch") {
1235
+ const json = options.json === true;
1236
+ const color = !json && useColor(stdout, env);
1237
+ const interrupt = interruptSignal(dependencies.watchSignal);
1238
+ const delay = dependencies.delay ?? defaultDelay;
1239
+ let state;
1240
+ let revision;
1241
+ try {
1242
+ while (!interrupt.signal.aborted) {
1243
+ const observed = await ask("observe_snapshot", {});
1244
+ if (!observed.ok) {
1245
+ writeFailure(stdout, stderr, command, observed.error.code, { kind: "failure" });
1246
+ writeUnknownMethodHint(stderr, observed.error.code);
1247
+ return gatewayCliExitCodes.failure;
1248
+ }
1249
+ if (observed.result.snapshotRevision !== revision) {
1250
+ revision = observed.result.snapshotRevision;
1251
+ const diff = diffWatch(state ?? emptyWatchState, observed.result.snapshot);
1252
+ // The first poll only establishes the baseline: replaying the
1253
+ // whole retained ledger the moment a tail starts is noise.
1254
+ if (state !== undefined) {
1255
+ for (const event of diff.events) {
1256
+ stdout.write(json ? `${JSON.stringify(event)}\n`
1257
+ : `${renderWatchEvent(event, color)}\n`);
1258
+ }
1259
+ }
1260
+ state = diff.state;
1261
+ }
1262
+ if (interrupt.signal.aborted)
1263
+ break;
1264
+ await delay(WATCH_POLL_INTERVAL_MS);
1265
+ }
1266
+ }
1267
+ finally {
1268
+ interrupt.dispose();
1269
+ }
1270
+ return gatewayCliExitCodes.ok;
1271
+ }
1272
+ const clock = dependencies.now ?? Date.now;
1273
+ let chosen = target;
1274
+ if (chosen === undefined) {
1275
+ const snapshot = await ask("list_snapshot", {});
1276
+ if (!snapshot.ok) {
1277
+ writeFailure(stdout, stderr, command, snapshot.error.code, { kind: "failure" });
1278
+ writeUnknownMethodHint(stderr, snapshot.error.code);
1279
+ return gatewayCliExitCodes.failure;
1280
+ }
1281
+ const candidates = snapshot.result.routes
1282
+ .filter((route) => route.provider === "codex" && route.enabled)
1283
+ .sort((left, right) => left.alias.localeCompare(right.alias));
1284
+ if (candidates.length === 0) {
1285
+ throw new CliFault("INVALID_ARGUMENTS", false, "checkNoTarget", undefined, { localHost: config.hostId });
1286
+ }
1287
+ // Eligibility is observation. A task nothing has observed — ever, or
1288
+ // within ten minutes — proves nothing about upstream drift; a send
1289
+ // into it just times out. The most recently observed eligible task is
1290
+ // the target, and when none qualifies every alias is named so the
1291
+ // operator can read its remedy.
1292
+ const observedAt = (route) => {
1293
+ const at = route.lastSeenAt === undefined ? Number.NaN : Date.parse(route.lastSeenAt);
1294
+ return Number.isFinite(at) ? at : undefined;
1295
+ };
1296
+ const eligible = candidates
1297
+ .map((route) => ({ route, observed: observedAt(route) }))
1298
+ .filter((row) => row.observed !== undefined && clock() - row.observed <= STATUS_ROUTE_STALE_AFTER_MS)
1299
+ .sort((left, right) => right.observed - left.observed || left.route.alias.localeCompare(right.route.alias));
1300
+ if (eligible.length === 0) {
1301
+ throw new CliFault("INVALID_ARGUMENTS", false, "checkAllStale", undefined, { aliases: candidates.map((route) => route.alias).join(", ") });
1302
+ }
1303
+ chosen = eligible[0].route.alias;
1304
+ }
1305
+ const checkInterrupt = interruptSignal(dependencies.watchSignal);
1306
+ try {
1307
+ return await runGatewayCheck({
1308
+ socketPath: config.controlSocketPath, hostId: config.hostId, target: chosen,
1309
+ timeoutMs: timeoutSeconds * 1_000, sendRequest, stdout, stderr,
1310
+ interrupt: checkInterrupt.signal, now: clock,
1311
+ delay: dependencies.delay ?? defaultDelay, color: useColor(stdout, env),
1312
+ });
1313
+ }
1314
+ finally {
1315
+ if (dependencies.watchSignal === undefined && checkInterrupt.signal.aborted)
1316
+ interruptedCheckExit = true;
1317
+ checkInterrupt.dispose();
1318
+ }
1319
+ }
669
1320
  if (command === "serve") {
1321
+ emptyParams(args);
670
1322
  await (dependencies.runServer ?? runGatewayServer)({
671
- env, locale, inboundMode: parseServeInboundMode(common.args),
1323
+ env,
672
1324
  ...(dependencies.serverSignal === undefined ? {} : { signal: dependencies.serverSignal }),
673
1325
  onReady: async (result) => {
674
1326
  if (serverReady)
@@ -681,34 +1333,11 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
681
1333
  fault("SERVER_NOT_READY");
682
1334
  return gatewayCliExitCodes.ok;
683
1335
  }
684
- if (command === "convert-state-v2-to-v3") {
685
- emptyParams(common.args);
686
- const config = loadConfig(env);
687
- const inventory = await (dependencies.loadNodeInventory ?? loadGatewayNodeInventory)(config.stateDir);
688
- const converted = await (dependencies.convertState ?? convertGatewayStateV2ToV3)({ stateDir: config.stateDir, hostId: inventory.host });
689
- success({ converted: true, backupFile: path.basename(converted.backupFile) });
690
- return gatewayCliExitCodes.ok;
691
- }
692
- if (command === "dashboard") {
693
- liveDashboardPort = parseLiveDashboardArgs(common.args);
694
- const outcome = await (dependencies.runLiveDashboard ?? runLiveDashboardCommand)({
695
- env, locale, port: liveDashboardPort, loadConfig, sendRequest, validateControlSocket: validateSocket,
696
- ...(dependencies.liveDashboardSignal === undefined ? {} : { signal: dependencies.liveDashboardSignal }),
697
- onReady: async (result) => {
698
- if (dashboardReady)
699
- fault("LIVE_DASHBOARD_READY_ALREADY_EMITTED");
700
- success(result);
701
- dashboardReady = true;
702
- },
703
- });
704
- if (!dashboardReady && outcome?.status === "cancelled")
705
- return gatewayCliExitCodes.ok;
706
- if (!dashboardReady)
707
- throw new Error("LIVE_DASHBOARD_NOT_READY");
708
- return gatewayCliExitCodes.ok;
709
- }
710
- const request = await buildRequest(command, common.args, env, stdin);
711
- const config = loadConfig(env);
1336
+ const request = await buildRequest(command, args, env, stdin, async () => {
1337
+ const { config, defaulted } = await loadIdentity();
1338
+ return { host: config.hostId, defaulted, stateDir: config.stateDir };
1339
+ });
1340
+ const { config } = await loadIdentity();
712
1341
  await validateSocket(config.stateDir, config.controlSocketPath);
713
1342
  let response;
714
1343
  let waited;
@@ -727,25 +1356,25 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
727
1356
  await writeComplete(stdout, current.result.frame);
728
1357
  }
729
1358
  catch {
730
- stderr.write(fixedStderr(locale, "failure"));
1359
+ stderr.write(fixedStderr("failure"));
731
1360
  return gatewayCliExitCodes.failure;
732
1361
  }
733
1362
  try {
734
1363
  const receipt = await sendRequest({ socketPath: config.controlSocketPath,
735
1364
  request: envelope("peer_receipt", { alias: request.params.alias, token: request.params.token, receipt: current.result.receipt }) });
736
1365
  if (!receipt.ok) {
737
- stderr.write(fixedStderr(locale, "failure"));
1366
+ stderr.write(fixedStderr("failure"));
738
1367
  return gatewayCliExitCodes.failure;
739
1368
  }
740
1369
  if (isRejectedResult(receipt.result)) {
741
- stderr.write(fixedStderr(locale, "decision"));
1370
+ stderr.write(fixedStderr("decision"));
742
1371
  return gatewayCliExitCodes.rejected;
743
1372
  }
744
1373
  return gatewayCliExitCodes.ok;
745
1374
  }
746
1375
  catch (error) {
747
1376
  const transport = error instanceof GatewayControlTransportError;
748
- stderr.write(fixedStderr(locale, transport ? error.ambiguous ? "ambiguous" : "unavailable" : "failure"));
1377
+ stderr.write(fixedStderr(transport ? error.ambiguous ? "ambiguous" : "unavailable" : "failure"));
749
1378
  return transport ? error.ambiguous ? gatewayCliExitCodes.ambiguous : gatewayCliExitCodes.unavailable : gatewayCliExitCodes.failure;
750
1379
  }
751
1380
  }
@@ -755,11 +1384,13 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
755
1384
  fault();
756
1385
  const outcome = await waitForDelivery(config.controlSocketPath, request, sendRequest, dependencies.now ?? Date.now, dependencies.delay ?? defaultDelay);
757
1386
  if (outcome.kind === "unknown") {
758
- writeFailure(stdout, stderr, locale, command, "DELIVERY_TOKEN_UNKNOWN", { kind: "tokenUnknown" });
1387
+ writeFailure(stdout, stderr, command, "DELIVERY_TOKEN_UNKNOWN", { kind: "tokenUnknown" });
759
1388
  return gatewayCliExitCodes.rejected;
760
1389
  }
761
- if (outcome.kind === "timeout") {
762
- writeFailure(stdout, stderr, locale, command, "DELIVERY_WAIT_TIMEOUT", { retryable: true, kind: "deliveryTimeout" });
1390
+ // `timeout` is the only other outcome here: an interrupt needs a signal,
1391
+ // and `wait-delivery` passes none.
1392
+ if (outcome.kind !== "response") {
1393
+ writeFailure(stdout, stderr, command, "DELIVERY_WAIT_TIMEOUT", { retryable: true, kind: "deliveryTimeout" });
763
1394
  return gatewayCliExitCodes.unavailable;
764
1395
  }
765
1396
  waited = outcome.response;
@@ -769,68 +1400,76 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
769
1400
  response = await sendRequest({ socketPath: config.controlSocketPath, request });
770
1401
  }
771
1402
  if (!response.ok) {
772
- writeFailure(stdout, stderr, locale, command, response.error.code, { kind: "failure" });
1403
+ writeFailure(stdout, stderr, command, response.error.code, { kind: "failure" });
1404
+ writeUnknownMethodHint(stderr, response.error.code);
773
1405
  return gatewayCliExitCodes.failure;
774
1406
  }
775
- if (command === "register-peer" && common.args.includes("--emit-env") && "token" in response.result) {
1407
+ if (command === "register-peer" && args.includes("--emit-env") && "token" in response.result) {
776
1408
  stdout.write(`export EMBASSY_PEER_TOKEN='${response.result.token}'\n`);
777
1409
  return gatewayCliExitCodes.ok;
778
1410
  }
779
- success(command === "doctor" ? { conditions: codexDoctorConditions(response.result) } : response.result);
1411
+ success(response.result);
780
1412
  const exitCode = waited === undefined ? responseExitCode(response) : waitDeliveryExitCode(waited);
781
1413
  if (exitCode === gatewayCliExitCodes.rejected) {
782
- stderr.write(fixedStderr(locale, "decision"));
783
- if (isProgressWatchOwnerConflict(response.result)) {
784
- stderr.write(`[embassy] ${getCliCopy(locale)["hint.progressWatchOwnerConflict"]}\n`);
785
- }
1414
+ stderr.write(fixedStderr("decision"));
1415
+ const hint = refusalHint(request, response.result);
1416
+ if (hint !== undefined)
1417
+ stderr.write(`[embassy] ${renderHint(hint.hint, hint.vars)}\n`);
786
1418
  }
787
1419
  else if (command === "wait-delivery" && exitCode === gatewayCliExitCodes.failure)
788
- stderr.write(fixedStderr(locale, "failure"));
1420
+ stderr.write(fixedStderr("failure"));
789
1421
  return exitCode;
790
1422
  }
791
1423
  catch (error) {
792
- if ((command === "serve" && serverReady) || (command === "dashboard" && dashboardReady)) {
793
- stderr.write(fixedStderr(locale, "failure"));
1424
+ if (command === "serve" && serverReady) {
1425
+ stderr.write(fixedStderr("failure"));
794
1426
  return gatewayCliExitCodes.failure;
795
1427
  }
796
1428
  if (error instanceof GatewayControlTransportError) {
797
1429
  const ambiguous = error.ambiguous;
798
- writeFailure(stdout, stderr, locale, command, error.code, {
1430
+ writeFailure(stdout, stderr, command, error.code, {
799
1431
  ambiguous, retryable: ambiguous ? false : error.recoverable,
800
1432
  kind: ambiguous ? "ambiguous" : "unavailable",
801
1433
  });
802
- if (error.code === "CONTROL_INVALID_RESPONSE") {
803
- stderr.write(`[embassy] ${getCliCopy(locale)["hint.controlInvalidResponse"]}\n`);
1434
+ if (error.code === "CONTROL_VERSION_MISMATCH") {
1435
+ stderr.write(`[embassy] ${CLI_HINT.controlVersionMismatch}\n`);
1436
+ }
1437
+ else if (error.code === "CONTROL_INVALID_RESPONSE") {
1438
+ stderr.write(`[embassy] ${CLI_HINT.controlInvalidResponse}\n`);
804
1439
  }
1440
+ if (error.code === "CONTROL_CONNECT_DENIED")
1441
+ stderr.write(`[embassy] ${CLI_HINT.controlConnectDenied}\n`);
1442
+ if (isNoBrokerCode(error.code))
1443
+ stderr.write(hintLine("noBrokerRunning", env));
805
1444
  return ambiguous ? gatewayCliExitCodes.ambiguous : gatewayCliExitCodes.unavailable;
806
1445
  }
807
1446
  if (error instanceof CliFault) {
808
- writeFailure(stdout, stderr, locale, command, error.code, {
1447
+ writeFailure(stdout, stderr, command, error.code, {
809
1448
  retryable: error.retryable, kind: error.kind ?? (error.retryable ? "unavailable" : "input"),
810
1449
  });
811
- if (error.hint !== undefined) {
812
- const hint = error.hint === "callerIdentityConflict"
813
- ? locale === "zh-CN" ? callerIdentityConflictHintZhCn : callerIdentityConflictHintEn
814
- : getCliCopy(locale)[error.hint];
815
- stderr.write(`[embassy] ${hint}\n`);
816
- }
1450
+ // Every hint may name the state directory; a fault that carries its own
1451
+ // bounded values overrides that default with them.
1452
+ if (error.hint !== undefined)
1453
+ stderr.write(`[embassy] ${renderHint(error.hint, { stateDir: resolvedStateDirForHint(env), ...error.hintVars })}\n`);
1454
+ if (isNoBrokerCode(error.code))
1455
+ stderr.write(hintLine("noBrokerRunning", env));
817
1456
  return error.retryable ? gatewayCliExitCodes.unavailable : gatewayCliExitCodes.invalidInput;
818
1457
  }
819
1458
  if (error instanceof BridgeError) {
820
1459
  if (error.code === "GATEWAY_STATE_COMMIT_OUTCOME_UNKNOWN") {
821
- writeFailure(stdout, stderr, locale, command, error.code, { ambiguous: true, kind: "ambiguous" });
1460
+ writeFailure(stdout, stderr, command, error.code, { ambiguous: true, kind: "ambiguous" });
822
1461
  return gatewayCliExitCodes.ambiguous;
823
1462
  }
824
- writeFailure(stdout, stderr, locale, command, error.code, {
1463
+ writeFailure(stdout, stderr, command, error.code, {
825
1464
  retryable: error.recoverable, kind: error.recoverable ? "unavailable" : "input",
826
1465
  });
827
- if (error.code === "LIVE_DASHBOARD_PORT_IN_USE" && liveDashboardPort !== undefined) {
828
- const hint = getCliCopy(locale)["hint.dashboardPortInUse"].replace("{port}", String(liveDashboardPort));
829
- stderr.write(`[embassy] ${hint}\n`);
830
- }
1466
+ writeStateResetHint(stderr, error.code);
1467
+ writeBridgeErrorHint(stderr, error, env);
1468
+ if (error.code === "CONTROL_CONNECT_DENIED")
1469
+ stderr.write(`[embassy] ${CLI_HINT[command === "serve" ? "stateAccessDenied" : "controlConnectDenied"]}\n`);
831
1470
  return error.recoverable ? gatewayCliExitCodes.unavailable : gatewayCliExitCodes.invalidInput;
832
1471
  }
833
- writeFailure(stdout, stderr, locale, command, "INTERNAL_ERROR", { kind: "failure" });
1472
+ writeFailure(stdout, stderr, command, "INTERNAL_ERROR", { kind: "failure" });
834
1473
  return gatewayCliExitCodes.failure;
835
1474
  }
836
1475
  }
@@ -851,8 +1490,13 @@ function isDirectExecution() {
851
1490
  if (isDirectExecution()) {
852
1491
  void runGatewayCli().then((exitCode) => {
853
1492
  process.exitCode = exitCode;
1493
+ // Bounded exit after an interrupted `check`: drain stdout, then stderr,
1494
+ // then leave — see `interruptedCheckExit`.
1495
+ if (interruptedCheckExit) {
1496
+ process.stdout.write("", () => process.stderr.write("", () => process.exit(exitCode)));
1497
+ }
854
1498
  }, () => {
855
- process.stderr.write(fixedStderr(fallbackCliLocale(process.argv.slice(3), process.env), "failure"));
1499
+ process.stderr.write(fixedStderr("failure"));
856
1500
  process.exitCode = gatewayCliExitCodes.failure;
857
1501
  });
858
1502
  }