agent-embassy 3.0.0 → 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 (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/CONTRIBUTING.md +4 -2
  3. package/README.md +26 -7
  4. package/SECURITY.md +17 -4
  5. package/dist/src/gateway/claude-helper-supervisor.js +36 -9
  6. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  7. package/dist/src/gateway/claude-helper.js.map +1 -1
  8. package/dist/src/gateway/claude-peer.js +1 -1
  9. package/dist/src/gateway/claude-peer.js.map +1 -1
  10. package/dist/src/gateway/cli.d.ts +2 -3
  11. package/dist/src/gateway/cli.js +135 -106
  12. package/dist/src/gateway/cli.js.map +1 -1
  13. package/dist/src/gateway/config.js +1 -1
  14. package/dist/src/gateway/config.js.map +1 -1
  15. package/dist/src/gateway/control.d.ts +23 -5
  16. package/dist/src/gateway/control.js +41 -11
  17. package/dist/src/gateway/control.js.map +1 -1
  18. package/dist/src/gateway/peer-protocol.d.ts +5 -0
  19. package/dist/src/gateway/peer-protocol.js +9 -0
  20. package/dist/src/gateway/peer-protocol.js.map +1 -1
  21. package/dist/src/gateway/providers.d.ts +4 -3
  22. package/dist/src/gateway/providers.js +33 -30
  23. package/dist/src/gateway/providers.js.map +1 -1
  24. package/dist/src/gateway/server.d.ts +1 -2
  25. package/dist/src/gateway/server.js +1 -4
  26. package/dist/src/gateway/server.js.map +1 -1
  27. package/dist/src/gateway/service.d.ts +6 -8
  28. package/dist/src/gateway/service.js +313 -252
  29. package/dist/src/gateway/service.js.map +1 -1
  30. package/dist/src/gateway/status-view.d.ts +2 -50
  31. package/dist/src/gateway/status-view.js +66 -70
  32. package/dist/src/gateway/status-view.js.map +1 -1
  33. package/dist/src/gateway/store.d.ts +4 -1
  34. package/dist/src/gateway/store.js +12 -11
  35. package/dist/src/gateway/store.js.map +1 -1
  36. package/dist/src/gateway/types.d.ts +17 -4
  37. package/dist/src/gateway/types.js +27 -36
  38. package/dist/src/gateway/types.js.map +1 -1
  39. package/docs/CONFIGURATION.md +3 -3
  40. package/docs/GATEWAY-ARCHITECTURE.md +27 -21
  41. package/package.json +1 -1
  42. package/skills/embassy-peer/SKILL.md +7 -5
  43. package/dist/src/gateway/claude-helper-client.d.ts +0 -2
  44. package/dist/src/gateway/claude-helper-client.js +0 -2
  45. package/dist/src/gateway/claude-helper-client.js.map +0 -1
@@ -20,7 +20,7 @@ const CLI_MAX_OUTPUT_BYTES = GATEWAY_CONTROL_MAX_RESPONSE_BYTES;
20
20
  const DELIVERY_POLL_INTERVAL_MS = 250;
21
21
  const DELIVERY_POLL_MIN_REQUEST_TIMEOUT_MS = 50;
22
22
  const PEER_AWAIT_REQUEST_TIMEOUT_MS = 35_000;
23
- export const EMBASSY_VERSION = "3.0.0";
23
+ export const EMBASSY_VERSION = "3.1.0";
24
24
  // RELEASE VERSION SWEEP — every place the version lives, seven sites:
25
25
  // package.json; npm-shrinkwrap.json (two: the root and packages[""]); this
26
26
  // constant; test/gateway-cli.test.ts's literal package-version assertion; and
@@ -33,7 +33,7 @@ export const gatewayCliCommands = [
33
33
  "unregister-codex",
34
34
  "send", "reply",
35
35
  "register-peer", "unregister-peer", "await",
36
- "peer-stdio",
36
+ "peer-stdio", "retire",
37
37
  ];
38
38
  export const gatewayCliExitCodes = Object.freeze({
39
39
  ok: 0,
@@ -60,8 +60,11 @@ Commands:
60
60
  check [--to <alias>] [--timeout <s>]
61
61
  Round-trip self-test against a registered peer
62
62
  refresh Rescan for Claude sessions
63
- register-codex Register or succeed a Codex task
64
- unregister-codex Unregister the current Codex task
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
65
68
  register-peer --alias <peer-alias> [--token-stdin|--emit-env]
66
69
  Register a universal shell peer
67
70
  unregister-peer --alias <peer-alias> [--token-stdin]
@@ -69,12 +72,15 @@ Commands:
69
72
  await --alias <peer-alias> [--token-stdin]
70
73
  Wait for one peer message and acknowledge stdout
71
74
  peer-stdio Serve the bounded federation protocol on stdin/stdout
72
- send Send stdin to a route with --to, or to a conversation
73
- you belong to with --conversation; a discovered Claude
74
- session's route installs on its first send
75
+ 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
75
79
  reply Deprecated alias for send --conversation
76
- delivery-status Read a delivery token
77
- wait-delivery Wait for terminal delivery status
80
+ delivery-status --token <delivery-token>
81
+ Read a delivery token
82
+ wait-delivery --token <delivery-token>
83
+ Wait for terminal delivery status
78
84
 
79
85
  Options:
80
86
  --token-stdin Read the peer token as the first LF-terminated stdin line
@@ -120,7 +126,8 @@ const CLI_HINT = {
120
126
  stateSyncFailed: "nodes.json was written at {stateDir} but the directory could not be synced; start again and check the volume",
121
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.",
122
128
  aliasCollision: "the alias names more than one live session; rename one, or address the session by UUID with --to <session-uuid>.",
123
- workspaceOverlap: "that session's workspace contains the gateway state directory; move one so they no longer overlap, then retry.",
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.",
124
131
  callerAliasMismatch: "--from must be the sending session's own alias; read the name embassy status shows for this session.",
125
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.",
126
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>`.",
@@ -246,7 +253,7 @@ function requireConversationId(options, name) {
246
253
  fault();
247
254
  return conversationId;
248
255
  }
249
- function requireClaudeSelector(options, name) {
256
+ function requireDestinationSelector(options, name) {
250
257
  const selector = requireString(options, name);
251
258
  if (!isClaudeSessionSelector(selector))
252
259
  fault();
@@ -422,6 +429,14 @@ async function buildRequest(command, args, env, stdin, loadLocalHost) {
422
429
  throw aliasHostFault(local, gatewayAliasHost(alias));
423
430
  return envelope("unregister_codex", { alias, threadId });
424
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 });
439
+ }
425
440
  case "register-peer":
426
441
  case "unregister-peer":
427
442
  case "await": {
@@ -463,7 +478,7 @@ async function buildRequest(command, args, env, stdin, loadLocalHost) {
463
478
  const conversationId = options.conversation === undefined
464
479
  ? undefined : requireConversationId(options, "conversation");
465
480
  const toAlias = options.to === undefined
466
- ? undefined : requireClaudeSelector(options, "to");
481
+ ? undefined : requireDestinationSelector(options, "to");
467
482
  // One target, and a conversation is always answered expecting a reply.
468
483
  if ((toAlias === undefined) === (conversationId === undefined))
469
484
  fault();
@@ -618,8 +633,10 @@ function refusalHint(request, result) {
618
633
  const from = request.method === "send" ? request.params.fromAlias : undefined;
619
634
  if (reason === "PEER_ALIAS_COLLISION")
620
635
  return { hint: "aliasCollision" };
621
- if (typeof reason === "string" && reason.startsWith("CLAUDE_PEER_WORKSPACE_"))
622
- return { hint: "workspaceOverlap" };
636
+ if (reason === "CLAUDE_PEER_WORKSPACE_BROAD")
637
+ return { hint: "workspaceBroad" };
638
+ if (reason === "CLAUDE_PEER_WORKSPACE_UNSAFE")
639
+ return { hint: "workspaceUnsafe" };
623
640
  // A Codex task with no registration under its alias — every task, after a
624
641
  // private state reset — is told to register; a registration held by another
625
642
  // task is a genuine mismatch with its own remedy; a conversation answered
@@ -645,6 +662,11 @@ function writeUnknownMethodHint(stderr, code) {
645
662
  if (code === "UNKNOWN_METHOD")
646
663
  stderr.write(`[embassy] ${CLI_HINT.unknownMethod}\n`);
647
664
  }
665
+ function reportControlFailure(command, code, stdout, stderr) {
666
+ writeFailure(stdout, stderr, command, code, { kind: "failure" });
667
+ writeUnknownMethodHint(stderr, code);
668
+ return gatewayCliExitCodes.failure;
669
+ }
648
670
  function responseExitCode(response) {
649
671
  return !response.ok ? gatewayCliExitCodes.failure
650
672
  : isRejectedResult(response.result) ? gatewayCliExitCodes.rejected : gatewayCliExitCodes.ok;
@@ -1042,6 +1064,92 @@ async function runGatewayCheck(options) {
1042
1064
  return summarize(exitCode);
1043
1065
  }
1044
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
+ }
1045
1153
  export async function runGatewayCli(argv = process.argv.slice(2), dependencies = {}) {
1046
1154
  const env = dependencies.env ?? process.env;
1047
1155
  const stdin = dependencies.stdin ?? process.stdin, stdout = dependencies.stdout ?? process.stdout;
@@ -1100,7 +1208,12 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
1100
1208
  },
1101
1209
  handoff: async (handoff) => {
1102
1210
  firstCatalog = undefined;
1103
- 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;
1104
1217
  },
1105
1218
  },
1106
1219
  });
@@ -1112,84 +1225,8 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
1112
1225
  return gatewayCliExitCodes.unavailable;
1113
1226
  }
1114
1227
  }
1115
- if (command === "service") {
1116
- const subcommand = args[0];
1117
- if (args.length !== 1)
1118
- fault();
1119
- if (subcommand !== "install" && subcommand !== "uninstall" && subcommand !== "status")
1120
- fault();
1121
- // Identity and the state directory are validated before the first
1122
- // launchd side effect. Loading them afterwards meant a missing
1123
- // inventory or an unusable state root exited 2 "request rejected"
1124
- // while the agent was already bootstrapped and looping.
1125
- const config = subcommand === "install" ? (await loadIdentity()).config : undefined;
1126
- const serviceDeps = {
1127
- homeDir: (dependencies.serviceHomeDir ?? (() => userInfo().homedir))(),
1128
- runLaunchctl: dependencies.runLaunchctl ?? defaultRunLaunchctl,
1129
- env, execPath: process.execPath, cliPath: fileURLToPath(import.meta.url),
1130
- uid: process.getuid(),
1131
- delay: dependencies.delay ?? defaultDelay, now: dependencies.now ?? monotonicNow,
1132
- probeHostLease: dependencies.probeHostLease ?? defaultProbeHostLease,
1133
- };
1134
- try {
1135
- if (subcommand === "install") {
1136
- const installed = await installServiceAgent(serviceDeps);
1137
- const health = await pollServiceHealth(config, sendRequest, validateSocket, dependencies.delay ?? defaultDelay, dependencies.now ?? monotonicNow);
1138
- if (!health.ok) {
1139
- // The agent stays installed either way: this is a report about
1140
- // the broker, not an install failure, so nothing is rolled back.
1141
- const elapsed = (health.elapsedMs / 1000).toFixed(1);
1142
- const decisive = SERVICE_HEALTH_DECISIVE.get(health.lastObserved);
1143
- writeFailure(stdout, stderr, command, "SERVICE_HEALTH_UNAVAILABLE", {
1144
- retryable: decisive?.retryable ?? true, kind: decisive?.kind ?? "unavailable",
1145
- detail: { lastObserved: health.lastObserved },
1146
- });
1147
- stderr.write(decisive === undefined
1148
- ? `[embassy] Installed, but the broker did not answer within ${elapsed} s; last observed ${health.lastObserved}. Run \`embassy service status\` or \`embassy health\`; log: ${installed.logPath}.\n`
1149
- : `[embassy] Installed, but the broker answered ${health.lastObserved} after ${elapsed} s. Run \`embassy health\` to diagnose it; log: ${installed.logPath}.\n`);
1150
- if (decisive?.hint !== undefined)
1151
- stderr.write(hintLine(decisive.hint, env));
1152
- return decisive?.exitCode ?? gatewayCliExitCodes.unavailable;
1153
- }
1154
- success({ subcommand, ...installed, health });
1155
- return gatewayCliExitCodes.ok;
1156
- }
1157
- if (subcommand === "uninstall") {
1158
- success({ subcommand, ...(await uninstallServiceAgent(serviceDeps)) });
1159
- return gatewayCliExitCodes.ok;
1160
- }
1161
- const status = await serviceAgentStatus(serviceDeps);
1162
- success({ subcommand, ...status });
1163
- if (status.state !== "unknown")
1164
- return gatewayCliExitCodes.ok;
1165
- stderr.write(fixedStderr("unavailable"));
1166
- stderr.write(`[embassy] ${status.note}${status.launchctlStderr === undefined ? "" : ` launchctl: ${status.launchctlStderr}`}\n`);
1167
- return gatewayCliExitCodes.unavailable;
1168
- }
1169
- catch (error) {
1170
- // launchctl's own stderr and the instance lease's own message are the
1171
- // whole value of these failures; the generic handler below discards
1172
- // the message and reports only the code. A genuine filesystem failure
1173
- // on this path (an unreadable plist, an undeletable one) is a real,
1174
- // recoverable service failure, not an INTERNAL_ERROR — but only an
1175
- // errno-shaped one. A string `code` alone would also match CliFault
1176
- // and the lease's own spawn failures, relabelling faults that already
1177
- // carry a truer code of their own.
1178
- const errno = error !== null && typeof error === "object" &&
1179
- (typeof error.errno === "number" ||
1180
- typeof error.syscall === "string");
1181
- if (!(error instanceof BridgeError) && !(errno && error instanceof Error))
1182
- throw error;
1183
- const failure = error instanceof BridgeError ? error : new BridgeError("SERVICE_AGENT_FILESYSTEM_FAILED", `The service command could not complete: ${boundedServiceDetail(error.message)}`, true);
1184
- writeFailure(stdout, stderr, command, failure.code, {
1185
- retryable: failure.recoverable,
1186
- kind: failure.code === "SERVICE_AGENT_PATH_UNSAFE" ? "unsafe"
1187
- : failure.recoverable ? "unavailable" : "input",
1188
- });
1189
- stderr.write(`[embassy] ${failure.message}\n`);
1190
- return failure.recoverable ? gatewayCliExitCodes.unavailable : gatewayCliExitCodes.invalidInput;
1191
- }
1192
- }
1228
+ if (command === "service")
1229
+ return await runServiceCommand(args, dependencies, loadIdentity);
1193
1230
  if (command === "status" || command === "watch" || command === "check") {
1194
1231
  // Options are parsed before anything is loaded or contacted, so an
1195
1232
  // unknown flag is a flat argument fault with no side effect at all.
@@ -1199,7 +1236,7 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
1199
1236
  count(options, 0, 2);
1200
1237
  const recent = boundedOption(options, "recent", STATUS_RECENT.default, STATUS_RECENT.minimum, STATUS_RECENT.maximum);
1201
1238
  const timeoutSeconds = boundedOption(options, "timeout", CHECK_TIMEOUT_DEFAULT_SECONDS, 1, CHECK_TIMEOUT_MAX_SECONDS);
1202
- const target = options.to === undefined ? undefined : requireClaudeSelector(options, "to");
1239
+ const target = options.to === undefined ? undefined : requireDestinationSelector(options, "to");
1203
1240
  const { config } = await loadIdentity();
1204
1241
  await validateSocket(config.stateDir, config.controlSocketPath);
1205
1242
  const ask = async (method, params) => await sendRequest({
@@ -1215,9 +1252,7 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
1215
1252
  // The header reports how old the scan is and offers `embassy refresh`.
1216
1253
  const response = await ask("list_snapshot", {});
1217
1254
  if (!response.ok) {
1218
- writeFailure(stdout, stderr, command, response.error.code, { kind: "failure" });
1219
- writeUnknownMethodHint(stderr, response.error.code);
1220
- return gatewayCliExitCodes.failure;
1255
+ return reportControlFailure(command, response.error.code, stdout, stderr);
1221
1256
  }
1222
1257
  if (options.json === true || !isTerminal(stdout)) {
1223
1258
  success(response.result);
@@ -1242,9 +1277,7 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
1242
1277
  while (!interrupt.signal.aborted) {
1243
1278
  const observed = await ask("observe_snapshot", {});
1244
1279
  if (!observed.ok) {
1245
- writeFailure(stdout, stderr, command, observed.error.code, { kind: "failure" });
1246
- writeUnknownMethodHint(stderr, observed.error.code);
1247
- return gatewayCliExitCodes.failure;
1280
+ return reportControlFailure(command, observed.error.code, stdout, stderr);
1248
1281
  }
1249
1282
  if (observed.result.snapshotRevision !== revision) {
1250
1283
  revision = observed.result.snapshotRevision;
@@ -1274,9 +1307,7 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
1274
1307
  if (chosen === undefined) {
1275
1308
  const snapshot = await ask("list_snapshot", {});
1276
1309
  if (!snapshot.ok) {
1277
- writeFailure(stdout, stderr, command, snapshot.error.code, { kind: "failure" });
1278
- writeUnknownMethodHint(stderr, snapshot.error.code);
1279
- return gatewayCliExitCodes.failure;
1310
+ return reportControlFailure(command, snapshot.error.code, stdout, stderr);
1280
1311
  }
1281
1312
  const candidates = snapshot.result.routes
1282
1313
  .filter((route) => route.provider === "codex" && route.enabled)
@@ -1400,9 +1431,7 @@ export async function runGatewayCli(argv = process.argv.slice(2), dependencies =
1400
1431
  response = await sendRequest({ socketPath: config.controlSocketPath, request });
1401
1432
  }
1402
1433
  if (!response.ok) {
1403
- writeFailure(stdout, stderr, command, response.error.code, { kind: "failure" });
1404
- writeUnknownMethodHint(stderr, response.error.code);
1405
- return gatewayCliExitCodes.failure;
1434
+ return reportControlFailure(command, response.error.code, stdout, stderr);
1406
1435
  }
1407
1436
  if (command === "register-peer" && args.includes("--emit-env") && "token" in response.result) {
1408
1437
  stdout.write(`export EMBASSY_PEER_TOKEN='${response.result.token}'\n`);