hyperbrane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +41 -0
  2. package/dist/agent/agentService.d.ts +31 -0
  3. package/dist/agent/agentService.js +388 -0
  4. package/dist/agent/agentStorage.d.ts +27 -0
  5. package/dist/agent/agentStorage.js +52 -0
  6. package/dist/agent/api.d.ts +264 -0
  7. package/dist/agent/api.js +14 -0
  8. package/dist/agent/gatewaySignIns.d.ts +5 -0
  9. package/dist/agent/gatewaySignIns.js +11 -0
  10. package/dist/agent/session/client.d.ts +14 -0
  11. package/dist/agent/session/client.js +47 -0
  12. package/dist/agent/session/sessionStorage.d.ts +66 -0
  13. package/dist/agent/session/sessionStorage.js +89 -0
  14. package/dist/agent/session/sharedConnection.d.ts +13 -0
  15. package/dist/agent/session/sharedConnection.js +55 -0
  16. package/dist/agent/session/worker/jsonRpc.d.ts +39 -0
  17. package/dist/agent/session/worker/jsonRpc.js +102 -0
  18. package/dist/agent/session/worker/protocol.d.ts +120 -0
  19. package/dist/agent/session/worker/protocol.js +32 -0
  20. package/dist/agent/session/workerConnection.d.ts +29 -0
  21. package/dist/agent/session/workerConnection.js +44 -0
  22. package/dist/agent/session/workerSseConnection.d.ts +15 -0
  23. package/dist/agent/session/workerSseConnection.js +26 -0
  24. package/dist/agent/workerBundle.d.ts +19 -0
  25. package/dist/agent/workerBundle.generated.d.ts +1 -0
  26. package/dist/agent/workerBundle.generated.js +2 -0
  27. package/dist/agent/workerBundle.js +31 -0
  28. package/dist/cli/agent.d.ts +3 -0
  29. package/dist/cli/agent.js +423 -0
  30. package/dist/cli/command.d.ts +46 -0
  31. package/dist/cli/command.js +98 -0
  32. package/dist/cli/dimension.d.ts +2 -0
  33. package/dist/cli/dimension.js +85 -0
  34. package/dist/cli/gateway.d.ts +6 -0
  35. package/dist/cli/gateway.js +245 -0
  36. package/dist/cli/main.d.ts +2 -0
  37. package/dist/cli/main.js +125 -0
  38. package/dist/cli/portal.d.ts +2 -0
  39. package/dist/cli/portal.js +39 -0
  40. package/dist/cli/services.d.ts +18 -0
  41. package/dist/cli/services.js +33 -0
  42. package/dist/cli/streamPrinter.d.ts +23 -0
  43. package/dist/cli/streamPrinter.js +251 -0
  44. package/dist/cli/table.d.ts +7 -0
  45. package/dist/cli/table.js +54 -0
  46. package/dist/dimension/api.d.ts +67 -0
  47. package/dist/dimension/api.js +81 -0
  48. package/dist/dimension/http.d.ts +7 -0
  49. package/dist/dimension/http.js +77 -0
  50. package/dist/dimension/image.d.ts +13 -0
  51. package/dist/dimension/image.js +43 -0
  52. package/dist/dimension/imageFiles.generated.d.ts +1 -0
  53. package/dist/dimension/imageFiles.generated.js +5 -0
  54. package/dist/dimension/paths.d.ts +5 -0
  55. package/dist/dimension/paths.js +7 -0
  56. package/dist/dimension/service.d.ts +22 -0
  57. package/dist/dimension/service.js +242 -0
  58. package/dist/dimension/storage.d.ts +39 -0
  59. package/dist/dimension/storage.js +78 -0
  60. package/dist/index.d.ts +18 -0
  61. package/dist/index.js +25 -0
  62. package/dist/lib/docker-api.d.ts +13787 -0
  63. package/dist/lib/docker-api.js +5 -0
  64. package/dist/lib/docker.d.ts +77 -0
  65. package/dist/lib/docker.js +214 -0
  66. package/dist/lib/image.d.ts +14 -0
  67. package/dist/lib/image.js +29 -0
  68. package/dist/lib/jsonDirectory.d.ts +28 -0
  69. package/dist/lib/jsonDirectory.js +102 -0
  70. package/dist/lib/sleep.d.ts +2 -0
  71. package/dist/lib/sleep.js +4 -0
  72. package/dist/lib/tar.d.ts +2 -0
  73. package/dist/lib/tar.js +65 -0
  74. package/dist/lib/version.d.ts +1 -0
  75. package/dist/lib/version.js +1 -0
  76. package/dist/portal-gateway/api.d.ts +98 -0
  77. package/dist/portal-gateway/api.js +12 -0
  78. package/dist/portal-gateway/fakes.d.ts +3 -0
  79. package/dist/portal-gateway/fakes.js +11 -0
  80. package/dist/portal-gateway/gatewayService.d.ts +37 -0
  81. package/dist/portal-gateway/gatewayService.js +380 -0
  82. package/dist/portal-gateway/gatewayStorage.d.ts +66 -0
  83. package/dist/portal-gateway/gatewayStorage.js +125 -0
  84. package/dist/portal-gateway/image.d.ts +6 -0
  85. package/dist/portal-gateway/image.js +13 -0
  86. package/dist/portal-gateway/imageFiles.generated.d.ts +1 -0
  87. package/dist/portal-gateway/imageFiles.generated.js +8 -0
  88. package/dist/portal-gateway/portalService.d.ts +48 -0
  89. package/dist/portal-gateway/portalService.js +327 -0
  90. package/dist/portal-gateway/portalStorage.d.ts +34 -0
  91. package/dist/portal-gateway/portalStorage.js +44 -0
  92. package/dist/portal-gateway/upstream.d.ts +157 -0
  93. package/dist/portal-gateway/upstream.js +4 -0
  94. package/dist/sandbox/api.d.ts +184 -0
  95. package/dist/sandbox/api.js +14 -0
  96. package/dist/sandbox/boatService.d.ts +52 -0
  97. package/dist/sandbox/boatService.js +335 -0
  98. package/dist/sandbox/containerService.d.ts +15 -0
  99. package/dist/sandbox/containerService.js +177 -0
  100. package/dist/sandbox/endpointProxy.d.ts +90 -0
  101. package/dist/sandbox/endpointProxy.js +701 -0
  102. package/dist/sandbox/exeDevAuth.d.ts +16 -0
  103. package/dist/sandbox/exeDevAuth.js +49 -0
  104. package/dist/sandbox/localService.d.ts +19 -0
  105. package/dist/sandbox/localService.js +182 -0
  106. package/dist/sandbox/sseConnection.d.ts +41 -0
  107. package/dist/sandbox/sseConnection.js +166 -0
  108. package/dist/sandbox/sshService.d.ts +30 -0
  109. package/dist/sandbox/sshService.js +647 -0
  110. package/dist/upstreams/anthropic.d.ts +7 -0
  111. package/dist/upstreams/anthropic.js +27 -0
  112. package/dist/upstreams/index.d.ts +6 -0
  113. package/dist/upstreams/index.js +7 -0
  114. package/dist/upstreams/openai.d.ts +7 -0
  115. package/dist/upstreams/openai.js +40 -0
  116. package/dist/vendor-types/claude/agent-sdk/agentSdkTypes.d.ts +1 -0
  117. package/dist/vendor-types/claude/agent-sdk/bridge.d.ts +388 -0
  118. package/dist/vendor-types/claude/agent-sdk/browser-sdk.d.ts +184 -0
  119. package/dist/vendor-types/claude/agent-sdk/core.d.ts +20 -0
  120. package/dist/vendor-types/claude/agent-sdk/extractFromBunfs.d.ts +1 -0
  121. package/dist/vendor-types/claude/agent-sdk/sdk-tools.d.ts +4153 -0
  122. package/dist/vendor-types/claude/agent-sdk/sdk.d.ts +9833 -0
  123. package/package.json +55 -0
@@ -0,0 +1,251 @@
1
+ /** Longest a tool input or result is shown, in characters. */
2
+ const PREVIEW_LENGTH = 400;
3
+ /** The pacer's clock: about a frame. */
4
+ const TICK_MS = 16;
5
+ /** Deltas arriving this close together are one burst. */
6
+ const BURST_GAP_MS = 20;
7
+ /** How far back the arrival rate is measured. */
8
+ const RATE_WINDOW_MS = 2_000;
9
+ /** The time a lone first burst is spread over, before there's a rate to go by. */
10
+ const FIRST_BURST_MS = 300;
11
+ /** Typing runs this much faster than text arrives, so the buffer drains rather than grows. */
12
+ const CATCH_UP = 1.1;
13
+ /** The slowest the pacer types, in characters per ms. */
14
+ const MIN_RATE = 0.06;
15
+ /** No character waits longer than this to be typed, whatever the pace. */
16
+ const MAX_LAG_MS = 1_000;
17
+ const DIM = "\x1b[2m";
18
+ const NORMAL = "\x1b[22m";
19
+ export function preview(value) {
20
+ const text = typeof value === "string" ? value : JSON.stringify(value);
21
+ return text.length > PREVIEW_LENGTH ? `${text.slice(0, PREVIEW_LENGTH)}…` : text;
22
+ }
23
+ /**
24
+ * Writes to `stream`, switching styles as text changes between answer and thinking: dimmed on a
25
+ * terminal, marked with [thinking] otherwise. Thinking always ends on a line of its own.
26
+ */
27
+ function createStyledWriter(stream) {
28
+ const tty = stream.isTTY === true;
29
+ let style = "text";
30
+ function switchTo(next) {
31
+ if (next === style)
32
+ return;
33
+ if (next === "thinking")
34
+ stream.write(tty ? DIM : "[thinking] ");
35
+ else
36
+ stream.write(tty ? `${NORMAL}\n\n` : "\n\n");
37
+ style = next;
38
+ }
39
+ return {
40
+ text(chunk, next) {
41
+ switchTo(next);
42
+ stream.write(chunk);
43
+ },
44
+ line(chunk) {
45
+ switchTo("text");
46
+ stream.write(chunk);
47
+ },
48
+ };
49
+ }
50
+ /** Writes everything at once, as it comes. */
51
+ function createDirectOutput(stream) {
52
+ const writer = createStyledWriter(stream);
53
+ return { text: writer.text, line: writer.line, close: () => writer.line("") };
54
+ }
55
+ /**
56
+ * Types text out at a steady pace instead of in the bursts it arrives in (model APIs send several
57
+ * words at once, then pause for a while): at the rate text has been arriving, a little faster, so
58
+ * each burst lasts until about when the next one comes, and never more than MAX_LAG_MS behind. A
59
+ * pause shows only when the model pauses for longer than that. Lines wait for the text before
60
+ * them, then go out whole.
61
+ */
62
+ function createPacedOutput(stream) {
63
+ const writer = createStyledWriter(stream);
64
+ const queue = [];
65
+ /** Recent bursts: when each came, and how many characters. */
66
+ const bursts = [];
67
+ let waitingChars = 0;
68
+ /** Characters the pace has allowed but not yet typed (fractions carry over between ticks). */
69
+ let credit = 0;
70
+ let lastTick = 0;
71
+ let timer;
72
+ function recordArrival(chars) {
73
+ const now = performance.now();
74
+ const last = bursts.at(-1);
75
+ if (last && now - last.at < BURST_GAP_MS)
76
+ last.chars += chars;
77
+ else
78
+ bursts.push({ at: now, chars });
79
+ while (bursts.length > 1 && now - bursts[0].at > RATE_WINDOW_MS)
80
+ bursts.shift();
81
+ }
82
+ /**
83
+ * Characters per ms the text has been arriving at: the bursts before the newest over the time they
84
+ * span up to it, since the newest is what covers the time ahead. A lone burst is spread over
85
+ * FIRST_BURST_MS.
86
+ */
87
+ function arrivalRate() {
88
+ if (bursts.length < 2)
89
+ return (bursts[0]?.chars ?? 0) / FIRST_BURST_MS;
90
+ const earlier = bursts.slice(0, -1).reduce((sum, burst) => sum + burst.chars, 0);
91
+ return earlier / (bursts.at(-1).at - bursts[0].at);
92
+ }
93
+ /**
94
+ * The least that keeps every waiting chunk within MAX_LAG_MS, spread evenly over the ticks left
95
+ * rather than all at once when it's due: for each chunk, the characters up to its end over the
96
+ * ticks until its deadline. Only matters when text arrives faster than the rate predicted.
97
+ */
98
+ function deadlinePace() {
99
+ const now = performance.now();
100
+ let before = 0;
101
+ let pace = 0;
102
+ for (const segment of queue) {
103
+ if (segment.kind !== "text")
104
+ continue;
105
+ before += segment.chars.length;
106
+ const ticksLeft = Math.max(1, Math.floor((segment.arrivedAt + MAX_LAG_MS - now) / TICK_MS));
107
+ pace = Math.max(pace, Math.ceil(before / ticksLeft));
108
+ }
109
+ return pace;
110
+ }
111
+ function tick() {
112
+ const now = performance.now();
113
+ // A late tick (a busy event loop) catches up, but not by more than a few frames at once.
114
+ credit += Math.max(MIN_RATE, arrivalRate() * CATCH_UP) * Math.min(now - lastTick, TICK_MS * 4);
115
+ lastTick = now;
116
+ let budget = Math.max(Math.floor(credit), deadlinePace());
117
+ const planned = budget;
118
+ while (queue.length > 0) {
119
+ const segment = queue[0];
120
+ if (segment.kind === "line") {
121
+ writer.line(segment.text);
122
+ queue.shift();
123
+ continue;
124
+ }
125
+ if (budget === 0)
126
+ break;
127
+ const taken = segment.chars.splice(0, budget);
128
+ budget -= taken.length;
129
+ waitingChars -= taken.length;
130
+ writer.text(taken.join(""), segment.style);
131
+ if (segment.chars.length === 0)
132
+ queue.shift();
133
+ }
134
+ credit = Math.max(0, credit - (planned - budget));
135
+ if (queue.length === 0)
136
+ stop();
137
+ }
138
+ function start() {
139
+ if (timer)
140
+ return;
141
+ lastTick = performance.now();
142
+ timer = setInterval(tick, TICK_MS);
143
+ }
144
+ // Nothing is banked while idle: the next burst paces from zero instead of coming out at once.
145
+ function stop() {
146
+ clearInterval(timer);
147
+ timer = undefined;
148
+ credit = 0;
149
+ }
150
+ function text(chunk, style) {
151
+ // Code points, so a character outside the BMP is never split across ticks.
152
+ const chars = [...chunk];
153
+ queue.push({ kind: "text", style, chars, arrivedAt: performance.now() });
154
+ waitingChars += chars.length;
155
+ recordArrival(chars.length);
156
+ start();
157
+ }
158
+ function line(chunk) {
159
+ queue.push({ kind: "line", text: chunk });
160
+ start();
161
+ }
162
+ function close() {
163
+ stop();
164
+ for (const segment of queue) {
165
+ if (segment.kind === "line")
166
+ writer.line(segment.text);
167
+ else
168
+ writer.text(segment.chars.join(""), segment.style);
169
+ }
170
+ queue.length = 0;
171
+ waitingChars = 0;
172
+ writer.line("");
173
+ }
174
+ return { text, line, close };
175
+ }
176
+ /**
177
+ * A message's content as a terminal transcript: the user's words as "> ...", tool calls and their
178
+ * results on lines of their own, and the assistant's text and thinking when `withText` (a stream has
179
+ * already printed them as partial output).
180
+ */
181
+ function printContent(output, role, content, withText) {
182
+ if (typeof content === "string") {
183
+ if (role === "user")
184
+ output.line(`\n> ${content}\n`);
185
+ else if (withText)
186
+ output.text(`${content}\n`, "text");
187
+ return;
188
+ }
189
+ if (!Array.isArray(content))
190
+ return;
191
+ for (const block of content) {
192
+ if (block.type === "text" && role === "user")
193
+ output.line(`\n> ${block.text}\n`);
194
+ if (block.type === "text" && role === "assistant" && withText)
195
+ output.text(`${block.text}\n`, "text");
196
+ if (block.type === "thinking" && withText && block.thinking)
197
+ output.text(`${block.thinking}\n`, "thinking");
198
+ if (block.type === "tool_use")
199
+ output.line(`\n[${block.name}] ${preview(block.input)}\n`);
200
+ if (block.type === "tool_result")
201
+ output.line(`[result${block.is_error ? ", error" : ""}] ${preview(block.content ?? "")}\n`);
202
+ }
203
+ }
204
+ /**
205
+ * Prints to `stream` (stdout); `paced` text is typed out steadily (see createPacedOutput) when it's
206
+ * a terminal. A complete assistant message's text is printed only if none of it streamed: claude
207
+ * reports failures it hits before reaching the API (a TLS error, say) as a synthetic message.
208
+ */
209
+ export function createTerminalPrinter({ paced, stream = process.stdout }) {
210
+ const output = paced && stream.isTTY ? createPacedOutput(stream) : createDirectOutput(stream);
211
+ let streamed = false;
212
+ function print(message) {
213
+ switch (message.type) {
214
+ case "stream_event": {
215
+ if (message.event.type !== "content_block_delta")
216
+ return;
217
+ const { delta } = message.event;
218
+ if (delta.type === "text_delta")
219
+ output.text(delta.text, "text");
220
+ else if (delta.type === "thinking_delta")
221
+ output.text(delta.thinking, "thinking");
222
+ else
223
+ return;
224
+ streamed = true;
225
+ return;
226
+ }
227
+ case "assistant":
228
+ printContent(output, "assistant", message.message.content, !streamed);
229
+ streamed = false;
230
+ return;
231
+ case "user":
232
+ return printContent(output, "user", message.message.content, false);
233
+ case "result":
234
+ if (message.subtype !== "success")
235
+ output.line(`\n[${message.subtype}] ${message.errors.join("; ")}\n`);
236
+ else if (message.is_error)
237
+ output.line(`\n[error] ${message.result}\n`);
238
+ else
239
+ output.line("\n[done]\n");
240
+ return;
241
+ case "system":
242
+ output.line(`[session ${message.session_id}, model ${message.model}]\n`);
243
+ return;
244
+ }
245
+ }
246
+ return {
247
+ print,
248
+ printTranscript: (role, content) => printContent(output, role, content, true),
249
+ close: output.close,
250
+ };
251
+ }
@@ -0,0 +1,7 @@
1
+ export declare function formatTable(headers: string[], rows: string[][]): string;
2
+ /** 268435456 -> "256MiB", 1597440 -> "1.523MiB" (docker's units.BytesSize). */
3
+ export declare function formatBytes(bytes: number): string;
4
+ /** 0.0512 cores -> "5.12%" (docker stats' CPU %: 100% is one full core). */
5
+ export declare function formatCpuPercent(cores: number): string;
6
+ /** Seconds -> "3 minutes", "About an hour", ... (docker's units.HumanDuration). */
7
+ export declare function formatDuration(seconds: number): string;
@@ -0,0 +1,54 @@
1
+ // Output in the style of the docker CLI (its tabwriter: min width 10, padding 3).
2
+ const MIN_WIDTH = 10;
3
+ const PADDING = 3;
4
+ export function formatTable(headers, rows) {
5
+ const widths = headers.map((header, column) => Math.max(MIN_WIDTH, ...[header, ...rows.map((row) => row[column] ?? "")].map((cell) => cell.length + PADDING)));
6
+ return [headers, ...rows]
7
+ .map((row) => row
8
+ .map((cell, column) => (column === row.length - 1 ? cell : cell.padEnd(widths[column])))
9
+ .join("")
10
+ .trimEnd())
11
+ .join("\n");
12
+ }
13
+ const BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
14
+ /** 268435456 -> "256MiB", 1597440 -> "1.523MiB" (docker's units.BytesSize). */
15
+ export function formatBytes(bytes) {
16
+ let value = bytes;
17
+ let unit = 0;
18
+ while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
19
+ value /= 1024;
20
+ unit++;
21
+ }
22
+ return `${Number(value.toPrecision(4))}${BYTE_UNITS[unit]}`;
23
+ }
24
+ /** 0.0512 cores -> "5.12%" (docker stats' CPU %: 100% is one full core). */
25
+ export function formatCpuPercent(cores) {
26
+ return `${(cores * 100).toFixed(2)}%`;
27
+ }
28
+ /** Seconds -> "3 minutes", "About an hour", ... (docker's units.HumanDuration). */
29
+ export function formatDuration(seconds) {
30
+ const minutes = seconds / 60;
31
+ const hours = minutes / 60;
32
+ const days = hours / 24;
33
+ if (seconds < 1)
34
+ return "Less than a second";
35
+ if (seconds === 1)
36
+ return "1 second";
37
+ if (seconds < 60)
38
+ return `${Math.floor(seconds)} seconds`;
39
+ if (Math.floor(minutes) === 1)
40
+ return "About a minute";
41
+ if (minutes < 60)
42
+ return `${Math.floor(minutes)} minutes`;
43
+ if (Math.round(hours) === 1)
44
+ return "About an hour";
45
+ if (hours < 48)
46
+ return `${Math.round(hours)} hours`;
47
+ if (days < 7 * 2)
48
+ return `${Math.floor(days)} days`;
49
+ if (days < 30 * 2)
50
+ return `${Math.floor(days / 7)} weeks`;
51
+ if (days < 365 * 2)
52
+ return `${Math.floor(days / 30)} months`;
53
+ return `${Math.floor(days / 365)} years`;
54
+ }
@@ -0,0 +1,67 @@
1
+ export type MemoryLimit = number & {
2
+ readonly __brand: "MemoryLimit";
3
+ };
4
+ export type CPULimit = number & {
5
+ readonly __brand: "CPULimit";
6
+ };
7
+ /** Bytes. Accepts "512", "10M", "1.5G", "10Mi", "10MB", "10MiB" (binary units, like docker). */
8
+ export declare function MemoryLimit(value: string): MemoryLimit;
9
+ /** Cores. Accepts "2", "0.5", or kubernetes-style millicores "500m". */
10
+ export declare function CPULimit(value: string): CPULimit;
11
+ export type Dimension = {
12
+ name: string;
13
+ /**
14
+ * The host directory mounted as its workspace. Relative ("work", "~/work") is the host user's,
15
+ * under their home: resolved when the dimension is created, and recorded resolved. Made (by the
16
+ * host user) if it doesn't exist.
17
+ */
18
+ directory: string;
19
+ memoryLimit: MemoryLimit;
20
+ cpuLimit: CPULimit;
21
+ portals?: string[];
22
+ };
23
+ /** "creating": provisioning in progress. "missing": recorded but no container (retry create). Otherwise docker's container state. */
24
+ /** What `updateDimension` can change on a running dimension; omitted limits stay as they are. */
25
+ export type DimensionLimits = {
26
+ memoryLimit?: MemoryLimit;
27
+ cpuLimit?: CPULimit;
28
+ };
29
+ export type DimensionStatus = "creating" | "missing" | "created" | "running" | "paused" | "restarting" | "removing" | "exited" | "dead";
30
+ export type DimensionInfo = Dimension & {
31
+ status: DimensionStatus;
32
+ /** Null unless running. */
33
+ uptimeSeconds: number | null;
34
+ /** Bytes. Null unless running. */
35
+ memoryUsage: number | null;
36
+ /** Cores. Null unless running. */
37
+ cpuUsage: number | null;
38
+ };
39
+ export interface DimensionClient {
40
+ /** POST /v1/dimensions */
41
+ createDimension(dimension: Dimension): Promise<Dimension>;
42
+ /** DELETE /v1/dimensions/:dimensionName */
43
+ destroyDimension(dimensionName: string): Promise<void>;
44
+ /** POST /v1/dimensions/:dimensionName/restart */
45
+ restartDimension(dimensionName: string): Promise<void>;
46
+ /** PATCH /v1/dimensions/:dimensionName — changes its limits in place, without a restart. */
47
+ updateDimension(dimensionName: string, limits: DimensionLimits): Promise<Dimension>;
48
+ /** GET /v1/dimensions */
49
+ listDimensions(): Promise<DimensionInfo[]>;
50
+ }
51
+ /** The request can't be satisfied as sent (bad directory, ...). */
52
+ export declare class DimensionInvalidError extends Error {
53
+ name: string;
54
+ }
55
+ /** No dimension (or no container for it) by that name. */
56
+ export declare class DimensionNotFoundError extends Error {
57
+ name: string;
58
+ }
59
+ /** The name is taken by a dimension with a different spec, or by a container hyperbrane doesn't own. */
60
+ export declare class DimensionConflictError extends Error {
61
+ name: string;
62
+ }
63
+ export declare class DimensionApiError extends Error {
64
+ readonly status: number;
65
+ constructor(status: number, message: string);
66
+ }
67
+ export declare function createDimensionClient(baseUrl: string): DimensionClient;
@@ -0,0 +1,81 @@
1
+ const MEMORY_UNITS = {
2
+ "": 1,
3
+ K: 1024,
4
+ M: 1024 ** 2,
5
+ G: 1024 ** 3,
6
+ T: 1024 ** 4,
7
+ };
8
+ /** Bytes. Accepts "512", "10M", "1.5G", "10Mi", "10MB", "10MiB" (binary units, like docker). */
9
+ export function MemoryLimit(value) {
10
+ const match = /^(\d+(?:\.\d+)?)\s*([KMGT]?)(?:iB|i|B)?$/i.exec(value.trim());
11
+ if (!match)
12
+ throw new Error(`Invalid memory limit: "${value}"`);
13
+ const bytes = Math.round(Number(match[1]) * MEMORY_UNITS[match[2].toUpperCase()]);
14
+ if (bytes <= 0)
15
+ throw new Error(`Memory limit must be positive: "${value}"`);
16
+ return bytes;
17
+ }
18
+ /** Cores. Accepts "2", "0.5", or kubernetes-style millicores "500m". */
19
+ export function CPULimit(value) {
20
+ const match = /^(\d+(?:\.\d+)?)\s*(m?)$/.exec(value.trim());
21
+ if (!match)
22
+ throw new Error(`Invalid CPU limit: "${value}"`);
23
+ const cores = match[2] === "m" ? Number(match[1]) / 1000 : Number(match[1]);
24
+ if (cores <= 0)
25
+ throw new Error(`CPU limit must be positive: "${value}"`);
26
+ return cores;
27
+ }
28
+ /** The request can't be satisfied as sent (bad directory, ...). */
29
+ export class DimensionInvalidError extends Error {
30
+ name = "DimensionInvalidError";
31
+ }
32
+ /** No dimension (or no container for it) by that name. */
33
+ export class DimensionNotFoundError extends Error {
34
+ name = "DimensionNotFoundError";
35
+ }
36
+ /** The name is taken by a dimension with a different spec, or by a container hyperbrane doesn't own. */
37
+ export class DimensionConflictError extends Error {
38
+ name = "DimensionConflictError";
39
+ }
40
+ export class DimensionApiError extends Error {
41
+ status;
42
+ constructor(status, message) {
43
+ super(message);
44
+ this.status = status;
45
+ this.name = "DimensionApiError";
46
+ }
47
+ }
48
+ export function createDimensionClient(baseUrl) {
49
+ async function request(method, path, body) {
50
+ const res = await fetch(new URL(path, baseUrl), {
51
+ method,
52
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
53
+ body: body === undefined ? undefined : JSON.stringify(body),
54
+ });
55
+ if (res.ok)
56
+ return res;
57
+ const text = await res.text();
58
+ let message = text;
59
+ try {
60
+ message = JSON.parse(text).error ?? text;
61
+ }
62
+ catch { }
63
+ throw new DimensionApiError(res.status, `${method} ${path} -> ${res.status}: ${message}`);
64
+ }
65
+ async function createDimension(dimension) {
66
+ return (await request("POST", "/v1/dimensions", dimension)).json();
67
+ }
68
+ async function destroyDimension(dimensionName) {
69
+ await request("DELETE", `/v1/dimensions/${encodeURIComponent(dimensionName)}`);
70
+ }
71
+ async function restartDimension(dimensionName) {
72
+ await request("POST", `/v1/dimensions/${encodeURIComponent(dimensionName)}/restart`);
73
+ }
74
+ async function listDimensions() {
75
+ return (await request("GET", "/v1/dimensions")).json();
76
+ }
77
+ async function updateDimension(dimensionName, limits) {
78
+ return (await request("PATCH", `/v1/dimensions/${encodeURIComponent(dimensionName)}`, limits)).json();
79
+ }
80
+ return { createDimension, destroyDimension, restartDimension, listDimensions, updateDimension };
81
+ }
@@ -0,0 +1,7 @@
1
+ import { type DimensionService } from "./service.ts";
2
+ export interface DimensionHttpServer {
3
+ /** Resolves with the URL the server is listening on (useful with port 0). */
4
+ start(): Promise<URL>;
5
+ stop(): Promise<void>;
6
+ }
7
+ export declare function createDimensionHttpServer(service: DimensionService, port: number): DimensionHttpServer;
@@ -0,0 +1,77 @@
1
+ import { DimensionConflictError, DimensionInvalidError, DimensionNotFoundError, } from "./api.js";
2
+ import { createDockerClient } from "../lib/docker.js";
3
+ import { createLocalSandbox } from "../sandbox/localService.js";
4
+ import { createDimensionImage } from "./image.js";
5
+ import { createDimensionService } from "./service.js";
6
+ import { createFileStorage } from "./storage.js";
7
+ export function createDimensionHttpServer(service, port) {
8
+ let server;
9
+ async function handleListDimensions() {
10
+ return Response.json(await service.getDimensions());
11
+ }
12
+ async function handleCreateDimension(req) {
13
+ // TODO: add validation with proper JSON reply
14
+ const dimension = (await req.json());
15
+ await service.createDimension(dimension);
16
+ return Response.json(dimension, { status: 201 });
17
+ }
18
+ async function handleDestroyDimension(req) {
19
+ await service.destroyDimension(req.params.dimensionName);
20
+ return new Response(null, { status: 204 });
21
+ }
22
+ async function handleUpdateDimension(req) {
23
+ const limits = (await req.json());
24
+ return Response.json(await service.updateDimension(req.params.dimensionName, limits));
25
+ }
26
+ async function handleRestartDimension(req) {
27
+ await service.restartDimension(req.params.dimensionName);
28
+ return new Response(null, { status: 204 });
29
+ }
30
+ function handleNotFound() {
31
+ return new Response("Not Found", { status: 404 });
32
+ }
33
+ function handleError(error) {
34
+ if (error instanceof DimensionInvalidError)
35
+ return Response.json({ error: error.message }, { status: 400 });
36
+ if (error instanceof DimensionConflictError)
37
+ return Response.json({ error: error.message }, { status: 409 });
38
+ if (error instanceof DimensionNotFoundError)
39
+ return Response.json({ error: error.message }, { status: 404 });
40
+ console.error(error);
41
+ return Response.json({ error: error.message }, { status: 500 });
42
+ }
43
+ async function start() {
44
+ server = Bun.serve({
45
+ port,
46
+ routes: {
47
+ "/v1/dimensions": {
48
+ GET: handleListDimensions,
49
+ POST: handleCreateDimension,
50
+ },
51
+ "/v1/dimensions/:dimensionName": {
52
+ DELETE: handleDestroyDimension,
53
+ PATCH: handleUpdateDimension,
54
+ },
55
+ "/v1/dimensions/:dimensionName/restart": {
56
+ POST: handleRestartDimension,
57
+ },
58
+ },
59
+ fetch: handleNotFound,
60
+ error: handleError,
61
+ });
62
+ console.log(`dimension service listening on ${server.url}`);
63
+ return server.url;
64
+ }
65
+ async function stop() {
66
+ await server?.stop();
67
+ server = undefined;
68
+ }
69
+ return { start, stop };
70
+ }
71
+ if (import.meta.main) {
72
+ const port = Number(process.env.PORT ?? 3000);
73
+ const host = createLocalSandbox("/");
74
+ const docker = createDockerClient(host);
75
+ const service = createDimensionService(createFileStorage(host), docker, createDimensionImage(docker), host);
76
+ await createDimensionHttpServer(service, port).start();
77
+ }
@@ -0,0 +1,13 @@
1
+ import { type DockerClient } from "../lib/docker.ts";
2
+ /**
3
+ * Bump whenever anything under ./image changes (and run `bun run embed:images`); the tag is how an
4
+ * existing build is recognised.
5
+ */
6
+ export declare const DIMENSION_IMAGE_VERSION = "v6";
7
+ export declare const DIMENSION_IMAGE_REPOSITORY = "hyperbrane-dimension";
8
+ export declare const DIMENSION_IMAGE = "hyperbrane-dimension:v6";
9
+ export interface DimensionImage {
10
+ /** Builds the base image if it isn't present yet, removes outdated ones, and returns its tag. */
11
+ ensure(): Promise<string>;
12
+ }
13
+ export declare function createDimensionImage(docker: DockerClient): DimensionImage;
@@ -0,0 +1,43 @@
1
+ import { DockerError } from "../lib/docker.js";
2
+ // The files under ./image, as strings (scripts/embed-image-files.ts), so they travel inside the module
3
+ // graph under any runtime or bundler instead of being looked up on disk at runtime.
4
+ import { IMAGE_FILES } from "./imageFiles.generated.js";
5
+ /**
6
+ * Bump whenever anything under ./image changes (and run `bun run embed:images`); the tag is how an
7
+ * existing build is recognised.
8
+ */
9
+ export const DIMENSION_IMAGE_VERSION = "v6";
10
+ export const DIMENSION_IMAGE_REPOSITORY = "hyperbrane-dimension";
11
+ export const DIMENSION_IMAGE = `${DIMENSION_IMAGE_REPOSITORY}:${DIMENSION_IMAGE_VERSION}`;
12
+ const BUILD_CONTEXT = IMAGE_FILES;
13
+ export function createDimensionImage(docker) {
14
+ // Concurrent builds of the same tag (from any process) are harmless: they produce the same image.
15
+ async function ensure() {
16
+ if (!(await docker.imageExists(DIMENSION_IMAGE))) {
17
+ await docker.buildImage({
18
+ tag: DIMENSION_IMAGE,
19
+ context: BUILD_CONTEXT,
20
+ labels: { hyperbrane: "true", "hyperbrane.image.version": DIMENSION_IMAGE_VERSION },
21
+ });
22
+ }
23
+ await removeOutdatedImages().catch((error) => console.warn("removing outdated dimension images failed:", error));
24
+ return DIMENSION_IMAGE;
25
+ }
26
+ // Images still used by a container are skipped (docker answers 409) and retried on a later ensure().
27
+ async function removeOutdatedImages() {
28
+ const images = await docker.listImages({ label: ["hyperbrane"], reference: [DIMENSION_IMAGE_REPOSITORY] });
29
+ const outdated = images
30
+ .flatMap((image) => image.RepoTags ?? [])
31
+ .filter((tag) => tag.startsWith(`${DIMENSION_IMAGE_REPOSITORY}:`) && tag !== DIMENSION_IMAGE);
32
+ for (const tag of outdated) {
33
+ try {
34
+ await docker.removeImage(tag);
35
+ }
36
+ catch (error) {
37
+ if (!(error instanceof DockerError && error.status === 409))
38
+ throw error;
39
+ }
40
+ }
41
+ }
42
+ return { ensure };
43
+ }
@@ -0,0 +1 @@
1
+ export declare const IMAGE_FILES: Record<string, string>;
@@ -0,0 +1,5 @@
1
+ // Generated by scripts/embed-image-files.ts from dimension/image/ — do not edit; run `bun run embed:images`.
2
+ export const IMAGE_FILES = {
3
+ "Dockerfile": "# bun runs the agent session worker (agent/session/), and the agent CLIs it installs.\nFROM oven/bun:1.3.13-slim AS bun\nFROM debian:13-slim\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends bash sudo git jq curl wget iputils-ping ca-certificates procps \\\n && rm -rf /var/lib/apt/lists/*\nCOPY --from=bun /usr/local/bin/bun /usr/local/bin/bun\nCOPY ensure-user.sh /usr/local/bin/hyb-ensure-user\nRUN chmod 0755 /usr/local/bin/hyb-ensure-user\nWORKDIR /workspace\nCMD [\"sleep\", \"infinity\"]\n",
4
+ "ensure-user.sh": "#!/bin/sh\n# Creates the dimension user for HYB_UID/HYB_GID (the host directory owner) with passwordless sudo.\n# Safe to re-run: every step checks before it acts, and concurrent runs are serialized by the lock.\nset -eu\n\nexec 9>/run/hyb-ensure-user.lock\nflock 9\n\nuid=\"$HYB_UID\"\ngid=\"$HYB_GID\"\n[ \"$uid\" = 0 ] && exit 0\n\ngroup=\"$(getent group \"$gid\" | cut -d: -f1 || true)\"\nif [ -z \"$group\" ]; then\n groupadd -g \"$gid\" dimension\n group=dimension\nfi\n\nif ! getent passwd \"$uid\" >/dev/null; then\n useradd -m -u \"$uid\" -g \"$group\" -d /home/dimension -s /bin/bash dimension\nfi\n\necho \"#$uid ALL=(ALL) NOPASSWD: ALL\" > /etc/sudoers.d/dimension\nchmod 0440 /etc/sudoers.d/dimension\n",
5
+ };
@@ -0,0 +1,5 @@
1
+ export declare const WORKSPACE_PATH = "/workspace";
2
+ /** Where the dimension's run directory (see Storage.ensureRunDirectory) is mounted. */
3
+ export declare const RUN_PATH = "/run/hyperbrane";
4
+ /** Where the dimension's state directory (see Storage.ensureStateDirectory) is mounted. */
5
+ export declare const STATE_PATH = "/var/lib/hyperbrane";
@@ -0,0 +1,7 @@
1
+ // Where things are inside every dimension. Its own module so code running in a dimension (the agent
2
+ // worker) can use them without the docker client service.ts brings along.
3
+ export const WORKSPACE_PATH = "/workspace";
4
+ /** Where the dimension's run directory (see Storage.ensureRunDirectory) is mounted. */
5
+ export const RUN_PATH = "/run/hyperbrane";
6
+ /** Where the dimension's state directory (see Storage.ensureStateDirectory) is mounted. */
7
+ export const STATE_PATH = "/var/lib/hyperbrane";
@@ -0,0 +1,22 @@
1
+ import { type DockerClient } from "../lib/docker.ts";
2
+ import { type Sandbox } from "../sandbox/api.ts";
3
+ import { type Dimension, type DimensionInfo, type DimensionLimits } from "./api.ts";
4
+ import type { DimensionImage } from "./image.ts";
5
+ import type { Storage } from "./storage.ts";
6
+ import { RUN_PATH, STATE_PATH, WORKSPACE_PATH } from "./paths.ts";
7
+ export { RUN_PATH, STATE_PATH, WORKSPACE_PATH };
8
+ export interface DimensionService {
9
+ getDimensions(): Promise<DimensionInfo[]>;
10
+ /** The dimensions' names, from the records alone: none of getDimensions' container status and usage. */
11
+ getDimensionNames(): Promise<string[]>;
12
+ createDimension(dimension: Dimension): Promise<void>;
13
+ destroyDimension(dimensionName: string): Promise<void>;
14
+ restartDimension(dimensionName: string): Promise<void>;
15
+ updateDimension(dimensionName: string, limits: DimensionLimits): Promise<Dimension>;
16
+ }
17
+ export declare function containerName(dimensionName: string): string;
18
+ /**
19
+ * `host` is the machine the docker daemon runs on, rooted at "/" so dimension directories
20
+ * (bind-mount sources) resolve the same way docker resolves them.
21
+ */
22
+ export declare function createDimensionService(storage: Storage, docker: DockerClient, image: DimensionImage, host: Sandbox): DimensionService;