agent-relay-server 0.11.2 → 0.11.4

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.10.21",
5
+ "version": "0.11.3",
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",
@@ -2725,7 +2725,7 @@
2725
2725
  "tags": [
2726
2726
  "Orchestrators"
2727
2727
  ],
2728
- "description": "Dispatches a host action to the orchestrator over the command bus. `restart`/`shutdown` target a managed agent (omit `agentId` for all). `upgrade` triggers a remote self-upgrade: the relay stamps a desired version and the orchestrator installs it via the bundled `agent-relay upgrade --no-restart` CLI, then restarts itself decoupled (systemd-run) so the bus keepalive survives its own teardown. The upgrade command is intentionally left `running` — the relay settles it (succeeded/failed) by reconciling the version the orchestrator reports on its post-restart re-register, within a deadline. Safety rails: semver-only `targetVersion` (defaults to the relay's own version), npm-registry providers only, and no downgrade unless `force` is set. Requires systemd --user supervision on the orchestrator host.",
2728
+ "description": "Dispatches a host action to the orchestrator over the command bus. `restart`/`shutdown` target a managed agent (omit `agentId` for all). `upgrade` triggers a remote self-upgrade: the relay stamps a desired version and the orchestrator installs the selected runtime packages, then restarts itself decoupled so the bus keepalive survives its own teardown. Runtime-prefix installs use npm directly; non-prefix installs require an available `agent-relay upgrade --no-restart` CLI. The upgrade command is intentionally left `running` — the relay settles it (succeeded/failed) by reconciling the version the orchestrator reports on its post-restart re-register, within a deadline, or immediately when the command fails. Safety rails: semver-only `targetVersion` (defaults to the relay's own version), explicit self-upgrade capability, systemd/launchd supervision, and no downgrade unless `force` is set.",
2729
2729
  "parameters": [
2730
2730
  {
2731
2731
  "name": "id",
@@ -6603,6 +6603,9 @@
6603
6603
  },
6604
6604
  "defaultTags": {
6605
6605
  "type": "string"
6606
+ },
6607
+ "chatCaptureMode": {
6608
+ "type": "string"
6606
6609
  }
6607
6610
  }
6608
6611
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -32,7 +32,7 @@
32
32
  "CONTRIBUTING.md"
33
33
  ],
34
34
  "dependencies": {
35
- "agent-relay-sdk": "0.2.2"
35
+ "agent-relay-sdk": "0.2.3"
36
36
  },
37
37
  "scripts": {
38
38
  "postinstall": "node scripts/install-bin-shim.cjs",
package/public/index.html CHANGED
@@ -123527,6 +123527,32 @@ function OrphanPanel() {
123527
123527
  })]
123528
123528
  });
123529
123529
  }
123530
+ function prLabel(url) {
123531
+ const match = url.match(/\/pull\/(\d+)/);
123532
+ return match ? `PR #${match[1]}` : "View PR";
123533
+ }
123534
+ function MergeOutcome({ workspace }) {
123535
+ const result = workspace.metadata?.mergeResult;
123536
+ const prUrl = result?.prUrl;
123537
+ const error = result?.error || workspace.metadata?.mergeError;
123538
+ if (!prUrl && !error) return null;
123539
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [prUrl && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("a", {
123540
+ href: prUrl,
123541
+ target: "_blank",
123542
+ rel: "noreferrer",
123543
+ title: prUrl,
123544
+ className: "inline-flex items-center gap-0.5 rounded border border-violet-500/30 px-1.5 py-0 text-[10px] text-violet-400 hover:text-violet-300",
123545
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(GitMerge, { className: "h-2.5 w-2.5" }), prLabel(prUrl)]
123546
+ }), error && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Badge$1, {
123547
+ variant: "outline",
123548
+ className: "max-w-[16rem] border-amber-500/30 text-amber-400 text-[10px]",
123549
+ title: error,
123550
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(TriangleAlert, { className: "mr-0.5 h-2.5 w-2.5 shrink-0" }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
123551
+ className: "truncate",
123552
+ children: ["merge: ", error]
123553
+ })]
123554
+ })] });
123555
+ }
123530
123556
  function WorkspaceRow({ workspace }) {
123531
123557
  const now = useNow();
123532
123558
  const [expanded, setExpanded] = (0, import_react.useState)(false);
@@ -123565,7 +123591,8 @@ function WorkspaceRow({ workspace }) {
123565
123591
  variant: "outline",
123566
123592
  className: `text-[10px] ${statusTone(workspace.status)}`,
123567
123593
  children: STATUS_LABEL[workspace.status] || workspace.status
123568
- })
123594
+ }),
123595
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MergeOutcome, { workspace })
123569
123596
  ]
123570
123597
  })]
123571
123598
  }),
@@ -152711,6 +152738,10 @@ if ("serviceWorker" in navigator) {
152711
152738
  max-width: 12rem;
152712
152739
  }
152713
152740
 
152741
+ .max-w-\[16rem\] {
152742
+ max-width: 16rem;
152743
+ }
152744
+
152714
152745
  .max-w-\[18rem\] {
152715
152746
  max-width: 18rem;
152716
152747
  }
@@ -155661,6 +155692,10 @@ if ("serviceWorker" in navigator) {
155661
155692
  color: var(--color-red-400);
155662
155693
  }
155663
155694
 
155695
+ .hover\:text-violet-300:hover {
155696
+ color: var(--color-violet-300);
155697
+ }
155698
+
155664
155699
  .hover\:text-zinc-100:hover {
155665
155700
  color: var(--color-zinc-100);
155666
155701
  }
package/src/db.ts CHANGED
@@ -4951,6 +4951,14 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
4951
4951
  const workspace = agent.workspace;
4952
4952
  if (!workspace) return null;
4953
4953
  if (workspace.mode === "isolated" && workspace.id && workspace.repoRoot && workspace.worktreePath) {
4954
+ // A live agent re-registers its workspace on every heartbeat. That must not
4955
+ // clobber progress the workspace has made past the live editing state
4956
+ // (a merge that opened a PR, ready/review/conflict, terminal states) nor
4957
+ // wipe accumulated metadata such as the merge result / PR link. Only the
4958
+ // "active" live state is refreshable from registration; everything else is
4959
+ // preserved and metadata is merged, not replaced.
4960
+ const existing = getWorkspace(workspace.id);
4961
+ const preserveStatus = existing != null && existing.status !== "active";
4954
4962
  return upsertWorkspace({
4955
4963
  id: workspace.id,
4956
4964
  repoRoot: workspace.repoRoot,
@@ -4961,11 +4969,11 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
4961
4969
  baseSha: workspace.baseSha,
4962
4970
  mode: workspace.mode,
4963
4971
  requestedMode: workspace.requestedMode,
4964
- status: workspace.status ?? "active",
4972
+ status: preserveStatus ? existing!.status : (workspace.status ?? "active"),
4965
4973
  ownerAgentId: agent.agentId || undefined,
4966
4974
  ownerPolicyName: agent.policyName,
4967
4975
  ownerAutomationRunId: agent.automationRunId,
4968
- metadata: { provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
4976
+ metadata: { ...(existing?.metadata ?? {}), provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
4969
4977
  });
4970
4978
  }
4971
4979
  // Shared-mode occupancy: one row per agent sharing a git repo, with no
package/src/dev.ts CHANGED
@@ -154,7 +154,7 @@ export async function executeDevInstallPlan(plan: DevInstallPlan, options: { dry
154
154
  const packed = await executeDevPackPlan(plan.packPlan);
155
155
  await mkdir(plan.prefix, { recursive: true });
156
156
  ensureDevPrefixPackageJson(plan.prefix);
157
- const command = ["install", "--prefix", plan.prefix, "--no-audit", "--no-fund", ...packed.tarballs];
157
+ const command = ["install", "--prefix", plan.prefix, "--no-audit", "--no-fund", "--force", ...packed.tarballs];
158
158
  const result = await execFileAsync("npm", command);
159
159
  const output = [
160
160
  packed.output,
@@ -353,7 +353,7 @@ export function formatDevInstallPlan(plan: DevInstallPlan): string {
353
353
  formatDevPackPlan(plan.packPlan),
354
354
  "",
355
355
  "Install command:",
356
- ` npm install --prefix ${plan.prefix} --no-audit --no-fund <packed tarballs...>`,
356
+ ` npm install --prefix ${plan.prefix} --no-audit --no-fund --force <packed tarballs...>`,
357
357
  ].join("\n");
358
358
  }
359
359
 
@@ -1,5 +1,4 @@
1
1
  import { isAbsolute, relative, resolve } from "node:path";
2
- import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
3
2
  import { createCommand } from "./commands-db";
4
3
  import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces, resolveQueuedPolicyMessages } from "./db";
5
4
  import {
@@ -9,8 +8,8 @@ import {
9
8
  upsertManagedAgentState,
10
9
  } from "./config-store";
11
10
  import { emitRelayEvent } from "./events";
12
- import { runnerRuntimeTokenEnv } from "./runtime-tokens";
13
- import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy, WorkspaceMode } from "./types";
11
+ import { buildManagedSpawnParams } from "./managed-policy";
12
+ import type { Command, ManagedAgent, ManagedAgentState, ManagedSessionExitDiagnostics, SpawnPolicy } from "./types";
14
13
 
15
14
  const DEFAULT_TICK_MS = 10_000;
16
15
  // A spawn that never reaches "running" (runner crashed, never registered, host
@@ -227,34 +226,8 @@ export class LifecycleManager {
227
226
  target: orch.agentId,
228
227
  correlationId: spawnRequestId,
229
228
  params: {
230
- action: "spawn",
231
- provider: policy.provider,
232
- cwd: policy.cwd,
233
- workspaceMode: effectivePolicyWorkspaceMode(policy),
234
- ...(policy.rig ? { rig: policy.rig } : {}),
235
- ...resolvedModelParams(policy),
236
- label: policy.label,
237
- tags: policy.tags,
238
- capabilities: policy.capabilities,
239
- approvalMode: policy.permissionMode,
240
- permissionMode: policy.permissionMode,
241
- providerArgs: policy.providerArgs,
242
- prompt: policy.prompt,
243
- headless: true,
244
- policyName: policy.name,
245
- spawnRequestId,
246
- env: runnerRuntimeTokenEnv({
247
- orchestratorId: policy.orchestratorId,
248
- cwd: policy.cwd,
249
- provider: policy.provider,
250
- label: policy.label,
251
- policyName: policy.name,
252
- spawnRequestId,
253
- createdBy: "lifecycle-manager",
254
- }),
229
+ ...buildManagedSpawnParams(policy, spawnRequestId, { createdBy: "lifecycle-manager", requestedAt: this.now() }),
255
230
  reason,
256
- requestedBy: "lifecycle-manager",
257
- requestedAt: this.now(),
258
231
  },
259
232
  });
260
233
  this.emitCommand(command);
@@ -302,7 +275,11 @@ export class LifecycleManager {
302
275
  }
303
276
 
304
277
  restartAgent(policy: SpawnPolicy, reason = "reconcile-restart"): Command | null {
278
+ const orch = getOrchestrator(policy.orchestratorId);
279
+ if (!orch) return null;
305
280
  const state = getManagedAgentState(policy.name);
281
+ const restartRequestId = `sp_${crypto.randomUUID()}`;
282
+ const restartSpawn = buildManagedSpawnParams(policy, restartRequestId, { createdBy: "lifecycle-manager", requestedAt: this.now() });
306
283
  const restarted = upsertManagedAgentState({
307
284
  policyName: policy.name,
308
285
  status: "stopping",
@@ -310,14 +287,35 @@ export class LifecycleManager {
310
287
  orchestratorId: policy.orchestratorId,
311
288
  provider: policy.provider,
312
289
  tmuxSession: state?.tmuxSession,
313
- spawnRequestId: state?.spawnRequestId,
314
- lastSpawnAt: state?.lastSpawnAt,
290
+ spawnRequestId: restartRequestId,
291
+ lastSpawnAt: this.now(),
315
292
  lastStopAt: this.now(),
316
293
  restartCount: (state?.restartCount ?? 0) + 1,
317
294
  consecutiveFailures: state?.consecutiveFailures ?? 0,
318
295
  });
319
296
  this.emitState(restarted, `restart: ${reason}`);
320
- return this.stopAgent(policy, true, reason);
297
+ const command = createCommand({
298
+ type: "agent.restart",
299
+ source: "system",
300
+ target: orch.agentId,
301
+ correlationId: state?.spawnRequestId,
302
+ params: {
303
+ action: "restart",
304
+ policyName: policy.name,
305
+ spawnRequestId: state?.spawnRequestId,
306
+ agentId: state?.agentId,
307
+ tmuxSession: state?.tmuxSession,
308
+ graceful: true,
309
+ timeoutMs: 10_000,
310
+ reason,
311
+ orchestratorId: orch.id,
312
+ restartSpawn,
313
+ requestedBy: "lifecycle-manager",
314
+ requestedAt: this.now(),
315
+ },
316
+ });
317
+ this.emitCommand(command);
318
+ return command;
321
319
  }
322
320
 
323
321
  private reconcilePolicy(policy: SpawnPolicy): void {
@@ -641,24 +639,3 @@ function alwaysReloadTags(tags: string[]): string[] {
641
639
  .filter(Boolean);
642
640
  }
643
641
 
644
- function effectivePolicyWorkspaceMode(policy: SpawnPolicy): WorkspaceMode {
645
- if (policy.workspaceMode && policy.workspaceMode !== "inherit") return policy.workspaceMode;
646
- return policy.binding?.type === "channel" ? "shared" : "inherit";
647
- }
648
-
649
- function resolvedModelParams(policy: SpawnPolicy): Record<string, string> {
650
- if (!policy.model && !policy.effort) return {};
651
- try {
652
- const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
653
- return {
654
- ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
655
- ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
656
- ...(selection.effort ? { effort: selection.effort } : {}),
657
- };
658
- } catch {
659
- return {
660
- ...(policy.model ? { model: policy.model } : {}),
661
- ...(policy.effort ? { effort: policy.effort } : {}),
662
- };
663
- }
664
- }
@@ -0,0 +1,80 @@
1
+ import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
2
+ import { getAgentProfile } from "./config-store";
3
+ import { runnerRuntimeTokenEnv } from "./runtime-tokens";
4
+ import type { SpawnPolicy, WorkspaceMode } from "./types";
5
+
6
+ export function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
7
+ if (policy.provider === "claude" && policy.mode === "always-on" && policy.prompt?.trim()) {
8
+ return [...policy.providerArgs, "--append-system-prompt", policy.prompt.trim()];
9
+ }
10
+ return policy.providerArgs;
11
+ }
12
+
13
+ export function managedPolicyLaunchPrompt(policy: SpawnPolicy): string | undefined {
14
+ if (policy.provider === "claude" && policy.mode === "always-on") return undefined;
15
+ return policy.prompt;
16
+ }
17
+
18
+ export function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): WorkspaceMode {
19
+ if (policy.workspaceMode && policy.workspaceMode !== "inherit") return policy.workspaceMode;
20
+ return policy.binding?.type === "channel" ? "shared" : "inherit";
21
+ }
22
+
23
+ function resolvedModelParams(policy: SpawnPolicy): Record<string, string> {
24
+ if (!policy.model && !policy.effort) return {};
25
+ try {
26
+ const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
27
+ return {
28
+ ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
29
+ ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
30
+ ...(selection.effort ? { effort: selection.effort } : {}),
31
+ };
32
+ } catch {
33
+ return {
34
+ ...(policy.model ? { model: policy.model } : {}),
35
+ ...(policy.effort ? { effort: policy.effort } : {}),
36
+ };
37
+ }
38
+ }
39
+
40
+ export interface ManagedSpawnContext {
41
+ createdBy: string;
42
+ requestedAt?: number;
43
+ }
44
+
45
+ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string, ctx: ManagedSpawnContext): Record<string, unknown> {
46
+ const providerArgs = managedPolicyProviderArgs(policy);
47
+ const prompt = managedPolicyLaunchPrompt(policy);
48
+ const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
49
+ return {
50
+ action: "spawn",
51
+ provider: policy.provider,
52
+ cwd: policy.cwd,
53
+ workspaceMode: effectiveManagedPolicyWorkspaceMode(policy),
54
+ ...(policy.rig ? { rig: policy.rig } : {}),
55
+ ...resolvedModelParams(policy),
56
+ ...(policy.profile ? { profile: policy.profile } : {}),
57
+ ...(agentProfile ? { agentProfile } : {}),
58
+ label: policy.label,
59
+ tags: policy.tags,
60
+ capabilities: policy.capabilities,
61
+ approvalMode: policy.permissionMode,
62
+ permissionMode: policy.permissionMode,
63
+ providerArgs,
64
+ ...(prompt ? { prompt } : {}),
65
+ headless: true,
66
+ policyName: policy.name,
67
+ spawnRequestId: requestId,
68
+ env: runnerRuntimeTokenEnv({
69
+ orchestratorId: policy.orchestratorId,
70
+ cwd: policy.cwd,
71
+ provider: policy.provider,
72
+ label: policy.label,
73
+ policyName: policy.name,
74
+ spawnRequestId: requestId,
75
+ createdBy: ctx.createdBy,
76
+ }),
77
+ requestedBy: ctx.createdBy,
78
+ requestedAt: ctx.requestedAt ?? Date.now(),
79
+ };
80
+ }
package/src/mcp.ts CHANGED
@@ -520,8 +520,8 @@ function relayAgentStatus(args: Record<string, unknown>): Record<string, unknown
520
520
 
521
521
  function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
522
522
  const provider = enumField(args.provider, "provider", VALID_SPAWN_PROVIDERS) as SpawnProvider;
523
- const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200));
524
523
  const cwd = optionalString(args.cwd, "cwd", 500);
524
+ const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd);
525
525
  const resolvedCwd = cwd || orchestrator.baseDir;
526
526
  if (cwd && !pathWithinBase(cwd, orchestrator.baseDir)) {
527
527
  throw new ValidationError(`cwd must be within orchestrator base directory: ${orchestrator.baseDir}`);
@@ -704,13 +704,21 @@ function policyStatusPayload(policy: NonNullable<ReturnType<typeof getSpawnPolic
704
704
  };
705
705
  }
706
706
 
707
- function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: string): NonNullable<ReturnType<typeof getOrchestrator>> {
708
- const orchestrator = orchestratorId
709
- ? getOrchestrator(orchestratorId)
710
- : listOrchestrators().find((item) => item.status === "online" && item.providers.includes(provider));
711
- if (!orchestrator) throw new McpNotFoundError(orchestratorId ? `orchestrator ${orchestratorId} not found` : `no orchestrator available for provider: ${provider}`);
712
- if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
713
- if (!orchestrator.providers.includes(provider)) throw new ValidationError(`orchestrator does not have provider available: ${provider}`);
707
+ function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: string, cwd?: string): NonNullable<ReturnType<typeof getOrchestrator>> {
708
+ if (orchestratorId) {
709
+ const orchestrator = getOrchestrator(orchestratorId);
710
+ if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
711
+ if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
712
+ if (!orchestrator.providers.includes(provider)) throw new ValidationError(`orchestrator does not have provider available: ${provider}`);
713
+ return orchestrator;
714
+ }
715
+ const candidates = listOrchestrators().filter((item) => item.status === "online" && item.providers.includes(provider));
716
+ if (cwd) {
717
+ const match = candidates.find((item) => pathWithinBase(cwd, item.baseDir));
718
+ if (match) return match;
719
+ }
720
+ const orchestrator = candidates[0];
721
+ if (!orchestrator) throw new McpNotFoundError(`no orchestrator available for provider: ${provider}`);
714
722
  return orchestrator;
715
723
  }
716
724
 
package/src/routes.ts CHANGED
@@ -143,6 +143,7 @@ import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeP
143
143
  import type { ProviderConfig } from "../runner/src/adapter";
144
144
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
145
145
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
146
+ import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
146
147
  import {
147
148
  getComponentAuth,
148
149
  getIntegrationAuth,
@@ -2232,16 +2233,35 @@ function restartSpawnParamsForAgent(
2232
2233
  const label = metaString(agent.meta, "label") ?? agent.label;
2233
2234
  const approvalMode = metaString(agent.meta, "approvalMode") as SpawnApprovalMode | undefined;
2234
2235
  const model = metaString(agent.meta, "model");
2235
- const effort = metaString(agent.meta, "effort");
2236
- const providerModel = metaString(agent.meta, "providerModel");
2236
+ const effort = metaString(agent.meta, "effort") as ProviderEffort | undefined;
2237
2237
  const providerArgs = metaStringArray(agent.meta, "providerArgs");
2238
+ const profileName = metaString(agent.meta, "profile");
2239
+ const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
2240
+ const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
2241
+ let resolvedModel: Record<string, string> = {};
2242
+ if (model || effort) {
2243
+ try {
2244
+ const selection = resolveProviderSelection({ provider, model, effort });
2245
+ resolvedModel = {
2246
+ ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
2247
+ ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
2248
+ ...(selection.effort ? { effort: selection.effort } : {}),
2249
+ };
2250
+ } catch {
2251
+ resolvedModel = {
2252
+ ...(model ? { model } : {}),
2253
+ ...(effort ? { effort } : {}),
2254
+ };
2255
+ }
2256
+ }
2238
2257
  return {
2239
2258
  action: "spawn",
2240
2259
  provider,
2241
- ...(model ? { model } : {}),
2242
- ...(providerModel ? { providerModel } : {}),
2243
- ...(effort ? { effort } : {}),
2260
+ ...resolvedModel,
2244
2261
  cwd,
2262
+ workspaceMode,
2263
+ ...(profileName ? { profile: profileName } : {}),
2264
+ ...(agentProfile ? { agentProfile } : {}),
2245
2265
  ...(label ? { label } : {}),
2246
2266
  agentId: agent.id,
2247
2267
  tags: agent.tags,
@@ -2250,6 +2270,7 @@ function restartSpawnParamsForAgent(
2250
2270
  permissionMode: approvalMode ?? "guarded",
2251
2271
  ...(providerArgs.length ? { providerArgs } : {}),
2252
2272
  ...(policyName ? { policyName } : {}),
2273
+ headless: true,
2253
2274
  spawnRequestId: requestId,
2254
2275
  env: runnerRuntimeTokenEnv({
2255
2276
  orchestratorId: orchestrator.id,
@@ -2269,6 +2290,15 @@ function spawnRequestIdForRestart(): string {
2269
2290
  return `sp_${crypto.randomUUID()}`;
2270
2291
  }
2271
2292
 
2293
+ function compactMemoryParams(agent: AgentCard): Record<string, unknown> {
2294
+ const alwaysReload = agent.tags
2295
+ .filter((tag) => tag.startsWith("memory-reload:"))
2296
+ .map((tag) => tag.slice("memory-reload:".length).trim())
2297
+ .filter(Boolean);
2298
+ if (!alwaysReload.length) return {};
2299
+ return { memory: { alwaysReload } };
2300
+ }
2301
+
2272
2302
  const postAgentAction: Handler = async (req, params) => {
2273
2303
  const parsed = await parseBody<unknown>(req);
2274
2304
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -2304,6 +2334,7 @@ const postAgentAction: Handler = async (req, params) => {
2304
2334
  ...(metaPolicyName ? { policyName: metaPolicyName } : {}),
2305
2335
  ...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
2306
2336
  ...(action === "restart" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId) } : {}),
2337
+ ...(action === "compact" ? compactMemoryParams(agent) : {}),
2307
2338
  requestedBy: "dashboard",
2308
2339
  requestedAt: Date.now(),
2309
2340
  },
@@ -3509,6 +3540,21 @@ function compareSemverCore(a: string, b: string): number {
3509
3540
  return 0;
3510
3541
  }
3511
3542
 
3543
+ function orchestratorSelfUpgradeError(orch: ReturnType<typeof getOrchestrator>): string | undefined {
3544
+ if (!orch) return "orchestrator not found";
3545
+ if (orch.status !== "online") return "orchestrator is offline";
3546
+ if (!orch.capabilities?.relayCommandBus) {
3547
+ return `orchestrator ${orch.id} does not support relay command bus upgrades; upgrade it manually first`;
3548
+ }
3549
+ if (!orch.capabilities?.selfUpgrade) {
3550
+ return `orchestrator ${orch.id}${orch.version ? ` v${orch.version}` : ""} does not advertise self-upgrade support; upgrade it manually once to ${VERSION} or newer`;
3551
+ }
3552
+ if ((orch.supervisor !== "systemd" && orch.supervisor !== "launchd") || !orch.selfUnit) {
3553
+ return `orchestrator ${orch.id} cannot self-upgrade under supervisor ${orch.supervisor ?? "unknown"}; upgrade it manually`;
3554
+ }
3555
+ return undefined;
3556
+ }
3557
+
3512
3558
  /**
3513
3559
  * Settle an in-flight orchestrator upgrade by comparing the version the
3514
3560
  * orchestrator now reports against the desired version (decision 2a). Called on
@@ -3517,11 +3563,18 @@ function compareSemverCore(a: string, b: string): number {
3517
3563
  function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>): void {
3518
3564
  if (!orch) return;
3519
3565
  const up = orch.upgrade;
3520
- if (!up || up.status !== "pending") return;
3566
+ if (!up) return;
3521
3567
  const reported = orch.version;
3522
- if (reported && reported === up.desiredVersion) {
3568
+ if (reported && reported === up.desiredVersion && up.status === "succeeded" && up.error) {
3569
+ const { error: _previousError, ...successState } = up;
3570
+ setOrchestratorUpgradeState(orch.id, successState);
3571
+ emitOrchestratorStatus(orch.id);
3572
+ return;
3573
+ }
3574
+ if (reported && reported === up.desiredVersion && (up.status === "pending" || up.status === "failed")) {
3523
3575
  if (up.commandId) updateCommand(up.commandId, { status: "succeeded", result: { version: reported, ...(up.fromVersion ? { fromVersion: up.fromVersion } : {}) } });
3524
- setOrchestratorUpgradeState(orch.id, { ...up, status: "succeeded", settledAt: Date.now() });
3576
+ const { error: _previousError, ...successState } = up;
3577
+ setOrchestratorUpgradeState(orch.id, { ...successState, status: "succeeded", settledAt: Date.now() });
3525
3578
  auditEvent({
3526
3579
  clientId: `server-orchestrator-upgraded-${orch.id}-${Date.now()}`,
3527
3580
  kind: "state",
@@ -3533,7 +3586,7 @@ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>):
3533
3586
  metadata: { orchestratorId: orch.id, version: reported, commandId: up.commandId },
3534
3587
  });
3535
3588
  emitOrchestratorStatus(orch.id);
3536
- } else if (Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
3589
+ } else if (up.status === "pending" && Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
3537
3590
  const errMsg = `upgrade timed out; orchestrator still reports ${reported ?? "unknown"} (wanted ${up.desiredVersion})`;
3538
3591
  if (up.commandId) updateCommand(up.commandId, { status: "failed", error: errMsg });
3539
3592
  setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
@@ -3551,6 +3604,28 @@ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>):
3551
3604
  }
3552
3605
  }
3553
3606
 
3607
+ function settleFailedOrchestratorUpgrade(command: Command): void {
3608
+ if (command.type !== "orchestrator.upgrade" || command.status !== "failed") return;
3609
+ const orchestratorId = typeof command.params?.orchestratorId === "string" ? command.params.orchestratorId : undefined;
3610
+ if (!orchestratorId) return;
3611
+ const orch = getOrchestrator(orchestratorId);
3612
+ const up = orch?.upgrade;
3613
+ if (!orch || !up || up.status !== "pending" || up.commandId !== command.id) return;
3614
+ const errMsg = command.error ?? "orchestrator upgrade failed";
3615
+ setOrchestratorUpgradeState(orch.id, { ...up, status: "failed", settledAt: Date.now(), error: errMsg });
3616
+ auditEvent({
3617
+ clientId: `server-orchestrator-upgrade-command-failed-${orch.id}-${command.id}`,
3618
+ kind: "state",
3619
+ title: "Orchestrator upgrade failed",
3620
+ body: errMsg,
3621
+ meta: orch.id,
3622
+ icon: "ti-alert-triangle",
3623
+ view: "orchestrators",
3624
+ metadata: { orchestratorId: orch.id, commandId: command.id },
3625
+ });
3626
+ emitOrchestratorStatus(orch.id);
3627
+ }
3628
+
3554
3629
  const postOrchestratorAction: Handler = async (req, params) => {
3555
3630
  const parsed = await parseBody<unknown>(req);
3556
3631
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -3571,6 +3646,8 @@ const postOrchestratorAction: Handler = async (req, params) => {
3571
3646
  const providers = (cleanStringArray(parsed.body.providers, "providers") ?? ["orchestrator"])
3572
3647
  .filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
3573
3648
  if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
3649
+ const selfUpgradeError = orchestratorSelfUpgradeError(orch);
3650
+ if (selfUpgradeError) return error(selfUpgradeError, 409);
3574
3651
  if (!force && orch.version && compareSemverCore(targetVersion, orch.version) < 0) {
3575
3652
  return error(`refusing downgrade ${orch.version} → ${targetVersion}; pass force to override`, 409);
3576
3653
  }
@@ -4284,6 +4361,7 @@ const patchCommand: Handler = async (req, params) => {
4284
4361
  }
4285
4362
  }
4286
4363
  }
4364
+ settleFailedOrchestratorUpgrade(command);
4287
4365
  emitCommand(command);
4288
4366
  auditCommandOutcome(command);
4289
4367
  return json(command);
@@ -4429,60 +4507,9 @@ function policyStatusPayload(policy: SpawnPolicy) {
4429
4507
  }
4430
4508
 
4431
4509
  function managedSpawnParams(policy: SpawnPolicy, requestId: string): Record<string, unknown> {
4432
- const selection = resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
4433
- const providerArgs = managedPolicyProviderArgs(policy);
4434
- const prompt = managedPolicyLaunchPrompt(policy);
4435
- const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
4436
- return {
4437
- action: "spawn",
4438
- provider: policy.provider,
4439
- model: selection.modelAlias,
4440
- providerModel: selection.providerModel,
4441
- effort: selection.effort,
4442
- profile: policy.profile,
4443
- agentProfile,
4444
- cwd: policy.cwd,
4445
- workspaceMode: effectiveManagedPolicyWorkspaceMode(policy),
4446
- label: policy.label,
4447
- tags: policy.tags,
4448
- capabilities: policy.capabilities,
4449
- approvalMode: policy.permissionMode,
4450
- permissionMode: policy.permissionMode,
4451
- providerArgs,
4452
- ...(prompt ? { prompt } : {}),
4453
- headless: true,
4454
- policyName: policy.name,
4455
- spawnRequestId: requestId,
4456
- env: runnerRuntimeTokenEnv({
4457
- orchestratorId: policy.orchestratorId,
4458
- cwd: policy.cwd,
4459
- provider: policy.provider,
4460
- label: policy.label,
4461
- policyName: policy.name,
4462
- spawnRequestId: requestId,
4463
- createdBy: "managed-agent",
4464
- }),
4465
- requestedBy: "managed-agent",
4466
- requestedAt: Date.now(),
4467
- };
4510
+ return buildManagedSpawnParams(policy, requestId, { createdBy: "managed-agent" });
4468
4511
  }
4469
4512
 
4470
- function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): WorkspaceMode {
4471
- if (policy.workspaceMode && policy.workspaceMode !== "inherit") return policy.workspaceMode;
4472
- return policy.binding?.type === "channel" ? "shared" : "inherit";
4473
- }
4474
-
4475
- function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
4476
- if (policy.provider === "claude" && policy.mode === "always-on" && policy.prompt?.trim()) {
4477
- return [...policy.providerArgs, "--append-system-prompt", policy.prompt.trim()];
4478
- }
4479
- return policy.providerArgs;
4480
- }
4481
-
4482
- function managedPolicyLaunchPrompt(policy: SpawnPolicy): string | undefined {
4483
- if (policy.provider === "claude" && policy.mode === "always-on") return undefined;
4484
- return policy.prompt;
4485
- }
4486
4513
 
4487
4514
  function requirePolicyAndOrchestrator(name: string): { policy: SpawnPolicy; orch: NonNullable<ReturnType<typeof getOrchestrator>> } | Response {
4488
4515
  const entry = getSpawnPolicy(name);