@moondeck/station 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +115 -8
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
  326. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,3 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /** Stable outbox position follows adapter async work, never a timestamp. */
3
+ export const agentOutputSource = new AsyncLocalStorage<string>();
@@ -0,0 +1,221 @@
1
+ /** Provider-backed process adapters shared by the agent protocols. */
2
+
3
+ import { EventEmitter } from "node:events";
4
+
5
+ import type {
6
+ TaskEnvironmentAgentSessionSurface,
7
+ TaskEnvironmentProcess,
8
+ TaskEnvironmentSessionRequest,
9
+ } from "../task-environment/types";
10
+ import { agentCliArgv } from "../agent-cli";
11
+
12
+ export type LineHandler = (line: string) => void;
13
+ export type ExitHandler = (code: number | null) => void;
14
+
15
+ /** How much trailing stderr to retain for error reporting. */
16
+ const STDERR_CAP = 8 * 1024;
17
+ /** Maximum unread stdout+stderr retained by a provider for an agent process. */
18
+ export const AGENT_SESSION_OUTPUT_BUFFER_BYTES = 1024 * 1024;
19
+
20
+ /**
21
+ * Line-oriented adapter over the provider-neutral environment
22
+ * stream. Startup may be asynchronous, so writes are serialized behind the
23
+ * provider's spawn promise without exposing a container command to callers.
24
+ */
25
+ export class EnvironmentLineProcess {
26
+ private readonly processPromise: Promise<TaskEnvironmentProcess>;
27
+ private process: TaskEnvironmentProcess | null = null;
28
+ private stdoutBuf = "";
29
+ private stderrBuf = "";
30
+ private readonly lineHandlers = new Set<LineHandler>();
31
+ private readonly exitHandlers = new Set<ExitHandler>();
32
+ private inputChain: Promise<void> = Promise.resolve();
33
+ private closed = false;
34
+ private finished = false;
35
+ private exitCode: number | null = null;
36
+ private readonly debug: string | null;
37
+
38
+ constructor(opts: {
39
+ environment: TaskEnvironmentAgentSessionSurface;
40
+ request: TaskEnvironmentSessionRequest;
41
+ debugLabel?: string;
42
+ }) {
43
+ this.debug =
44
+ opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
45
+ if (this.debug) this.log(`spawn: ${opts.request.argv.join(" ")}`);
46
+ this.processPromise = opts.environment.spawnSession(opts.request);
47
+ void this.consume();
48
+ }
49
+
50
+ private log(message: string): void {
51
+ console.error(`[uai-agent ${this.debug}] ${message}`);
52
+ }
53
+
54
+ private async consume(): Promise<void> {
55
+ try {
56
+ const process = await this.processPromise;
57
+ this.process = process;
58
+ const stdout = this.consumeStdout(process.stdout);
59
+ const stderr = this.consumeStderr(process.stderr);
60
+ const exit = await process.completion;
61
+ await Promise.allSettled([stdout, stderr]);
62
+ this.finish(exit.exitCode);
63
+ } catch (error) {
64
+ this.stderrBuf = (
65
+ this.stderrBuf +
66
+ (error instanceof Error ? error.message : String(error))
67
+ ).slice(-STDERR_CAP);
68
+ this.finish(null);
69
+ }
70
+ }
71
+
72
+ private async consumeStdout(stream: AsyncIterable<Uint8Array>): Promise<void> {
73
+ for await (const chunk of stream) this.onStdout(Buffer.from(chunk).toString("utf8"));
74
+ }
75
+
76
+ private async consumeStderr(stream: AsyncIterable<Uint8Array>): Promise<void> {
77
+ for await (const chunk of stream) {
78
+ const text = Buffer.from(chunk).toString("utf8");
79
+ this.stderrBuf = (this.stderrBuf + text).slice(-STDERR_CAP);
80
+ if (this.debug) this.log(`stderr: ${text.trimEnd()}`);
81
+ }
82
+ }
83
+
84
+ private onStdout(chunk: string): void {
85
+ this.stdoutBuf += chunk;
86
+ let newline: number;
87
+ while ((newline = this.stdoutBuf.indexOf("\n")) >= 0) {
88
+ const line = this.stdoutBuf.slice(0, newline).trim();
89
+ this.stdoutBuf = this.stdoutBuf.slice(newline + 1);
90
+ if (!line) continue;
91
+ if (this.debug) this.log(`<- ${line.slice(0, 1000)}`);
92
+ for (const handler of this.lineHandlers) {
93
+ try {
94
+ handler(line);
95
+ } catch {
96
+ // A broken protocol handler must not wedge stream consumption.
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ private finish(code: number | null): void {
103
+ if (this.finished) return;
104
+ this.finished = true;
105
+ this.closed = true;
106
+ this.exitCode = code;
107
+ if (this.debug) this.log(`exit: code ${code}`);
108
+ for (const handler of this.exitHandlers) handler(code);
109
+ }
110
+
111
+ onLine(handler: LineHandler): void {
112
+ this.lineHandlers.add(handler);
113
+ }
114
+
115
+ onExit(handler: ExitHandler): void {
116
+ this.exitHandlers.add(handler);
117
+ if (this.finished) queueMicrotask(() => handler(this.exitCode));
118
+ }
119
+
120
+ writeLine(value: unknown): void {
121
+ if (this.closed) return;
122
+ const json = JSON.stringify(value);
123
+ if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
124
+ this.inputChain = this.inputChain
125
+ .then(async () => {
126
+ const process = await this.processPromise;
127
+ await process.write(Buffer.from(`${json}\n`));
128
+ })
129
+ .catch((error: unknown) => {
130
+ this.stderrBuf = (
131
+ this.stderrBuf +
132
+ (error instanceof Error ? error.message : String(error))
133
+ ).slice(-STDERR_CAP);
134
+ this.finish(null);
135
+ });
136
+ }
137
+
138
+ get stderrTail(): string {
139
+ return this.stderrBuf;
140
+ }
141
+
142
+ get isClosed(): boolean {
143
+ return this.closed;
144
+ }
145
+
146
+ async close(): Promise<void> {
147
+ if (this.closed) return;
148
+ try {
149
+ const process = this.process ?? (await this.processPromise);
150
+ await process.closeInput().catch(() => {});
151
+ await process.terminate("SIGTERM").catch(() => {});
152
+ } catch {
153
+ // Startup already failed; consume() owns the terminal notification.
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Minimal ChildProcess-shaped view retained by the one-shot adapters. */
159
+ export interface AgentEnvironmentProcess {
160
+ readonly stdout: EventEmitter;
161
+ readonly stderr: EventEmitter;
162
+ on(event: "exit", listener: (code: number | null) => void): this;
163
+ on(event: "error", listener: (error: Error) => void): this;
164
+ kill(signal?: "SIGTERM" | "SIGKILL"): boolean;
165
+ }
166
+
167
+ /** Bridge a provider stream into the event shape used by one-shot adapters. */
168
+ export function spawnEnvironmentProcess(
169
+ environment: TaskEnvironmentAgentSessionSurface,
170
+ request: TaskEnvironmentSessionRequest,
171
+ ): AgentEnvironmentProcess {
172
+ const launchRequest: TaskEnvironmentSessionRequest = {
173
+ ...request,
174
+ argv: agentCliArgv(environment, request.argv),
175
+ };
176
+ class EnvironmentProcess extends EventEmitter implements AgentEnvironmentProcess {
177
+ readonly stdout = new EventEmitter();
178
+ readonly stderr = new EventEmitter();
179
+ private process: TaskEnvironmentProcess | null = null;
180
+ private readonly processPromise: Promise<TaskEnvironmentProcess>;
181
+
182
+ constructor() {
183
+ super();
184
+ this.processPromise = environment.spawnSession(launchRequest);
185
+ void this.consume();
186
+ }
187
+
188
+ private async consume(): Promise<void> {
189
+ try {
190
+ const process = await this.processPromise;
191
+ this.process = process;
192
+ const stdout = pumpEnvironmentStream(process.stdout, this.stdout);
193
+ const stderr = pumpEnvironmentStream(process.stderr, this.stderr);
194
+ const exit = await process.completion;
195
+ await Promise.allSettled([stdout, stderr]);
196
+ this.emit("exit", exit.exitCode);
197
+ } catch (error) {
198
+ this.emit(
199
+ "error",
200
+ error instanceof Error ? error : new Error(String(error)),
201
+ );
202
+ }
203
+ }
204
+
205
+ kill(signal: "SIGTERM" | "SIGKILL" = "SIGTERM"): boolean {
206
+ const process = this.process;
207
+ if (process) void process.terminate(signal).catch(() => {});
208
+ else void this.processPromise.then((pending) => pending.terminate(signal)).catch(() => {});
209
+ return true;
210
+ }
211
+ }
212
+
213
+ return new EnvironmentProcess();
214
+ }
215
+
216
+ async function pumpEnvironmentStream(
217
+ stream: AsyncIterable<Uint8Array>,
218
+ target: EventEmitter,
219
+ ): Promise<void> {
220
+ for await (const chunk of stream) target.emit("data", Buffer.from(chunk));
221
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Rate-limit / quota classification for agent errors (ADR-076).
3
+ *
4
+ * When an engine reports an error that means "this account is temporarily out
5
+ * of capacity" (usage cap, 429, quota, overloaded), the orchestrator cools the
6
+ * current account and fails over to the next one. Each adapter's pure mapper
7
+ * runs the error text through {@link isRateLimitMessage}, emits a secret-free
8
+ * account usage observation, and tags the resulting `error` event with
9
+ * `retryable: "rate_limit"` so rotation can act on it without re-parsing
10
+ * engine-specific strings downstream.
11
+ *
12
+ * Deliberately broad but anchored on unambiguous tokens — a false positive
13
+ * only rotates to another account (cheap, self-correcting), a false negative
14
+ * just surfaces the error as today.
15
+ */
16
+ import type { AgentAccountUsage } from "./types";
17
+
18
+ const RATE_LIMIT_PATTERNS: RegExp[] = [
19
+ /rate[\s_-]?limit/i,
20
+ /\b429\b/,
21
+ /too many requests/i,
22
+ /quota/i,
23
+ /usage limit/i,
24
+ /usage cap/i,
25
+ // Claude subscription caps: "You've hit your weekly limit · resets …",
26
+ // "reached your 5-hour limit", "exceeded your limit". Anchored on
27
+ // hit/reached/exceeded so an "approaching your … limit" WARNING won't match
28
+ // (a warning isn't exhaustion — rotating on it would waste the account).
29
+ /(?:hit|reached|exceeded)\s+your\s+.{0,40}?\blimit\b/i,
30
+ /\blimit reached\b/i,
31
+ // A reset hint sitting next to "limit" ("limit · resets Jul 27") is a cap.
32
+ /\blimit\b[^.\n]{0,40}\bresets?\b/i,
33
+ /overloaded/i,
34
+ /capacity/i,
35
+ /resource[\s_-]?exhausted/i,
36
+ /insufficient_quota/i,
37
+ /billing (?:hard )?limit/i,
38
+ ];
39
+
40
+ /** Whether an engine error message looks like a rate-limit / quota exhaustion. */
41
+ export function isRateLimitMessage(message: string | undefined | null): boolean {
42
+ if (!message) return false;
43
+ return RATE_LIMIT_PATTERNS.some((re) => re.test(message));
44
+ }
45
+
46
+ /** Build the account observation shared by every adapter's error-text path. */
47
+ export function rateLimitErrorUsage(
48
+ message: string,
49
+ now: number = Date.now(),
50
+ timeZone?: string,
51
+ ): AgentAccountUsage | null {
52
+ if (!isRateLimitMessage(message)) return null;
53
+ const resetsAt = parseResetHint(message, now, timeZone);
54
+ return {
55
+ status: "limited",
56
+ ...(resetsAt === null ? {} : { resetsAt }),
57
+ windows: [],
58
+ source: "error-text",
59
+ observedAt: now,
60
+ };
61
+ }
62
+
63
+ const MONTHS = new Map(
64
+ ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].map(
65
+ (month, index) => [month, index + 1] as const,
66
+ ),
67
+ );
68
+ const MAX_RESET_SECONDS = 100_000_000_000;
69
+ const MAX_RESET_AHEAD_MS = 365 * 24 * 60 * 60_000;
70
+
71
+ /** Convert a provider Unix-seconds reset without accepting milliseconds or a
72
+ * corrupt timestamp that is more than a year beyond this observation. */
73
+ export function resetSecondsToEpochMs(
74
+ value: unknown,
75
+ observedAt: number,
76
+ ): number | undefined {
77
+ if (
78
+ typeof value !== "number" ||
79
+ !Number.isSafeInteger(value) ||
80
+ value <= 0 ||
81
+ value > MAX_RESET_SECONDS ||
82
+ !Number.isFinite(observedAt)
83
+ ) {
84
+ return undefined;
85
+ }
86
+ const candidate = value * 1_000;
87
+ return candidate <= observedAt + MAX_RESET_AHEAD_MS
88
+ ? candidate
89
+ : undefined;
90
+ }
91
+
92
+ interface CivilTime {
93
+ year: number;
94
+ month: number;
95
+ day: number;
96
+ hour: number;
97
+ minute: number;
98
+ second: number;
99
+ }
100
+
101
+ function formatter(timeZone: string): Intl.DateTimeFormat | null {
102
+ try {
103
+ return new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", {
104
+ timeZone,
105
+ year: "numeric",
106
+ month: "2-digit",
107
+ day: "2-digit",
108
+ hour: "2-digit",
109
+ minute: "2-digit",
110
+ second: "2-digit",
111
+ hourCycle: "h23",
112
+ });
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ function civilAt(epochMs: number, fmt: Intl.DateTimeFormat): CivilTime | null {
119
+ const values = new Map(
120
+ fmt
121
+ .formatToParts(new Date(epochMs))
122
+ .filter((part) => part.type !== "literal")
123
+ .map((part) => [part.type, Number(part.value)]),
124
+ );
125
+ const civil = {
126
+ year: values.get("year") ?? NaN,
127
+ month: values.get("month") ?? NaN,
128
+ day: values.get("day") ?? NaN,
129
+ hour: values.get("hour") ?? NaN,
130
+ minute: values.get("minute") ?? NaN,
131
+ second: values.get("second") ?? NaN,
132
+ };
133
+ return Object.values(civil).every(Number.isFinite) ? civil : null;
134
+ }
135
+
136
+ function sameCivil(left: CivilTime | null, right: CivilTime): boolean {
137
+ return (
138
+ left !== null &&
139
+ left.year === right.year &&
140
+ left.month === right.month &&
141
+ left.day === right.day &&
142
+ left.hour === right.hour &&
143
+ left.minute === right.minute &&
144
+ left.second === right.second
145
+ );
146
+ }
147
+
148
+ /** Convert a civil time in an IANA zone to an instant. Sampling the offsets
149
+ * on both sides of the date makes DST folds visible: zero or multiple matching
150
+ * instants are refused instead of guessing. */
151
+ function epochForCivil(civil: CivilTime, timeZone: string): number | null {
152
+ const fmt = formatter(timeZone);
153
+ if (!fmt) return null;
154
+ const target = Date.UTC(
155
+ civil.year,
156
+ civil.month - 1,
157
+ civil.day,
158
+ civil.hour,
159
+ civil.minute,
160
+ civil.second,
161
+ );
162
+ const normalized = new Date(target);
163
+ if (
164
+ normalized.getUTCFullYear() !== civil.year ||
165
+ normalized.getUTCMonth() + 1 !== civil.month ||
166
+ normalized.getUTCDate() !== civil.day ||
167
+ normalized.getUTCHours() !== civil.hour ||
168
+ normalized.getUTCMinutes() !== civil.minute
169
+ ) {
170
+ return null;
171
+ }
172
+
173
+ const offsets = new Set<number>();
174
+ for (let hours = -36; hours <= 36; hours += 6) {
175
+ const probe = target + hours * 60 * 60_000;
176
+ const parts = civilAt(probe, fmt);
177
+ if (!parts) continue;
178
+ offsets.add(
179
+ Date.UTC(
180
+ parts.year,
181
+ parts.month - 1,
182
+ parts.day,
183
+ parts.hour,
184
+ parts.minute,
185
+ parts.second,
186
+ ) - probe,
187
+ );
188
+ }
189
+ const candidates = [...offsets]
190
+ .map((offset) => target - offset)
191
+ .filter((candidate) => sameCivil(civilAt(candidate, fmt), civil));
192
+ const unique = [...new Set(candidates)];
193
+ return unique.length === 1 ? unique[0]! : null;
194
+ }
195
+
196
+ function localDate(now: number, timeZone: string): CivilTime | null {
197
+ const fmt = formatter(timeZone);
198
+ return fmt ? civilAt(now, fmt) : null;
199
+ }
200
+
201
+ function parsedZone(raw: string | undefined, fallback: string): string | null {
202
+ if (!raw) return fallback;
203
+ const zone = raw.trim();
204
+ if (/^(?:UTC|GMT)$/i.test(zone)) return "UTC";
205
+ // Abbreviations such as CST name several zones and are therefore unsafe.
206
+ if (!zone.includes("/")) return null;
207
+ return formatter(zone) ? zone : null;
208
+ }
209
+
210
+ function clockHour(rawHour: string, meridiem: string): number | null {
211
+ const hour = Number(rawHour);
212
+ if (!Number.isInteger(hour) || hour < 1 || hour > 12) return null;
213
+ return (hour % 12) + (meridiem.toLowerCase() === "pm" ? 12 : 0);
214
+ }
215
+
216
+ /**
217
+ * Parse the deliberately small set of reset hints emitted by engine CLIs.
218
+ * Returns epoch milliseconds, or null when the text is invalid, names an
219
+ * ambiguous civil instant, contains multiple hints, or needs assumptions the
220
+ * message did not supply.
221
+ */
222
+ export function parseResetHint(
223
+ message: string,
224
+ now: number,
225
+ timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
226
+ ): number | null {
227
+ if (!message || !Number.isFinite(now) || !formatter(timeZone)) return null;
228
+ const found: number[] = [];
229
+
230
+ // Claude also emits a machine-readable suffix in some terminal errors.
231
+ // It is Unix seconds; reject millisecond-scale or implausibly distant input
232
+ // rather than turning malformed text into a long account bench.
233
+ const claudeEpoch = /\bClaude AI usage limit reached\|(\d+)\b/gi;
234
+ for (const match of message.matchAll(claudeEpoch)) {
235
+ const seconds = Number(match[1]);
236
+ const candidate = resetSecondsToEpochMs(seconds, now);
237
+ if (candidate !== undefined && candidate > now) found.push(candidate);
238
+ }
239
+
240
+ const relative = /\b(?:try\s+again|resets?)\s+in\s+((?:\d+\s*(?:days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\s*){1,4})(?=$|[.,;!?])/gi;
241
+ for (const match of message.matchAll(relative)) {
242
+ const units = new Set<string>();
243
+ let duration = 0;
244
+ const token = /(\d+)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)/gi;
245
+ let consumed = "";
246
+ for (const part of match[1]!.matchAll(token)) {
247
+ const value = Number(part[1]);
248
+ const rawUnit = part[2]!.toLowerCase();
249
+ const unit = rawUnit.startsWith("d")
250
+ ? "d"
251
+ : rawUnit.startsWith("h")
252
+ ? "h"
253
+ : rawUnit.startsWith("m")
254
+ ? "m"
255
+ : "s";
256
+ if (!Number.isSafeInteger(value) || value <= 0 || units.has(unit)) {
257
+ duration = NaN;
258
+ break;
259
+ }
260
+ units.add(unit);
261
+ consumed += part[0].replace(/\s+/g, "");
262
+ duration +=
263
+ value *
264
+ (unit === "d"
265
+ ? 24 * 60 * 60_000
266
+ : unit === "h"
267
+ ? 60 * 60_000
268
+ : unit === "m"
269
+ ? 60_000
270
+ : 1_000);
271
+ }
272
+ if (
273
+ Number.isSafeInteger(duration) &&
274
+ duration > 0 &&
275
+ consumed.toLowerCase() === match[1]!.replace(/\s+/g, "").toLowerCase()
276
+ ) {
277
+ found.push(now + duration);
278
+ }
279
+ }
280
+
281
+ const monthNames = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
282
+ const dated = new RegExp(
283
+ `\\bresets?\\s+(${monthNames})\\s+(\\d{1,2})(?:,?\\s+(\\d{4}))?(?:,?\\s+(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm))?(?:\\s*\\(([^)]+)\\))?(?=$|[.,;!?])`,
284
+ "gi",
285
+ );
286
+ for (const match of message.matchAll(dated)) {
287
+ const month = MONTHS.get(match[1]!.slice(0, 3).toLowerCase());
288
+ const day = Number(match[2]);
289
+ const explicitYear = match[3] === undefined ? null : Number(match[3]);
290
+ const hour = match[4] === undefined ? 0 : clockHour(match[4], match[6]!);
291
+ const minute = match[5] === undefined ? 0 : Number(match[5]);
292
+ // The CLI runs in a container whose local zone need not match this host
293
+ // process. A calendar date is zone-sensitive even when it names no clock,
294
+ // so every absolute hint must name its zone.
295
+ const zone =
296
+ match[7] === undefined ? null : parsedZone(match[7], timeZone);
297
+ const base = zone ? localDate(now, zone) : null;
298
+ if (!base || !month || !zone || hour === null || minute < 0 || minute > 59) continue;
299
+ let year = explicitYear ?? base.year;
300
+ let candidate = epochForCivil({ year, month, day, hour, minute, second: 0 }, zone);
301
+ if (explicitYear === null && candidate !== null && candidate <= now) {
302
+ year += 1;
303
+ candidate = epochForCivil({ year, month, day, hour, minute, second: 0 }, zone);
304
+ }
305
+ if (candidate !== null && candidate > now) found.push(candidate);
306
+ }
307
+
308
+ const clock = /\bresets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s*\(([^)]+)\))?(?=$|[.,;!?])/gi;
309
+ for (const match of message.matchAll(clock)) {
310
+ const hour = clockHour(match[1]!, match[3]!);
311
+ const minute = match[2] === undefined ? 0 : Number(match[2]);
312
+ const zone = match[4] === undefined ? null : parsedZone(match[4], timeZone);
313
+ const base = zone ? localDate(now, zone) : null;
314
+ if (!base || hour === null || minute < 0 || minute > 59 || !zone) continue;
315
+ let candidate = epochForCivil(
316
+ { year: base.year, month: base.month, day: base.day, hour, minute, second: 0 },
317
+ zone,
318
+ );
319
+ if (candidate !== null && candidate <= now) {
320
+ const tomorrow = new Date(Date.UTC(base.year, base.month - 1, base.day + 1));
321
+ candidate = epochForCivil(
322
+ {
323
+ year: tomorrow.getUTCFullYear(),
324
+ month: tomorrow.getUTCMonth() + 1,
325
+ day: tomorrow.getUTCDate(),
326
+ hour,
327
+ minute,
328
+ second: 0,
329
+ },
330
+ zone,
331
+ );
332
+ }
333
+ if (candidate !== null && candidate > now) found.push(candidate);
334
+ }
335
+
336
+ return found.length === 1 ? found[0]! : null;
337
+ }