agent-relay-server 0.29.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.29.0",
3
+ "version": "0.30.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.18"
36
+ "agent-relay-sdk": "0.2.19"
37
37
  },
38
38
  "scripts": {
39
39
  "prepack": "bun run build:dashboard:bundle >&2",
package/public/index.html CHANGED
@@ -11190,6 +11190,17 @@ function shortPath$1(cwd, segments = 2) {
11190
11190
  const parts = cwd.replace(/\/+$/, "").split("/");
11191
11191
  return parts.length <= segments ? cwd : parts.slice(-segments).join("/");
11192
11192
  }
11193
+ function normalizePathForCompare(path) {
11194
+ return path.trim().replace(/\\/g, "/").replace(/\/+$/, "") || "/";
11195
+ }
11196
+ function pathWithinBase(path, baseDir) {
11197
+ if (!path?.trim() || !baseDir?.trim()) return false;
11198
+ const normalizedPath = normalizePathForCompare(path);
11199
+ const normalizedBase = normalizePathForCompare(baseDir);
11200
+ if (normalizedBase === "/") return normalizedPath.startsWith("/");
11201
+ if (normalizedPath === "/") return false;
11202
+ return normalizedPath === normalizedBase || normalizedPath.startsWith(normalizedBase + "/");
11203
+ }
11193
11204
  function isBuiltInAgent(agent) {
11194
11205
  return agent?.meta?.builtin === true || agent?.id === "user" || agent?.id === "system";
11195
11206
  }
@@ -11355,7 +11366,7 @@ function isClaimableTaskWaiting(task) {
11355
11366
  function isClaimableMessageWaiting(msg) {
11356
11367
  return Boolean(msg.claimable && !msg.claimedBy && !(msg.kind === "task" && Number.isSafeInteger(msg.payload?.taskId)));
11357
11368
  }
11358
- function targetMatchesAgent(target, agent) {
11369
+ function dashboardTargetMatchesAgent(target, agent) {
11359
11370
  if (!target || !agent) return false;
11360
11371
  if (target === "broadcast" || target === agent.id) return true;
11361
11372
  if (target.startsWith("tag:")) return (agent.tags || []).includes(target.slice(4));
@@ -11749,7 +11760,7 @@ function timeAgo(now, iso) {
11749
11760
  if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
11750
11761
  return Math.floor(diff / 86400) + "d ago";
11751
11762
  }
11752
- function fmtTime$1(iso) {
11763
+ function fmtTime(iso) {
11753
11764
  if (!iso) return "";
11754
11765
  return new Date(iso).toLocaleString();
11755
11766
  }
@@ -12066,12 +12077,6 @@ function firstAvailableProvider(orchestrator) {
12066
12077
  function defaultModelFor(provider) {
12067
12078
  return PROVIDER_CATALOG[provider]?.defaultModel ?? "";
12068
12079
  }
12069
- function pathWithinBase(path, baseDir) {
12070
- const value = path.trim().replace(/\\/g, "/").replace(/\/+$/, "");
12071
- const base = (baseDir || "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
12072
- if (!value || !base) return false;
12073
- return value === base || value.startsWith(base + "/");
12074
- }
12075
12080
  function indexAgents(agents) {
12076
12081
  const byId = {};
12077
12082
  for (const agent of agents) byId[agent.id] = agent;
@@ -12191,16 +12196,8 @@ function renderTimelineMarkdown(events, exportedAt) {
12191
12196
  }
12192
12197
  return lines.join("\n");
12193
12198
  }
12194
- function normalizePathForCompare$1(path) {
12195
- return path.replace(/\/+$/, "") || "/";
12196
- }
12197
- function pathWithin$1(path, baseDir) {
12198
- const normalizedPath = normalizePathForCompare$1(path);
12199
- const normalizedBase = normalizePathForCompare$1(baseDir);
12200
- return normalizedPath === normalizedBase || normalizedPath.startsWith(normalizedBase + "/");
12201
- }
12202
12199
  function orchestratorForPath(orchestrators, path) {
12203
- return orchestrators.filter((orch) => orch.status === "online" && pathWithin$1(path, orch.baseDir)).sort((a, b) => normalizePathForCompare$1(b.baseDir).length - normalizePathForCompare$1(a.baseDir).length)[0];
12200
+ return orchestrators.filter((orch) => orch.status === "online" && pathWithinBase(path, orch.baseDir)).sort((a, b) => normalizePathForCompare(b.baseDir).length - normalizePathForCompare(a.baseDir).length)[0];
12204
12201
  }
12205
12202
  function orchestratorForAgent(orchestrators, agent) {
12206
12203
  const managed = orchestrators.find((orch) => orch.managedAgents.some((managedAgent) => managedAgent.agentId === agent.id));
@@ -12293,6 +12290,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
12293
12290
  health: null,
12294
12291
  maintenanceJobs: [],
12295
12292
  workspaces: [],
12293
+ workspaceFocusId: null,
12296
12294
  workspaceGitState: {},
12297
12295
  workspaceMergePreview: {},
12298
12296
  workspaceDiff: {},
@@ -12593,6 +12591,10 @@ var useRelayStore = create$1()(persist((set, get) => ({
12593
12591
  if (view === "files") await s.fetchOrchestrators();
12594
12592
  if (view === "analytics") await s.fetchAnalytics();
12595
12593
  },
12594
+ async openWorkspaceFocus(workspaceId) {
12595
+ set({ workspaceFocusId: workspaceId });
12596
+ await get().switchView("workspaces");
12597
+ },
12596
12598
  startClock() {
12597
12599
  useClock.getState().start();
12598
12600
  },
@@ -25823,7 +25825,7 @@ function useAgentAttention(agent) {
25823
25825
  const archivedAt = Number(archivedThreads[agent.id] || 0);
25824
25826
  const unread = archivedAt >= peerMessages.filter(isHumanInboundMessage).reduce((max, m) => Math.max(max, m.id), 0) && archivedAt > 0 ? 0 : peerMessages.filter((m) => isHumanInboundMessage(m) && !isSessionActivityStep(m) && !(m.readBy || []).includes("user") && m.id > cursor).length;
25825
25827
  const pendingPairInvite = pairs.find((p) => (p.status === "active" || p.status === "pending") && (p.requesterId === agent.id || p.targetId === agent.id))?.status === "pending";
25826
- const claimableTasks = tasks.filter((t) => isClaimableTaskWaiting(t) && targetMatchesAgent(t.target, agent)).length + messages.filter((m) => isClaimableMessageWaiting(m) && targetMatchesAgent(m.to, agent)).length;
25828
+ const claimableTasks = tasks.filter((t) => isClaimableTaskWaiting(t) && dashboardTargetMatchesAgent(t.target, agent)).length + messages.filter((m) => isClaimableMessageWaiting(m) && dashboardTargetMatchesAgent(m.to, agent)).length;
25827
25829
  return {
25828
25830
  unread,
25829
25831
  pendingPairInvite,
@@ -109163,6 +109165,60 @@ function formatTokenCount(tokens) {
109163
109165
  return String(tokens);
109164
109166
  }
109165
109167
  //#endregion
109168
+ //#region src/components/shared/branch-state-badge.tsx
109169
+ var BRANCH_STATE_META = {
109170
+ idle: {
109171
+ label: "idle",
109172
+ tip: "Branch worktree — nothing to land yet.",
109173
+ className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20",
109174
+ Icon: Circle
109175
+ },
109176
+ changes: {
109177
+ label: "changes",
109178
+ tip: "Unlanded work in this branch — use \"Mark workspace ready\" to land it.",
109179
+ className: "bg-amber-500/10 text-amber-400 border-amber-500/20",
109180
+ Icon: GitCommitHorizontal
109181
+ },
109182
+ ready: {
109183
+ label: "ready",
109184
+ tip: "Handed off — the relay auto-merge will land it (~2 min sweep).",
109185
+ className: "bg-blue-500/10 text-blue-400 border-blue-500/20",
109186
+ Icon: CircleArrowUp
109187
+ },
109188
+ steward: {
109189
+ label: "steward",
109190
+ tip: "Under reconciliation — a steward is landing it. No action needed.",
109191
+ className: "bg-orange-500/10 text-orange-400 border-orange-500/20",
109192
+ Icon: Wrench
109193
+ },
109194
+ blocked: {
109195
+ label: "blocked",
109196
+ tip: "Escalated — the auto-merge/steward path is stuck. Needs human attention.",
109197
+ className: "bg-red-500/10 text-red-400 border-red-500/20",
109198
+ Icon: TriangleAlert
109199
+ }
109200
+ };
109201
+ function BranchStateBadge({ state, onClick, iconOnly = false, className }) {
109202
+ const meta = BRANCH_STATE_META[state];
109203
+ const { Icon } = meta;
109204
+ const interactive = Boolean(onClick);
109205
+ const tip = interactive ? `${meta.tip} Click to open this workspace.` : meta.tip;
109206
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
109207
+ role: interactive ? "button" : void 0,
109208
+ tabIndex: interactive ? 0 : void 0,
109209
+ title: tip,
109210
+ onClick,
109211
+ onKeyDown: interactive ? (e) => {
109212
+ if (e.key === "Enter" || e.key === " ") {
109213
+ e.preventDefault();
109214
+ onClick?.(e);
109215
+ }
109216
+ } : void 0,
109217
+ className: cn$2("inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none", meta.className, interactive && "cursor-pointer hover:brightness-125 focus:outline-none focus:ring-1 focus:ring-current", className),
109218
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Icon, { className: "h-2.5 w-2.5 shrink-0" }), !iconOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: meta.label })]
109219
+ });
109220
+ }
109221
+ //#endregion
109166
109222
  //#region node_modules/@xterm/xterm/lib/xterm.mjs
109167
109223
  /**
109168
109224
  * Copyright (c) 2014-2024 The xterm.js authors. All rights reserved.
@@ -127362,16 +127418,8 @@ function parentPath$1(path) {
127362
127418
  const index = trimmed.lastIndexOf("/");
127363
127419
  return index > 0 ? trimmed.slice(0, index) : "/";
127364
127420
  }
127365
- function normalizePathForCompare(path) {
127366
- return path.replace(/\/+$/, "") || "/";
127367
- }
127368
- function pathWithin(path, baseDir) {
127369
- const normalizedPath = normalizePathForCompare(path);
127370
- const normalizedBase = normalizePathForCompare(baseDir);
127371
- return normalizedPath === normalizedBase || normalizedPath.startsWith(normalizedBase + "/");
127372
- }
127373
127421
  function orchestratorForFilePreview(orchestrators, path) {
127374
- return orchestrators.filter((orch) => orch.status === "online" && pathWithin(path, orch.baseDir)).sort((a, b) => normalizePathForCompare(b.baseDir).length - normalizePathForCompare(a.baseDir).length)[0] || orchestrators.find((orch) => orch.status === "online");
127422
+ return orchestrators.filter((orch) => orch.status === "online" && pathWithinBase(path, orch.baseDir)).sort((a, b) => normalizePathForCompare(b.baseDir).length - normalizePathForCompare(a.baseDir).length)[0] || orchestrators.find((orch) => orch.status === "online");
127375
127423
  }
127376
127424
  function supportsSideFilePreview() {
127377
127425
  return typeof window !== "undefined" && window.matchMedia("(min-width: 1280px)").matches;
@@ -127689,7 +127737,7 @@ function AgentListPanel({ threads, onSelectAgent }) {
127689
127737
  className: "flex items-center gap-1 shrink-0",
127690
127738
  children: [lastActivityAt > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
127691
127739
  className: "text-[10px] text-muted-foreground",
127692
- title: fmtTime$1(lastActivityAt),
127740
+ title: fmtTime(lastActivityAt),
127693
127741
  children: timeAgo(now, lastActivityAt)
127694
127742
  }), unread > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Badge$1, {
127695
127743
  className: "bg-red-500 text-white text-xs min-w-[18px] h-[18px] flex items-center justify-center shrink-0 px-1",
@@ -129204,6 +129252,8 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129204
129252
  const setReplyDraft = useRelayStore((s) => s.setReplyDraft);
129205
129253
  const exportThread = useRelayStore((s) => s.exportThread);
129206
129254
  const doAgentAction = useRelayStore((s) => s.doAgentAction);
129255
+ const workspaceAction = useRelayStore((s) => s.workspaceAction);
129256
+ const openWorkspaceFocus = useRelayStore((s) => s.openWorkspaceFocus);
129207
129257
  const forkFromAgent = useRelayStore((s) => s.forkFromAgent);
129208
129258
  const chatHistoryImports = useRelayStore((s) => s.chatHistoryImports);
129209
129259
  const peerHistory = useRelayStore((s) => s.threadHistory[s.selectedInboxThread] ?? NO_THREAD_HISTORY);
@@ -129552,7 +129602,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129552
129602
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
129553
129603
  className: "flex h-full min-w-0",
129554
129604
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
129555
- className: "flex h-full min-w-0 flex-1 flex-col",
129605
+ className: "@container/chat flex h-full min-w-0 flex-1 flex-col",
129556
129606
  children: [
129557
129607
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
129558
129608
  className: "flex items-center justify-between px-3 md:px-4 py-2.5 md:py-3 border-b border-border shrink-0",
@@ -129596,11 +129646,16 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129596
129646
  agent && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AgentRuntimeChips, {
129597
129647
  agent,
129598
129648
  compact: true,
129599
- className: "hidden md:flex shrink-0"
129649
+ className: "hidden @4xl/chat:flex shrink-0"
129600
129650
  }),
129601
129651
  agent?.context && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ContextRing, { utilization: agent.context.utilization }),
129652
+ agent?.branchState && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BranchStateBadge, {
129653
+ state: agent.branchState,
129654
+ className: "shrink-0",
129655
+ onClick: () => void openWorkspaceFocus(agent.branchWorkspaceId)
129656
+ }),
129602
129657
  thread?.attention.unread ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Badge$1, {
129603
- className: "bg-red-500/20 text-red-400 text-xs shrink-0 hidden sm:flex",
129658
+ className: "bg-red-500/20 text-red-400 text-xs shrink-0 hidden @2xl/chat:flex",
129604
129659
  children: [thread.attention.unread, " unread"]
129605
129660
  }) : null
129606
129661
  ]
@@ -129623,7 +129678,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129623
129678
  canOpenTerminal && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129624
129679
  variant: "ghost",
129625
129680
  size: "icon-sm",
129626
- className: "hidden sm:inline-flex",
129681
+ className: "hidden @4xl/chat:inline-flex",
129627
129682
  disabled: terminalOpening,
129628
129683
  title: "Terminal",
129629
129684
  onClick: () => void handleOpenTerminal(),
@@ -129637,10 +129692,18 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129637
129692
  onClick: interruptAgent,
129638
129693
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CircleStop, { className: "w-3.5 h-3.5" })
129639
129694
  }),
129695
+ agent.branchState === "changes" && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129696
+ variant: "ghost",
129697
+ size: "icon-sm",
129698
+ className: "hidden @4xl/chat:inline-flex text-amber-400 hover:text-amber-300",
129699
+ title: "Mark workspace ready — hand off to the auto-merge",
129700
+ onClick: () => openConfirm("Mark Workspace Ready", `Mark ${displayName(agent)}'s branch ready to land? The relay auto-merge will rebase and land it.`, () => workspaceAction(agent.branchWorkspaceId, "ready")),
129701
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Flag, { className: "w-3.5 h-3.5" })
129702
+ }),
129640
129703
  canCompact && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129641
129704
  variant: "ghost",
129642
129705
  size: "icon-sm",
129643
- className: "hidden sm:inline-flex",
129706
+ className: "hidden @4xl/chat:inline-flex",
129644
129707
  title: "Compact",
129645
129708
  onClick: () => openConfirm("Compact Agent", `Compact context for ${displayName(agent)}?`, () => doAgentAction(agent, "compact")),
129646
129709
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Minimize2, { className: "w-3.5 h-3.5" })
@@ -129648,7 +129711,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129648
129711
  canClearContext && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129649
129712
  variant: "ghost",
129650
129713
  size: "icon-sm",
129651
- className: "hidden sm:inline-flex",
129714
+ className: "hidden @4xl/chat:inline-flex",
129652
129715
  title: "Clear context",
129653
129716
  onClick: () => openConfirm("Clear Context", `Clear context for ${displayName(agent)}?`, () => doAgentAction(agent, "clearContext")),
129654
129717
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Eraser, { className: "w-3.5 h-3.5" })
@@ -129656,7 +129719,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129656
129719
  canShutdown && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129657
129720
  variant: "ghost",
129658
129721
  size: "icon-sm",
129659
- className: "hidden sm:inline-flex",
129722
+ className: "hidden @4xl/chat:inline-flex",
129660
129723
  title: "Shutdown",
129661
129724
  onClick: () => openConfirm("Shutdown Agent", `Shutdown ${displayName(agent)}?`, () => doAgentAction(agent, "shutdown")),
129662
129725
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Power, { className: "w-3.5 h-3.5" })
@@ -129664,7 +129727,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129664
129727
  agent && typeof agent.meta?.cwd === "string" && agent.meta.cwd && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129665
129728
  variant: "ghost",
129666
129729
  size: "icon-sm",
129667
- className: "hidden sm:inline-flex",
129730
+ className: "hidden @4xl/chat:inline-flex",
129668
129731
  title: `Open ${agent.meta.cwd} in Files`,
129669
129732
  onClick: () => void openFilesForAgent(agent),
129670
129733
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FolderOpen, { className: "w-3.5 h-3.5" })
@@ -129672,7 +129735,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129672
129735
  agent && thread && thread.messages.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129673
129736
  variant: "ghost",
129674
129737
  size: "icon-sm",
129675
- className: "hidden sm:inline-flex",
129738
+ className: "hidden @4xl/chat:inline-flex",
129676
129739
  title: "Fork — spawn new agent with this chat history",
129677
129740
  onClick: () => forkFromAgent(agent.id, thread.messages),
129678
129741
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GitFork, { className: "w-3.5 h-3.5" })
@@ -129680,7 +129743,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129680
129743
  thread && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129681
129744
  variant: "ghost",
129682
129745
  size: "icon-sm",
129683
- className: "hidden sm:inline-flex",
129746
+ className: "hidden @4xl/chat:inline-flex",
129684
129747
  title: "Mark unread",
129685
129748
  onClick: () => markInboxThreadUnread(selectedInboxThread),
129686
129749
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MailX, { className: "w-3.5 h-3.5" })
@@ -129688,7 +129751,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129688
129751
  thread && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129689
129752
  variant: "ghost",
129690
129753
  size: "icon-sm",
129691
- className: "hidden sm:inline-flex",
129754
+ className: "hidden @4xl/chat:inline-flex",
129692
129755
  title: "Mark read",
129693
129756
  onClick: () => markInboxThreadRead(thread.peer, thread.messages),
129694
129757
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MailOpen, { className: "w-3.5 h-3.5" })
@@ -129696,7 +129759,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129696
129759
  exportableThread && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129697
129760
  variant: "ghost",
129698
129761
  size: "icon-sm",
129699
- className: "hidden sm:inline-flex",
129762
+ className: "hidden @4xl/chat:inline-flex",
129700
129763
  title: "Export thread",
129701
129764
  onClick: () => exportThread({
129702
129765
  ...exportableThread,
@@ -129709,7 +129772,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129709
129772
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129710
129773
  variant: "ghost",
129711
129774
  size: "icon-sm",
129712
- className: "sm:hidden",
129775
+ className: "@4xl/chat:hidden",
129713
129776
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Ellipsis, { className: "w-3.5 h-3.5" })
129714
129777
  })
129715
129778
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownMenuContent, {
@@ -129724,6 +129787,10 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129724
129787
  onClick: interruptAgent,
129725
129788
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CircleStop, { className: "w-3.5 h-3.5" }), "Interrupt"]
129726
129789
  }),
129790
+ agent.branchState === "changes" && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownMenuItem, {
129791
+ onClick: () => openConfirm("Mark Workspace Ready", `Mark ${displayName(agent)}'s branch ready to land? The relay auto-merge will rebase and land it.`, () => workspaceAction(agent.branchWorkspaceId, "ready")),
129792
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Flag, { className: "w-3.5 h-3.5" }), "Mark Ready"]
129793
+ }),
129727
129794
  canCompact && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownMenuItem, {
129728
129795
  onClick: () => openConfirm("Compact Agent", `Compact context for ${displayName(agent)}?`, () => doAgentAction(agent, "compact")),
129729
129796
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Minimize2, { className: "w-3.5 h-3.5" }), "Compact"]
@@ -130101,6 +130168,8 @@ function AgentCard({ agent }) {
130101
130168
  const doAgentAction = useRelayStore((s) => s.doAgentAction);
130102
130169
  const doDeleteAgent = useRelayStore((s) => s.doDeleteAgent);
130103
130170
  const openFilesForAgent = useRelayStore((s) => s.openFilesForAgent);
130171
+ const workspaceAction = useRelayStore((s) => s.workspaceAction);
130172
+ const openWorkspaceFocus = useRelayStore((s) => s.openWorkspaceFocus);
130104
130173
  const { terminalOpen, terminalTarget, terminalOpening, openTerminal: handleOpenTerminal, closeTerminal: handleCloseTerminal } = useAgentTerminal(agent.id);
130105
130174
  const pairsMap = usePairsByAgentId();
130106
130175
  const rawAttention = useAgentAttention(agent);
@@ -130152,6 +130221,14 @@ function AgentCard({ agent }) {
130152
130221
  children: shortPath$1(agent.meta.cwd)
130153
130222
  })
130154
130223
  ]
130224
+ }),
130225
+ agent.branchState && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BranchStateBadge, {
130226
+ state: agent.branchState,
130227
+ className: "mt-0.5 shrink-0",
130228
+ onClick: (e) => {
130229
+ e.stopPropagation();
130230
+ openWorkspaceFocus(agent.branchWorkspaceId);
130231
+ }
130155
130232
  })
130156
130233
  ]
130157
130234
  }),
@@ -130236,6 +130313,14 @@ function AgentCard({ agent }) {
130236
130313
  onClick: () => void handleOpenTerminal(),
130237
130314
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Terminal, { className: "w-3 h-3" })
130238
130315
  }),
130316
+ agent.branchState === "changes" && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
130317
+ size: "icon",
130318
+ variant: "ghost",
130319
+ className: "h-7 w-7 text-amber-400 hover:text-amber-300",
130320
+ title: "Mark workspace ready — hand off to the auto-merge",
130321
+ onClick: () => openConfirm("Mark Workspace Ready", `Mark ${displayName(agent)}'s branch ready to land? The relay auto-merge will rebase and land it.`, () => workspaceAction(agent.branchWorkspaceId, "ready")),
130322
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Flag, { className: "w-3 h-3" })
130323
+ }),
130239
130324
  canCompact && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
130240
130325
  size: "icon",
130241
130326
  variant: "ghost",
@@ -130572,10 +130657,6 @@ function LogViewer({ orchestratorId, session, lines = 200 }) {
130572
130657
  }
130573
130658
  //#endregion
130574
130659
  //#region src/components/views/managed-agents.tsx
130575
- function fmtTime(value) {
130576
- if (!value) return "-";
130577
- return new Date(value).toLocaleString();
130578
- }
130579
130660
  function terminalSessionFor(item, agent) {
130580
130661
  const agentSession = agent?.meta?.tmuxSession;
130581
130662
  if (typeof agentSession === "string" && agentSession.trim()) return agentSession;
@@ -131857,11 +131938,20 @@ function WorkspaceRow({ workspace }) {
131857
131938
  const now = useNow();
131858
131939
  const [expanded, setExpanded] = (0, import_react.useState)(false);
131859
131940
  const agentsById = useRelayStore((s) => s.agentsById);
131941
+ const focused = useRelayStore((s) => s.workspaceFocusId === workspace.id);
131860
131942
  const owner = workspace.ownerAgentId ? agentsById[workspace.ownerAgentId] : void 0;
131861
131943
  const steward = workspace.stewardAgentId ? agentsById[workspace.stewardAgentId] : void 0;
131862
131944
  const path = workspace.worktreePath || workspace.sourceCwd || workspace.repoRoot;
131945
+ const rowRef = (0, import_react.useRef)(null);
131946
+ (0, import_react.useEffect)(() => {
131947
+ if (focused) rowRef.current?.scrollIntoView({
131948
+ behavior: "smooth",
131949
+ block: "center"
131950
+ });
131951
+ }, [focused]);
131863
131952
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
131864
- className: "border-t border-border",
131953
+ ref: rowRef,
131954
+ className: `border-t border-border ${focused ? "bg-primary/5 ring-1 ring-inset ring-primary/40" : ""}`,
131865
131955
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
131866
131956
  className: "grid gap-3 px-3 py-3 text-sm lg:grid-cols-[minmax(180px,1.2fr)_minmax(220px,1.5fr)_minmax(160px,1fr)_minmax(170px,auto)]",
131867
131957
  children: [
@@ -132006,6 +132096,14 @@ function RepoGroup({ repoRoot, workspaces }) {
132006
132096
  function WorkspacesView() {
132007
132097
  const [filter, setFilter] = (0, import_react.useState)("active");
132008
132098
  const workspaces = useRelayStore((s) => s.workspaces);
132099
+ const set = useRelayStore((s) => s.set);
132100
+ const workspaceFocusId = useRelayStore((s) => s.workspaceFocusId);
132101
+ (0, import_react.useEffect)(() => {
132102
+ if (workspaceFocusId) setFilter("all");
132103
+ }, [workspaceFocusId]);
132104
+ (0, import_react.useEffect)(() => () => {
132105
+ set({ workspaceFocusId: null });
132106
+ }, [set]);
132009
132107
  const visibleWorkspaces = (0, import_react.useMemo)(() => filterWorkspaces(workspaces, filter), [workspaces, filter]);
132010
132108
  const grouped = (0, import_react.useMemo)(() => groupWorkspaces(visibleWorkspaces), [visibleWorkspaces]);
132011
132109
  const liveCount = workspaces.filter((item) => LIVE_STATUSES.has(item.status)).length;
@@ -133615,7 +133713,7 @@ function MemoryView() {
133615
133713
  }),
133616
133714
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
133617
133715
  className: "text-xs text-muted-foreground",
133618
- title: fmtTime$1(selectedMemory.updatedAt),
133716
+ title: fmtTime(selectedMemory.updatedAt),
133619
133717
  children: ["updated ", timeAgo(now, selectedMemory.updatedAt)]
133620
133718
  }),
133621
133719
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
@@ -133806,7 +133904,7 @@ function MemoryRow({ memory, selected, now, onSelect }) {
133806
133904
  }),
133807
133905
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
133808
133906
  className: "text-[10px] text-muted-foreground",
133809
- title: fmtTime$1(memory.updatedAt),
133907
+ title: fmtTime(memory.updatedAt),
133810
133908
  children: timeAgo(now, memory.updatedAt)
133811
133909
  }),
133812
133910
  memory.accessCount > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -133956,7 +134054,7 @@ function ActivityView() {
133956
134054
  }),
133957
134055
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
133958
134056
  className: "text-xs text-muted-foreground ml-auto shrink-0",
133959
- title: fmtTime$1(item.ts ?? item.createdAt),
134057
+ title: fmtTime(item.ts ?? item.createdAt),
133960
134058
  children: timeAgo(now, item.ts ?? item.createdAt)
133961
134059
  })
133962
134060
  ]
@@ -134119,7 +134217,7 @@ function PairCard({ pair, now, agentName, onAccept, onReject, onMessage, onHangu
134119
134217
  children: pair.id.slice(-10)
134120
134218
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
134121
134219
  className: "text-xs text-muted-foreground",
134122
- title: fmtTime$1(pair.updatedAt),
134220
+ title: fmtTime(pair.updatedAt),
134123
134221
  children: timeAgo(now, pair.updatedAt)
134124
134222
  })]
134125
134223
  })]
@@ -134412,7 +134510,7 @@ function MessageRow({ msg, now, agentLabel, agentsById, onReply, onClaim, onRetr
134412
134510
  }),
134413
134511
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
134414
134512
  className: "ml-auto shrink-0",
134415
- title: fmtTime$1(msg.createdAt),
134513
+ title: fmtTime(msg.createdAt),
134416
134514
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
134417
134515
  className: "opacity-40 mr-1",
134418
134516
  children: ["#", msg.id]
@@ -134822,7 +134920,7 @@ function TaskCard({ task, now, agentName, onClaim, onEvents, onStatusChange }) {
134822
134920
  })]
134823
134921
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
134824
134922
  className: "text-xs text-muted-foreground shrink-0",
134825
- title: fmtTime$1(task.createdAt),
134923
+ title: fmtTime(task.createdAt),
134826
134924
  children: ["#", task.id]
134827
134925
  })]
134828
134926
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
@@ -134866,7 +134964,7 @@ function TaskCard({ task, now, agentName, onClaim, onEvents, onStatusChange }) {
134866
134964
  }),
134867
134965
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
134868
134966
  className: "flex items-center gap-1 mt-0.5",
134869
- title: fmtTime$1(task.updatedAt),
134967
+ title: fmtTime(task.updatedAt),
134870
134968
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
134871
134969
  className: "text-muted-foreground/60",
134872
134970
  children: "updated"
@@ -135449,7 +135547,7 @@ function AutomationCard({ automation, runs, now, selected, onEdit, onRun, onTogg
135449
135547
  className: "flex flex-wrap items-center gap-2 text-xs text-muted-foreground",
135450
135548
  children: [
135451
135549
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
135452
- title: automation.nextRunAt ? fmtTime$1(automation.nextRunAt) : void 0,
135550
+ title: automation.nextRunAt ? fmtTime(automation.nextRunAt) : void 0,
135453
135551
  children: ["next ", automation.nextRunAt ? timeAgo(now, automation.nextRunAt) : "disabled"]
135454
135552
  }),
135455
135553
  latestRun && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Badge$1, {
@@ -135961,7 +136059,7 @@ function RunHistory({ runs, automations, agents, orchestrators, now, logRunId, o
135961
136059
  }),
135962
136060
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
135963
136061
  className: "text-muted-foreground",
135964
- title: fmtTime$1(run.updatedAt),
136062
+ title: fmtTime(run.updatedAt),
135965
136063
  children: timeAgo(now, run.updatedAt)
135966
136064
  }),
135967
136065
  logTarget ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
@@ -155739,14 +155837,14 @@ function MaintenanceCard({ job, now, onRun }) {
155739
155837
  className: "text-muted-foreground",
155740
155838
  children: "Last run"
155741
155839
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dd", { children: job.lastRunAt ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
155742
- title: fmtTime$1(job.lastRunAt),
155840
+ title: fmtTime(job.lastRunAt),
155743
155841
  children: timeAgo(now, job.lastRunAt)
155744
155842
  }) : "never" })] }),
155745
155843
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("dt", {
155746
155844
  className: "text-muted-foreground",
155747
155845
  children: "Next run"
155748
155846
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dd", { children: job.nextRunAt ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
155749
- title: fmtTime$1(job.nextRunAt),
155847
+ title: fmtTime(job.nextRunAt),
155750
155848
  children: nextRunText(now, job.nextRunAt)
155751
155849
  }) : "-" })] }),
155752
155850
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("dt", {
@@ -155817,14 +155915,14 @@ function MaintenanceRow({ job, now, onRun }) {
155817
155915
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
155818
155916
  className: "px-3 py-3 text-xs text-muted-foreground",
155819
155917
  children: job.lastRunAt ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
155820
- title: fmtTime$1(job.lastRunAt),
155918
+ title: fmtTime(job.lastRunAt),
155821
155919
  children: timeAgo(now, job.lastRunAt)
155822
155920
  }) : "never"
155823
155921
  }),
155824
155922
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
155825
155923
  className: "px-3 py-3 text-xs text-muted-foreground",
155826
155924
  children: job.nextRunAt ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
155827
- title: fmtTime$1(job.nextRunAt),
155925
+ title: fmtTime(job.nextRunAt),
155828
155926
  children: nextRunText(now, job.nextRunAt)
155829
155927
  }) : "-"
155830
155928
  }),
@@ -156961,7 +157059,7 @@ function AgentDiagnostics({ agent, orchestrators }) {
156961
157059
  }),
156962
157060
  managedAgent.startedAt && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(KV, {
156963
157061
  label: "Started",
156964
- value: fmtTime$1(managedAgent.startedAt)
157062
+ value: fmtTime(managedAgent.startedAt)
156965
157063
  }),
156966
157064
  managedOrch && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(KV, {
156967
157065
  label: "Orchestrator",
@@ -157636,7 +157734,7 @@ function AgentDetailDrawer() {
157636
157734
  children: "Created"
157637
157735
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
157638
157736
  className: "truncate",
157639
- children: fmtTime$1(agent.createdAt)
157737
+ children: fmtTime(agent.createdAt)
157640
157738
  })]
157641
157739
  }),
157642
157740
  agent.machine && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -158098,7 +158196,7 @@ function ActiveMemoryRow({ memory, now, onOpen }) {
158098
158196
  ]
158099
158197
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
158100
158198
  className: "shrink-0 text-right text-[10px] text-muted-foreground",
158101
- title: fmtTime$1(memory.lastAccessedAt ?? memory.updatedAt),
158199
+ title: fmtTime(memory.lastAccessedAt ?? memory.updatedAt),
158102
158200
  children: timeAgo(now, memory.lastAccessedAt ?? memory.updatedAt)
158103
158201
  })]
158104
158202
  })
@@ -158224,7 +158322,7 @@ function TimelineRow({ entry, now }) {
158224
158322
  children: entry.title
158225
158323
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
158226
158324
  className: "ml-auto shrink-0 text-[10px] text-muted-foreground",
158227
- title: fmtTime$1(entry.ts),
158325
+ title: fmtTime(entry.ts),
158228
158326
  children: timeAgo(now, entry.ts)
158229
158327
  })]
158230
158328
  }),
@@ -158897,7 +158995,7 @@ function TaskEventsModal() {
158897
158995
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
158898
158996
  className: "text-xs text-muted-foreground mt-1",
158899
158997
  children: [
158900
- fmtTime$1(evt.createdAt),
158998
+ fmtTime(evt.createdAt),
158901
158999
  " · ",
158902
159000
  evt.source
158903
159001
  ]
@@ -161107,6 +161205,10 @@ if ("serviceWorker" in navigator) {
161107
161205
  container: card-header / inline-size;
161108
161206
  }
161109
161207
 
161208
+ .\@container\/chat {
161209
+ container: chat / inline-size;
161210
+ }
161211
+
161110
161212
  .pointer-events-none {
161111
161213
  pointer-events: none;
161112
161214
  }
@@ -164684,6 +164786,10 @@ if ("serviceWorker" in navigator) {
164684
164786
  animation-play-state: paused;
164685
164787
  }
164686
164788
 
164789
+ .ring-inset {
164790
+ --tw-ring-inset: inset;
164791
+ }
164792
+
164687
164793
  .running {
164688
164794
  animation-play-state: running;
164689
164795
  }
@@ -165011,6 +165117,10 @@ if ("serviceWorker" in navigator) {
165011
165117
  }
165012
165118
  }
165013
165119
 
165120
+ .hover\:text-amber-300:hover {
165121
+ color: var(--color-amber-300);
165122
+ }
165123
+
165014
165124
  .hover\:text-blue-300:hover {
165015
165125
  color: var(--color-blue-300);
165016
165126
  }
@@ -165066,13 +165176,36 @@ if ("serviceWorker" in navigator) {
165066
165176
  .hover\:opacity-100:hover {
165067
165177
  opacity: 1;
165068
165178
  }
165179
+
165180
+ .hover\:brightness-125:hover {
165181
+ --tw-brightness: brightness(125%);
165182
+ filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, );
165183
+ }
165069
165184
  }
165070
165185
 
165071
165186
  .focus\:bg-accent:focus {
165072
165187
  background-color: var(--accent);
165073
165188
  }
165074
165189
 
165075
- .focus\:text-accent-foreground:focus, :is(.focus\:\*\*\:text-accent-foreground:focus *), :is(.not-data-\[variant\=destructive\]\:focus\:\*\*\:text-accent-foreground:not([data-variant="destructive"]):focus *) {
165190
+ .focus\:text-accent-foreground:focus {
165191
+ color: var(--accent-foreground);
165192
+ }
165193
+
165194
+ .focus\:ring-1:focus {
165195
+ --tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
165196
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
165197
+ }
165198
+
165199
+ .focus\:ring-current:focus {
165200
+ --tw-ring-color: currentcolor;
165201
+ }
165202
+
165203
+ .focus\:outline-none:focus {
165204
+ --tw-outline-style: none;
165205
+ outline-style: none;
165206
+ }
165207
+
165208
+ :is(.focus\:\*\*\:text-accent-foreground:focus *), :is(.not-data-\[variant\=destructive\]\:focus\:\*\*\:text-accent-foreground:not([data-variant="destructive"]):focus *) {
165076
165209
  color: var(--accent-foreground);
165077
165210
  }
165078
165211
 
@@ -165557,10 +165690,6 @@ if ("serviceWorker" in navigator) {
165557
165690
  display: block;
165558
165691
  }
165559
165692
 
165560
- .sm\:flex {
165561
- display: flex;
165562
- }
165563
-
165564
165693
  .sm\:hidden {
165565
165694
  display: none;
165566
165695
  }
@@ -165868,6 +165997,26 @@ if ("serviceWorker" in navigator) {
165868
165997
  }
165869
165998
  }
165870
165999
 
166000
+ @container chat (min-width: 42rem) {
166001
+ .\@2xl\/chat\:flex {
166002
+ display: flex;
166003
+ }
166004
+ }
166005
+
166006
+ @container chat (min-width: 56rem) {
166007
+ .\@4xl\/chat\:flex {
166008
+ display: flex;
166009
+ }
166010
+
166011
+ .\@4xl\/chat\:hidden {
166012
+ display: none;
166013
+ }
166014
+
166015
+ .\@4xl\/chat\:inline-flex {
166016
+ display: inline-flex;
166017
+ }
166018
+ }
166019
+
165871
166020
  .dark\:border-input:is(.dark *) {
165872
166021
  border-color: var(--input);
165873
166022
  }
@@ -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/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" };
@@ -15,13 +15,23 @@
15
15
  // anti-panic signal.
16
16
 
17
17
  import { TERMINAL_WORKSPACE_STATUS_VALUES } from "agent-relay-sdk";
18
- import type { WorkspaceRecord, WorkspaceStatus } from "./types";
18
+ import type { BranchState, WorkspaceRecord, WorkspaceStatus } from "./types";
19
+ import { workspaceActiveClaim } from "./workspace-claim";
19
20
 
20
21
  // Statuses where the worktree's lifecycle is over — landed or torn down. Single
21
22
  // home; imported by maintenance (stale reap), routes (orphan scan), and the MCP
22
23
  // initialize primer (don't brief an agent on a dead workspace). Was duplicated.
23
24
  export const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(TERMINAL_WORKSPACE_STATUS_VALUES);
24
25
 
26
+ // The "this is the worktree the agent is actively branch-working in" predicate:
27
+ // isolated mode and not yet landed/torn down. SINGLE HOME — the MCP owner-workspace
28
+ // resolver, the db owner lookup, and the #236 badge enrichment all mean the same
29
+ // thing; they drifted into private copies before. listWorkspaces is ORDER BY
30
+ // updated_at DESC, so the first match per owner is the most recent live worktree.
31
+ export function isLiveIsolatedWorkspace(ws: Pick<WorkspaceRecord, "mode" | "status">): boolean {
32
+ return ws.mode === "isolated" && !TERMINAL_WORKSPACE_STATUSES.has(ws.status);
33
+ }
34
+
25
35
  // The "handed off, waiting to land" statuses — an agent has finished and the
26
36
  // auto-merge-back is responsible for getting the branch onto base. SINGLE HOME:
27
37
  // the auto-land consumer (maintenance `autoMergeCleanFastForwards`) and the
@@ -209,6 +219,64 @@ export function describeWorkspacePhase(
209
219
  }
210
220
  }
211
221
 
222
+ function metaNumber(meta: Record<string, unknown> | undefined, key: string): number | undefined {
223
+ const v = meta?.[key];
224
+ return typeof v === "number" ? v : undefined;
225
+ }
226
+
227
+ // THE branch-state projection for the human (#236) — the sibling of
228
+ // describeWorkspacePhase, which targets the agent. Maps a workspace to one compact
229
+ // state for the agent-card/chat badge, so the recurring "does this agent have
230
+ // unlanded work, and where is it in the merge-back" question is answered at a
231
+ // glance. SINGLE HOME (server-side); the dashboard never recomputes it.
232
+ //
233
+ // idle/changes need the worktree's ahead/dirty counts, which the relay isn't in the
234
+ // git path to know live — the ~2 min conflict scan stashes them in metadata
235
+ // (`gitAhead`/`gitDirtyCount`). Until the first scan they're absent, so an active
236
+ // worktree shows the optimistic `changes` (the high-value "mark ready" affordance);
237
+ // the scan then settles it to `idle` when genuinely empty (#236 v1 option a).
238
+ //
239
+ // Returns undefined for non-branch / torn-down workspaces (no badge).
240
+ export function deriveBranchState(
241
+ workspace: Pick<WorkspaceRecord, "status" | "metadata" | "readyAt"> | null | undefined,
242
+ opts: { now?: number; stallMs?: number } = {},
243
+ ): BranchState | undefined {
244
+ if (!workspace) return undefined;
245
+ const now = opts.now ?? Date.now();
246
+ switch (workspace.status) {
247
+ case "active": {
248
+ const meta = workspace.metadata as Record<string, unknown> | undefined;
249
+ const ahead = metaNumber(meta, "gitAhead");
250
+ const dirty = metaNumber(meta, "gitDirtyCount");
251
+ if (ahead === undefined && dirty === undefined) return "changes";
252
+ return (ahead ?? 0) > 0 || (dirty ?? 0) > 0 ? "changes" : "idle";
253
+ }
254
+ case "ready":
255
+ case "review_requested":
256
+ // Handed off, waiting for the auto-merge. A steward holding the claim means a
257
+ // human-out-of-loop reconciliation is underway → 🟠, otherwise the robot has it → 🔵.
258
+ return workspaceActiveClaim(workspace, now)?.purpose === "steward" ? "steward" : "ready";
259
+ case "merge_planned":
260
+ // Merge dispatched / under reconciliation — robot-or-steward, either way not the human's move.
261
+ return "steward";
262
+ case "conflict": {
263
+ // Held by a steward → reconciling (🟠). Past the stall window with nobody
264
+ // holding it → the steward path isn't progressing → escalate to the human (🔴).
265
+ if (workspaceActiveClaim(workspace, now)?.purpose === "steward") return "steward";
266
+ const stallMs = opts.stallMs ?? LAND_PENDING_STALL_MS;
267
+ const since = typeof workspace.readyAt === "number" ? now - workspace.readyAt : undefined;
268
+ if (since !== undefined && since > stallMs) return "blocked";
269
+ return "steward";
270
+ }
271
+ case "merged":
272
+ case "abandoned":
273
+ case "cleanup_requested":
274
+ case "cleaned":
275
+ // Terminal/torn down: the owner lookup filters these out, so the badge clears.
276
+ return undefined;
277
+ }
278
+ }
279
+
212
280
  // Plain-language contract printed/returned right when an agent marks a workspace
213
281
  // ready, so the whole "what happens next" is stated up front instead of being
214
282
  // decoded from status enums over the following minutes (#235).