@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -1,6 +1,8 @@
1
1
  import { homedir } from "node:os";
2
2
 
3
3
  const DEFAULT_OUTPUT_BYTES = 64 * 1024;
4
+ const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
5
+ const TUNNEL_OUTPUT_EXIT_GRACE_MS = 250;
4
6
  const HINT_MAX_CHARS = 160;
5
7
  // Written as escapes on purpose: invisible and bidi controls must never sit literally in source.
6
8
  const ANSI_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]/g;
@@ -93,10 +95,21 @@ export class SshRunnerError extends Error {
93
95
  }
94
96
  }
95
97
 
96
- async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void, fatalUtf8 = true): Promise<string> {
98
+ async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void, fatalUtf8 = true, exited?: Promise<number>, signal?: AbortSignal): Promise<string> {
97
99
  const reader = stream.getReader();
98
100
  const chunks: Uint8Array[] = [];
99
101
  let total = 0;
102
+ let finished = false;
103
+ let timer: ReturnType<typeof setTimeout> | undefined;
104
+ const cancel = (): void => { void reader.cancel().catch(() => {}); };
105
+ const onExit = (): void => {
106
+ if (!finished) timer = setTimeout(cancel, TUNNEL_OUTPUT_EXIT_GRACE_MS);
107
+ };
108
+ signal?.addEventListener("abort", cancel, { once: true });
109
+ if (signal?.aborted) cancel();
110
+ // A descendant may inherit a pipe after ssh exits. Cancellation completes pending reads
111
+ // immediately; do not await the underlying source's possibly unbounded cancel promise.
112
+ void exited?.then(onExit, onExit);
100
113
  try {
101
114
  for (;;) {
102
115
  const next = await reader.read();
@@ -104,11 +117,15 @@ async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number,
104
117
  total += next.value.byteLength;
105
118
  if (total > maxBytes) {
106
119
  kill?.();
120
+ cancel();
107
121
  throw new SshRunnerError("output_limit", `ssh output exceeded ${maxBytes} bytes`);
108
122
  }
109
123
  chunks.push(next.value);
110
124
  }
111
125
  } finally {
126
+ finished = true;
127
+ if (timer !== undefined) clearTimeout(timer);
128
+ signal?.removeEventListener("abort", cancel);
112
129
  reader.releaseLock();
113
130
  }
114
131
  const bytes = new Uint8Array(total);
@@ -149,8 +166,8 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
149
166
  } catch (error) {
150
167
  throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
151
168
  }
152
- const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
153
- const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false);
169
+ const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), true, child.exited);
170
+ const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false, child.exited);
154
171
  void stdout.catch(() => {});
155
172
  void stderr.catch(() => {});
156
173
  return {
@@ -165,9 +182,13 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
165
182
 
166
183
  return {
167
184
  async run(argv, options = {}) {
185
+ const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
186
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
187
+ throw new SshRunnerError("timeout", "timeoutMs must be a positive integer at most 2147483647");
188
+ }
168
189
  const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_BYTES;
169
- if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 0) {
170
- throw new SshRunnerError("output_limit", "maxOutputBytes must be a non-negative integer");
190
+ if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 0 || maxOutputBytes > MAX_OUTPUT_BYTES) {
191
+ throw new SshRunnerError("output_limit", `maxOutputBytes must be an integer between 0 and ${MAX_OUTPUT_BYTES}`);
171
192
  }
172
193
  let child: Bun.Subprocess;
173
194
  try {
@@ -181,41 +202,50 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
181
202
  throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
182
203
  }
183
204
 
184
- const stdout = readOutput(outputStream(child.stdout), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"));
185
- const stderr = readOutput(outputStream(child.stderr), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"), false);
186
- if (options.stdin !== undefined) {
187
- try {
188
- const input = child.stdin;
189
- if (!input || typeof input === "number") throw new Error("ssh stdin was not piped");
190
- await input.write(options.stdin);
191
- await input.end();
192
- } catch (error) {
193
- child.kill("SIGTERM");
194
- throw new SshRunnerError("spawn", "could not write ssh stdin", { cause: error });
195
- }
196
- }
197
-
198
- const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
199
- let timedOut = false;
205
+ const cancellation = new AbortController();
206
+ let stdout: Promise<string> | undefined;
207
+ let stderr: Promise<string> | undefined;
200
208
  let timer: ReturnType<typeof setTimeout> | undefined;
201
209
  const timeout = new Promise<never>((_, reject) => {
202
210
  timer = setTimeout(() => {
203
- timedOut = true;
204
- defaultKill(child, "SIGTERM");
205
211
  reject(new SshRunnerError("timeout", `ssh command exceeded ${timeoutMs}ms`));
206
212
  }, timeoutMs);
207
213
  });
208
214
  try {
209
- const code = await Promise.race([child.exited, timeout]);
210
- const [out, err] = await Promise.all([stdout, stderr]);
211
- if (timedOut) throw new SshRunnerError("timeout", `ssh command exceeded ${timeoutMs}ms`);
215
+ const outStream = outputStream(child.stdout), errStream = outputStream(child.stderr);
216
+ stdout = readOutput(outStream, maxOutputBytes, undefined, true, undefined, cancellation.signal);
217
+ stderr = readOutput(errStream, Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), undefined, false, undefined, cancellation.signal);
218
+ const stdin = async (): Promise<void> => {
219
+ if (options.stdin === undefined) return;
220
+ try {
221
+ const input = child.stdin;
222
+ if (!input || typeof input === "number") throw new Error("ssh stdin was not piped");
223
+ await input.write(options.stdin);
224
+ // A timed-out write may settle later; do not start another operation after failure.
225
+ if (cancellation.signal.aborted) return;
226
+ await input.end();
227
+ } catch (error) {
228
+ throw new SshRunnerError("spawn", "could not write ssh stdin", { cause: error });
229
+ }
230
+ };
231
+ // Observe all rejections immediately, including errors arriving after timeout. One
232
+ // deadline covers stdin, exit and EOF: an inherited output pipe is still unfinished work.
233
+ const [, code, out, err] = await Promise.race([
234
+ Promise.all([stdin(), child.exited, stdout, stderr]), timeout,
235
+ ]);
212
236
  return { code, stdout: out, stderr: err };
213
237
  } catch (error) {
238
+ cancellation.abort();
239
+ // Only terminate this local SSH child. A kill failure must not hide the original error.
240
+ try { defaultKill(child, "SIGTERM"); } catch { /* Preserve the command failure when an already-gone child cannot be signalled. */ }
214
241
  if (error instanceof SshRunnerError) throw error;
215
242
  throw new SshRunnerError("spawn", "ssh command failed", { cause: error });
216
243
  } finally {
217
244
  if (timer !== undefined) clearTimeout(timer);
218
- void Promise.allSettled([stdout, stderr, child.exited]);
245
+ void child.exited.catch(() => {});
246
+ // reader.cancel closes pending reads even if the source's cancel promise never settles.
247
+ // Do not await stdin or process exit: neither is guaranteed to settle after SIGTERM.
248
+ await Promise.allSettled([stdout, stderr]);
219
249
  }
220
250
  },
221
251
  spawnTunnel,
@@ -227,3 +257,4 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
227
257
  }
228
258
 
229
259
  export const SSH_OUTPUT_CAP_BYTES = DEFAULT_OUTPUT_BYTES;
260
+ export const SSH_MAX_OUTPUT_BYTES = MAX_OUTPUT_BYTES;
@@ -2,6 +2,7 @@ import type { LinkStore } from "./store";
2
2
  import type { LinkTunnelStatus } from "./supervisor";
3
3
  import type { TunnelState } from "./tunnel-state";
4
4
  import { readCompensation, type CompensationStore } from "./compensation";
5
+ import type { OcxClientConnectionConfig } from "../types";
5
6
 
6
7
  export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
7
8
 
@@ -25,6 +26,8 @@ export interface LinkStatusDto {
25
26
  };
26
27
  }
27
28
 
29
+ export interface ClientLinkIdentity { direction: "hub-initiated" | "client-initiated"; alias?: string }
30
+
28
31
  export interface LinkListenerStatusProjection {
29
32
  state: "off" | "listening" | "failed";
30
33
  port: number | null;
@@ -32,6 +35,7 @@ export interface LinkListenerStatusProjection {
32
35
 
33
36
  export interface LinkStatusConfig {
34
37
  runtimeRole?: "standalone" | "hub" | "client";
38
+ client?: Pick<OcxClientConnectionConfig, "transport" | "link" | "serverUrl" | "connectedAt">;
35
39
  }
36
40
 
37
41
  function asWireState(state: TunnelState | "client-owned"): LinkWireState {
@@ -60,7 +64,7 @@ function stateDetails(
60
64
 
61
65
  /**
62
66
  * Project private link state into the exact K16 wire DTO. `clientChild` lets a client-side caller
63
- * report a child state this module cannot read itself (an invalid client sidecar); link code
67
+ * report a child state this module cannot read itself (including an invalid client sidecar); link code
64
68
  * stays free of client imports, so the caller supplies it.
65
69
  */
66
70
  export function projectLinkStatus(
@@ -70,6 +74,7 @@ export function projectLinkStatus(
70
74
  config: LinkStatusConfig,
71
75
  compensation: CompensationStore = readCompensation(),
72
76
  clientChild: LinkStatusDto["child"] = null,
77
+ identity: ClientLinkIdentity | null = null,
73
78
  ): LinkStatusDto {
74
79
  const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
75
80
  const links = store.links.map(record => ({
@@ -79,18 +84,31 @@ export function projectLinkStatus(
79
84
  ...stateDetails(record, byId.get(record.id), compensation),
80
85
  tunnelPort: record.tunnelPort,
81
86
  }));
82
- const childRecord = config.runtimeRole === "client" ? store.links[0] : undefined;
83
- const invalidClientSidecar = config.runtimeRole === "client" ? clientChild : null;
87
+ const childRecord = config.runtimeRole === "client"
88
+ ? (config.client?.transport === "link" ? store.links.find(record => record.id === config.client?.link?.linkId) : store.links[0])
89
+ : undefined;
90
+ const projectedClientChild = config.runtimeRole === "client" ? clientChild : null;
84
91
  const childStatus = childRecord
85
92
  ? stateDetails(childRecord, byId.get(childRecord.id), compensation)
86
93
  : undefined;
87
94
  const role = config.runtimeRole === "client"
88
95
  ? "child"
89
96
  : store.links.length > 0 ? "home" : "standalone";
97
+ // Home owns the record and reverse SSH process. The Child's persisted connection supplies
98
+ // identity and port, while an optional caller observation supplies live tunnel state.
99
+ const client = config.runtimeRole === "client" && config.client?.transport === "link" ? config.client : undefined;
100
+ const child = projectedClientChild ?? (childRecord && childStatus ? { alias: childRecord.alias, ...childStatus }
101
+ : client?.link ? { alias: identity?.alias ?? client.serverUrl, state: "idle" as const, since: client.connectedAt, reason: null } : null);
102
+ if (client?.link && child && identity) {
103
+ const active = links.find(link => link.id === client.link!.linkId);
104
+ const projected = { id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias };
105
+ if (active) Object.assign(active, projected, compensation.entries[active.id] && childRecord ? stateDetails(childRecord, byId.get(active.id), compensation) : {});
106
+ else links.push(projected);
107
+ }
90
108
  return {
91
109
  role,
92
110
  listener: { state: listenerStatus.state, port: listenerStatus.port },
93
111
  links,
94
- child: invalidClientSidecar ?? (childRecord && childStatus ? { alias: childRecord.alias, ...childStatus } : null),
112
+ child,
95
113
  };
96
114
  }
@@ -7,6 +7,8 @@ import { createSshRunner, type SshChild, type SshRunner } from "./ssh-runner";
7
7
  import {
8
8
  classifySshStderr,
9
9
  dueForSpawn,
10
+ failedTunnel,
11
+ HOME_TUNNEL_RETRY_POLICY,
10
12
  IDLE,
11
13
  reduceTunnel,
12
14
  type TunnelState,
@@ -33,6 +35,13 @@ export interface LinkSupervisor {
33
35
  ensureStarted(): Promise<void>;
34
36
  reload(): Promise<void>;
35
37
  stopLink(linkId: string): Promise<void>;
38
+ /** Reserve a link against reconnects and automatic spawns for a removal transaction. */
39
+ holdLink?(linkId: string): () => void;
40
+ /**
41
+ * Restart one Home-initiated tunnel now: stop its child, forget the backoff or failed state,
42
+ * and spawn a fresh attempt. False when the link is not a Home-initiated link of this store.
43
+ */
44
+ reconnect(linkId: string): Promise<boolean>;
36
45
  status(): readonly LinkTunnelStatus[];
37
46
  notifyAuthenticatedRequest?(apiKeyId: string): void;
38
47
  stop(): Promise<void>;
@@ -67,6 +76,20 @@ export interface LinkSupervisorDeps {
67
76
 
68
77
  const TIMER_MS = 1_000;
69
78
  const SPAWN_GRACE_MS = 5_000;
79
+ /**
80
+ * A retry runs while the network may still be down, and ssh can wait out its whole ConnectTimeout
81
+ * (10 s for the TCP connect, the SSH handshake and key exchange, see ssh-argv.ts) before it exits.
82
+ * A retry is promoted only once it has outlived that, so an attempt that never reached the Child
83
+ * does not read connected and restart the outage clock. Like the first attempt's five seconds,
84
+ * liveness is a heuristic: authentication and the forward request come after ConnectTimeout, so
85
+ * one that stalls past the grace reads connected until ssh exits.
86
+ */
87
+ const RETRY_GRACE_MS = 15_000;
88
+
89
+ /** A retry whose ssh child is still running: reconnecting, or failed under the retry policy. */
90
+ function retryInFlight(state: TunnelState): boolean {
91
+ return (state.kind === "reconnecting" || state.kind === "failed") && state.inFlight === true;
92
+ }
70
93
 
71
94
  function sameArgv(left: readonly string[], right: readonly string[]): boolean {
72
95
  return left.length === right.length && left.every((value, index) => value === right[index]);
@@ -129,10 +152,19 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
129
152
  let started = false;
130
153
  let stopping = false;
131
154
  let lifecycleFlight: Promise<void> | undefined;
155
+ let lifecycleRevision = 0;
132
156
  let timer: ReturnType<typeof setInterval> | undefined;
133
157
  const states = new Map<string, TunnelState>();
134
- const children = new Map<string, { child: SshChild; argv: readonly string[] }>();
158
+ const children = new Map<string, { child: SshChild; argv: readonly string[]; spawnedAt: number }>();
159
+ /** Per-link generation, advanced by spawns and explicit stop/removal reservations. */
160
+ const generations = new Map<string, number>();
161
+ const linkRevisions = new Map<string, number>();
162
+ /** Reconnects in flight, so overlapping requests for one link restart it once. */
163
+ const reconnects = new Map<string, Promise<boolean>>();
164
+ const holds = new Map<string, number>();
135
165
  const orphanUnverified = new Set<string>();
166
+ /** The generation of the child whose exit is still being classified, per link. */
167
+ const exiting = new Map<string, number>();
136
168
  const recordInstances = new Map<string, string>();
137
169
 
138
170
  const reconcileApiKeys = (): void => {
@@ -187,14 +219,17 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
187
219
  };
188
220
 
189
221
  const setEvent = (linkId: string, event: Parameters<typeof reduceTunnel>[1]): TunnelState => {
190
- const next = reduceTunnel(states.get(linkId) ?? IDLE, event, deps.random);
222
+ const next = reduceTunnel(states.get(linkId) ?? IDLE, event, deps.random, HOME_TUNNEL_RETRY_POLICY);
191
223
  states.set(linkId, next);
192
224
  return next;
193
225
  };
194
226
 
195
227
  const spawnFor = (record: LinkRecord): void => {
196
- if (stopping || record.direction !== "hub-initiated" || children.has(record.id)) return;
197
- if (states.get(record.id)?.kind === "failed") return;
228
+ if (stopping || holds.has(record.id) || record.direction !== "hub-initiated" || children.has(record.id)) return;
229
+ // Only a fresh link or a due attempt spawns. A reload must neither skip the backoff nor start
230
+ // a second child while an exited one is still reporting its stderr.
231
+ const prior = states.get(record.id);
232
+ if (prior && prior.kind !== "idle" && !dueForSpawn(prior, now())) return;
198
233
  if (store.listenerPort === null) {
199
234
  states.set(record.id, { kind: "failed", since: now(), reason: "forward" });
200
235
  return;
@@ -209,7 +244,11 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
209
244
  knownHostsFile: linkKnownHostsPath(),
210
245
  });
211
246
  const child = runner.spawnTunnel(argv);
212
- children.set(record.id, { child, argv });
247
+ const generation = (generations.get(record.id) ?? 0) + 1;
248
+ generations.set(record.id, generation);
249
+ // An older child's exit still being classified no longer speaks for this link.
250
+ exiting.delete(record.id);
251
+ children.set(record.id, { child, argv, spawnedAt: now() });
213
252
  orphanUnverified.delete(record.id);
214
253
  setEvent(record.id, { type: "spawn", now: now() });
215
254
  writePidfile(pidfilePath(record.id), { version: 1, linkId: record.id, pid: child.pid, argv });
@@ -218,9 +257,17 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
218
257
  children.delete(record.id);
219
258
  conditionalRemovePidfile(record.id, child.pid);
220
259
  if (stopping) return;
221
- const stderr = child.stderr ? await child.stderr : "";
222
- const next = setEvent(record.id, { type: "exit", now: now(), stderrClass: classifySshStderr(stderr) });
223
- if (next.kind === "failed") return;
260
+ exiting.set(record.id, generation);
261
+ try {
262
+ let stderr = "";
263
+ // An unreadable stderr (the runner rejects it past its size cap) still reports the exit.
264
+ try { stderr = child.stderr ? await child.stderr : ""; } catch { /* classified as unknown */ }
265
+ // A reconnect may have started a fresh child meanwhile; its own lifecycle owns the state.
266
+ if (generations.get(record.id) !== generation) return;
267
+ setEvent(record.id, { type: "exit", now: now(), stderrClass: classifySshStderr(stderr) });
268
+ } finally {
269
+ if (exiting.get(record.id) === generation) exiting.delete(record.id);
270
+ }
224
271
  }).catch(() => {
225
272
  if (children.get(record.id)?.child !== child) return;
226
273
  children.delete(record.id);
@@ -228,23 +275,40 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
228
275
  if (!stopping) setEvent(record.id, { type: "exit", now: now(), stderrClass: "network" });
229
276
  });
230
277
  } catch {
231
- states.set(record.id, { kind: "failed", since: now(), reason: "forward" });
278
+ const at = now();
279
+ states.set(record.id, failedTunnel("forward", at, HOME_TUNNEL_RETRY_POLICY, prior?.kind === "failed" ? prior.since : at));
232
280
  }
233
281
  };
234
282
 
235
283
  const tick = (): void => {
236
- if (stopping) return;
284
+ if (stopping || lifecycleFlight) return;
237
285
  const current = now();
238
286
  for (const record of store.links) {
239
287
  if (record.direction !== "hub-initiated") continue;
240
- const next = setEvent(record.id, { type: "tick", now: current });
288
+ if (holds.has(record.id)) continue;
289
+ // The exit is still being classified. Failing the link on the outage clock now would make
290
+ // a later host-key or auth exit land on a failed state that ignores it.
291
+ if (exiting.has(record.id)) continue;
292
+ const before = states.get(record.id) ?? IDLE;
241
293
  const child = children.get(record.id);
242
- if (next.kind === "failed" && child) {
294
+ // Promotion is checked before the outage limit, so a child that has outlived its grace by the
295
+ // time this tick runs (at the limit, or late after a sleep) is promoted rather than killed.
296
+ // An idle Child may send no catalog request for hours; for a retry, staying up past the
297
+ // retry grace is the proof instead, as five seconds is for a first attempt.
298
+ if (child && ((before.kind === "connecting" && current - before.since >= SPAWN_GRACE_MS)
299
+ || (retryInFlight(before) && current - child.spawnedAt >= RETRY_GRACE_MS))) {
300
+ setEvent(record.id, { type: "ready", now: current });
301
+ continue;
302
+ }
303
+ const next = setEvent(record.id, { type: "tick", now: current });
304
+ if (next.kind === "failed" && !next.inFlight && child && retryInFlight(before)) {
305
+ // A retry started just before the outage limit has not had its grace yet. It carries on as
306
+ // the failed state's retry instead of being killed with a link that may already be back.
307
+ states.set(record.id, { ...next, inFlight: true });
308
+ } else if (next.kind === "failed" && !next.inFlight && child) {
243
309
  child.child.kill("SIGTERM");
244
310
  children.delete(record.id);
245
311
  conditionalRemovePidfile(record.id, child.child.pid);
246
- } else if (next.kind === "connecting" && child && current - next.since >= SPAWN_GRACE_MS) {
247
- setEvent(record.id, { type: "ready", now: current });
248
312
  } else if (dueForSpawn(next, current)) {
249
313
  spawnFor(record);
250
314
  }
@@ -273,37 +337,45 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
273
337
  syncTimer();
274
338
  };
275
339
 
276
- let queuedLifecycle: (() => void | Promise<void>) | undefined;
277
340
  const runLifecycle = (operation: () => void | Promise<void>): Promise<void> => {
278
- if (lifecycleFlight) {
279
- queuedLifecycle = operation;
280
- return lifecycleFlight;
281
- }
282
- lifecycleFlight = (async () => {
283
- let next: (() => void | Promise<void>) | undefined = operation;
284
- while (next) {
285
- await next();
286
- next = queuedLifecycle;
287
- queuedLifecycle = undefined;
288
- }
289
- })().finally(() => {
290
- lifecycleFlight = undefined;
291
- queuedLifecycle = undefined;
341
+ // Every caller keeps its own operation and completion; no later reload can replace a stop.
342
+ const flight = (lifecycleFlight ?? Promise.resolve()).catch(() => {}).then(operation).finally(() => {
343
+ if (lifecycleFlight === flight) lifecycleFlight = undefined;
292
344
  });
293
- return lifecycleFlight;
345
+ lifecycleFlight = flight;
346
+ return flight;
294
347
  };
295
348
 
296
- const stopLink = async (linkId: string): Promise<void> => {
349
+ const stopChild = async (linkId: string): Promise<void> => {
297
350
  const current = children.get(linkId);
298
351
  if (!current) {
299
352
  states.set(linkId, IDLE);
300
353
  return;
301
354
  }
355
+ const generation = generations.get(linkId);
302
356
  children.delete(linkId);
303
357
  current.child.kill("SIGTERM");
304
358
  await current.child.exited;
305
359
  conditionalRemovePidfile(linkId, current.child.pid);
306
- states.set(linkId, IDLE);
360
+ // A stop or removal reservation made while this child exited owns the next state.
361
+ if (generations.get(linkId) === generation) states.set(linkId, IDLE);
362
+ };
363
+
364
+ const invalidateLink = (linkId: string): void => {
365
+ linkRevisions.set(linkId, (linkRevisions.get(linkId) ?? 0) + 1);
366
+ generations.set(linkId, (generations.get(linkId) ?? 0) + 1);
367
+ };
368
+
369
+ const holdLink = (linkId: string): (() => void) => {
370
+ holds.set(linkId, (holds.get(linkId) ?? 0) + 1);
371
+ invalidateLink(linkId);
372
+ let released = false;
373
+ return () => {
374
+ if (released) return;
375
+ released = true;
376
+ const remaining = (holds.get(linkId) ?? 1) - 1;
377
+ if (remaining) holds.set(linkId, remaining); else holds.delete(linkId);
378
+ };
307
379
  };
308
380
 
309
381
  return {
@@ -315,6 +387,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
315
387
  return runLifecycle(begin);
316
388
  },
317
389
  async reload() {
390
+ lifecycleRevision += 1;
318
391
  await runLifecycle(async () => {
319
392
  if (stopping) return;
320
393
  if (!started) {
@@ -332,7 +405,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
332
405
  store = nextStore;
333
406
  for (const [linkId] of [...children]) {
334
407
  if (nextInstances.has(linkId) && !changed.has(linkId)) continue;
335
- await stopLink(linkId);
408
+ await stopChild(linkId);
336
409
  states.delete(linkId);
337
410
  orphanUnverified.delete(linkId);
338
411
  recordInstances.delete(linkId);
@@ -353,12 +426,42 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
353
426
  syncTimer();
354
427
  });
355
428
  },
356
- stopLink,
429
+ stopLink(linkId: string) {
430
+ invalidateLink(linkId);
431
+ return runLifecycle(() => stopChild(linkId));
432
+ },
433
+ holdLink,
434
+ reconnect(linkId: string) {
435
+ const inFlight = reconnects.get(linkId);
436
+ if (inFlight) return inFlight;
437
+ const linkRevision = linkRevisions.get(linkId);
438
+ let restarted = false;
439
+ const flight = runLifecycle(async () => {
440
+ if (stopping || !started || holds.has(linkId) || linkRevisions.get(linkId) !== linkRevision) return;
441
+ const record = store.links.find(link => link.id === linkId && link.direction === "hub-initiated");
442
+ if (!record) return;
443
+ // A preceding reload may have spawned a new generation; it is the current one we drain.
444
+ const generation = generations.get(linkId);
445
+ const revision = lifecycleRevision;
446
+ const instance = recordInstance(record, store.listenerPort);
447
+ await stopChild(linkId);
448
+ const latest = readStore();
449
+ const fresh = latest.links.find(link => link.id === linkId && link.direction === "hub-initiated");
450
+ if (stopping || holds.has(linkId) || generations.get(linkId) !== generation
451
+ || lifecycleRevision !== revision || !fresh || recordInstance(fresh, latest.listenerPort) !== instance) return;
452
+ spawnFor(fresh);
453
+ restarted = true;
454
+ }).then(() => restarted).finally(() => { reconnects.delete(linkId); });
455
+ reconnects.set(linkId, flight);
456
+ return flight;
457
+ },
357
458
  notifyAuthenticatedRequest(apiKeyId: string) {
358
459
  for (const record of store.links) {
359
460
  if (record.direction === "hub-initiated" && record.apiKeyId === apiKeyId && children.has(record.id)) {
360
461
  const state = states.get(record.id);
361
- if (state?.kind === "connecting" || state?.kind === "reconnecting") setEvent(record.id, { type: "ready", now: now() });
462
+ if (state?.kind === "connecting" || state?.kind === "reconnecting" || (state && retryInFlight(state))) {
463
+ setEvent(record.id, { type: "ready", now: now() });
464
+ }
362
465
  }
363
466
  }
364
467
  },
@@ -383,20 +486,12 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
383
486
  clearTimer(timer);
384
487
  timer = undefined;
385
488
  }
386
- const active = [...children.entries()];
387
- for (const [linkId, current] of active) {
388
- children.delete(linkId);
389
- current.child.kill("SIGTERM");
390
- }
391
- await Promise.all(active.map(async ([linkId, current]) => {
392
- await current.child.exited;
393
- conditionalRemovePidfile(linkId, current.child.pid);
394
- states.set(linkId, IDLE);
395
- }));
396
- for (const record of store.links) {
397
- if (record.direction === "hub-initiated") states.set(record.id, IDLE);
398
- }
399
- if (lifecycleFlight) await lifecycleFlight;
489
+ await runLifecycle(async () => {
490
+ await Promise.all([...children.keys()].map(stopChild));
491
+ for (const record of store.links) {
492
+ if (record.direction === "hub-initiated") states.set(record.id, IDLE);
493
+ }
494
+ });
400
495
  },
401
496
  };
402
497
  }
@@ -6,10 +6,10 @@
6
6
  * - reconnecting: a transient failure; requests through the link fail with 503 meanwhile, and a
7
7
  * new attempt is due at `retryAt`.
8
8
  * - failed: auth, host key and forward failures, and a link that stayed down for FAILED_AFTER_MS.
9
- * Without a retry policy (the Home's `-R` supervisor) it needs the user. With one (the Child's
10
- * own `-L` tunnel) a reason that has a delay is tried again at `retryAt`, and the retry attempt
11
- * runs with `inFlight` while the state still reads failed; a reason without a delay stays
12
- * terminal.
9
+ * Without a retry policy it needs the user. With one (the Home's `-R` supervisor and the Child's
10
+ * own `-L` tunnel both run with one) a reason that has a delay is tried again at `retryAt`, and
11
+ * the retry attempt runs with `inFlight` while the state still reads failed; a reason without a
12
+ * delay stays terminal.
13
13
  */
14
14
 
15
15
  export type TunnelFailure = "auth" | "hostkey" | "forward" | "timeout";
@@ -48,6 +48,13 @@ export const CLIENT_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = {
48
48
  retryFailedAfterMs: { timeout: 60_000, forward: 60_000, auth: 5 * 60_000 },
49
49
  };
50
50
 
51
+ /**
52
+ * The Home's `-R` supervisor dials the Child's sshd on the same cadence and limits. A sleep, an
53
+ * outage longer than FAILED_AFTER_MS, or a forward the Child's sshd still holds for the session
54
+ * that just died heals about a minute after it clears, instead of waiting for a proxy restart.
55
+ */
56
+ export const HOME_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = CLIENT_TUNNEL_RETRY_POLICY;
57
+
51
58
  export const IDLE: TunnelState = { kind: "idle" };
52
59
 
53
60
  /** Capped exponential backoff with ±20% jitter. `random` is injectable for tests. */
@@ -1,6 +1,7 @@
1
1
  import { detectClaudeCodeToken, hasClaudeCredentialContinuity } from "./local-token-detect";
2
2
  import { normalizeAnthropicIdentity, resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity";
3
3
  import { credentialGeneration, type AuthStore } from "./store";
4
+ import { mergeOAuthPlan } from "./plan";
4
5
  import { REFRESH_SKEW_MS } from "./refresh-policy";
5
6
  import type { OAuthCredentials, ProviderAccountSet } from "./types";
6
7
  import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
@@ -30,7 +31,7 @@ export async function newerClaudeCredentialForInstance(
30
31
  const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access);
31
32
  if (hasClaudeCredentialContinuity(stored, disk)) {
32
33
  return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
33
- ...stored, ...disk, anthropicIdentity: disk.access === stored.access ? before : undefined,
34
+ ...stored, ...disk, plan: mergeOAuthPlan(disk, stored), anthropicIdentity: disk.access === stored.access ? before : undefined,
34
35
  } };
35
36
  }
36
37
  // Independent observations share a deadline through the caller signal; neither extends the other.
@@ -42,7 +43,7 @@ export async function newerClaudeCredentialForInstance(
42
43
  if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration };
43
44
  if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration };
44
45
  return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
45
- ...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity,
46
+ ...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity, plan: newProof?.plan ?? null,
46
47
  } };
47
48
  }
48
49
 
@@ -75,5 +76,5 @@ export function captureAnthropicCredentialOwnerForInstance(instance: AnthropicIn
75
76
  }
76
77
 
77
78
  function identityMetadata(credential: OAuthCredentials): string {
78
- return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity]);
79
+ return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity, credential.plan]);
79
80
  }
@@ -4,12 +4,13 @@ import { createHash } from "node:crypto";
4
4
  import { readBoundedResponseBody } from "../lib/bounded-body";
5
5
  import { CLAUDE_CLI_USER_AGENT } from "../providers/claude-cli-identity";
6
6
  import type { OAuthCredentials } from "./types";
7
+ import { normalizeOAuthPlan } from "./plan";
7
8
 
8
9
  const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
9
10
  const PROFILE_TIMEOUT_MS = 10_000;
10
11
  const PROFILE_MAX_BYTES = 65_536;
11
12
  export type AnthropicIdentity = NonNullable<OAuthCredentials["anthropicIdentity"]>;
12
- export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<AnthropicIdentity | undefined>;
13
+ export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<(AnthropicIdentity & { plan?: string }) | undefined>;
13
14
 
14
15
  export function bindAnthropicIdentity(access: string, accountUuid: unknown): AnthropicIdentity | undefined {
15
16
  if (!access || typeof accountUuid !== "string" || !accountUuid.length || accountUuid.length > 128
@@ -48,8 +49,18 @@ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async
48
49
  const data: unknown = JSON.parse(body.text);
49
50
  if (!data || typeof data !== "object" || !("account" in data)) return undefined;
50
51
  const account = data.account;
51
- return account && typeof account === "object" && "uuid" in account
52
+ const identity = account && typeof account === "object" && "uuid" in account
52
53
  ? bindAnthropicIdentity(access, account.uuid) : undefined;
54
+ if (!identity) return undefined;
55
+ const organization = "organization" in data ? data.organization : undefined;
56
+ const org = organization && typeof organization === "object"
57
+ ? organization as Record<string, unknown> : undefined;
58
+ const strip = (value: unknown, prefix: string) => {
59
+ const label = normalizeOAuthPlan(value);
60
+ return label ? normalizeOAuthPlan(label.startsWith(prefix) ? label.slice(prefix.length) : label) : undefined;
61
+ };
62
+ const plan = strip(org?.rate_limit_tier, "default_claude_") ?? strip(org?.organization_type, "claude_");
63
+ return { ...identity, ...(plan ? { plan } : {}) };
53
64
  } catch {
54
65
  // Unavailable identity is an explicit unresolved result, never an ownership guess.
55
66
  return undefined;