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