agent-relay-server 0.17.0 → 0.19.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.
@@ -1,5 +1,7 @@
1
1
  import { getDb, ValidationError } from "./db";
2
+ import { cleanEnum, cleanString, cleanStringArray } from "./validation";
2
3
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
4
+ import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
3
5
  import type {
4
6
  AgentProfile,
5
7
  AgentProfileBase,
@@ -24,18 +26,15 @@ const INSIGHTS_NAMESPACE = "insights";
24
26
  const INSIGHTS_KEY = "default";
25
27
  const WORKSPACE_NAMESPACE = "workspace";
26
28
  const WORKSPACE_KEY = "default";
27
- const VALID_PROVIDERS = ["claude", "codex"] as const;
28
29
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
29
30
  const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
30
31
  const VALID_PROFILE_INSTRUCTION_POLICIES = ["allow", "ignore"] as const;
31
32
  const VALID_PROFILE_CATEGORY_MODES = ["host", "profile", "repo", "none"] as const;
32
33
  const VALID_PROFILE_ASSET_SOURCES = ["relay", "repo", "inline", "provider"] as const;
33
34
  const VALID_PROFILE_FILESYSTEM_SCOPES = ["repo", "workspace", "host"] as const;
34
- const VALID_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
35
35
  const VALID_PERMISSION_MODES = ["open", "guarded", "read-only"] as const;
36
36
  const VALID_POLICY_MODES = ["always-on", "on-demand"] as const;
37
37
  const VALID_MANAGED_STATUSES = ["stopped", "starting", "running", "stopping", "backoff"] as const;
38
- const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
39
38
  const BUILT_IN_AGENT_PROFILE_NAMES = new Set(["default-relay", "minimal", "isolated-research"]);
40
39
 
41
40
  const BUILT_IN_AGENT_PROFILES: AgentProfile[] = [
@@ -156,33 +155,6 @@ function rowToManagedAgentState(row: ManagedAgentStateRow): ManagedAgentState {
156
155
  };
157
156
  }
158
157
 
159
- function isRecord(value: unknown): value is Record<string, unknown> {
160
- return typeof value === "object" && value !== null && !Array.isArray(value);
161
- }
162
-
163
- function cleanString(value: unknown, field: string, opts: { required?: boolean; max?: number } = {}): string | undefined {
164
- if (value === undefined || value === null) {
165
- if (opts.required) throw new ValidationError(`${field} required`);
166
- return undefined;
167
- }
168
- if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
169
- const trimmed = value.trim();
170
- if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
171
- if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
172
- return trimmed || undefined;
173
- }
174
-
175
- function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
176
- if (value === undefined || value === null) {
177
- if (opts.required) throw new ValidationError(`${field} required`);
178
- return [];
179
- }
180
- if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
181
- const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 120 })).filter(Boolean) as string[];
182
- if (cleaned.length > 100) throw new ValidationError(`${field} can contain at most 100 values`);
183
- return [...new Set(cleaned)];
184
- }
185
-
186
158
  function cleanStringRecord(value: unknown, field: string): Record<string, string> {
187
159
  if (value === undefined || value === null) return {};
188
160
  if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
@@ -213,13 +185,6 @@ function cleanNumber(value: unknown, field: string, opts: { min: number; max: nu
213
185
  return value;
214
186
  }
215
187
 
216
- function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
217
- if (typeof value !== "string" || !valid.includes(value)) {
218
- throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
219
- }
220
- return value as T[number];
221
- }
222
-
223
188
  function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
224
189
  const isolated = input.base !== "host";
225
190
  return {
@@ -296,7 +261,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
296
261
  : cleanEnum(value.provider, "provider", VALID_PROFILE_PROVIDERS),
297
262
  instructions: {
298
263
  system: cleanString(instructions.system, "instructions.system", { max: 16_000 }),
299
- append: cleanStringArray(instructions.append, "instructions.append"),
264
+ append: cleanStringArray(instructions.append, "instructions.append", { itemMax: 120, maxItems: 100 }) ?? [],
300
265
  repoInstructions: instructions.repoInstructions === undefined
301
266
  ? defaults.instructions.repoInstructions
302
267
  : cleanEnum(instructions.repoInstructions, "instructions.repoInstructions", VALID_PROFILE_INSTRUCTION_POLICIES),
@@ -343,16 +308,16 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
343
308
  enabled: value.enabled === undefined ? true : cleanBoolean(value.enabled, "enabled"),
344
309
  orchestratorId: cleanString(value.orchestratorId, "orchestratorId", { required: true, max: 200 })!,
345
310
  cwd: cleanString(value.cwd, "cwd", { required: true, max: 1000 })!,
346
- provider: cleanEnum(value.provider, "provider", VALID_PROVIDERS) as SpawnProvider,
311
+ provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
347
312
  workspaceMode: value.workspaceMode === undefined || value.workspaceMode === null ? "inherit" : cleanEnum(value.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
348
313
  rig: cleanString(value.rig, "rig", { max: 120 }),
349
314
  model: cleanString(value.model, "model", { max: 120 }),
350
315
  effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
351
316
  profile: cleanString(value.profile, "profile", { max: 120 }),
352
- providerArgs: cleanStringArray(value.providerArgs, "providerArgs"),
317
+ providerArgs: cleanStringArray(value.providerArgs, "providerArgs", { itemMax: 120, maxItems: 100 }) ?? [],
353
318
  prompt: cleanString(value.prompt, "prompt", { max: 16_000 }),
354
- tags: cleanStringArray(value.tags, "tags"),
355
- capabilities: cleanStringArray(value.capabilities, "capabilities"),
319
+ tags: cleanStringArray(value.tags, "tags", { itemMax: 120, maxItems: 100 }) ?? [],
320
+ capabilities: cleanStringArray(value.capabilities, "capabilities", { itemMax: 120, maxItems: 100 }) ?? [],
356
321
  label: cleanString(value.label, "label", { max: 120 }),
357
322
  mode,
358
323
  permissionMode: cleanEnum(value.permissionMode, "permissionMode", VALID_PERMISSION_MODES),
@@ -363,7 +328,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
363
328
  try {
364
329
  resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
365
330
  } catch (error) {
366
- throw new ValidationError(error instanceof Error ? error.message : String(error));
331
+ throw new ValidationError(errMessage(error));
367
332
  }
368
333
  if (policy.profile && !getAgentProfile(policy.profile)) throw new ValidationError("agent profile not found");
369
334
  if (mode === "on-demand") policy.onDemand = cleanOnDemand(value.onDemand);
@@ -411,7 +376,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
411
376
  if (!isRecord(value)) throw new ValidationError("steward config value must be an object");
412
377
  const config: StewardConfig = {
413
378
  enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
414
- provider: cleanEnum(value.provider, "provider", VALID_PROVIDERS) as SpawnProvider,
379
+ provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
415
380
  model: cleanString(value.model, "model", { max: 120 }),
416
381
  effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
417
382
  permissionMode: (value.permissionMode === undefined || value.permissionMode === null
@@ -426,7 +391,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
426
391
  try {
427
392
  resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort });
428
393
  } catch (error) {
429
- throw new ValidationError(error instanceof Error ? error.message : String(error));
394
+ throw new ValidationError(errMessage(error));
430
395
  }
431
396
  return config;
432
397
  }
@@ -484,7 +449,7 @@ const WORKSPACE_CONFIG_DEFAULTS: WorkspaceConfig = {
484
449
 
485
450
  function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
486
451
  if (!isRecord(value)) throw new ValidationError("workspace config value must be an object");
487
- const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths");
452
+ const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths", { itemMax: 120, maxItems: 100 }) ?? [];
488
453
  // Reject absolute paths and parent-traversal up front: symlink sources must stay
489
454
  // inside the main checkout. The orchestrator re-checks containment at link time,
490
455
  // but failing here gives the operator immediate feedback in the dashboard.
@@ -721,7 +686,7 @@ function listManagedAgentStates(): ManagedAgentState[] {
721
686
 
722
687
  export function upsertManagedAgentState(input: ManagedAgentStateInput): ManagedAgentState {
723
688
  if (!VALID_MANAGED_STATUSES.includes(input.status)) throw new ValidationError("status must be a managed-agent status");
724
- if (!VALID_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
689
+ if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
725
690
  const now = input.updatedAt ?? Date.now();
726
691
  getDb().query(`
727
692
  INSERT INTO managed_agent_state (
package/src/connectors.ts CHANGED
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, ConnectorSummary } from "./types";
5
5
  import { ValidationError } from "./db";
6
+ import { isRecord } from "agent-relay-sdk";
6
7
 
7
8
  const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
8
9
  const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
@@ -34,10 +35,6 @@ function readRecordFile(path: string): Record<string, unknown> | undefined {
34
35
  return isRecord(parsed) ? parsed : undefined;
35
36
  }
36
37
 
37
- function isRecord(value: unknown): value is Record<string, unknown> {
38
- return typeof value === "object" && value !== null && !Array.isArray(value);
39
- }
40
-
41
38
  function validateConnectorId(id: string): void {
42
39
  if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(id)) {
43
40
  throw new ValidationError("connector id must be lowercase alphanumeric plus dot, underscore, or dash");
package/src/contracts.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isRecord, stringValue } from "agent-relay-sdk";
2
+
1
3
  export const CONTRACT_VERSIONS = {
2
4
  relayApi: 1,
3
5
  orchestratorProtocol: 3,
@@ -95,10 +97,6 @@ function rangeLabel(requirement: ContractRequirement): string {
95
97
  return `>=${requirement.min} <${requirement.maxExclusive}`;
96
98
  }
97
99
 
98
- function stringValue(value: unknown): string | undefined {
99
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
100
- }
101
-
102
100
  function positiveInteger(value: unknown): number | undefined {
103
101
  const parsed = typeof value === "number"
104
102
  ? value
@@ -106,7 +104,3 @@ function positiveInteger(value: unknown): number | undefined {
106
104
  if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) return undefined;
107
105
  return parsed;
108
106
  }
109
-
110
- function isRecord(value: unknown): value is Record<string, unknown> {
111
- return typeof value === "object" && value !== null && !Array.isArray(value);
112
- }
package/src/daemon.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
2
3
  import { constants, existsSync } from "node:fs";
3
4
  import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
@@ -460,10 +461,6 @@ function quoteSystemdArg(value: string): string {
460
461
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
461
462
  }
462
463
 
463
- function shellQuote(value: string): string {
464
- return `'${value.replace(/'/g, "'\\''")}'`;
465
- }
466
-
467
464
  function xmlEscape(value: string): string {
468
465
  return value
469
466
  .replace(/&/g, "&amp;")
package/src/db.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { isRecord, stringValue } from "agent-relay-sdk";
3
4
  import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "./config.ts";
5
+ import { parseJson } from "./utils";
4
6
  import {
5
7
  CONTRACT_REQUIREMENTS,
6
8
  contractCompatibility,
@@ -1173,13 +1175,6 @@ export function getDb(): Database {
1173
1175
  export class ValidationError extends Error {}
1174
1176
  class ClaimError extends Error {}
1175
1177
 
1176
- function parseJson<T>(raw: string, fallback: T): T {
1177
- try {
1178
- return JSON.parse(raw);
1179
- } catch {
1180
- return fallback;
1181
- }
1182
- }
1183
1178
 
1184
1179
  function parseStringArray(raw: string): string[] {
1185
1180
  const parsed = parseJson<unknown>(raw, []);
@@ -1191,10 +1186,6 @@ function normalizeTags(tags: string[] | undefined): string[] {
1191
1186
  return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
1192
1187
  }
1193
1188
 
1194
- function stringValue(value: unknown): string | undefined {
1195
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
1196
- }
1197
-
1198
1189
  function inferAgentKind(input: Pick<RegisterAgentInput, "id" | "kind" | "tags" | "capabilities" | "meta">): AgentKind {
1199
1190
  if (input.kind) return input.kind;
1200
1191
  if (input.id === "user") return "user";
@@ -2418,10 +2409,6 @@ function runtimeTokenJtisFromMeta(meta: Record<string, unknown>): string[] {
2418
2409
  return [...jtis];
2419
2410
  }
2420
2411
 
2421
- function isRecord(value: unknown): value is Record<string, unknown> {
2422
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2423
- }
2424
-
2425
2412
  // --- Tasks ---
2426
2413
 
2427
2414
  const TASK_SELECT = "SELECT * FROM tasks";
@@ -5092,14 +5079,21 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
5092
5079
  // preserved and metadata is merged, not replaced.
5093
5080
  const existing = getWorkspace(workspace.id);
5094
5081
  const preserveStatus = existing != null && existing.status !== "active";
5082
+ // The branch (and advanced base) change ONLY via the relay's own land-and-continue
5083
+ // recycle (setWorkspaceBranch repoints to `<branch>-N` and bumps base_sha). The
5084
+ // runner keeps re-reporting its spawn-time branch on every heartbeat, and the
5085
+ // recycle returns status to "active" — so without this the next heartbeat clobbers
5086
+ // the repoint back to the original branch, and the next land targets a deleted
5087
+ // branch and strands the work (vent #62 follow-up). Trust the existing row's
5088
+ // branch/base over registration; only a brand-new row takes the runner's values.
5095
5089
  return upsertWorkspace({
5096
5090
  id: workspace.id,
5097
5091
  repoRoot: workspace.repoRoot,
5098
5092
  sourceCwd: workspace.sourceCwd ?? agent.cwd,
5099
5093
  worktreePath: workspace.worktreePath,
5100
- branch: workspace.branch,
5094
+ branch: existing?.branch ?? workspace.branch,
5101
5095
  baseRef: workspace.baseRef,
5102
- baseSha: workspace.baseSha,
5096
+ baseSha: existing?.baseSha ?? workspace.baseSha,
5103
5097
  mode: workspace.mode,
5104
5098
  requestedMode: workspace.requestedMode,
5105
5099
  status: preserveStatus ? existing!.status : (workspace.status ?? "active"),
@@ -5241,6 +5235,18 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
5241
5235
  return getWorkspace(id);
5242
5236
  }
5243
5237
 
5238
+ // Repoint a workspace row at a recycled branch after a land-and-continue merge
5239
+ // (#206): the worktree switched to a fresh branch cut from the advanced base, so
5240
+ // the row must track the new branch (else the next merge command targets a branch
5241
+ // that no longer exists) and the new base sha. No-op if the row is gone.
5242
+ export function setWorkspaceBranch(id: string, branch: string, baseSha?: string): WorkspaceRecord | null {
5243
+ const existing = getWorkspace(id);
5244
+ if (!existing) return null;
5245
+ db.query(`UPDATE workspaces SET branch = ?, base_sha = coalesce(?, base_sha), updated_at = ? WHERE id = ?`)
5246
+ .run(branch, baseSha ?? null, Date.now(), id);
5247
+ return getWorkspace(id);
5248
+ }
5249
+
5244
5250
  // Workspace statuses that count as "live" for stewardship — an agent owning one
5245
5251
  // of these is a candidate steward; the repo is worth coordinating.
5246
5252
  const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
package/src/dev.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
5
  import { homedir, hostname as osHostname } from "node:os";
@@ -504,7 +505,3 @@ function quote(value: string): string {
504
505
  if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
505
506
  return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
506
507
  }
507
-
508
- function shellQuote(value: string): string {
509
- return `'${value.replace(/'/g, "'\\''")}'`;
510
- }
@@ -0,0 +1,49 @@
1
+ /** Concatenate body chunks into a single contiguous Uint8Array. */
2
+ export function concatBytes(chunks: Uint8Array[]): Uint8Array {
3
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
4
+ const output = new Uint8Array(total);
5
+ let offset = 0;
6
+ for (const chunk of chunks) {
7
+ output.set(chunk, offset);
8
+ offset += chunk.byteLength;
9
+ }
10
+ return output;
11
+ }
12
+
13
+ /** Wrap bytes in a single-chunk ReadableStream (e.g. for artifact `storage.store`). */
14
+ export function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
15
+ return new ReadableStream<Uint8Array>({
16
+ start(controller) {
17
+ controller.enqueue(bytes);
18
+ controller.close();
19
+ },
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Drain a request body stream into a single Uint8Array, enforcing a byte cap.
25
+ * Returns a 413 result the moment the running total exceeds `maxBytes` (cancels
26
+ * the reader). Single home for the read-loop that `parseBody` (routes) and
27
+ * `parseJsonRpcRequest` (mcp) each open-coded — they differed only in the cap.
28
+ * Callers handle body-absence and decoding.
29
+ */
30
+ export async function readBodyBytes(
31
+ body: ReadableStream<Uint8Array>,
32
+ maxBytes: number,
33
+ ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 413; error: string }> {
34
+ const reader = body.getReader();
35
+ const chunks: Uint8Array[] = [];
36
+ let total = 0;
37
+ while (true) {
38
+ const { done, value } = await reader.read();
39
+ if (done) break;
40
+ if (!value) continue;
41
+ total += value.byteLength;
42
+ if (total > maxBytes) {
43
+ await reader.cancel().catch(() => {});
44
+ return { ok: false, status: 413, error: `request body exceeds ${maxBytes} bytes` };
45
+ }
46
+ chunks.push(value);
47
+ }
48
+ return { ok: true, bytes: concatBytes(chunks) };
49
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "./compaction-watch";
11
11
  import { getConfig, setConfig } from "./config-store";
12
12
  import { startConnectorStatusPoller } from "./connectors";
13
13
  import { resolve, sep } from "path";
14
+ import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
14
15
  import {
15
16
  VERSION,
16
17
  } from "./config";
@@ -29,6 +30,7 @@ import {
29
30
  } from "./security";
30
31
  import { handleCli } from "./cli";
31
32
  import { startMaintenanceScheduler } from "./maintenance";
33
+ import { errMessage } from "agent-relay-sdk";
32
34
 
33
35
  async function main(): Promise<void> {
34
36
  const result = await handleCli(process.argv.slice(2));
@@ -264,8 +266,7 @@ export function createFetchHandler(
264
266
  }
265
267
  const file = Bun.file(resolved);
266
268
  if (await file.exists()) {
267
- const headers = staticHeaders(requested);
268
- return new Response(file, { headers });
269
+ return await serveStaticFile(req, resolved, requested, file);
269
270
  }
270
271
 
271
272
  return Response.json({ error: "not found" }, { status: 404 });
@@ -275,13 +276,108 @@ export function createFetchHandler(
275
276
  };
276
277
  }
277
278
 
279
+ // In-memory compressed-variant cache, keyed by absolute path. Invalidated when
280
+ // the file's mtime/size changes, so a rebuilt bundle is recompressed on next
281
+ // request. The single-file dashboard bundle (~10 MB unminified) is served
282
+ // uncompressed by Bun.serve otherwise; brotli/gzip cuts it ~6-7x on the wire,
283
+ // which is the dominant cost over high-latency links. Further wins (minify,
284
+ // code-split) tracked in #200 / #201.
285
+ type CompressedVariant = { body: Uint8Array; encoding: "br" | "gzip" };
286
+ const compressedCache = new Map<
287
+ string,
288
+ { mtimeMs: number; size: number; br?: Uint8Array; gzip?: Uint8Array }
289
+ >();
290
+
291
+ const COMPRESSIBLE = /\.(html|js|css|svg|json|webmanifest|map)$/;
292
+
293
+ function isCompressible(pathname: string): boolean {
294
+ return pathname === "/" || pathname.endsWith("/") || COMPRESSIBLE.test(pathname);
295
+ }
296
+
297
+ function negotiateEncoding(req: Request): "br" | "gzip" | null {
298
+ const accept = req.headers.get("accept-encoding") ?? "";
299
+ if (/\bbr\b/.test(accept)) return "br";
300
+ if (/\bgzip\b/.test(accept)) return "gzip";
301
+ return null;
302
+ }
303
+
304
+ async function getCompressedVariant(
305
+ resolved: string,
306
+ mtimeMs: number,
307
+ size: number,
308
+ encoding: "br" | "gzip",
309
+ raw: () => Promise<Uint8Array>,
310
+ ): Promise<CompressedVariant> {
311
+ let entry = compressedCache.get(resolved);
312
+ if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) {
313
+ entry = { mtimeMs, size };
314
+ compressedCache.set(resolved, entry);
315
+ }
316
+ const cached = entry[encoding];
317
+ if (cached) return { body: cached, encoding };
318
+ const data = await raw();
319
+ const body =
320
+ encoding === "br"
321
+ ? brotliCompressSync(data, {
322
+ params: {
323
+ // Quality 5 ~ near-gzip CPU, much better ratio; this runs once per
324
+ // file version then serves from cache, so cost is amortized away.
325
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 5,
326
+ [zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.byteLength,
327
+ },
328
+ })
329
+ : gzipSync(data, { level: 6 });
330
+ entry[encoding] = body;
331
+ return { body, encoding };
332
+ }
333
+
334
+ async function serveStaticFile(
335
+ req: Request,
336
+ resolved: string,
337
+ requested: string,
338
+ file: ReturnType<typeof Bun.file>,
339
+ ): Promise<Response> {
340
+ const stat = await file.stat();
341
+ const headers = staticHeaders(requested);
342
+ // Strong-ish validator from mtime + size; encoding-suffixed so a client never
343
+ // gets a 304 for a variant it didn't store.
344
+ const baseTag = `${stat.mtime.getTime().toString(36)}-${stat.size.toString(36)}`;
345
+
346
+ const encoding = isCompressible(requested) ? negotiateEncoding(req) : null;
347
+ const etag = `"${baseTag}${encoding ? `-${encoding}` : ""}"`;
348
+ headers.set("ETag", etag);
349
+ if (req.headers.get("if-none-match") === etag) {
350
+ return new Response(null, { status: 304, headers });
351
+ }
352
+
353
+ if (encoding) {
354
+ const { body } = await getCompressedVariant(
355
+ resolved,
356
+ stat.mtime.getTime(),
357
+ stat.size,
358
+ encoding,
359
+ () => file.bytes(),
360
+ );
361
+ headers.set("Content-Encoding", encoding);
362
+ headers.set("Vary", "Accept-Encoding");
363
+ headers.set("Content-Length", String(body.byteLength));
364
+ // Uint8Array is a valid BodyInit at runtime in Bun; the DOM lib types omit it.
365
+ return new Response(body as unknown as BodyInit, { headers });
366
+ }
367
+
368
+ return new Response(file, { headers });
369
+ }
370
+
278
371
  function staticHeaders(pathname: string): Headers {
279
372
  const headers = new Headers();
280
373
  const contentType = staticContentType(pathname);
281
374
  if (contentType) headers.set("Content-Type", contentType);
282
- if (pathname === "/sw.js") {
375
+ // The shell + PWA control files must always revalidate (ETag makes this a
376
+ // cheap 304 when unchanged). Hashing isn't available — viteSingleFile inlines
377
+ // everything into index.html — so no immutable long-cache assets exist.
378
+ if (pathname === "/sw.js" || pathname === "/manifest.webmanifest") {
283
379
  headers.set("Cache-Control", "no-cache");
284
- } else if (pathname === "/manifest.webmanifest") {
380
+ } else if (pathname === "/" || pathname.endsWith("/") || pathname.endsWith(".html")) {
285
381
  headers.set("Cache-Control", "no-cache");
286
382
  }
287
383
  return headers;
@@ -298,7 +394,7 @@ function staticContentType(pathname: string): string | undefined {
298
394
 
299
395
  if (import.meta.main) {
300
396
  main().catch((error) => {
301
- console.error(error instanceof Error ? error.message : String(error));
397
+ console.error(errMessage(error));
302
398
  process.exit(1);
303
399
  });
304
400
  }
@@ -1,5 +1,5 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
2
1
  import { createCommand } from "./commands-db";
2
+ import { isPathWithinBase } from "./utils";
3
3
  import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces, resolveQueuedPolicyMessages } from "./db";
4
4
  import {
5
5
  getManagedAgentState,
@@ -8,7 +8,9 @@ import {
8
8
  upsertManagedAgentState,
9
9
  } from "./config-store";
10
10
  import { emitRelayEvent } from "./events";
11
+ import { emitCommandEvent } from "./command-events";
11
12
  import { buildManagedSpawnParams } from "./managed-policy";
13
+ import { generateSpawnRequestId } from "./spawn-command";
12
14
  import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy } from "./types";
13
15
 
14
16
  const DEFAULT_TICK_MS = 10_000;
@@ -208,7 +210,7 @@ export class LifecycleManager {
208
210
  this.emitState(state);
209
211
  return null;
210
212
  }
211
- const spawnRequestId = `sp_${crypto.randomUUID()}`;
213
+ const spawnRequestId = generateSpawnRequestId();
212
214
  const state = upsertManagedAgentState({
213
215
  policyName: policy.name,
214
216
  status: "starting",
@@ -230,7 +232,7 @@ export class LifecycleManager {
230
232
  reason,
231
233
  },
232
234
  });
233
- this.emitCommand(command);
235
+ emitCommandEvent(command, "command.requested");
234
236
  return command;
235
237
  }
236
238
 
@@ -270,7 +272,7 @@ export class LifecycleManager {
270
272
  requestedAt: this.now(),
271
273
  },
272
274
  });
273
- this.emitCommand(command);
275
+ emitCommandEvent(command, "command.requested");
274
276
  return command;
275
277
  }
276
278
 
@@ -278,7 +280,7 @@ export class LifecycleManager {
278
280
  const orch = getOrchestrator(policy.orchestratorId);
279
281
  if (!orch) return null;
280
282
  const state = getManagedAgentState(policy.name);
281
- const restartRequestId = `sp_${crypto.randomUUID()}`;
283
+ const restartRequestId = generateSpawnRequestId();
282
284
  const restartSpawn = buildManagedSpawnParams(policy, restartRequestId, { createdBy: "lifecycle-manager", requestedAt: this.now() });
283
285
  const restarted = upsertManagedAgentState({
284
286
  policyName: policy.name,
@@ -314,7 +316,7 @@ export class LifecycleManager {
314
316
  requestedAt: this.now(),
315
317
  },
316
318
  });
317
- this.emitCommand(command);
319
+ emitCommandEvent(command, "command.requested");
318
320
  return command;
319
321
  }
320
322
 
@@ -406,7 +408,7 @@ export class LifecycleManager {
406
408
  requestedAt: this.now(),
407
409
  },
408
410
  });
409
- this.emitCommand(command);
411
+ emitCommandEvent(command, "command.requested");
410
412
  return command;
411
413
  }
412
414
 
@@ -564,15 +566,6 @@ export class LifecycleManager {
564
566
  });
565
567
  }
566
568
 
567
- private emitCommand(command: Command): void {
568
- emitRelayEvent({
569
- type: "command.requested",
570
- source: command.source,
571
- subject: command.id,
572
- data: { command },
573
- });
574
- }
575
-
576
569
  // When an agent disappears, its isolated worktrees would otherwise sit
577
570
  // `active` on disk forever. Dispatch a workspace.reconcile command to the
578
571
  // owning orchestrator, which probes the worktree and either removes it (no
@@ -593,7 +586,7 @@ export class LifecycleManager {
593
586
  const orchestrators = listOrchestrators();
594
587
  for (const ws of candidates) {
595
588
  const orch = orchestrators.find(
596
- (candidate) => candidate.status === "online" && pathWithinBaseDir(ws.sourceCwd, candidate.baseDir),
589
+ (candidate) => candidate.status === "online" && isPathWithinBase(ws.sourceCwd, candidate.baseDir),
597
590
  );
598
591
  if (!orch) continue;
599
592
  const command = createCommand({
@@ -614,17 +607,11 @@ export class LifecycleManager {
614
607
  requestedAt: this.now(),
615
608
  },
616
609
  });
617
- this.emitCommand(command);
610
+ emitCommandEvent(command, "command.requested");
618
611
  }
619
612
  }
620
613
  }
621
614
 
622
- function pathWithinBaseDir(path: string | undefined, baseDir: string | undefined): boolean {
623
- if (!path || !baseDir) return false;
624
- const rel = relative(resolve(baseDir), resolve(path));
625
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
626
- }
627
-
628
615
  let singleton: LifecycleManager | null = null;
629
616
 
630
617
  export function getLifecycleManager(): LifecycleManager {