agent-relay-server 0.17.0 → 0.18.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.
package/src/cli.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  import { formatMemoryBrokerSmokeResult, runMemoryBrokerSmoke } from "./memory-broker-smoke";
45
45
  import { MAX_BODY_BYTES, VERSION } from "./config";
46
46
  import { DEFAULT_CONTEXT_PROBE_STATE_DIR, runContextProbe } from "agent-relay-sdk/context-probe";
47
+ import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
47
48
 
48
49
  const HELP = `
49
50
  agent-relay ${VERSION}
@@ -62,6 +63,8 @@ Usage:
62
63
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
63
64
  agent-relay token <create|list|revoke|verify> [options]
64
65
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
66
+ agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--execute] [--json]
67
+ agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
65
68
  agent-relay message <target> <body> [options]
66
69
  agent-relay get-message <messageId> [--json|--body]
67
70
  agent-relay /pair <target|accept|reject|send|status> [...]
@@ -219,6 +222,16 @@ Labels and tags
219
222
  agent-relay /label [LABEL]
220
223
  agent-relay /tags [TAG ...]
221
224
 
225
+ Isolated workspaces
226
+ If you are working in an isolated workspace (a git worktree on an agent
227
+ branch, not the main checkout), you do NOT rebase, merge, or push yourself —
228
+ Relay does. Just commit your work in the worktree, then:
229
+ agent-relay workspace ready Hand off: Relay rebases onto the latest base,
230
+ lands your work, and pushes.
231
+ agent-relay workspace status Show your workspace's branch, base, status.
232
+ The base branch will move as other agents land in parallel — that is normal,
233
+ let the merge handle it. Never push your branch yourself; it is local-only.
234
+
222
235
  Rules of thumb
223
236
  If you are handling relay message #123, reply with:
224
237
  agent-relay /reply 123 "<response>"
@@ -347,6 +360,14 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
347
360
  await handleTagsCommand(args.slice(1));
348
361
  return "handled";
349
362
  }
363
+ if (command === "workspace" || command === "workspaces") {
364
+ await handleWorkspaceCommand(args.slice(1));
365
+ return "handled";
366
+ }
367
+ if (command === "steward" || command === "stewards") {
368
+ await handleStewardCommand(args.slice(1));
369
+ return "handled";
370
+ }
350
371
  if (command === "/reconnect") {
351
372
  console.log("Reconnect is handled automatically by provider runners; use `agent-relay pair status` to inspect current pair state.");
352
373
  return "handled";
@@ -994,7 +1015,7 @@ async function exchangeOrchestratorBootstrapToken(relayUrl: string, bootstrapTok
994
1015
  method: "POST",
995
1016
  headers: {
996
1017
  "Content-Type": "application/json",
997
- "X-Agent-Relay-Token": bootstrapToken,
1018
+ [RELAY_TOKEN_HEADER]: bootstrapToken,
998
1019
  },
999
1020
  body: JSON.stringify({ id, baseDir }),
1000
1021
  });
@@ -1376,6 +1397,161 @@ async function handlePairCommand(args: string[]): Promise<void> {
1376
1397
  }
1377
1398
  }
1378
1399
 
1400
+ // The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
1401
+ // by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
1402
+ function currentWorkspaceId(): string | undefined {
1403
+ const json = process.env.AGENT_RELAY_WORKSPACE_JSON;
1404
+ if (!json) return undefined;
1405
+ try {
1406
+ const parsed = JSON.parse(json) as { id?: string };
1407
+ return typeof parsed.id === "string" && parsed.id ? parsed.id : undefined;
1408
+ } catch {
1409
+ return undefined;
1410
+ }
1411
+ }
1412
+
1413
+ function formatWorkspaceStatus(ws: any): string {
1414
+ const lines = [
1415
+ `Workspace ${ws.id}`,
1416
+ ` status: ${ws.status}`,
1417
+ ` branch: ${ws.branch ?? "(none)"}`,
1418
+ ` base: ${ws.baseRef ?? "(none)"}`,
1419
+ ` worktree: ${ws.worktreePath ?? "(none)"}`,
1420
+ ];
1421
+ return lines.join("\n");
1422
+ }
1423
+
1424
+ // Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
1425
+ // steward coordination (#208).
1426
+ // status — read your workspace row ready — hand off for review/landing
1427
+ // land — request a base merge (operator) list — all workspaces
1428
+ // diagnostics — joined briefing + recommended action
1429
+ // claim/release — TTL'd steward lease auto-merge yields to
1430
+ // cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
1431
+ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1432
+ const action = args[0];
1433
+ const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale"]);
1434
+ if (!action || !valid.has(action)) {
1435
+ throw new Error("Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--execute] [--json]");
1436
+ }
1437
+
1438
+ let id = currentWorkspaceId();
1439
+ let strategy: string | undefined;
1440
+ let purpose: string | undefined;
1441
+ let repo: string | undefined;
1442
+ let execute = false;
1443
+ let json = false;
1444
+ for (let i = 1; i < args.length; i++) {
1445
+ const arg = args[i];
1446
+ if (arg === "--id" && i + 1 < args.length) id = args[++i];
1447
+ else if (arg === "--strategy" && i + 1 < args.length) strategy = args[++i];
1448
+ else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
1449
+ else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1450
+ else if (arg === "--execute") execute = true;
1451
+ else if (arg === "--json") json = true;
1452
+ else throw new Error(`Unknown workspace option "${arg}".`);
1453
+ }
1454
+
1455
+ if (action === "list") {
1456
+ console.log(JSON.stringify(await apiRequest("GET", "/api/workspaces"), null, 2));
1457
+ return;
1458
+ }
1459
+
1460
+ if (action === "cleanup-stale") {
1461
+ const result = await apiRequest("POST", "/api/workspaces/actions/cleanup-stale", { repoRoot: repo, dryRun: !execute });
1462
+ console.log(JSON.stringify(result, null, 2));
1463
+ return;
1464
+ }
1465
+
1466
+ if (!id) throw new Error("No current workspace detected (AGENT_RELAY_WORKSPACE_JSON unset). Pass --id WORKSPACE_ID — only isolated-workspace agents have one.");
1467
+
1468
+ if (action === "status") {
1469
+ const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`);
1470
+ if (json) console.log(JSON.stringify(ws, null, 2));
1471
+ else console.log(formatWorkspaceStatus(ws));
1472
+ return;
1473
+ }
1474
+
1475
+ if (action === "diagnostics" || action === "diag") {
1476
+ console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
1477
+ return;
1478
+ }
1479
+
1480
+ const from = await detectAgentId();
1481
+ const actionBody: Record<string, unknown> =
1482
+ action === "ready" ? { action: "request-review", agentId: from }
1483
+ : action === "claim" ? { action: "claim", agentId: from, purpose }
1484
+ : action === "release" ? { action: "release-claim", agentId: from }
1485
+ : { action: "merge", agentId: from, ...(strategy ? { strategy } : {}) };
1486
+ const result = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, actionBody);
1487
+ if (json) {
1488
+ console.log(JSON.stringify(result, null, 2));
1489
+ return;
1490
+ }
1491
+ console.log(
1492
+ action === "ready" ? `Workspace ${id} marked ready — Relay will rebase onto the latest base, land, and push.`
1493
+ : action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
1494
+ : action === "release" ? `Workspace ${id} claim released.`
1495
+ : `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
1496
+ );
1497
+ }
1498
+
1499
+ // Steward briefing commands (#208): queue of workspaces needing attention, a
1500
+ // per-workspace diagnostics inspection, and a check-command suggestion.
1501
+ async function handleStewardCommand(args: string[]): Promise<void> {
1502
+ const action = args[0];
1503
+ if (!action || !["queue", "inspect", "checks"].includes(action)) {
1504
+ throw new Error("Usage: agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]");
1505
+ }
1506
+
1507
+ let repo: string | undefined;
1508
+ let json = false;
1509
+ const positional: string[] = [];
1510
+ for (let i = 1; i < args.length; i++) {
1511
+ const arg = args[i];
1512
+ if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1513
+ else if (arg === "--json") json = true;
1514
+ else if (!arg!.startsWith("--")) positional.push(arg!);
1515
+ else throw new Error(`Unknown steward option "${arg}".`);
1516
+ }
1517
+
1518
+ if (action === "queue") {
1519
+ const all = await apiRequest("GET", "/api/workspaces") as any[];
1520
+ const attention = new Set(["conflict", "review_requested", "merge_planned"]);
1521
+ const queue = all.filter((ws) => attention.has(ws.status) && (!repo || ws.repoRoot === repo));
1522
+ if (json) { console.log(JSON.stringify(queue, null, 2)); return; }
1523
+ if (!queue.length) { console.log("Steward queue empty — no workspaces awaiting review, merge, or conflict resolution."); return; }
1524
+ for (const ws of queue) console.log(`${ws.status.padEnd(16)} ${ws.branch ?? ws.id} (${ws.repoRoot})`);
1525
+ return;
1526
+ }
1527
+
1528
+ const id = positional[0];
1529
+ if (!id) throw new Error(`Usage: agent-relay steward ${action} WORKSPACE_ID [--json]`);
1530
+
1531
+ if (action === "inspect") {
1532
+ console.log(JSON.stringify(await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diagnostics`), null, 2));
1533
+ return;
1534
+ }
1535
+
1536
+ // checks: suggest validation commands from the workspace's changed files.
1537
+ const diff = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}/diff?patch=0`) as any;
1538
+ const files: string[] = Array.isArray(diff?.files) ? diff.files.map((f: any) => f.path) : [];
1539
+ const checks = suggestStewardChecks(files);
1540
+ console.log(JSON.stringify({ workspaceId: id, changedFiles: files.length, checks }, null, 2));
1541
+ }
1542
+
1543
+ // Heuristic check suggestions from changed file paths. Repo-agnostic defaults a
1544
+ // steward can refine; cheaper than re-deriving from project docs every run.
1545
+ function suggestStewardChecks(files: string[]): Array<{ command: string; reason: string }> {
1546
+ const checks: Array<{ command: string; reason: string }> = [];
1547
+ const has = (re: RegExp) => files.some((f) => re.test(f));
1548
+ if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
1549
+ if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
1550
+ else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
1551
+ if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
1552
+ return checks;
1553
+ }
1554
+
1379
1555
  async function handleMessageCommand(args: string[], defaults: { claimable?: boolean } = {}): Promise<void> {
1380
1556
  const target = args[0];
1381
1557
  if (!target || target.startsWith("--")) {
@@ -1989,7 +2165,7 @@ async function apiRequest(method: string, path: string, body?: unknown): Promise
1989
2165
  const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
1990
2166
  const headers: Record<string, string> = {};
1991
2167
  const token = process.env.AGENT_RELAY_TOKEN;
1992
- if (token) headers["X-Agent-Relay-Token"] = token;
2168
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
1993
2169
  if (body !== undefined) headers["Content-Type"] = "application/json";
1994
2170
  const response = await fetch(new URL(path, baseUrl), {
1995
2171
  method,
@@ -2009,7 +2185,7 @@ async function apiRawRequest(method: string, path: string, body: BodyInit, extra
2009
2185
  const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
2010
2186
  const headers: Record<string, string> = { ...extraHeaders };
2011
2187
  const token = process.env.AGENT_RELAY_TOKEN;
2012
- if (token) headers["X-Agent-Relay-Token"] = token;
2188
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
2013
2189
  const response = await fetch(new URL(path, baseUrl), { method, headers, body });
2014
2190
  const text = await response.text();
2015
2191
  const payload = text ? JSON.parse(text) : null;
@@ -0,0 +1,26 @@
1
+ import { emitRelayEvent } from "./events";
2
+ import type { Command } from "./types";
3
+
4
+ /**
5
+ * Emit the relay event for a command. Single home — was duplicated as bus.ts's
6
+ * `emitCommandEvent(command, type)` and lifecycle-manager's private
7
+ * `emitCommand(command)` (which hardcoded "command.requested").
8
+ *
9
+ * The caller passes the exact event type: "command.requested" for a freshly
10
+ * created command (the relay auto-emits the follow-up "command.dispatched" — see
11
+ * `events.ts`), or `command.<status>` after an update.
12
+ *
13
+ * Deliberately NO `commandEventType(command)` derivation helper: a
14
+ * pending→"command.requested" mapping would diverge from the literal
15
+ * `command.<status>` used on the update path and spuriously trigger the
16
+ * requested→dispatched fan-out for a (valid, externally reachable) update to
17
+ * status "pending". Pass the type explicitly.
18
+ */
19
+ export function emitCommandEvent(command: Command, type: string): void {
20
+ emitRelayEvent({
21
+ type,
22
+ source: command.source,
23
+ subject: command.id,
24
+ data: { command },
25
+ });
26
+ }
@@ -1,5 +1,7 @@
1
1
  import { getDb, ValidationError } from "./db";
2
+ import { cleanString } from "./validation";
2
3
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
4
+ import { 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,22 +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
158
  function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
176
159
  if (value === undefined || value === null) {
177
160
  if (opts.required) throw new ValidationError(`${field} required`);
@@ -343,7 +326,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
343
326
  enabled: value.enabled === undefined ? true : cleanBoolean(value.enabled, "enabled"),
344
327
  orchestratorId: cleanString(value.orchestratorId, "orchestratorId", { required: true, max: 200 })!,
345
328
  cwd: cleanString(value.cwd, "cwd", { required: true, max: 1000 })!,
346
- provider: cleanEnum(value.provider, "provider", VALID_PROVIDERS) as SpawnProvider,
329
+ provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
347
330
  workspaceMode: value.workspaceMode === undefined || value.workspaceMode === null ? "inherit" : cleanEnum(value.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
348
331
  rig: cleanString(value.rig, "rig", { max: 120 }),
349
332
  model: cleanString(value.model, "model", { max: 120 }),
@@ -411,7 +394,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
411
394
  if (!isRecord(value)) throw new ValidationError("steward config value must be an object");
412
395
  const config: StewardConfig = {
413
396
  enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
414
- provider: cleanEnum(value.provider, "provider", VALID_PROVIDERS) as SpawnProvider,
397
+ provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
415
398
  model: cleanString(value.model, "model", { max: 120 }),
416
399
  effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
417
400
  permissionMode: (value.permissionMode === undefined || value.permissionMode === null
@@ -721,7 +704,7 @@ function listManagedAgentStates(): ManagedAgentState[] {
721
704
 
722
705
  export function upsertManagedAgentState(input: ManagedAgentStateInput): ManagedAgentState {
723
706
  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");
707
+ if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
725
708
  const now = input.updatedAt ?? Date.now();
726
709
  getDb().query(`
727
710
  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/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";
@@ -5241,6 +5228,18 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
5241
5228
  return getWorkspace(id);
5242
5229
  }
5243
5230
 
5231
+ // Repoint a workspace row at a recycled branch after a land-and-continue merge
5232
+ // (#206): the worktree switched to a fresh branch cut from the advanced base, so
5233
+ // the row must track the new branch (else the next merge command targets a branch
5234
+ // that no longer exists) and the new base sha. No-op if the row is gone.
5235
+ export function setWorkspaceBranch(id: string, branch: string, baseSha?: string): WorkspaceRecord | null {
5236
+ const existing = getWorkspace(id);
5237
+ if (!existing) return null;
5238
+ db.query(`UPDATE workspaces SET branch = ?, base_sha = coalesce(?, base_sha), updated_at = ? WHERE id = ?`)
5239
+ .run(branch, baseSha ?? null, Date.now(), id);
5240
+ return getWorkspace(id);
5241
+ }
5242
+
5244
5243
  // Workspace statuses that count as "live" for stewardship — an agent owning one
5245
5244
  // of these is a candidate steward; the repo is worth coordinating.
5246
5245
  const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
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";
@@ -264,8 +265,7 @@ export function createFetchHandler(
264
265
  }
265
266
  const file = Bun.file(resolved);
266
267
  if (await file.exists()) {
267
- const headers = staticHeaders(requested);
268
- return new Response(file, { headers });
268
+ return await serveStaticFile(req, resolved, requested, file);
269
269
  }
270
270
 
271
271
  return Response.json({ error: "not found" }, { status: 404 });
@@ -275,13 +275,108 @@ export function createFetchHandler(
275
275
  };
276
276
  }
277
277
 
278
+ // In-memory compressed-variant cache, keyed by absolute path. Invalidated when
279
+ // the file's mtime/size changes, so a rebuilt bundle is recompressed on next
280
+ // request. The single-file dashboard bundle (~10 MB unminified) is served
281
+ // uncompressed by Bun.serve otherwise; brotli/gzip cuts it ~6-7x on the wire,
282
+ // which is the dominant cost over high-latency links. Further wins (minify,
283
+ // code-split) tracked in #200 / #201.
284
+ type CompressedVariant = { body: Uint8Array; encoding: "br" | "gzip" };
285
+ const compressedCache = new Map<
286
+ string,
287
+ { mtimeMs: number; size: number; br?: Uint8Array; gzip?: Uint8Array }
288
+ >();
289
+
290
+ const COMPRESSIBLE = /\.(html|js|css|svg|json|webmanifest|map)$/;
291
+
292
+ function isCompressible(pathname: string): boolean {
293
+ return pathname === "/" || pathname.endsWith("/") || COMPRESSIBLE.test(pathname);
294
+ }
295
+
296
+ function negotiateEncoding(req: Request): "br" | "gzip" | null {
297
+ const accept = req.headers.get("accept-encoding") ?? "";
298
+ if (/\bbr\b/.test(accept)) return "br";
299
+ if (/\bgzip\b/.test(accept)) return "gzip";
300
+ return null;
301
+ }
302
+
303
+ async function getCompressedVariant(
304
+ resolved: string,
305
+ mtimeMs: number,
306
+ size: number,
307
+ encoding: "br" | "gzip",
308
+ raw: () => Promise<Uint8Array>,
309
+ ): Promise<CompressedVariant> {
310
+ let entry = compressedCache.get(resolved);
311
+ if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) {
312
+ entry = { mtimeMs, size };
313
+ compressedCache.set(resolved, entry);
314
+ }
315
+ const cached = entry[encoding];
316
+ if (cached) return { body: cached, encoding };
317
+ const data = await raw();
318
+ const body =
319
+ encoding === "br"
320
+ ? brotliCompressSync(data, {
321
+ params: {
322
+ // Quality 5 ~ near-gzip CPU, much better ratio; this runs once per
323
+ // file version then serves from cache, so cost is amortized away.
324
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 5,
325
+ [zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.byteLength,
326
+ },
327
+ })
328
+ : gzipSync(data, { level: 6 });
329
+ entry[encoding] = body;
330
+ return { body, encoding };
331
+ }
332
+
333
+ async function serveStaticFile(
334
+ req: Request,
335
+ resolved: string,
336
+ requested: string,
337
+ file: ReturnType<typeof Bun.file>,
338
+ ): Promise<Response> {
339
+ const stat = await file.stat();
340
+ const headers = staticHeaders(requested);
341
+ // Strong-ish validator from mtime + size; encoding-suffixed so a client never
342
+ // gets a 304 for a variant it didn't store.
343
+ const baseTag = `${stat.mtime.getTime().toString(36)}-${stat.size.toString(36)}`;
344
+
345
+ const encoding = isCompressible(requested) ? negotiateEncoding(req) : null;
346
+ const etag = `"${baseTag}${encoding ? `-${encoding}` : ""}"`;
347
+ headers.set("ETag", etag);
348
+ if (req.headers.get("if-none-match") === etag) {
349
+ return new Response(null, { status: 304, headers });
350
+ }
351
+
352
+ if (encoding) {
353
+ const { body } = await getCompressedVariant(
354
+ resolved,
355
+ stat.mtime.getTime(),
356
+ stat.size,
357
+ encoding,
358
+ () => file.bytes(),
359
+ );
360
+ headers.set("Content-Encoding", encoding);
361
+ headers.set("Vary", "Accept-Encoding");
362
+ headers.set("Content-Length", String(body.byteLength));
363
+ // Uint8Array is a valid BodyInit at runtime in Bun; the DOM lib types omit it.
364
+ return new Response(body as unknown as BodyInit, { headers });
365
+ }
366
+
367
+ return new Response(file, { headers });
368
+ }
369
+
278
370
  function staticHeaders(pathname: string): Headers {
279
371
  const headers = new Headers();
280
372
  const contentType = staticContentType(pathname);
281
373
  if (contentType) headers.set("Content-Type", contentType);
282
- if (pathname === "/sw.js") {
374
+ // The shell + PWA control files must always revalidate (ETag makes this a
375
+ // cheap 304 when unchanged). Hashing isn't available — viteSingleFile inlines
376
+ // everything into index.html — so no immutable long-cache assets exist.
377
+ if (pathname === "/sw.js" || pathname === "/manifest.webmanifest") {
283
378
  headers.set("Cache-Control", "no-cache");
284
- } else if (pathname === "/manifest.webmanifest") {
379
+ } else if (pathname === "/" || pathname.endsWith("/") || pathname.endsWith(".html")) {
285
380
  headers.set("Cache-Control", "no-cache");
286
381
  }
287
382
  return headers;