agent-relay-server 0.29.0 → 0.30.1

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.
@@ -174,7 +174,7 @@ export function profileAllowsRelayFeature(config: RunnerSpawnConfig, feature: ke
174
174
 
175
175
  export const RELAY_CONTEXT = `[agent-relay] You are connected to Agent Relay, a real-time message bus between agents and users. When you receive a relay message: read it, do what it asks, and reply through the relay when a text response is needed. Use agent-relay /react <messageId> <emoji> for lightweight acknowledgement or approval. If Relay MCP tools are available, prefer relay_reply, relay_get_message, relay_get_thread, relay_send_message, relay_upload_artifact, relay_attach_artifact, relay_agent_status, relay_find_agents, relay_spawn_agent, and relay_shutdown_agent. You never need to know or pass your own agent id — relay fills it from your token; use relay_whoami only if you need to reason about yourself. relay_spawn_agent / relay_shutdown_agent only appear if your profile grants spawning (a live-children quota); when present you can stand up long-living child agents and shut down your own — find them later with relay_find_agents spawnedBy:me. CLI fallback: agent-relay /reply <messageId> --stdin < response.md; if a delivered message says it was truncated, fetch the full body with: agent-relay get-message <messageId>. For command details, run: agent-relay /guide`;
176
176
 
177
- const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
177
+ export const PROVIDER_MESSAGE_BODY_PREVIEW_CHARS = 4000;
178
178
 
179
179
  function attachmentRefs(message: Message): Record<string, unknown>[] {
180
180
  const payloadRefs = message.payload?.attachments;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir, hostname } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
- import { stringValue } from "agent-relay-sdk";
4
+ import { DEFAULT_RELAY_URL, stringValue } from "agent-relay-sdk";
5
5
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
6
  import type { ProviderConfig } from "./adapter";
7
7
 
@@ -15,8 +15,6 @@ interface LoadedProviderConfig extends ProviderConfig {
15
15
  path: string;
16
16
  }
17
17
 
18
- const DEFAULT_RELAY_URL = "http://127.0.0.1:4850";
19
-
20
18
  function agentRelayHome(): string {
21
19
  return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay");
22
20
  }
@@ -0,0 +1,36 @@
1
+ // Branch-state enrichment for the agent payload (#236). The single server-side
2
+ // place that hangs the derived `branchState` (+ owning `branchWorkspaceId`) onto an
3
+ // AgentCard before it leaves over the API or SSE — so the dashboard never recomputes
4
+ // it and the projection can't drift between surfaces. The mapping itself lives in
5
+ // deriveBranchState (workspace-phase.ts, pure & CLI-importable); this module is just
6
+ // the agent↔workspace join.
7
+
8
+ import { listWorkspaces, ownedIsolatedWorkspace } from "./db";
9
+ import type { AgentCard, WorkspaceRecord } from "./types";
10
+ import { deriveBranchState, isLiveIsolatedWorkspace } from "./workspace-phase";
11
+
12
+ function withWorkspace(agent: AgentCard, workspace: WorkspaceRecord | undefined): AgentCard {
13
+ const branchState = deriveBranchState(workspace);
14
+ if (!branchState || !workspace) return agent;
15
+ return { ...agent, branchState, branchWorkspaceId: workspace.id };
16
+ }
17
+
18
+ /** Enrich a single agent with its branch-state badge fields (one workspace query). */
19
+ export function attachBranchState(agent: AgentCard): AgentCard {
20
+ return withWorkspace(agent, ownedIsolatedWorkspace(agent.id));
21
+ }
22
+
23
+ /**
24
+ * Batched enrichment for list endpoints: one workspace scan, mapped by owner, so a
25
+ * fleet of N agents costs a single query instead of N. listWorkspaces is ORDER BY
26
+ * updated_at DESC, so the first live isolated workspace seen per owner is the most
27
+ * recent one — same selection as ownedIsolatedWorkspace.
28
+ */
29
+ export function attachBranchStates(agents: AgentCard[]): AgentCard[] {
30
+ const byOwner = new Map<string, WorkspaceRecord>();
31
+ for (const ws of listWorkspaces()) {
32
+ if (!ws.ownerAgentId || !isLiveIsolatedWorkspace(ws)) continue;
33
+ if (!byOwner.has(ws.ownerAgentId)) byOwner.set(ws.ownerAgentId, ws);
34
+ }
35
+ return agents.map((agent) => withWorkspace(agent, byOwner.get(agent.id)));
36
+ }
package/src/agent-ref.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // never silently picks among several — it reports candidates instead.
13
13
 
14
14
  import { STALE_TTL_MS } from "./config";
15
- import type { AgentCard } from "./types";
15
+ import type { AgentCard, Message } from "./types";
16
16
 
17
17
  interface ResolveOptions {
18
18
  /** Exclude this agent id from matches (e.g. the requester, when pairing). */
@@ -121,6 +121,23 @@ export function resolveAgentRef(ref: string, agents: AgentCard[], opts: ResolveO
121
121
  return { status: "not_found", offlineMatches: matches };
122
122
  }
123
123
 
124
+ export function targetMatchesAgent(target: string, agentId: string, agent?: AgentCard | null): boolean {
125
+ if (!agent) return false;
126
+ if (target === agentId || target === "broadcast") return true;
127
+ if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
128
+ if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
129
+ if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
130
+ return false;
131
+ }
132
+
133
+ export function messageMatchesAgent(msg: Message, agentId: string, agent?: AgentCard | null): boolean {
134
+ if (!agent) return false;
135
+ if (msg.claimable && msg.claimedBy && msg.claimedBy !== agentId) return false;
136
+ if (msg.resolvedToAgent === agentId) return true;
137
+ if (msg.to === agentId || msg.from === agentId) return true;
138
+ return targetMatchesAgent(msg.to, agentId, agent);
139
+ }
140
+
124
141
  // --- messaging send planning -------------------------------------------------
125
142
 
126
143
  export interface DeliveryReceipt {
@@ -18,6 +18,13 @@ export interface BranchLandedInput {
18
18
  subject?: string;
19
19
  /** Fresh branch the worktree was recycled onto (land-and-continue), if any. */
20
20
  newBranch?: string;
21
+ /**
22
+ * Whether the advanced base was pushed to its remote. `false` means the land is
23
+ * local-only (push disabled, or the base has no upstream) and has NOT reached
24
+ * `origin` yet — the notice says so instead of implying a published merge (#285).
25
+ * `undefined` from older orchestrators that don't report it → message stays generic.
26
+ */
27
+ pushed?: boolean;
21
28
  }
22
29
 
23
30
  /**
@@ -47,6 +54,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
47
54
  subject: input.subject,
48
55
  author: workspace.ownerAgentId,
49
56
  newBranch: input.newBranch,
57
+ pushed: input.pushed,
50
58
  },
51
59
  });
52
60
 
@@ -65,8 +73,14 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
65
73
  sha: input.mergedSha,
66
74
  author,
67
75
  newBranch: input.newBranch,
76
+ pushed: input.pushed,
68
77
  };
69
78
 
79
+ // A local-only land (push disabled, or base has no upstream) has NOT reached the
80
+ // remote — say so rather than implying a published merge an agent would report as
81
+ // shipped (#285). `undefined` (older orchestrators) keeps the message generic.
82
+ const publishLabel = input.pushed === false ? ` Not yet pushed to \`origin/${base}\` — local only.` : "";
83
+
70
84
  // The branch author cares most — push regardless of online (store-ahead delivers it on
71
85
  // next poll if they've moved on, #234). They land-and-continue onto the recycled branch.
72
86
  if (author) {
@@ -76,7 +90,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
76
90
  : " Worktree reclaimed.";
77
91
  notifySystemMessage(author, {
78
92
  subject: "Your branch landed",
79
- body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${continueLabel}`,
93
+ body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}${continueLabel}`,
80
94
  payload,
81
95
  replyExpected: false,
82
96
  });
@@ -92,7 +106,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
92
106
  for (const agent of agentsOnMain(workspace.repoRoot, author)) {
93
107
  notifySystemMessage(agent.id, {
94
108
  subject: `Merged to ${base}`,
95
- body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.`,
109
+ body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
96
110
  payload,
97
111
  replyExpected: false,
98
112
  });
package/src/bus.ts CHANGED
@@ -7,6 +7,7 @@ import { emitCommandEvent } from "./command-events";
7
7
  import { getLifecycleManager } from "./lifecycle-manager";
8
8
  import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
9
9
  import { applyCommandToRecipe } from "./recipe-runner";
10
+ import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
10
11
  import {
11
12
  BusProtocolError,
12
13
  parseBusFrame,
@@ -465,11 +466,11 @@ function broadcastRelayEvent(event: RelayEvent): void {
465
466
  function connectionWantsEvent(conn: BusConnection, event: RelayEvent): boolean {
466
467
  if (!matchesSubscription(conn.subscriptions, event.type)) return false;
467
468
  if (event.type === "message.new" && conn.agentId) {
468
- return messageMatchesAgent(event.data as unknown as Message, conn.agentId);
469
+ return messageMatchesAgent(event.data as unknown as Message, conn.agentId, getAgent(conn.agentId));
469
470
  }
470
471
  if (event.type.startsWith("task.") && conn.agentId) {
471
472
  const target = typeof event.data.target === "string" ? event.data.target : "";
472
- return targetMatchesAgent(target, conn.agentId);
473
+ return targetMatchesAgent(target, conn.agentId, getAgent(conn.agentId));
473
474
  }
474
475
  if (event.type.startsWith("command.")) {
475
476
  const command = isRecord(event.data.command) ? event.data.command : undefined;
@@ -490,25 +491,6 @@ function matchesSubscription(subscriptions: Set<string>, eventType: string): boo
490
491
  return false;
491
492
  }
492
493
 
493
- function messageMatchesAgent(msg: Message, agentId: string): boolean {
494
- const agent = getAgent(agentId);
495
- if (!agent) return false;
496
- if (msg.claimable && msg.claimedBy && msg.claimedBy !== agentId) return false;
497
- if (msg.resolvedToAgent === agentId) return true;
498
- if (msg.to === agentId || msg.from === agentId) return true;
499
- return targetMatchesAgent(msg.to, agentId, agent);
500
- }
501
-
502
- function targetMatchesAgent(target: string, agentId: string, knownAgent?: AgentCard): boolean {
503
- const agent = knownAgent ?? getAgent(agentId);
504
- if (!agent) return false;
505
- if (target === agentId || target === "broadcast") return true;
506
- if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
507
- if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
508
- if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
509
- return false;
510
- }
511
-
512
494
  function outboxToPayload(event: BusEvent) {
513
495
  return {
514
496
  seq: event.seq,
package/src/cli.ts CHANGED
@@ -143,7 +143,8 @@ Upgrade options:
143
143
  --providers LIST Provider integrations to upgrade: auto, all, codex, claude, orchestrator
144
144
  --host ID Upgrade a remote orchestrator host over the relay (repeatable). Skips the local upgrade
145
145
  --all-hosts Upgrade this host, then fan out to every connected remote host that is behind
146
- --no-restart Do not restart agent-relay.service
146
+ --no-restart Do not restart agent-relay.service (warns you to restart it manually)
147
+ --restart-deferred Like --no-restart, but the caller restarts the services itself; suppresses the manual-restart warning (used by the release script)
147
148
  --dry-run Print detected state and planned commands
148
149
  --yes Skip confirmation prompts
149
150
 
@@ -402,6 +403,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
402
403
  let targetVersion: string | undefined;
403
404
  let dryRun = false;
404
405
  let noRestart = false;
406
+ let restartDeferred = false;
405
407
  let yes = false;
406
408
  let json = false;
407
409
  let runtimePrefix: string | undefined;
@@ -425,6 +427,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
425
427
  else if (arg === "--all") providers.push("all");
426
428
  else if (arg === "--dry-run") dryRun = true;
427
429
  else if (arg === "--no-restart") noRestart = true;
430
+ else if (arg === "--restart-deferred") restartDeferred = true;
428
431
  else if (arg === "--yes" || arg === "-y") yes = true;
429
432
  else if (arg === "--json") json = true;
430
433
  else throw new Error(`Unknown upgrade option "${arg}"`);
@@ -449,6 +452,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
449
452
  ...(runtimePrefix ? { runtimePrefix } : {}),
450
453
  providers,
451
454
  noRestart,
455
+ restartDeferred,
452
456
  });
453
457
 
454
458
  if (json) {
package/src/db.ts CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { isRecord, stringValue, isMechanicalMessageKind } from "agent-relay-sdk";
4
4
  import { ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "./config.ts";
5
5
  import { parseJson } from "./utils";
6
+ import { isLiveIsolatedWorkspace } from "./workspace-phase";
6
7
  import {
7
8
  CONTRACT_REQUIREMENTS,
8
9
  contractCompatibility,
@@ -5281,20 +5282,24 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
5281
5282
  // preserved and metadata is merged, not replaced.
5282
5283
  const existing = getWorkspace(workspace.id);
5283
5284
  const preserveStatus = existing != null && existing.status !== "active";
5284
- // The branch (and advanced base) change ONLY via the relay's own land-and-continue
5285
- // recycle (setWorkspaceBranch repoints to `<branch>-N` and bumps base_sha). The
5286
- // runner keeps re-reporting its spawn-time branch on every heartbeat, and the
5287
- // recycle returns status to "active" — so without this the next heartbeat clobbers
5288
- // the repoint back to the original branch, and the next land targets a deleted
5289
- // branch and strands the work (vent #62 follow-up). Trust the existing row's
5290
- // branch/base over registration; only a brand-new row takes the runner's values.
5285
+ // The branch and base change ONLY via the relay's own land-and-continue recycle
5286
+ // (setWorkspaceBranch repoints to `<branch>-N` and bumps base_sha; base_ref is fixed
5287
+ // at spawn and never re-targeted). The runner keeps re-reporting its spawn-time
5288
+ // branch/base on every heartbeat, and the recycle returns status to "active" — so
5289
+ // without this the next heartbeat clobbers the repoint back to the original branch,
5290
+ // and the next land targets a deleted branch and strands the work (vent #62 follow-up).
5291
+ // baseRef needs the same pin: a heartbeat carrying a stale/wrong base (e.g. a recycled
5292
+ // `-N` branch when the agent was spawned from inside a managed worktree) would otherwise
5293
+ // overwrite the true base, and the next land advances a local-only branch that never
5294
+ // reaches origin/main (#285). Trust the existing row's branch/base over registration;
5295
+ // only a brand-new row takes the runner's values.
5291
5296
  return upsertWorkspace({
5292
5297
  id: workspace.id,
5293
5298
  repoRoot: workspace.repoRoot,
5294
5299
  sourceCwd: workspace.sourceCwd ?? agent.cwd,
5295
5300
  worktreePath: workspace.worktreePath,
5296
5301
  branch: existing?.branch ?? workspace.branch,
5297
- baseRef: workspace.baseRef,
5302
+ baseRef: existing?.baseRef ?? workspace.baseRef,
5298
5303
  baseSha: existing?.baseSha ?? workspace.baseSha,
5299
5304
  mode: workspace.mode,
5300
5305
  requestedMode: workspace.requestedMode,
@@ -5394,6 +5399,14 @@ export function deleteWorkspace(id: string): boolean {
5394
5399
  return db.query("DELETE FROM workspaces WHERE id = ?").run(id).changes > 0;
5395
5400
  }
5396
5401
 
5402
+ // The agent's current branch worktree: its most recent live (non-terminal) isolated
5403
+ // workspace, or undefined. SINGLE HOME for the agent→workspace link — the MCP
5404
+ // owner-resolver and the #236 branch-state badge both go through here. listWorkspaces
5405
+ // is ORDER BY updated_at DESC, so `.find` returns the most recently active one.
5406
+ export function ownedIsolatedWorkspace(agentId: string): WorkspaceRecord | undefined {
5407
+ return listWorkspaces({ ownerAgentId: agentId }).find(isLiveIsolatedWorkspace);
5408
+ }
5409
+
5397
5410
  // Shared-mode rows are pure occupancy markers (no worktree on disk) that only
5398
5411
  // mean something while their owner is online. Deletion is normally driven by
5399
5412
  // the reaper's onAgentDisappeared hook, but agents also leave via clean
@@ -5416,6 +5429,26 @@ export function pruneOrphanedSharedWorkspaces(): string[] {
5416
5429
  })();
5417
5430
  }
5418
5431
 
5432
+ // Late-bound listeners for "a workspace row changed in a way that may change its
5433
+ // owner's branch-state badge" (#236). A hook, not a direct emit, because db.ts is
5434
+ // the lowest layer — sse.ts imports db, so db can't import sse without a cycle. sse
5435
+ // registers a listener at startup that re-emits the owner agent's status over SSE.
5436
+ type WorkspaceChangeListener = (workspace: WorkspaceRecord) => void;
5437
+ const workspaceChangeListeners = new Set<WorkspaceChangeListener>();
5438
+ export function onWorkspaceChange(listener: WorkspaceChangeListener): void {
5439
+ workspaceChangeListeners.add(listener);
5440
+ }
5441
+ export function emitWorkspaceChange(workspace: WorkspaceRecord | null | undefined): void {
5442
+ if (!workspace) return;
5443
+ for (const listener of workspaceChangeListeners) {
5444
+ try {
5445
+ listener(workspace);
5446
+ } catch {
5447
+ // A badge-refresh listener must never break a workspace write.
5448
+ }
5449
+ }
5450
+ }
5451
+
5419
5452
  export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metadata: Record<string, unknown> = {}): WorkspaceRecord | null {
5420
5453
  const existing = getWorkspace(id);
5421
5454
  if (!existing) return null;
@@ -5434,7 +5467,11 @@ export function updateWorkspaceStatus(id: string, status: WorkspaceStatus, metad
5434
5467
  id,
5435
5468
  );
5436
5469
  electWorkspaceStewards(existing.repoRoot);
5437
- return getWorkspace(id);
5470
+ const updated = getWorkspace(id);
5471
+ // Every status transition can flip the owner's badge (active→ready→steward→merged),
5472
+ // so refresh it regardless of caller. Fires on the row's CURRENT owner.
5473
+ emitWorkspaceChange(updated);
5474
+ return updated;
5438
5475
  }
5439
5476
 
5440
5477
  // Repoint a workspace row at a recycled branch after a land-and-continue merge
@@ -19,6 +19,7 @@ import {
19
19
  pruneOfflineAgents,
20
20
  pruneOldMessages,
21
21
  deleteWorkspace,
22
+ emitWorkspaceChange,
22
23
  pruneOrphanedSharedWorkspaces,
23
24
  reapStaleAgents,
24
25
  reapStaleOrchestrators,
@@ -34,7 +35,7 @@ import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./
34
35
  import { requestWorkspaceMerge } from "./workspace-merge";
35
36
  import { workspaceActiveClaim } from "./workspace-claim";
36
37
  import { reapOrphanedWorktrees } from "./workspace-orphans";
37
- import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
38
+ import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
38
39
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
39
40
  import { getStewardConfig } from "./config-store";
40
41
  import { ensureRepoSteward } from "./steward";
@@ -583,6 +584,20 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
583
584
 
584
585
  const meta = ws.metadata as Record<string, unknown>;
585
586
 
587
+ // #236: stash the latest ahead/dirty counts so deriveBranchState can tell idle
588
+ // (⚪) from changes (🟡) for an active worktree — the relay isn't in the git
589
+ // path, so this scan is its only window onto the tree. Only write (and refresh
590
+ // the owner's badge) when the derived state actually flips, to avoid SSE churn
591
+ // every 2 min on unchanged workspaces. Status-changing branches below own their
592
+ // own badge refresh via updateWorkspaceStatus.
593
+ const nextAhead = p.ahead ?? 0;
594
+ const nextDirty = p.dirtyCount ?? 0;
595
+ if (meta.gitAhead !== nextAhead || meta.gitDirtyCount !== nextDirty) {
596
+ const before = deriveBranchState(ws);
597
+ const patched = patchWorkspaceMetadata(ws.id, { gitAhead: nextAhead, gitDirtyCount: nextDirty, gitProbedAt: Date.now() });
598
+ if (patched && deriveBranchState(patched) !== before) emitWorkspaceChange(patched);
599
+ }
600
+
586
601
  // Landing wins over everything else. Once the work is in base — whether the
587
602
  // PR was squash/cherry-pick merged on GitHub or fast-forwarded locally — the
588
603
  // workspace is done, even if `git merge-tree` still predicts a textual
package/src/mcp.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  linkArtifact,
24
24
  listAgents,
25
25
  listWorkspaces,
26
+ ownedIsolatedWorkspace,
26
27
  searchAgents,
27
28
  type AgentSearchFilter,
28
29
  type AgentSearchSort,
@@ -45,7 +46,7 @@ import {
45
46
  } from "./security";
46
47
  import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
47
48
  import { applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
48
- import { describeWorkspacePhase, landReceipt, readyContract, TERMINAL_WORKSPACE_STATUSES, worktreeMcpInstructions } from "./workspace-phase";
49
+ import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
49
50
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
50
51
  import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
51
52
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
@@ -964,7 +965,7 @@ function resolveWorkspaceForCaller(auth: McpAuthContext, args: Record<string, un
964
965
  function callerIsolatedWorkspace(auth: McpAuthContext): WorkspaceRecord | undefined {
965
966
  const caller = callerAgentId(auth);
966
967
  if (!caller) return undefined;
967
- return listWorkspaces({ ownerAgentId: caller }).find((w) => w.mode === "isolated" && !TERMINAL_WORKSPACE_STATUSES.has(w.status));
968
+ return ownedIsolatedWorkspace(caller);
968
969
  }
969
970
 
970
971
  async function relayWorkspaceStatus(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
package/src/routes.ts CHANGED
@@ -160,6 +160,7 @@ import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES
160
160
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
161
161
  import { listHostDirectories } from "./agent-spawn";
162
162
  import { planSend } from "./agent-ref";
163
+ import { attachBranchState, attachBranchStates } from "./agent-branch-state";
163
164
  import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
164
165
  import type { ProviderConfig } from "../runner/src/adapter";
165
166
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
@@ -1327,7 +1328,7 @@ const getAgents: Handler = (req) => {
1327
1328
  const tag = url.searchParams.get("tag") ?? undefined;
1328
1329
  const machine = url.searchParams.get("machine") ?? undefined;
1329
1330
  const status = url.searchParams.get("status") ?? undefined;
1330
- return json(listAgents({ tag, machine, status }));
1331
+ return json(attachBranchStates(listAgents({ tag, machine, status })));
1331
1332
  };
1332
1333
 
1333
1334
  const findAgents: Handler = (req) => {
@@ -1361,7 +1362,7 @@ const postRouteAdvice: Handler = async (req) => {
1361
1362
 
1362
1363
  const getAgentById: Handler = (_req, params) => {
1363
1364
  const agent = getAgent(params.id!);
1364
- return agent ? json(agent) : error("agent not found", 404);
1365
+ return agent ? json(attachBranchState(agent)) : error("agent not found", 404);
1365
1366
  };
1366
1367
 
1367
1368
  const getAgentReplyObligations: Handler = (_req, params) => {
@@ -4495,8 +4496,12 @@ const patchCommand: Handler = async (req, params) => {
4495
4496
  // Repoint the row so the next merge targets the live branch, not the deleted one.
4496
4497
  const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
4497
4498
  const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
4499
+ // base_sha tracks the tip the recycled workspace forks from. On a no-ff land
4500
+ // (#287) that's the merge commit (result.baseSha), not the preserved landed
4501
+ // commit (mergedSha); fall back to mergedSha for older orchestrators.
4502
+ const baseSha = cleanString(command.result.baseSha, "result.baseSha", { max: 64 });
4498
4503
  if (newBranch) {
4499
- setWorkspaceBranch(workspaceId, newBranch, mergedSha);
4504
+ setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
4500
4505
  }
4501
4506
  // #239 — push the author a "your branch landed" notice (no polling). Only on a
4502
4507
  // real land; a no-op resolution (#230) merged nothing, so it earns no notice.
@@ -4506,6 +4511,7 @@ const patchCommand: Handler = async (req, params) => {
4506
4511
  mergedSha,
4507
4512
  subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
4508
4513
  newBranch,
4514
+ pushed: typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
4509
4515
  });
4510
4516
  }
4511
4517
  }
package/src/sse.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { getAgent, getOrchestrator } from "./db";
1
+ import { getAgent, getOrchestrator, onWorkspaceChange } from "./db";
2
+ import { attachBranchState } from "./agent-branch-state";
2
3
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
4
+ import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
3
5
  import type { ActivityEvent, AgentCard, Message, RelayNotification, Task } from "./types";
4
6
  import { isRecord } from "agent-relay-sdk";
5
7
 
@@ -68,18 +70,6 @@ function send(conn: Connection, event: string, data: unknown) {
68
70
  }
69
71
  }
70
72
 
71
- function messageMatchesAgent(msg: Message, agentId: string): boolean {
72
- const agent = getAgent(agentId);
73
- if (!agent) return false;
74
- if (msg.resolvedToAgent === agentId) return true;
75
- if (msg.to === agentId || msg.from === agentId) return true;
76
- if (msg.to === "broadcast") return true;
77
- if (msg.to.startsWith("tag:") && agent.tags.includes(msg.to.slice(4))) return true;
78
- if (msg.to.startsWith("cap:") && agent.capabilities.includes(msg.to.slice(4))) return true;
79
- if (msg.to.startsWith("label:") && agent.label === msg.to.slice(6)) return true;
80
- return false;
81
- }
82
-
83
73
  function fanout(event: RelayEvent): void {
84
74
  if (event.type === "message.new") {
85
75
  sendNewMessage(event.data as unknown as Message);
@@ -100,8 +90,7 @@ function sendNewMessage(msg: Message): void {
100
90
  send(conn, "message.new", msg);
101
91
  continue;
102
92
  }
103
- if (!messageMatchesAgent(msg, conn.agentId)) continue;
104
- if (msg.claimable && msg.claimedBy && msg.claimedBy !== conn.agentId) continue;
93
+ if (!messageMatchesAgent(msg, conn.agentId, getAgent(conn.agentId))) continue;
105
94
  send(conn, "message.new", msg);
106
95
  }
107
96
  }
@@ -114,12 +103,21 @@ export function emitNewMessage(msg: Message) {
114
103
 
115
104
  export function emitAgentStatus(agentId: string) {
116
105
  const agent = getAgent(agentId);
117
- const data = agent ?? { id: agentId, status: "offline" };
106
+ // Enrich with the branch-state badge fields (#236) so the dashboard's live agent
107
+ // updates carry the same projection as GET /api/agents.
108
+ const data = agent ? attachBranchState(agent) : { id: agentId, status: "offline" };
118
109
  emitRelayEvent({ type: "agent.status", source: "server", subject: agentId, data: data as unknown as Record<string, unknown> });
119
110
  const notification = agent ? notificationForAgentStatus(agent) : undefined;
120
111
  if (notification) emitNotificationCreated(notification);
121
112
  }
122
113
 
114
+ // A workspace row changed (status transition or an idle↔changes git snapshot) →
115
+ // re-emit its owner's agent.status so the branch-state badge updates live (#236).
116
+ // Registered against the db hook to avoid a db→sse import cycle.
117
+ onWorkspaceChange((workspace) => {
118
+ if (workspace.ownerAgentId) emitAgentStatus(workspace.ownerAgentId);
119
+ });
120
+
123
121
  export function emitAgentRemoved(agentId: string) {
124
122
  emitRelayEvent({ type: "agent.removed", source: "server", subject: agentId, data: { id: agentId } });
125
123
  }
@@ -165,7 +163,7 @@ export function emitMessageReactionUpdated(msg: Message) {
165
163
 
166
164
  function sendTaskChanged(task: Task, eventType = "task.updated"): void {
167
165
  for (const conn of connections.values()) {
168
- if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId)) continue;
166
+ if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId, getAgent(conn.agentId))) continue;
169
167
  send(conn, eventType, task);
170
168
  }
171
169
  }
@@ -194,16 +192,6 @@ export function getConnectionCount(): number {
194
192
  return connections.size;
195
193
  }
196
194
 
197
- function targetMatchesAgent(target: string, agentId: string): boolean {
198
- const agent = getAgent(agentId);
199
- if (!agent) return false;
200
- if (target === agentId || target === "broadcast") return true;
201
- if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
202
- if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
203
- if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
204
- return false;
205
- }
206
-
207
195
  export function emitOrchestratorStatus(orchestratorId: string) {
208
196
  const orch = getOrchestrator(orchestratorId);
209
197
  const data = orch ?? { id: orchestratorId, status: "offline" };
package/src/upgrade.ts CHANGED
@@ -13,6 +13,13 @@ type UpgradeOptions = {
13
13
  targetVersion?: string;
14
14
  providers?: UpgradeProvider[];
15
15
  noRestart?: boolean;
16
+ /**
17
+ * Like `noRestart` (no restart action → no premature post-restart verify),
18
+ * but the caller restarts the services itself right after install (the
19
+ * release script does). Suppresses the "restart manually" warning, which is
20
+ * a false alarm in that flow — only the caller knows the restart is coming.
21
+ */
22
+ restartDeferred?: boolean;
16
23
  runtimePrefix?: string;
17
24
  };
18
25
 
@@ -206,10 +213,14 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
206
213
  warnings.push("Agent Relay orchestrator not detected; skipping orchestrator package upgrade.");
207
214
  }
208
215
 
216
+ // `restartDeferred` means a caller restarts for us — skip the restart action
217
+ // (and thus the post-restart verify) exactly like `noRestart`, but without the
218
+ // "restart manually" warning, which would be a false alarm in that flow.
219
+ const deferRestart = Boolean(options.noRestart || options.restartDeferred);
209
220
  const serverRestartNeeded = serverPackageUpdated || Boolean(snapshot.runningServerVersion && snapshot.runningServerVersion !== targetVersion);
210
221
  if (snapshot.hasSystemdUserService && serverRestartNeeded) {
211
- if (options.noRestart) {
212
- warnings.push("agent-relay.service detected but --no-restart was set; restart manually to run the upgraded server.");
222
+ if (deferRestart) {
223
+ if (!options.restartDeferred) warnings.push("agent-relay.service detected but --no-restart was set; restart manually to run the upgraded server.");
213
224
  } else {
214
225
  actions.push({
215
226
  label: "Restart Agent Relay service",
@@ -226,8 +237,8 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
226
237
  Boolean((orch.version && orch.version !== targetVersion) || orch.health?.restartRequired)
227
238
  );
228
239
  if (snapshot.hasSystemdUserOrchestratorService && orchestratorRestartNeeded) {
229
- if (options.noRestart) {
230
- warnings.push("agent-relay-orchestrator.service detected but --no-restart was set; restart manually to run the upgraded orchestrator.");
240
+ if (deferRestart) {
241
+ if (!options.restartDeferred) warnings.push("agent-relay-orchestrator.service detected but --no-restart was set; restart manually to run the upgraded orchestrator.");
231
242
  } else {
232
243
  actions.push({
233
244
  label: "Restart Agent Relay orchestrator service",