@bitkyc08/opencodex 2.87.0 → 2.89.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 (246) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/index-uchD-ngf.js +94 -0
  7. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  13. package/src/adapters/anthropic-replay.ts +135 -0
  14. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  15. package/src/adapters/anthropic.ts +86 -48
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/google-http.ts +3 -2
  18. package/src/adapters/google-progress-history.ts +53 -0
  19. package/src/adapters/google-vertex-endpoint.ts +67 -0
  20. package/src/adapters/google.ts +52 -30
  21. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  22. package/src/adapters/openai-responses/passthrough.ts +17 -12
  23. package/src/adapters/openai-responses/reasoning.ts +68 -1
  24. package/src/adapters/registry.ts +1 -1
  25. package/src/adapters/upstream-http-error.ts +3 -2
  26. package/src/adapters/zed.ts +7 -4
  27. package/src/bridge/internal.ts +17 -3
  28. package/src/bridge/response-json.ts +4 -4
  29. package/src/bridge/sse.ts +17 -10
  30. package/src/claude/inbound-content-options.ts +66 -0
  31. package/src/claude/inbound.ts +12 -26
  32. package/src/claude/outbound.ts +18 -5
  33. package/src/cli/account-api.ts +13 -1
  34. package/src/cli/account-extended.ts +17 -5
  35. package/src/cli/account-key-quota.ts +4 -1
  36. package/src/cli/account.ts +1 -1
  37. package/src/cli/capabilities-agents-routing.ts +45 -8
  38. package/src/cli/capabilities-base.ts +5 -5
  39. package/src/cli/capabilities-provider-models.ts +5 -4
  40. package/src/cli/combo-result.ts +8 -2
  41. package/src/cli/combo.ts +17 -0
  42. package/src/cli/cross-home-owner.ts +16 -2
  43. package/src/cli/help.ts +1 -1
  44. package/src/cli/loopback-family.ts +31 -0
  45. package/src/cli/message-command.ts +4 -1
  46. package/src/cli/message-remote-args.ts +69 -0
  47. package/src/cli/message-remote-command.ts +77 -0
  48. package/src/cli/provider-runtime.ts +69 -1
  49. package/src/cli/registry.ts +3 -3
  50. package/src/codex/account-pause.ts +6 -1
  51. package/src/codex/account-runtime-state.ts +5 -0
  52. package/src/codex/account-store.ts +30 -3
  53. package/src/codex/auth-api/account-list.ts +3 -0
  54. package/src/codex/auth-api/main-account-probe.ts +3 -2
  55. package/src/codex/auth-context.ts +9 -4
  56. package/src/codex/catalog/aggregation.ts +15 -0
  57. package/src/codex/catalog/auto-review.ts +2 -1
  58. package/src/codex/catalog/build-entries.ts +17 -7
  59. package/src/codex/catalog/derive-entry.ts +3 -1
  60. package/src/codex/catalog/metadata.ts +2 -0
  61. package/src/codex/catalog/model-visibility.ts +0 -2
  62. package/src/codex/catalog/ownership.ts +71 -0
  63. package/src/codex/catalog/parsing.ts +12 -3
  64. package/src/codex/catalog/retained-sync.ts +2 -1
  65. package/src/codex/catalog/routed-gather.ts +3 -1
  66. package/src/codex/catalog/routed-removal.ts +4 -4
  67. package/src/codex/convergence.ts +2 -1
  68. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  69. package/src/codex/low-quota-protection.ts +57 -31
  70. package/src/codex/main-account-cache.ts +77 -3
  71. package/src/codex/main-account.ts +6 -2
  72. package/src/codex/native-residue.ts +2 -3
  73. package/src/codex/quota.ts +26 -2
  74. package/src/codex/websocket-activity.ts +161 -0
  75. package/src/codex/websocket-registry.ts +25 -1
  76. package/src/combos/failover.ts +2 -1
  77. package/src/combos/jev-model-backend.ts +30 -7
  78. package/src/combos/jev-model-request.ts +20 -0
  79. package/src/combos/jev-quota-activation.ts +153 -0
  80. package/src/combos/jev-quota-config.ts +62 -0
  81. package/src/combos/jev-quota-route.ts +26 -0
  82. package/src/combos/jev-quota.ts +88 -0
  83. package/src/combos/jev.ts +25 -12
  84. package/src/combos/types.ts +27 -0
  85. package/src/compatibility/index.ts +3 -0
  86. package/src/compatibility/openai-responses.ts +8 -1
  87. package/src/compatibility/opengateway-responses.ts +61 -0
  88. package/src/config/account-auto-pause-revisions.ts +136 -0
  89. package/src/config/diagnostics.ts +13 -0
  90. package/src/config/live-reconcile.ts +9 -0
  91. package/src/config/opencode-zen-upgrade.ts +9 -8
  92. package/src/config/persist-unlocked.ts +10 -1
  93. package/src/config/removed-opencode-free.ts +135 -0
  94. package/src/config/schema/account-auto-pause.ts +14 -0
  95. package/src/config/schema/config-schema.ts +15 -0
  96. package/src/config/schema/leaf-validators.ts +7 -0
  97. package/src/config.ts +10 -3
  98. package/src/generated/compatibility-version.json +411 -175
  99. package/src/images/loop.ts +101 -27
  100. package/src/lib/account-auto-pause-response.ts +16 -0
  101. package/src/lib/bun-runtime.ts +18 -1
  102. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  103. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  104. package/src/lib/decision-quota-sink.ts +76 -0
  105. package/src/lib/request-failure-model.ts +2 -0
  106. package/src/lib/state-store-registrations.ts +25 -0
  107. package/src/link/ssh-argv.ts +3 -0
  108. package/src/messaging/envelope.ts +6 -3
  109. package/src/messaging/remote-auth.ts +83 -0
  110. package/src/messaging/remote-bridge.ts +270 -0
  111. package/src/messaging/remote-contract.ts +108 -0
  112. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  113. package/src/messaging/remote-enrollment.ts +244 -0
  114. package/src/messaging/remote-envelope.ts +40 -0
  115. package/src/messaging/remote-files.ts +117 -0
  116. package/src/messaging/remote-owner.ts +169 -0
  117. package/src/messaging/remote-ports.ts +56 -0
  118. package/src/messaging/remote-process.ts +131 -0
  119. package/src/messaging/remote-replies.ts +98 -0
  120. package/src/messaging/remote-rpc-admission.ts +32 -0
  121. package/src/messaging/remote-send.ts +72 -0
  122. package/src/messaging/remote-store.ts +111 -0
  123. package/src/messaging/remote-tunnels.ts +65 -0
  124. package/src/messaging/rpc.ts +13 -1
  125. package/src/oauth/account-auto-pause-policy.ts +66 -0
  126. package/src/oauth/account-auto-pause.ts +171 -0
  127. package/src/oauth/account-quota-rank.ts +2 -1
  128. package/src/oauth/anthropic-account-refusal.ts +6 -0
  129. package/src/oauth/anthropic-model-family.ts +8 -0
  130. package/src/oauth/anthropic-model-quota.ts +2 -6
  131. package/src/oauth/api-key-pool-preserve.ts +37 -0
  132. package/src/oauth/generic-account-failover.ts +7 -4
  133. package/src/oauth/index.ts +5 -35
  134. package/src/oauth/store.ts +35 -8
  135. package/src/oauth/types.ts +2 -0
  136. package/src/plugins/upstream-hooks.ts +14 -1
  137. package/src/protocols/encoders/adapter-events.ts +5 -5
  138. package/src/protocols/encoders/messages.ts +2 -2
  139. package/src/providers/api-key-pause.ts +32 -0
  140. package/src/providers/api-key-resolve.ts +10 -4
  141. package/src/providers/api-key-selection.ts +6 -1
  142. package/src/providers/api-keys.ts +8 -4
  143. package/src/providers/claude-cli-usage.ts +5 -0
  144. package/src/providers/key-failover.ts +11 -11
  145. package/src/providers/model-rename-fields.ts +1 -0
  146. package/src/providers/opencode-zen-rate-limit.ts +12 -73
  147. package/src/providers/quota/account-cache.ts +15 -1
  148. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  150. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  151. package/src/providers/quota/antigravity.ts +39 -6
  152. package/src/providers/quota/report-cache.ts +14 -0
  153. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  154. package/src/providers/quota-decision-publication.ts +77 -0
  155. package/src/providers/quota-decision-snapshot.ts +145 -0
  156. package/src/providers/quota-routing-cache.ts +3 -0
  157. package/src/providers/quota-types.ts +6 -1
  158. package/src/providers/quota.ts +3 -2
  159. package/src/providers/registry/entries-extended.ts +8 -52
  160. package/src/providers/registry/model-seeds.ts +1 -1
  161. package/src/providers/registry.ts +1 -2
  162. package/src/providers/stale-vision-classification-migration.ts +1 -1
  163. package/src/responses/parser.ts +18 -10
  164. package/src/responses/reasoning-replay-cache.ts +50 -4
  165. package/src/responses/web-search-replay.ts +138 -0
  166. package/src/routing/analytics.ts +7 -15
  167. package/src/routing/compatibility/behavior.ts +4 -1
  168. package/src/server/auth-cors.ts +7 -0
  169. package/src/server/background-lifecycle.ts +2 -1
  170. package/src/server/chat-native.ts +28 -13
  171. package/src/server/claude-messages.ts +4 -2
  172. package/src/server/index/live-sideband.ts +3 -2
  173. package/src/server/index/optional-listeners.ts +6 -0
  174. package/src/server/index/serve-options.ts +11 -5
  175. package/src/server/index/websocket-handler.ts +18 -4
  176. package/src/server/index.ts +7 -7
  177. package/src/server/lifecycle.ts +13 -1
  178. package/src/server/management/combo-routes.ts +17 -2
  179. package/src/server/management/oauth-account-routes.ts +10 -1
  180. package/src/server/management/provider-completion-test.ts +289 -0
  181. package/src/server/management/provider-routes.ts +9 -1
  182. package/src/server/management/route-registry.ts +2 -0
  183. package/src/server/messages-native.ts +34 -7
  184. package/src/server/messages-shared.ts +4 -1
  185. package/src/server/request-log.ts +7 -2
  186. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  187. package/src/server/responses/adapter-continuation.ts +34 -18
  188. package/src/server/responses/adapter-delivery.ts +53 -6
  189. package/src/server/responses/adapter-dispatch.ts +104 -29
  190. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  191. package/src/server/responses/combo-session-recall.ts +25 -0
  192. package/src/server/responses/combo-stream-preflight.ts +6 -3
  193. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  194. package/src/server/responses/compact.ts +8 -5
  195. package/src/server/responses/compaction-recovery.ts +2 -0
  196. package/src/server/responses/context-overflow.ts +141 -2
  197. package/src/server/responses/core-codex-account.ts +9 -3
  198. package/src/server/responses/core-combo.ts +34 -7
  199. package/src/server/responses/core-lifetime.ts +3 -2
  200. package/src/server/responses/core-options.ts +2 -0
  201. package/src/server/responses/core-replay.ts +5 -1
  202. package/src/server/responses/core.ts +12 -9
  203. package/src/server/responses/empty-completion-guard.ts +2 -0
  204. package/src/server/responses/fetch-helpers.ts +35 -4
  205. package/src/server/responses/jev-model-invoke.ts +13 -14
  206. package/src/server/responses/native-steering-log.ts +2 -2
  207. package/src/server/responses/passthrough-delivery.ts +3 -2
  208. package/src/server/responses/passthrough-dispatch.ts +30 -33
  209. package/src/server/responses/passthrough-execution.ts +3 -2
  210. package/src/server/responses/passthrough-observations.ts +38 -0
  211. package/src/server/responses/request-compression.ts +98 -0
  212. package/src/server/responses/request-prepare.ts +1 -0
  213. package/src/server/responses/request-transport.ts +32 -3
  214. package/src/server/responses/response-effects.ts +1 -0
  215. package/src/server/responses/response-tool-ownership.ts +21 -0
  216. package/src/server/responses/run-turn-execution.ts +2 -0
  217. package/src/server/responses/sidecar-execution.ts +59 -10
  218. package/src/server/responses/sidecar-send-budget.ts +2 -2
  219. package/src/server/responses/terminal-guard.ts +2 -0
  220. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  221. package/src/server/startup-action-control.ts +2 -2
  222. package/src/server/startup-health-cache.ts +2 -2
  223. package/src/server/windows-tray-control.ts +2 -2
  224. package/src/server/ws-bridge.ts +2 -0
  225. package/src/service/definition.ts +9 -1
  226. package/src/types/account-auto-pause.ts +15 -0
  227. package/src/types/config.ts +23 -1
  228. package/src/types/opencode-zen.ts +9 -3
  229. package/src/types/provider.ts +9 -5
  230. package/src/types/request.ts +30 -4
  231. package/src/types/wire.ts +4 -2
  232. package/src/types.ts +2 -0
  233. package/src/usage/cost.ts +17 -1
  234. package/src/usage/expected-prices.ts +3 -1
  235. package/src/usage/jev-stats.ts +4 -0
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/summary.ts +16 -3
  238. package/src/usage/telemetry-contract.ts +1 -0
  239. package/src/web-search/anthropic-history.ts +59 -0
  240. package/src/web-search/loop.ts +212 -73
  241. package/src/web-search/progress-stream.ts +14 -1
  242. package/src/web-search/run-turn-loop.ts +9 -3
  243. package/src/web-search/sidecar-search-replay.ts +96 -0
  244. package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
  245. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  246. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -0,0 +1,131 @@
1
+ import { linkSshSpawnEnv } from "../link/ssh-runner";
2
+ import { MessageBudget } from "./budget";
3
+ import { RemoteCapacity, remoteError, REMOTE_LIMITS } from "./remote-contract";
4
+ import { LocalMessagingError } from "./types";
5
+
6
+ export interface RemoteHelper {
7
+ readonly pid: number; readonly exited: Promise<number>; readonly output: Promise<string>;
8
+ close(): Promise<void>;
9
+ }
10
+ /** Narrow subprocess seam for offline lifecycle tests; production always uses owned Bun children. */
11
+ export interface RemoteHelperProcess {
12
+ readonly pid: number; readonly exitCode: number | null; readonly exited: Promise<number>;
13
+ readonly stdout: ReadableStream<Uint8Array>; readonly stderr: ReadableStream<Uint8Array>;
14
+ readonly stdin: Pick<Bun.FileSink, "write" | "end">;
15
+ }
16
+ export interface RemoteHelperOptions {
17
+ purpose?: "persistent" | "transient";
18
+ spawn?: (argv: readonly string[], env: NodeJS.ProcessEnv | undefined) => RemoteHelperProcess;
19
+ signalGroup?: (pid: number, kind: NodeJS.Signals) => void;
20
+ successCodes?: readonly number[];
21
+ }
22
+ /** Spawn only invocation-owned process groups; bounded drains and cleanup are shared by helpers/tunnels. */
23
+ export function spawnRemoteHelper(argv: readonly string[], capacity: RemoteCapacity, signal?: AbortSignal,
24
+ stdin?: string, env = linkSshSpawnEnv(), options: RemoteHelperOptions = {}): RemoteHelper {
25
+ if (signal?.aborted) throw remoteError("cancelled", "Messaging helper was cancelled before spawn.");
26
+ const release = capacity.reserveHelper(options.purpose ?? "persistent");
27
+ let child: RemoteHelperProcess;
28
+ try { child = options.spawn ? options.spawn(argv, env)
29
+ : Bun.spawn([...argv], { stdin: "pipe", stdout: "pipe", stderr: "pipe", detached: true, env }); }
30
+ catch { release(); throw remoteError("ssh_unavailable", "The messaging SSH helper could not start."); }
31
+ const readers = [child.stdout.getReader(), child.stderr.getReader()];
32
+ let closing: Promise<void> | undefined;
33
+ let failure: Error | undefined;
34
+ const cancelledPipes = [false, false];
35
+ const pipeEnds: (() => void)[] = [];
36
+ const naturalPipes = readers.map(() => new Promise<void>(resolve => { pipeEnds.push(resolve); }));
37
+ const physicallyClosed = Promise.all([child.exited, ...naturalPipes]);
38
+ void physicallyClosed.then(() => release()).catch(() => {});
39
+ let stopOutput!: (reason: unknown) => void;
40
+ const stopped = new Promise<never>((_, reject) => { stopOutput = reject; });
41
+ const signalGroup = (kind: NodeJS.Signals) => {
42
+ if (child.exitCode !== null) return;
43
+ try { if (options.signalGroup) options.signalGroup(child.pid, kind); else process.kill(-child.pid, kind); }
44
+ catch { /* Exit racing owned cleanup is harmless. */ }
45
+ };
46
+ const close = (): Promise<void> => closing ??= (async () => {
47
+ signal?.removeEventListener("abort", abort);
48
+ signalGroup("SIGTERM");
49
+ const force = setTimeout(() => signalGroup("SIGKILL"), 500);
50
+ let timer: ReturnType<typeof setTimeout> | undefined;
51
+ let cleanupFailure: unknown;
52
+ try {
53
+ await Promise.race([physicallyClosed, new Promise<never>((_, reject) => {
54
+ timer = setTimeout(() => reject(remoteError("cleanup_incomplete", "An owned messaging helper or its pipes did not settle within cleanup budget.")), REMOTE_LIMITS.shutdownMs);
55
+ })]);
56
+ } catch (error) {
57
+ cleanupFailure = error;
58
+ if (error instanceof Error && failure) error.cause = failure;
59
+ throw error; // Incomplete cleanup remains visible even when an earlier failure triggered it.
60
+ }
61
+ finally {
62
+ clearTimeout(force); clearTimeout(timer);
63
+ // Escaped descendants retaining a pipe cannot hold shutdown open forever.
64
+ for (const [index, reader] of readers.entries()) {
65
+ cancelledPipes[index] = true; void reader.cancel().catch(() => {});
66
+ }
67
+ stopOutput(cleanupFailure ?? failure ?? remoteError("ssh_incomplete", "Messaging helper output capture was stopped."));
68
+ // Cancellation cannot prove natural EOF; unresolved ownership keeps its helper capacity charged.
69
+ }
70
+ })();
71
+ const abort = () => { void close().catch(() => {}); };
72
+ signal?.addEventListener("abort", abort, { once: true });
73
+ if (signal?.aborted) abort();
74
+ const drain = async (index: number): Promise<string> => {
75
+ const chunks: Uint8Array[] = [], reservations: (() => void)[] = [];
76
+ let bytes = 0, discarding = false;
77
+ try {
78
+ for (;;) {
79
+ const next = await readers[index]!.read();
80
+ if (next.done) { if (!cancelledPipes[index]) pipeEnds[index]!(); break; }
81
+ if (index === 1 || discarding) continue; // stderr is never retained or returned.
82
+ try {
83
+ bytes += next.value.byteLength;
84
+ if (bytes > 64 * 1024) throw remoteError("ssh_output_limit", "Messaging helper output exceeded its limit.");
85
+ reservations.push(capacity.reserve("outputBytes", next.value.byteLength || 1)); chunks.push(next.value);
86
+ } catch (error) {
87
+ failure ??= error instanceof LocalMessagingError ? error : remoteError("ssh_incomplete", "Messaging helper output could not be captured safely.");
88
+ discarding = true; chunks.length = 0;
89
+ for (const free of reservations.splice(0)) free();
90
+ abort(); // Continue discarding until natural EOF proves cleanup, rather than abandoning the reader.
91
+ }
92
+ }
93
+ return index === 0 ? new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)) : "";
94
+ } catch {
95
+ failure ??= remoteError("ssh_incomplete", "Messaging helper output could not be captured safely.");
96
+ abort(); return "";
97
+ } finally { for (const free of reservations) free(); readers[index]!.releaseLock(); }
98
+ };
99
+ const input = (async () => {
100
+ if (stdin !== undefined) await child.stdin.write(stdin);
101
+ await child.stdin.end();
102
+ })().catch(() => {
103
+ failure ??= remoteError("ssh_incomplete", "Messaging control input could not be written.");
104
+ abort(); throw failure;
105
+ });
106
+ const output = Promise.race([stopped, Promise.all([drain(0), drain(1), input]).then(([out]) => {
107
+ if (failure) throw failure; return out;
108
+ })]);
109
+ void output.catch(() => {});
110
+ void child.exited.then(() => { signal?.removeEventListener("abort", abort); }).catch(() => {});
111
+ return { pid: child.pid, exited: child.exited, output, close };
112
+ }
113
+
114
+ /** One bounded batch covers helper startup, stdin, exit and output; errors never include raw output. */
115
+ export async function runRemoteHelper(argv: readonly string[], budget: MessageBudget,
116
+ capacity: RemoteCapacity, stdin?: string, env?: NodeJS.ProcessEnv, options: RemoteHelperOptions = {}): Promise<string> {
117
+ budget.throwIfEnded();
118
+ const helper = spawnRemoteHelper(argv, capacity, budget.signal, stdin, env ?? linkSshSpawnEnv(), { ...options, purpose: "transient" });
119
+ let cancelled!: () => void;
120
+ const cancellation = new Promise<never>((_, reject) => {
121
+ cancelled = () => reject(budget.signal.reason);
122
+ budget.signal.addEventListener("abort", cancelled, { once: true });
123
+ if (budget.signal.aborted) cancelled();
124
+ });
125
+ try {
126
+ const [code, output] = await Promise.race([Promise.all([helper.exited, helper.output]), cancellation]);
127
+ budget.throwIfEnded();
128
+ if (!(options.successCodes ?? [0]).includes(code)) throw remoteError(code === 127 ? "remote_ocx_missing" : "ssh_failed", "Messaging SSH command did not complete successfully.");
129
+ return output;
130
+ } finally { budget.signal.removeEventListener("abort", cancelled); await helper.close(); }
131
+ }
@@ -0,0 +1,98 @@
1
+ import { exactRecord, remoteError, type RemotePeer } from "./remote-contract";
2
+ import type { RemoteMessageStore } from "./remote-store";
3
+ import type { messageEnvelope } from "./envelope";
4
+ import { isThreadId } from "./types";
5
+
6
+ interface ReplyGrant {
7
+ peer: string;
8
+ transaction: string;
9
+ thread: string;
10
+ expires: number;
11
+ busy: boolean;
12
+ }
13
+ export const REPLY_GRANT_LIMIT = 256;
14
+ export const REPLY_GRANT_MS = 24 * 60 * 60 * 1000;
15
+
16
+ /** Owner-lifetime, bounded return routes; enrollment alone never authorizes unrelated return traffic. */
17
+ export class RemoteReplyGrants {
18
+ private readonly grants = new Map<string, ReplyGrant>();
19
+ constructor(private readonly store: RemoteMessageStore, private readonly now = () => performance.now()) {}
20
+
21
+ private current(peer: RemotePeer) {
22
+ return this.store.requireEnabled().peers.find(item => item.machine.id === peer.machine.id
23
+ && item.transaction === peer.transaction && item.incoming === peer.incoming && item.outgoing === peer.outgoing);
24
+ }
25
+ private prune() {
26
+ const peers = this.store.requireEnabled().peers;
27
+ for (const [id, grant] of this.grants) {
28
+ if (grant.expires <= this.now() || !peers.some(peer =>
29
+ peer.machine.id === grant.peer && peer.transaction === grant.transaction)) {
30
+ this.grants.delete(id);
31
+ }
32
+ }
33
+ }
34
+ /** Local-control only: register before dispatch, forget only on definite request non-submission. */
35
+ control(value: unknown) {
36
+ const raw = exactRecord(value, ["peerId", "transaction", "messageId", "threadId", "action"]);
37
+ if (![raw.peerId, raw.transaction, raw.messageId, raw.threadId].every(isThreadId)
38
+ || !["open", "forget"].includes(String(raw.action))) {
39
+ throw remoteError("invalid_reply_route", "Invalid reply route registration.");
40
+ }
41
+ const peer = this.store.peer(raw.peerId as string);
42
+ if (peer.transaction !== raw.transaction) {
43
+ throw remoteError("peer_revoked", "Reply route enrollment changed.");
44
+ }
45
+ this.prune();
46
+ const id = raw.messageId as string;
47
+ if (raw.action === "forget") {
48
+ const existing = this.grants.get(id);
49
+ if (existing && (existing.peer !== raw.peerId || existing.transaction !== raw.transaction
50
+ || existing.thread !== raw.threadId || existing.busy)) {
51
+ throw remoteError("invalid_reply_route", "Reply route is not removable.");
52
+ }
53
+ this.grants.delete(id);
54
+ return { registered: false };
55
+ }
56
+ if (this.grants.has(id) || this.grants.size >= REPLY_GRANT_LIMIT) {
57
+ throw remoteError("remote_capacity", "The owner's bounded reply-route capacity was reached; no new request was dispatched.");
58
+ }
59
+ this.grants.set(id, { peer: peer.machine.id, transaction: peer.transaction, thread: raw.threadId as string,
60
+ expires: this.now() + REPLY_GRANT_MS, busy: false });
61
+ return { registered: true };
62
+ }
63
+ /** Response-only peers can discover only the original local senders awaiting their response. */
64
+ visible(peer: RemotePeer, thread: string): boolean {
65
+ this.prune();
66
+ const current = this.current(peer);
67
+ return !!current && (current.allowInbound === true || [...this.grants.values()].some(grant =>
68
+ grant.peer === peer.machine.id && grant.transaction === peer.transaction && grant.thread === thread && !grant.busy));
69
+ }
70
+ /** Reserve synchronously before native dispatch; an uncertain response consumes its grant, never replays it. */
71
+ begin(peer: RemotePeer, header: ReturnType<typeof messageEnvelope>["header"], thread: string) {
72
+ this.prune();
73
+ const current = this.current(peer);
74
+ if (!current) {
75
+ throw remoteError("peer_revoked", "Message enrollment changed before dispatch.");
76
+ }
77
+ const grant = header.kind === "response" && header.inReplyTo ? this.grants.get(header.inReplyTo) : undefined;
78
+ if (!grant || grant.peer !== peer.machine.id || grant.transaction !== peer.transaction
79
+ || grant.thread !== thread) {
80
+ if (current.allowInbound === true) {
81
+ return (_submitted: boolean) => {};
82
+ }
83
+ throw remoteError("reply_route_required", "This peer may send only a correlated response to an opened local reply route.");
84
+ }
85
+ if (grant.busy) {
86
+ throw remoteError("reply_route_required", "A correlated response is already in flight on this reply route.");
87
+ }
88
+ // Broad consent may allow unrelated traffic, but a matching response still spends its one-shot grant.
89
+ grant.busy = true;
90
+ return (submitted: boolean) => {
91
+ if (submitted) {
92
+ this.grants.delete(header.inReplyTo!);
93
+ } else {
94
+ grant.busy = false;
95
+ }
96
+ };
97
+ }
98
+ }
@@ -0,0 +1,32 @@
1
+ import { exactRecord, remoteError } from "./remote-contract";
2
+ import { isThreadId } from "./types";
3
+
4
+ /** Validate only the native operations this gateway implements; never pass arbitrary JSON-RPC through. */
5
+ export function admitRemoteRpc(method: string, value: unknown): Record<string, unknown> {
6
+ if (method === "initialize") {
7
+ const raw = exactRecord(value, ["clientInfo", "capabilities"]);
8
+ const client = exactRecord(raw.clientInfo, ["name", "version"]);
9
+ const caps = exactRecord(raw.capabilities, ["experimentalApi"]);
10
+ if (client.name !== "opencodex_message" || client.version !== "1.0.0" || caps.experimentalApi !== true) throw new Error();
11
+ return raw;
12
+ }
13
+ if (method === "initialized") return exactRecord(value, []);
14
+ if (method === "thread/loaded/list") {
15
+ const raw = exactRecord(value, value && typeof value === "object" && Object.hasOwn(value, "cursor") ? ["limit", "cursor"] : ["limit"]);
16
+ if (raw.limit !== 50 || (raw.cursor !== undefined && (typeof raw.cursor !== "string" || !raw.cursor || raw.cursor.length > 4096))) throw new Error();
17
+ return raw;
18
+ }
19
+ if (method === "thread/read") {
20
+ const raw = exactRecord(value, ["threadId", "includeTurns"]);
21
+ if (!isThreadId(raw.threadId) || raw.includeTurns !== false) throw new Error(); return raw;
22
+ }
23
+ if (method === "thread/queue/add") {
24
+ const raw = exactRecord(value, ["threadId", "input", "clientUserMessageId"]);
25
+ if (!isThreadId(raw.threadId) || !isThreadId(raw.clientUserMessageId) || !Array.isArray(raw.input) || raw.input.length !== 1) throw new Error();
26
+ const input = exactRecord(raw.input[0], ["type", "text"]);
27
+ if (input.type !== "text" || typeof input.text !== "string" || !input.text.trim()
28
+ || input.text.includes("\0") || Buffer.byteLength(input.text) > 32 * 1024) throw new Error();
29
+ return raw;
30
+ }
31
+ throw remoteError("remote_method_refused", "This RPC method is not admitted by the messaging gateway.");
32
+ }
@@ -0,0 +1,72 @@
1
+ import { MessageBudget } from "./budget";
2
+ import { authenticatedRemoteSocket, remoteControl } from "./remote-auth";
3
+ import { discoverLoaded, resolveLoaded } from "./discovery";
4
+ import { messageEnvelope, validateMessage, type MessageOptions } from "./envelope";
5
+ import { ownerEndpoint, resolveRemoteRoute } from "./remote-owner";
6
+ import { LocalMessageRpc } from "./rpc";
7
+ import { localDaemonEndpoint } from "./socket";
8
+ import { messageFailure, type MessageReceipt } from "./send";
9
+ import type { RemoteMessageStore } from "./remote-store";
10
+ import { isThreadId, LocalMessagingError } from "./types";
11
+
12
+ /** Remote discovery is explicit and loaded-only; it uses a live owner route and one proven connection. */
13
+ export async function remoteSessions(store: RemoteMessageStore, host: string, budget: MessageBudget) {
14
+ const route = await resolveRemoteRoute(store, host, budget);
15
+ store.requireCurrentPeer(route.state, route.peer);
16
+ const rpc = await LocalMessageRpc.attach(await authenticatedRemoteSocket(route.state, route.endpoint, budget), budget);
17
+ try {
18
+ const sessions = await discoverLoaded(rpc, budget);
19
+ store.requireCurrentPeer(route.state, route.peer);
20
+ return { machine: route.peer.machine, sessions };
21
+ }
22
+ finally { rpc.close(); }
23
+ }
24
+ /** Attribute locally, discover/revalidate/queue remotely on one socket, and never replay an unknown result. */
25
+ export async function sendRemoteMessage(store: RemoteMessageStore,
26
+ options: MessageOptions & { host: string; thread?: string; name?: string; body: string },
27
+ context: { home: string; senderId?: string }, budget: MessageBudget) {
28
+ const receipt: MessageReceipt & { machine?: { id: string; name: string }; targetMachine?: { id: string; name: string } } = {
29
+ schema: "ocx-message/1", messageId: crypto.randomUUID(), kind: options.kind, inReplyTo: options.inReplyTo ?? null,
30
+ status: "not_sent", sender: null, target: null,
31
+ };
32
+ let rpc: LocalMessageRpc | undefined;
33
+ try {
34
+ validateMessage(options, options.body);
35
+ if (context.senderId !== undefined && !isThreadId(context.senderId)) throw new LocalMessagingError("invalid_sender", "CODEX_THREAD_ID must be a UUID, not a claimed peer identity.");
36
+ const route = await resolveRemoteRoute(store, options.host, budget);
37
+ receipt.machine = route.state.machine; receipt.targetMachine = route.peer.machine;
38
+ let sender = null;
39
+ if (context.senderId) {
40
+ const local = await LocalMessageRpc.connect(localDaemonEndpoint(context.home).url, budget);
41
+ try { sender = resolveLoaded(await discoverLoaded(local, budget), { thread: context.senderId }); }
42
+ finally { local.close(); }
43
+ }
44
+ receipt.sender = sender ? { threadId: sender.id, name: sender.name, identitySource: "CODEX_THREAD_ID" } : null;
45
+ store.requireCurrentPeer(route.state, route.peer);
46
+ rpc = await LocalMessageRpc.attach(await authenticatedRemoteSocket(route.state, route.endpoint, budget), budget);
47
+ const target = resolveLoaded(await discoverLoaded(rpc, budget), options);
48
+ receipt.target = { threadId: target.id, name: target.name };
49
+ const envelope = messageEnvelope(receipt.messageId, options, options.body, sender, route.state.machine);
50
+ const fresh = await rpc.readThread(target.id);
51
+ if (fresh.status === "notLoaded") throw new LocalMessagingError("target_not_loaded", "The destination unloaded before remote submission.");
52
+ budget.throwIfEnded();
53
+ store.requireCurrentPeer(route.state, route.peer);
54
+ const replyRoute = sender && options.kind === "request" ? {
55
+ peerId: route.peer.machine.id, transaction: route.peer.transaction,
56
+ messageId: receipt.messageId, threadId: sender.id,
57
+ } : undefined;
58
+ if (replyRoute) {
59
+ await remoteControl(route.state, ownerEndpoint(route.state), "message/reply-route", { ...replyRoute, action: "open" }, budget);
60
+ store.requireCurrentPeer(route.state, route.peer);
61
+ budget.throwIfEnded();
62
+ }
63
+ Object.assign(receipt, await rpc.queueMessage(target.id, envelope.text, receipt.messageId));
64
+ if (replyRoute && receipt.status === "not_sent") {
65
+ try {
66
+ await remoteControl(route.state, ownerEndpoint(route.state), "message/reply-route", { ...replyRoute, action: "forget" }, budget);
67
+ } catch { /* No message was submitted; an unused bounded grant expires with the owner or its deadline. */ }
68
+ }
69
+ } catch (error) { receipt.error = messageFailure(error); }
70
+ finally { rpc?.close(); }
71
+ return receipt;
72
+ }
@@ -0,0 +1,111 @@
1
+ import { hostname } from "node:os";
2
+ import { join } from "node:path";
3
+ import { isSshAlias } from "../link/ssh-argv";
4
+ import { isThreadId } from "./types";
5
+ import { capability, exactRecord, REMOTE_LIMITS, REMOTE_PROTOCOL, remoteError, validAlias,
6
+ validCapability, validMachine, validPort, type RemotePeer, type RemoteState } from "./remote-contract";
7
+ import { createRemoteDirectory, readRemoteFile, withRemoteLock, writeRemoteFile } from "./remote-files";
8
+
9
+ /** Strict versioned state; neither unknown fields nor duplicate aliases/identities are ignored. */
10
+ export function parseRemoteState(value: unknown): RemoteState {
11
+ const raw = exactRecord(value, ["protocol", "machine", "enabled", "port", "controlKey", "generation", "peers"]);
12
+ if (raw.protocol !== REMOTE_PROTOCOL || !validMachine(raw.machine) || typeof raw.enabled !== "boolean"
13
+ || !validPort(raw.port) || !validCapability(raw.controlKey) || !isThreadId(raw.generation)
14
+ || !Array.isArray(raw.peers) || raw.peers.length > REMOTE_LIMITS.peers) throw remoteError("invalid_remote_state", "Messaging state has an invalid schema.");
15
+ const aliases = new Set<string>(), ids = new Set<string>(), transactions = new Set<string>();
16
+ for (const item of raw.peers) {
17
+ const peer = exactRecord(item, ["alias", "machine", "transaction", "incoming", "outgoing", "port", "ssh", "hostKey", "fingerprint",
18
+ ...(Object.hasOwn(item, "allowInbound") ? ["allowInbound"] : [])]);
19
+ if (!validAlias(peer.alias) || !validMachine(peer.machine) || peer.machine.id === raw.machine.id
20
+ || !isThreadId(peer.transaction) || !validCapability(peer.incoming) || !validCapability(peer.outgoing)
21
+ || peer.incoming === peer.outgoing || !validPort(peer.port)
22
+ || (Object.hasOwn(peer, "allowInbound") && typeof peer.allowInbound !== "boolean")
23
+ || (peer.ssh !== null && (typeof peer.ssh !== "string" || !isSshAlias(peer.ssh)))
24
+ || (peer.hostKey !== null && (typeof peer.hostKey !== "string" || peer.hostKey.length > 8192))
25
+ || (peer.fingerprint !== null && (typeof peer.fingerprint !== "string" || !/^SHA256:[A-Za-z0-9+/=]{1,64}$/.test(peer.fingerprint)))
26
+ || (peer.ssh === null) !== (peer.hostKey === null) || (peer.ssh === null) !== (peer.fingerprint === null)
27
+ || aliases.has(peer.alias) || ids.has(peer.machine.id) || transactions.has(peer.transaction)) throw remoteError("invalid_remote_state", "Messaging peer state is invalid or ambiguous.");
28
+ aliases.add(peer.alias); ids.add(peer.machine.id); transactions.add(peer.transaction);
29
+ }
30
+ return raw as unknown as RemoteState;
31
+ }
32
+
33
+ /** Messaging owns a small separate private JSON store, never Remote Link/provider state. */
34
+ export class RemoteMessageStore {
35
+ readonly directory: string;
36
+ readonly path: string;
37
+ /** Select an explicit configuration home without reading or creating it. */
38
+ constructor(home: string) { this.directory = join(home, "messaging-remote"); this.path = join(this.directory, "state.json"); }
39
+ /** Absent state stays absent; malformed or unsafe state does not become a default. */
40
+ read(): RemoteState | null {
41
+ const text = readRemoteFile(this.path);
42
+ if (text === null) return null;
43
+ try { return parseRemoteState(JSON.parse(text)); }
44
+ catch { throw remoteError("invalid_remote_state", "Messaging state cannot be safely interpreted."); }
45
+ }
46
+ /** Enable explicitly, retaining stable identity and existing peer capabilities. */
47
+ enable(port = 39176): RemoteState {
48
+ if (!validPort(port)) throw remoteError("invalid_port", "Choose an unprivileged messaging port.");
49
+ createRemoteDirectory(this.directory);
50
+ return withRemoteLock(this.directory, () => {
51
+ const state = this.read() ?? { protocol: REMOTE_PROTOCOL, machine: { id: crypto.randomUUID(),
52
+ name: hostname().replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, 128) || "node" }, enabled: false,
53
+ port, controlKey: capability(), generation: crypto.randomUUID(), peers: [] };
54
+ if (!state.enabled || state.port !== port) state.generation = crypto.randomUUID();
55
+ state.enabled = true; state.port = port;
56
+ writeRemoteFile(this.path, JSON.stringify(parseRemoteState(state)));
57
+ return state;
58
+ });
59
+ }
60
+ /** Publish under one lock, then complete related journal cleanup before releasing ownership. */
61
+ mutate(action: (state: RemoteState) => void, afterPublish?: () => void): RemoteState {
62
+ this.requireEnabled();
63
+ return withRemoteLock(this.directory, () => {
64
+ const state = this.requireEnabled(); action(state);
65
+ writeRemoteFile(this.path, JSON.stringify(parseRemoteState(state))); afterPublish?.(); return state;
66
+ });
67
+ }
68
+ /** Disabling revokes current admission and invalidates every owner generation. */
69
+ disable(): void { this.mutate(state => { state.enabled = false; state.generation = crypto.randomUUID(); }); }
70
+ /** Require deliberate enablement rather than repairing or creating state on reads. */
71
+ requireEnabled(): RemoteState {
72
+ const state = this.read();
73
+ if (!state?.enabled) throw remoteError("remote_disabled", "Explicitly enable messaging on this node first.");
74
+ return state;
75
+ }
76
+ /** Select only one exact enrolled alias or machine UUID. */
77
+ peer(selector: string): RemotePeer {
78
+ const peers = this.requireEnabled().peers.filter(peer => peer.alias === selector || peer.machine.id === selector);
79
+ if (peers.length !== 1) throw remoteError("unknown_peer", "No unique enrolled messaging peer matches this exact host.");
80
+ return peers[0]!;
81
+ }
82
+ /** Local, explicit consent only; possession of a peer capability cannot widen inbound access. */
83
+ setInbound(selector: string, allow: boolean) {
84
+ const peer = this.peer(selector);
85
+ this.mutate(state => {
86
+ const current = state.peers.find(item => item.machine.id === peer.machine.id && item.transaction === peer.transaction);
87
+ if (!current) throw remoteError("peer_revoked", "Enrollment changed before inbound consent was saved.");
88
+ current.allowInbound = allow;
89
+ });
90
+ return { machine: peer.machine, inbound: allow ? "messages" : "responses-only" };
91
+ }
92
+ /** Revalidate captured route authority after awaits without retiring unrelated peer routes. */
93
+ requireCurrentPeer(state: RemoteState, peer: RemotePeer): void {
94
+ const current = this.requireEnabled();
95
+ const enrolled = current.peers.find(item => item.machine.id === peer.machine.id);
96
+ if (current.generation !== state.generation || current.machine.id !== state.machine.id
97
+ || current.controlKey !== state.controlKey || current.port !== state.port || !enrolled
98
+ || enrolled.transaction !== peer.transaction || enrolled.incoming !== peer.incoming
99
+ || enrolled.outgoing !== peer.outgoing || enrolled.port !== peer.port) {
100
+ throw remoteError("peer_revoked", "Messaging route authority changed before dispatch; no new work was sent.");
101
+ }
102
+ }
103
+ /** Listings never include capabilities, known-hosts records or private configuration paths. */
104
+ publicState() {
105
+ const state = this.read();
106
+ return state ? { protocol: state.protocol, machine: state.machine, enabled: state.enabled, port: state.port,
107
+ peers: state.peers.map(peer => ({ alias: peer.alias, machine: peer.machine, direction: peer.ssh ? "initiating" : "return-only",
108
+ inbound: peer.allowInbound === true ? "messages" : "responses-only",
109
+ fingerprint: peer.fingerprint })) } : { protocol: REMOTE_PROTOCOL, enabled: false, peers: [] };
110
+ }
111
+ }
@@ -0,0 +1,65 @@
1
+ import { join } from "node:path";
2
+ import { buildExecArgv, buildTunnelArgv, remoteOcxArgv } from "../link/ssh-argv";
3
+ import { MessageBudget } from "./budget";
4
+ import { exactRecord, RemoteCapacity, remoteError, validPort, type RemotePeer } from "./remote-contract";
5
+ import { withRemoteLock, writeRemoteFile } from "./remote-files";
6
+ import { remotePortCandidate, remotePortIsLoopback } from "./remote-ports";
7
+ import { runRemoteHelper, spawnRemoteHelper, type RemoteHelper } from "./remote-process";
8
+ import type { RemoteMessageStore } from "./remote-store";
9
+
10
+ export interface RemoteTunnelPair {
11
+ localPort: number; returnPort: number; readonly alive: boolean; readonly exited: Promise<unknown>;
12
+ close(): Promise<void>;
13
+ }
14
+ export interface RemoteTunnelDeps {
15
+ run?: typeof runRemoteHelper;
16
+ spawn?: typeof spawnRemoteHelper;
17
+ }
18
+ /** Own one -L/-R pair with shared SSH policy and one setup deadline; never adopt/kill existing tunnels. */
19
+ export async function startRemoteTunnels(store: RemoteMessageStore, peer: RemotePeer, budget: MessageBudget,
20
+ capacity: RemoteCapacity, ownerSignal: AbortSignal, deps: RemoteTunnelDeps = {}): Promise<RemoteTunnelPair> {
21
+ if (!peer.ssh || !peer.hostKey) throw remoteError("return_route_only", "This peer has no initiating SSH enrollment; wait for its return route.");
22
+ const knownHosts = join(store.directory, `${peer.machine.id}.known_hosts`);
23
+ withRemoteLock(store.directory, () => writeRemoteFile(knownHosts, peer.hostKey!));
24
+ const result = exactRecord(JSON.parse(await (deps.run ?? runRemoteHelper)(buildExecArgv({ alias: peer.ssh,
25
+ knownHostsFile: knownHosts, argv: remoteOcxArgv(["message", "_port"]) }), budget, capacity)), ["port"]);
26
+ if (!validPort(result.port)) throw remoteError("remote_incompatible", "Remote node did not offer an unprivileged return port.");
27
+ const localPort = remotePortCandidate(), returnPort = result.port, helpers: RemoteHelper[] = [];
28
+ let alive = true, closing: Promise<void> | undefined;
29
+ const close = (): Promise<void> => closing ??= (async () => {
30
+ alive = false; ownerSignal.removeEventListener("abort", abort);
31
+ const results = await Promise.allSettled(helpers.map(helper => helper.close()));
32
+ const failure = results.find(result => result.status === "rejected");
33
+ if (failure?.status === "rejected") throw failure.reason;
34
+ })();
35
+ const abort = () => { void close().catch(() => {}); };
36
+ ownerSignal.addEventListener("abort", abort, { once: true });
37
+ const cancelSetup = () => { void close().catch(() => {}); };
38
+ budget.signal.addEventListener("abort", cancelSetup, { once: true });
39
+ try {
40
+ budget.throwIfEnded(); if (ownerSignal.aborted) throw new Error();
41
+ for (const [direction, bindPort, targetPort] of [["L", localPort, peer.port], ["R", returnPort, store.requireEnabled().port]] as const) {
42
+ budget.throwIfEnded();
43
+ const helper = (deps.spawn ?? spawnRemoteHelper)(buildTunnelArgv({ alias: peer.ssh, knownHostsFile: knownHosts,
44
+ direction, bindPort, targetPort }), capacity, ownerSignal);
45
+ helpers.push(helper);
46
+ void helper.exited.then(() => close()).catch(() => {});
47
+ }
48
+ let ready = false;
49
+ while (!ready) {
50
+ budget.throwIfEnded();
51
+ if (!alive) throw new Error();
52
+ ready = await remotePortIsLoopback(localPort, budget, capacity);
53
+ if (!ready) await new Promise<void>((resolve, reject) => {
54
+ const timer = setTimeout(() => { budget.signal.removeEventListener("abort", cancel); resolve(); }, budget.remainingMs(100));
55
+ const cancel = () => { clearTimeout(timer); budget.signal.removeEventListener("abort", cancel); reject(budget.signal.reason); };
56
+ budget.signal.addEventListener("abort", cancel, { once: true });
57
+ if (budget.signal.aborted) cancel();
58
+ });
59
+ }
60
+ const exited = Promise.race(helpers.map(helper => helper.exited));
61
+ void exited.then(() => close()).catch(() => {});
62
+ return { localPort, returnPort, get alive() { return alive; }, exited, close };
63
+ } catch (error) { await close(); throw error; }
64
+ finally { budget.signal.removeEventListener("abort", cancelSetup); }
65
+ }
@@ -59,6 +59,16 @@ export class LocalMessageRpc implements LocalMetadataClient {
59
59
  if (budget.signal.aborted) abort();
60
60
  });
61
61
  } catch (error) { socket.terminate(); throw error; }
62
+ return LocalMessageRpc.attach(socket, budget, rpcTimeoutMs);
63
+ }
64
+
65
+ /** Initialize an already authenticated transport; local connect still accepts Unix sockets only. */
66
+ static async attach(socket: LocalSocket, budget: MessageBudget, rpcTimeoutMs = 10_000): Promise<LocalMessageRpc> {
67
+ try { budget.throwIfEnded(); } catch (error) { socket.terminate(); throw error; }
68
+ if (socket.readyState !== 1 || !Number.isInteger(rpcTimeoutMs) || rpcTimeoutMs <= 0 || rpcTimeoutMs > 10_000) {
69
+ socket.terminate();
70
+ throw new LocalMessagingError("invalid_transport", "Messaging transport must be connected with a bounded RPC deadline.");
71
+ }
62
72
  const rpc = new LocalMessageRpc(socket, budget, rpcTimeoutMs);
63
73
  try {
64
74
  const result = await rpc.request("initialize", {
@@ -97,7 +107,8 @@ export class LocalMessageRpc implements LocalMetadataClient {
97
107
  }
98
108
 
99
109
  /** Submit once on this connection; after an accepted write only a correlated reply is definitive. */
100
- async queueMessage(threadId: string, text: string, messageId: string): Promise<QueueResult> {
110
+ async queueMessage(threadId: string, text: string, messageId: string,
111
+ acknowledge?: (submissionId: string) => void): Promise<QueueResult> {
101
112
  let written = false;
102
113
  try {
103
114
  if (!isThreadId(threadId) || !isThreadId(messageId) || !text || text.includes("\0")
@@ -115,6 +126,7 @@ export class LocalMessageRpc implements LocalMetadataClient {
115
126
  && (!Array.isArray(queued.input[0].text_elements) || queued.input[0].text_elements.length !== 0))) {
116
127
  throw this.invalidMetadata();
117
128
  }
129
+ acknowledge?.(queued.id);
118
130
  return { status: "queued" };
119
131
  } catch (error) {
120
132
  if (error instanceof QueueRejection || !written) {
@@ -0,0 +1,66 @@
1
+ import type { AccountPauseReason, AccountAutoPausePolicy } from "../types/account-auto-pause";
2
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
+
4
+ /** Only whole, account-scoped messages are authority; model/resource policy errors are not. */
5
+ export function classifyPermanentAccountRefusal(status: number, payload: unknown): AccountPauseReason | undefined {
6
+ if (status !== 401 && status !== 403 || !payload || typeof payload !== "object" || Array.isArray(payload)) return;
7
+ const error = (payload as { error?: unknown }).error;
8
+ if (!error || typeof error !== "object" || Array.isArray(error)) return;
9
+ const { type: rawType, code, message, status: googleStatus } = error as Record<string, unknown>;
10
+ const google = googleStatus === "PERMISSION_DENIED" || googleStatus === "UNAUTHENTICATED";
11
+ const type = rawType ?? (googleStatus === "PERMISSION_DENIED" ? "permission_error" : googleStatus === "UNAUTHENTICATED" ? "authentication_error" : undefined);
12
+ if (typeof message !== "string" || !["permission_error", "authentication_error", "invalid_request_error"].includes(String(type))) return;
13
+ if (code != null && !(google && code === status) && !["permission_error", "permission_denied", "subscription_inactive", "subscription_expired", "subscription_required", "plan_downgraded"].includes(String(code))) return;
14
+ const text = message.trim();
15
+ if (/^OAuth authentication is (?:currently )?not allowed for this organi[sz]ation[.!]?$/i.test(text)) return "oauth_organization_disabled";
16
+ if (/^(?:Your |The )?(?:Claude |ChatGPT )?subscription (?:has expired|is expired)[.!]?$/i.test(text)) return "subscription_expired";
17
+ if (/^(?:(?:Your |The )?(?:Claude |ChatGPT )?subscription is inactive|Your account does not have an active subscription)[.!]?$/i.test(text)) return "subscription_inactive";
18
+ if (/^Your (?:account|plan) (?:has been |was )?downgraded to (?:the )?free(?: plan)? (?:and |with )?(?:has )?(?:no|without) (?:remaining )?credits[.!]?$/i.test(text)) return "plan_downgraded";
19
+ if (type === "permission_error" && /^Your account (?:does not have access to Claude Code|is (?:disabled|suspended)|is not permitted to use (?:the |this )?API)[.!]?$/i.test(text)) return "account_permission_denied";
20
+ }
21
+
22
+ export async function permanentAccountRefusal(response: Response, signal?: AbortSignal): Promise<AccountPauseReason | undefined> {
23
+ if (response.status !== 401 && response.status !== 403) return;
24
+ try {
25
+ const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true,
26
+ maxBytes: 16_384, totalTimeoutMs: 250, inactivityTimeoutMs: 250 });
27
+ if (!body.displaySafe || body.truncated) return;
28
+ return classifyPermanentAccountRefusal(response.status, JSON.parse(body.text));
29
+ } catch { return; }
30
+ }
31
+
32
+ export function autoPauseThreshold(policy: AccountAutoPausePolicy | undefined): number | undefined {
33
+ if (policy?.enabled !== true) return;
34
+ const threshold = policy.consecutiveFailures ?? 2;
35
+ return Number.isInteger(threshold) && threshold >= 1 && threshold <= 100 ? threshold : undefined;
36
+ }
37
+
38
+ type Streak = { incarnation: string; count: number; tail: Promise<unknown>; paused: boolean };
39
+ /** Process-local streaks, serialized in response-arrival order, including asynchronous reads/writes. */
40
+ export class AccountRefusalStreaks {
41
+ private readonly accounts = new Map<string, Streak>();
42
+ observe(key: string, incarnation: string, threshold: number, verdict: Promise<AccountPauseReason | undefined>, success: boolean,
43
+ current: () => boolean, pause: (reason: AccountPauseReason) => Promise<boolean>): Promise<boolean> {
44
+ if (!current()) return Promise.resolve(false);
45
+ let streak = this.accounts.get(key);
46
+ if (!streak || streak.incarnation !== incarnation) {
47
+ // Capacity refusal forgets no live streak and never pauses on incomplete evidence.
48
+ if (!streak && this.accounts.size >= 2048) return Promise.resolve(false);
49
+ streak = { incarnation, count: 0, tail: Promise.resolve(), paused: false };
50
+ this.accounts.set(key, streak);
51
+ }
52
+ const owned = streak;
53
+ const result = owned.tail.catch(() => undefined).then(async () => {
54
+ const reason = await verdict;
55
+ if (this.accounts.get(key) !== owned || owned.paused || !current()) return false;
56
+ if (success) { owned.count = 0; return false; }
57
+ if (!reason) return false;
58
+ owned.count = Math.min(owned.count + 1, threshold);
59
+ if (owned.count < threshold) return false;
60
+ owned.paused = await pause(reason);
61
+ return owned.paused;
62
+ });
63
+ owned.tail = result;
64
+ return result;
65
+ }
66
+ }