agent-relay-server 0.16.0 → 0.17.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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.12.4",
5
+ "version": "0.16.0",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
@@ -5957,6 +5957,100 @@
5957
5957
  ]
5958
5958
  }
5959
5959
  },
5960
+ "/api/workspace-config": {
5961
+ "get": {
5962
+ "operationId": "getWorkspaceConfigRoute",
5963
+ "summary": "Get global workspace (isolated worktree) config",
5964
+ "tags": [
5965
+ "Other"
5966
+ ],
5967
+ "description": "Returns the global workspace provisioning config `{ symlinkPaths: string[] }` — untracked files/filename patterns symlinked from the main checkout into each isolated worktree (the dashboard \"Workspace\" spawn option). Defaults seed `AGENTS.md` and `.claude-rig`; merged over defaults until set.",
5968
+ "responses": {
5969
+ "200": {
5970
+ "description": "Success",
5971
+ "content": {
5972
+ "application/json": {}
5973
+ }
5974
+ },
5975
+ "400": {
5976
+ "description": "Bad request",
5977
+ "content": {
5978
+ "application/json": {
5979
+ "schema": {
5980
+ "$ref": "#/components/schemas/Error"
5981
+ }
5982
+ }
5983
+ }
5984
+ }
5985
+ },
5986
+ "security": [
5987
+ {
5988
+ "bearerAuth": []
5989
+ },
5990
+ {
5991
+ "tokenHeader": []
5992
+ },
5993
+ {
5994
+ "tokenQuery": []
5995
+ }
5996
+ ]
5997
+ },
5998
+ "put": {
5999
+ "operationId": "putWorkspaceConfigRoute",
6000
+ "summary": "Set global workspace (isolated worktree) config",
6001
+ "tags": [
6002
+ "Other"
6003
+ ],
6004
+ "description": "Updates the global workspace config. Body: `{ value: { symlinkPaths: [...] }, updatedBy? }`. Each entry is a relative path or glob pattern; plain names match files and directories, entries with `*?[]{}` are expanded against main. A path is only linked if it exists in main — missing entries are ignored at spawn time. Rejects absolute paths and `..` traversal (400). Versioned with full config history.",
6005
+ "requestBody": {
6006
+ "required": true,
6007
+ "content": {
6008
+ "application/json": {
6009
+ "schema": {
6010
+ "type": "object",
6011
+ "properties": {
6012
+ "value": {
6013
+ "type": "string"
6014
+ },
6015
+ "updatedBy": {
6016
+ "type": "string"
6017
+ }
6018
+ }
6019
+ }
6020
+ }
6021
+ }
6022
+ },
6023
+ "responses": {
6024
+ "200": {
6025
+ "description": "Success",
6026
+ "content": {
6027
+ "application/json": {}
6028
+ }
6029
+ },
6030
+ "400": {
6031
+ "description": "Bad request",
6032
+ "content": {
6033
+ "application/json": {
6034
+ "schema": {
6035
+ "$ref": "#/components/schemas/Error"
6036
+ }
6037
+ }
6038
+ }
6039
+ }
6040
+ },
6041
+ "security": [
6042
+ {
6043
+ "bearerAuth": []
6044
+ },
6045
+ {
6046
+ "tokenHeader": []
6047
+ },
6048
+ {
6049
+ "tokenQuery": []
6050
+ }
6051
+ ]
6052
+ }
6053
+ },
5960
6054
  "/api/insights/config": {
5961
6055
  "get": {
5962
6056
  "operationId": "getInsightsConfigRoute",
@@ -6143,6 +6237,9 @@
6143
6237
  },
6144
6238
  "source": {
6145
6239
  "type": "string"
6240
+ },
6241
+ "occurredAt": {
6242
+ "type": "string"
6146
6243
  }
6147
6244
  }
6148
6245
  }
@@ -7217,6 +7314,9 @@
7217
7314
  "maxAgeSeconds": {
7218
7315
  "type": "string"
7219
7316
  },
7317
+ "occurredAt": {
7318
+ "type": "string"
7319
+ },
7220
7320
  "channel": {
7221
7321
  "type": "string"
7222
7322
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.7"
36
+ "agent-relay-sdk": "0.2.8"
37
37
  },
38
38
  "scripts": {
39
39
  "prepack": "bun run build:dashboard:bundle >&2",
package/public/index.html CHANGED
@@ -125641,6 +125641,9 @@ function chatTimestamp(iso) {
125641
125641
  minute: "2-digit"
125642
125642
  });
125643
125643
  }
125644
+ function messageEventTime(msg) {
125645
+ return new Date(msg.occurredAt ?? msg.createdAt).getTime();
125646
+ }
125644
125647
  function dateSeparatorLabel(dateStr) {
125645
125648
  const today = /* @__PURE__ */ new Date();
125646
125649
  const [y, m, d] = dateStr.split("-").map(Number);
@@ -126444,7 +126447,7 @@ var MessageBubble = (0, import_react.memo)(function MessageBubble({ msg, peer, o
126444
126447
  const pointerStartRef = (0, import_react.useRef)(null);
126445
126448
  const suppressBubbleClickRef = (0, import_react.useRef)(false);
126446
126449
  const body = messageBody(msg);
126447
- const time = chatTimestamp(msg.createdAt);
126450
+ const time = chatTimestamp(msg.occurredAt ?? msg.createdAt);
126448
126451
  const attachments = messageAttachments(msg);
126449
126452
  const reactions = groupedReactions(msg);
126450
126453
  const receipt = isOutbound ? outboundReceipt(msg, peer) : null;
@@ -126580,7 +126583,7 @@ function sessionActivityStep(msg) {
126580
126583
  kind: s.type,
126581
126584
  label: typeof s.label === "string" ? s.label : void 0,
126582
126585
  text: msg.body,
126583
- ts: new Date(msg.createdAt).getTime(),
126586
+ ts: messageEventTime(msg),
126584
126587
  turnId: typeof s.turnId === "string" ? s.turnId : void 0
126585
126588
  };
126586
126589
  }
@@ -126674,7 +126677,7 @@ function buildTimeline(messages, statusEvents, createdAt, importedHistory = [])
126674
126677
  continue;
126675
126678
  }
126676
126679
  raw.push({
126677
- ts: new Date(msg.createdAt).getTime(),
126680
+ ts: messageEventTime(msg),
126678
126681
  entry: {
126679
126682
  type: "message",
126680
126683
  msg
@@ -153682,11 +153685,80 @@ function SettingsView() {
153682
153685
  ]
153683
153686
  })]
153684
153687
  }),
153688
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WorkspaceSettings, {}),
153685
153689
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StewardSettings, {}),
153686
153690
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VoiceSettings, {})
153687
153691
  ]
153688
153692
  });
153689
153693
  }
153694
+ var DEFAULT_WORKSPACE = { symlinkPaths: [] };
153695
+ function WorkspaceSettings() {
153696
+ const [config, setConfig] = (0, import_react.useState)(DEFAULT_WORKSPACE);
153697
+ const [status, setStatus] = (0, import_react.useState)("");
153698
+ (0, import_react.useEffect)(() => {
153699
+ api("GET", "/workspace-config").then((entry) => setConfig({
153700
+ ...DEFAULT_WORKSPACE,
153701
+ ...entry.value
153702
+ })).catch((e) => setStatus(e.message));
153703
+ }, []);
153704
+ async function save() {
153705
+ try {
153706
+ const saved = await api("PUT", "/workspace-config", config);
153707
+ setConfig({
153708
+ ...DEFAULT_WORKSPACE,
153709
+ ...saved.value
153710
+ });
153711
+ setStatus("Workspace config saved");
153712
+ } catch (e) {
153713
+ setStatus(e.message);
153714
+ }
153715
+ }
153716
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", {
153717
+ className: "space-y-3 rounded-lg border p-4",
153718
+ children: [
153719
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
153720
+ className: "flex items-center gap-2",
153721
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(FolderSymlink, { className: "w-4 h-4" }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
153722
+ className: "text-sm font-semibold",
153723
+ children: "Isolated worktree symlinks"
153724
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", {
153725
+ className: "text-xs text-muted-foreground",
153726
+ children: [
153727
+ "Untracked files or filename patterns symlinked from main into each isolated worktree (the \"Workspace\" spawn option), one per line. Plain names like ",
153728
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: "AGENTS.md" }),
153729
+ " or ",
153730
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: ".claude-rig" }),
153731
+ " match files and directories; entries with ",
153732
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("code", { children: ["*?[]", "{}"] }),
153733
+ " are expanded as globs. A path is only linked if it exists in main."
153734
+ ]
153735
+ })] })]
153736
+ }),
153737
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Field, {
153738
+ label: "Paths / patterns",
153739
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Textarea, {
153740
+ rows: 5,
153741
+ placeholder: "AGENTS.md\n.claude-rig",
153742
+ value: config.symlinkPaths.join("\n"),
153743
+ onChange: (e) => setConfig({
153744
+ ...config,
153745
+ symlinkPaths: lines(e.target.value)
153746
+ })
153747
+ })
153748
+ }),
153749
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
153750
+ className: "flex items-center gap-2 pt-1",
153751
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button, {
153752
+ onClick: save,
153753
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Save, { className: "w-4 h-4" }), "Save"]
153754
+ }), status && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
153755
+ className: "text-sm text-muted-foreground",
153756
+ children: status
153757
+ })]
153758
+ })
153759
+ ]
153760
+ });
153761
+ }
153690
153762
  function VoiceSettings() {
153691
153763
  const voiceInputMode = useRelayStore((s) => s.voiceInputMode);
153692
153764
  const setVoiceInputMode = useRelayStore((s) => s.setVoiceInputMode);
@@ -12,7 +12,7 @@ import {
12
12
  ValidationError,
13
13
  } from "./db";
14
14
  import { createCommand } from "./commands-db";
15
- import { getAgentProfile, getSpawnPolicy } from "./config-store";
15
+ import { getAgentProfile, getSpawnPolicy, workspaceSpawnParams } from "./config-store";
16
16
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
17
17
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
18
18
  import type {
@@ -559,6 +559,7 @@ function dispatchOnDemandAutomation(
559
559
  effort: selection.effort,
560
560
  profile: policy.profile,
561
561
  agentProfile,
562
+ ...workspaceSpawnParams(),
562
563
  cwd: policy.cwd || orchestrator.baseDir,
563
564
  workspaceMode: policy.workspaceMode ?? "inherit",
564
565
  label,
@@ -12,6 +12,7 @@ import type {
12
12
  SpawnPolicy,
13
13
  SpawnProvider,
14
14
  StewardConfig,
15
+ WorkspaceConfig,
15
16
  } from "./types";
16
17
 
17
18
  const CONFIG_HISTORY_LIMIT = 50;
@@ -21,6 +22,8 @@ const STEWARD_NAMESPACE = "steward";
21
22
  const STEWARD_KEY = "default";
22
23
  const INSIGHTS_NAMESPACE = "insights";
23
24
  const INSIGHTS_KEY = "default";
25
+ const WORKSPACE_NAMESPACE = "workspace";
26
+ const WORKSPACE_KEY = "default";
24
27
  const VALID_PROVIDERS = ["claude", "codex"] as const;
25
28
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
26
29
  const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
@@ -472,12 +475,34 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
472
475
  };
473
476
  }
474
477
 
478
+ // Global workspace provisioning config for isolated worktrees (#159 follow-up).
479
+ // Defaults seed the two untracked paths an isolated agent almost always needs:
480
+ // the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
481
+ const WORKSPACE_CONFIG_DEFAULTS: WorkspaceConfig = {
482
+ symlinkPaths: ["AGENTS.md", ".claude-rig"],
483
+ };
484
+
485
+ function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
486
+ if (!isRecord(value)) throw new ValidationError("workspace config value must be an object");
487
+ const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths");
488
+ // Reject absolute paths and parent-traversal up front: symlink sources must stay
489
+ // inside the main checkout. The orchestrator re-checks containment at link time,
490
+ // but failing here gives the operator immediate feedback in the dashboard.
491
+ for (const entry of symlinkPaths) {
492
+ if (entry.startsWith("/") || entry.split(/[\\/]/).includes("..")) {
493
+ throw new ValidationError(`symlinkPaths entry must be a relative path within the repo: ${entry}`);
494
+ }
495
+ }
496
+ return { symlinkPaths };
497
+ }
498
+
475
499
  function normalizeValue(namespace: string, key: string, value: unknown): unknown {
476
500
  if (value === undefined) throw new ValidationError("value required");
477
501
  if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
478
502
  if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
479
503
  if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
480
504
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
505
+ if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
481
506
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
482
507
  return value;
483
508
  }
@@ -614,6 +639,40 @@ export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEnt
614
639
  return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
615
640
  }
616
641
 
642
+ /** Global workspace config, merged over defaults (always returns a usable value). */
643
+ export function getWorkspaceConfig(): WorkspaceConfig {
644
+ const entry = getConfig<Partial<WorkspaceConfig>>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
645
+ if (!entry) return { ...WORKSPACE_CONFIG_DEFAULTS };
646
+ return validateWorkspaceConfig({ ...WORKSPACE_CONFIG_DEFAULTS, ...entry.value });
647
+ }
648
+
649
+ export function getWorkspaceConfigEntry(): ConfigEntry<WorkspaceConfig> {
650
+ const entry = getConfig<WorkspaceConfig>(WORKSPACE_NAMESPACE, WORKSPACE_KEY);
651
+ return entry ?? {
652
+ namespace: WORKSPACE_NAMESPACE,
653
+ key: WORKSPACE_KEY,
654
+ value: { ...WORKSPACE_CONFIG_DEFAULTS },
655
+ version: 0,
656
+ updatedAt: "default",
657
+ updatedBy: "system",
658
+ };
659
+ }
660
+
661
+ export function setWorkspaceConfig(value: unknown, updatedBy?: string): ConfigEntry<WorkspaceConfig> {
662
+ return setConfig(WORKSPACE_NAMESPACE, WORKSPACE_KEY, value as WorkspaceConfig, updatedBy);
663
+ }
664
+
665
+ /**
666
+ * Spawn-param fragment carrying the global workspace symlink list to the orchestrator.
667
+ * Spread into every spawn command's params next to `agentProfile` so any isolated
668
+ * worktree — direct spawn, managed policy, restart, automation — provisions the same
669
+ * untracked paths. Returns `{}` when nothing is configured (keeps params lean).
670
+ */
671
+ export function workspaceSpawnParams(): { workspaceSymlinks?: string[] } {
672
+ const { symlinkPaths } = getWorkspaceConfig();
673
+ return symlinkPaths.length ? { workspaceSymlinks: symlinkPaths } : {};
674
+ }
675
+
617
676
  function builtInProfileEntry(profile: AgentProfile): ConfigEntry<AgentProfile> {
618
677
  return {
619
678
  namespace: AGENT_PROFILE_NAMESPACE,
package/src/db.ts CHANGED
@@ -239,7 +239,8 @@ export function initDb(path: string = "agent-relay.db"): Database {
239
239
  payload TEXT NOT NULL DEFAULT '{}',
240
240
  meta TEXT NOT NULL DEFAULT '{}',
241
241
  read_by TEXT NOT NULL DEFAULT '[]',
242
- created_at INTEGER NOT NULL
242
+ created_at INTEGER NOT NULL,
243
+ occurred_at INTEGER
243
244
  );
244
245
 
245
246
  CREATE INDEX IF NOT EXISTS idx_msg_to ON messages(to_target);
@@ -890,6 +891,11 @@ export function initDb(path: string = "agent-relay.db"): Database {
890
891
  if (!colNames.includes("resolved_to_agent")) {
891
892
  db.run("ALTER TABLE messages ADD COLUMN resolved_to_agent TEXT");
892
893
  }
894
+ // Event time (#196): when a Runner queues a message in its durable outbox during an
895
+ // outage, occurred_at preserves when it really happened vs. the later receive time.
896
+ if (!colNames.includes("occurred_at")) {
897
+ db.run("ALTER TABLE messages ADD COLUMN occurred_at INTEGER");
898
+ }
893
899
  db.query(
894
900
  "UPDATE messages SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
895
901
  ).run(Date.now(), CLAIM_LEASE_MS);
@@ -1308,6 +1314,7 @@ function rowToMessage(row: any): Message {
1308
1314
  reactions: parseJson(row.reactions_json ?? "[]", []),
1309
1315
  readBy: parseJson(row.read_by_agents ?? "[]", []),
1310
1316
  createdAt: row.created_at,
1317
+ occurredAt: row.occurred_at ?? undefined,
1311
1318
  };
1312
1319
  }
1313
1320
 
@@ -3680,6 +3687,16 @@ function findRecentDuplicateReply(input: SendMessageInput, threadId: number | nu
3680
3687
  return row ? rowToMessage(row) : null;
3681
3688
  }
3682
3689
 
3690
+ // Event time may be queued-then-backfilled, so it can legitimately be older than the
3691
+ // receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
3692
+ // readers fall back to created_at) for absent/invalid values or anything more than a minute
3693
+ // in the future (clock-skew guard). Only a real backfilled time is stored.
3694
+ function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
3695
+ if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return null;
3696
+ if (occurredAt <= 0 || occurredAt > receivedAt + 60_000) return null;
3697
+ return Math.floor(occurredAt);
3698
+ }
3699
+
3683
3700
  export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
3684
3701
  const now = Date.now();
3685
3702
  const payload = input.payload ?? {};
@@ -3710,12 +3727,12 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
3710
3727
  INSERT INTO messages (
3711
3728
  from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable,
3712
3729
  idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
3713
- payload, meta, created_at
3730
+ payload, meta, created_at, occurred_at
3714
3731
  )
3715
3732
  VALUES (
3716
3733
  $from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable,
3717
3734
  $idempotencyKey, $deliveryStatus, $queuedAt, $maxAgeSeconds, $resolvedToAgent,
3718
- $payload, $meta, $now
3735
+ $payload, $meta, $now, $occurredAt
3719
3736
  )
3720
3737
  `);
3721
3738
  const setSelfThread = db.query("UPDATE messages SET thread_id = ? WHERE id = ?");
@@ -3756,6 +3773,8 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
3756
3773
  $payload: JSON.stringify(payload),
3757
3774
  $meta: JSON.stringify(input.meta ?? {}),
3758
3775
  $now: now,
3776
+ // Sanitize: only accept a plausible epoch-ms event time, else fall back to receive time.
3777
+ $occurredAt: sanitizeOccurredAt(input.occurredAt, now),
3759
3778
  });
3760
3779
  const newId = Number(result.lastInsertRowid);
3761
3780
  if (threadId === null) setSelfThread.run(newId, newId);
@@ -1,5 +1,5 @@
1
1
  import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
2
- import { getAgentProfile } from "./config-store";
2
+ import { getAgentProfile, workspaceSpawnParams } from "./config-store";
3
3
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
4
4
  import type { SpawnPolicy, WorkspaceMode } from "./types";
5
5
 
@@ -55,6 +55,7 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
55
55
  ...resolvedModelParams(policy),
56
56
  ...(policy.profile ? { profile: policy.profile } : {}),
57
57
  ...(agentProfile ? { agentProfile } : {}),
58
+ ...workspaceSpawnParams(),
58
59
  label: policy.label,
59
60
  tags: policy.tags,
60
61
  capabilities: policy.capabilities,
package/src/routes.ts CHANGED
@@ -108,6 +108,9 @@ import {
108
108
  getStewardConfigEntry,
109
109
  getInsightsConfigEntry,
110
110
  setInsightsConfig,
111
+ getWorkspaceConfigEntry,
112
+ setWorkspaceConfig,
113
+ workspaceSpawnParams,
111
114
  listAgentProfiles,
112
115
  listSpawnPolicies,
113
116
  listConfig,
@@ -594,6 +597,12 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
594
597
  }
595
598
  input.maxAgeSeconds = body.maxAgeSeconds;
596
599
  }
600
+ if (body.occurredAt !== undefined) {
601
+ if (typeof body.occurredAt !== "number" || !Number.isFinite(body.occurredAt) || body.occurredAt <= 0) {
602
+ throw new ValidationError("occurredAt must be a positive epoch-ms number");
603
+ }
604
+ input.occurredAt = body.occurredAt;
605
+ }
597
606
 
598
607
  const channel = cleanString(body.channel, "channel", { max: 120 });
599
608
  if (channel) input.channel = channel;
@@ -2296,6 +2305,7 @@ function restartSpawnParamsForAgent(
2296
2305
  workspaceMode,
2297
2306
  ...(profileName ? { profile: profileName } : {}),
2298
2307
  ...(agentProfile ? { agentProfile } : {}),
2308
+ ...workspaceSpawnParams(),
2299
2309
  ...(label ? { label } : {}),
2300
2310
  agentId: agent.id,
2301
2311
  tags: agent.tags,
@@ -3612,6 +3622,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3612
3622
  permissionMode: approvalMode,
3613
3623
  profile,
3614
3624
  agentProfile,
3625
+ ...workspaceSpawnParams(),
3615
3626
  providerArgs,
3616
3627
  prompt,
3617
3628
  systemPromptAppend,
@@ -4599,6 +4610,25 @@ const putStewardConfigRoute: Handler = async (req) => {
4599
4610
  }
4600
4611
  };
4601
4612
 
4613
+ const getWorkspaceConfigRoute: Handler = () => json(getWorkspaceConfigEntry());
4614
+
4615
+ const putWorkspaceConfigRoute: Handler = async (req) => {
4616
+ const parsed = await parseBody<unknown>(req);
4617
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4618
+ try {
4619
+ const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
4620
+ ? parsed.body.value
4621
+ : parsed.body;
4622
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
4623
+ const entry = setWorkspaceConfig(value, updatedBy);
4624
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
4625
+ return json(entry, entry.version === 1 ? 201 : 200);
4626
+ } catch (e) {
4627
+ if (e instanceof ValidationError) return error(e.message, 400);
4628
+ throw e;
4629
+ }
4630
+ };
4631
+
4602
4632
  // --- Insights / self-improvement (epic #183, docs/self-improvement.md) ---
4603
4633
 
4604
4634
  const getInsightsConfigRoute: Handler = () => json(getInsightsConfigEntry());
@@ -4635,6 +4665,14 @@ const getInsightsObservationsRoute: Handler = (req) => {
4635
4665
  });
4636
4666
  };
4637
4667
 
4668
+ // Accept a backfilled event time only when it's a sane epoch-ms value within a minute of
4669
+ // now (clock-skew guard); otherwise let recordObservation default to receive time.
4670
+ function sanitizeObservationOccurredAt(occurredAt: unknown): number | undefined {
4671
+ if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return undefined;
4672
+ if (occurredAt <= 0 || occurredAt > Date.now() + 60_000) return undefined;
4673
+ return Math.floor(occurredAt);
4674
+ }
4675
+
4638
4676
  const postInsightsObservationRoute: Handler = async (req) => {
4639
4677
  const parsed = await parseBody<unknown>(req);
4640
4678
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -4656,6 +4694,10 @@ const postInsightsObservationRoute: Handler = async (req) => {
4656
4694
  value: isRecord(body.value) ? body.value : {},
4657
4695
  outcome: isRecord(body.outcome) ? body.outcome : undefined,
4658
4696
  source: body.source === "server" ? "server" : "agent",
4697
+ // For insights the event time IS the record time (end-of-session). When the Runner
4698
+ // backfilled this through its durable outbox (#196), occurredAt preserves the real
4699
+ // moment rather than the later server-receive time.
4700
+ createdAt: sanitizeObservationOccurredAt(body.occurredAt),
4659
4701
  });
4660
4702
  return json(observation, 201);
4661
4703
  } catch (e) {
@@ -6569,6 +6611,8 @@ const routes: Route[] = [
6569
6611
  route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
6570
6612
  route("GET", "/api/steward-config", getStewardConfigRoute),
6571
6613
  route("PUT", "/api/steward-config", putStewardConfigRoute),
6614
+ route("GET", "/api/workspace-config", getWorkspaceConfigRoute),
6615
+ route("PUT", "/api/workspace-config", putWorkspaceConfigRoute),
6572
6616
  route("GET", "/api/insights/config", getInsightsConfigRoute),
6573
6617
  route("PUT", "/api/insights/config", putInsightsConfigRoute),
6574
6618
  route("GET", "/api/insights/observations", getInsightsObservationsRoute),