agent-relay-server 0.28.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.
@@ -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,16 +12,16 @@
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
- export interface ResolveOptions {
17
+ interface ResolveOptions {
18
18
  /** Exclude this agent id from matches (e.g. the requester, when pairing). */
19
19
  excludeId?: string;
20
20
  /** Clock injection for tests. */
21
21
  now?: number;
22
22
  }
23
23
 
24
- export type ResolveResult =
24
+ type ResolveResult =
25
25
  | { status: "resolved"; agent: AgentCard }
26
26
  | { status: "ambiguous"; candidates: AgentCard[] }
27
27
  | { status: "not_found"; offlineMatches: AgentCard[] };
@@ -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 {
@@ -136,7 +153,7 @@ export interface DeliveryReceipt {
136
153
  reason?: string;
137
154
  }
138
155
 
139
- export type SendPlan =
156
+ type SendPlan =
140
157
  // resolved/fan-out/passthrough carry the (possibly rewritten) canonical `to`
141
158
  | { kind: "direct" | "fanout" | "passthrough"; to: string; receipt: DeliveryReceipt }
142
159
  | { kind: "not_found"; message: string }
@@ -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
@@ -44,7 +44,7 @@ import {
44
44
  } from "./upgrade";
45
45
  import { formatMemoryBrokerSmokeResult, runMemoryBrokerSmoke } from "./memory-broker-smoke";
46
46
  import { MAX_BODY_BYTES, VERSION } from "./config";
47
- import { DEFAULT_CONTEXT_PROBE_STATE_DIR, runContextProbe } from "agent-relay-sdk/context-probe";
47
+ import { runContextProbe } from "agent-relay-sdk/context-probe";
48
48
  import { shellQuote } from "agent-relay-sdk/shell-utils";
49
49
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
50
50
  import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
@@ -686,7 +686,7 @@ async function handleContextProbeCommand(args: string[]): Promise<void> {
686
686
  let wrapCommand: string | undefined;
687
687
  let wrapRequested = false;
688
688
  let agentId: string | undefined;
689
- let stateDir = DEFAULT_CONTEXT_PROBE_STATE_DIR;
689
+ let stateDir: string | undefined;
690
690
  let standalone = false;
691
691
 
692
692
  for (let i = 0; i < inputArgs.length; i++) {
@@ -718,7 +718,7 @@ async function handleContextProbeCommand(args: string[]): Promise<void> {
718
718
  "context-probe",
719
719
  ...(wrapRequested ? ["--wrap", ...(wrapCommand ? [shellQuote(wrapCommand)] : [])] : ["--standalone"]),
720
720
  ...(agentId ? ["--agent-id", shellQuote(agentId)] : []),
721
- ...(stateDir !== DEFAULT_CONTEXT_PROBE_STATE_DIR ? ["--state-dir", shellQuote(stateDir)] : []),
721
+ ...(stateDir ? ["--state-dir", shellQuote(stateDir)] : []),
722
722
  ].join(" ");
723
723
  console.log(command);
724
724
  return;
package/src/contracts.ts CHANGED
@@ -21,7 +21,7 @@ export interface RuntimePackageMetadata {
21
21
  version: string;
22
22
  }
23
23
 
24
- export interface ContractRequirement {
24
+ interface ContractRequirement {
25
25
  min: number;
26
26
  maxExclusive: number;
27
27
  }
package/src/db.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
- import { isRecord, stringValue } from "agent-relay-sdk";
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,
@@ -4331,7 +4332,7 @@ function messageRequiresReply(message: Message): boolean {
4331
4332
  // Server-owned notification flag (#283) wins over every kind/sender heuristic below: an
4332
4333
  // explicit replyExpected:false is a fire-and-forget message that must never become an obligation.
4333
4334
  if (message.replyExpected === false) return false;
4334
- if (message.kind === "system" || message.kind === "control" || message.kind === "session") return false;
4335
+ if (isMechanicalMessageKind(message.kind)) return false;
4335
4336
  if (message.from === "user") return true;
4336
4337
  if (message.kind === "task" || message.kind === "channel.event") return true;
4337
4338
  return Boolean(message.payload?.source);
@@ -4793,7 +4794,7 @@ export type AnalyticsPeriod = keyof typeof ANALYTICS_PERIODS;
4793
4794
  // Message → category, the ONE place this mapping lives (server-side SQL). Order is
4794
4795
  // significant: a claimable/system/pair/channel message is classified as such even
4795
4796
  // when it is also a reply; only an otherwise-plain reply counts as "Replies".
4796
- export const ANALYTICS_CATEGORIES = ["Messages", "Replies", "Work items", "System", "Pair", "Channel"] as const;
4797
+ const ANALYTICS_CATEGORIES = ["Messages", "Replies", "Work items", "System", "Pair", "Channel"] as const;
4797
4798
  export type AnalyticsCategory = (typeof ANALYTICS_CATEGORIES)[number];
4798
4799
  const ANALYTICS_CATEGORY_SQL = `
4799
4800
  CASE
@@ -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
@@ -5453,7 +5490,7 @@ export function setWorkspaceBranch(id: string, branch: string, baseSha?: string)
5453
5490
  // of these is a candidate steward; the repo is worth coordinating.
5454
5491
  const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
5455
5492
 
5456
- export interface RepoStewardRecord {
5493
+ interface RepoStewardRecord {
5457
5494
  repoRoot: string;
5458
5495
  stewardAgentId?: string;
5459
5496
  lastStewardAgentId?: string;
@@ -5570,7 +5607,7 @@ function electWorkspaceStewardsForAgent(agentId: string): void {
5570
5607
 
5571
5608
  // --- Per-repo merge serialization lease (issue #157) -----------------------
5572
5609
 
5573
- export interface MergeLeaseRecord {
5610
+ interface MergeLeaseRecord {
5574
5611
  repoRoot: string;
5575
5612
  workspaceId: string;
5576
5613
  commandId?: string;
package/src/http-body.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Concatenate body chunks into a single contiguous Uint8Array. */
2
- export function concatBytes(chunks: Uint8Array[]): Uint8Array {
2
+ function concatBytes(chunks: Uint8Array[]): Uint8Array {
3
3
  const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
4
4
  const output = new Uint8Array(total);
5
5
  let offset = 0;
@@ -46,7 +46,7 @@ function rowToObservation(row: ObservationRow): InsightObservation {
46
46
  };
47
47
  }
48
48
 
49
- export interface RecordObservationInput {
49
+ interface RecordObservationInput {
50
50
  sessionId: string;
51
51
  agentId?: string;
52
52
  project?: string;
@@ -99,7 +99,7 @@ export function getObservation(id: number): InsightObservation | null {
99
99
  return row ? rowToObservation(row) : null;
100
100
  }
101
101
 
102
- export interface ListObservationsQuery {
102
+ interface ListObservationsQuery {
103
103
  project?: string;
104
104
  signal?: string;
105
105
  sessionId?: string;
@@ -8,6 +8,7 @@ import {
8
8
  upsertManagedAgentState,
9
9
  } from "./config-store";
10
10
  import { emitRelayEvent } from "./events";
11
+ import { emitMessageDeliveryUpdated } from "./sse";
11
12
  import { emitCommandEvent } from "./command-events";
12
13
  import { buildManagedSpawnParams } from "./managed-policy";
13
14
  import { generateSpawnRequestId } from "./spawn-command";
@@ -95,6 +96,9 @@ export class LifecycleManager {
95
96
  subject: `policy:${meta.policyName}`,
96
97
  data: { policyName: meta.policyName, agentId, messageIds: available.map((message) => message.id), count: available.length },
97
98
  });
99
+ // queued → pending changed delivery_status; refresh the dashboard delivery
100
+ // badge now rather than letting it sit stale until the next poll (#265).
101
+ for (const message of available) emitMessageDeliveryUpdated(message);
98
102
  }
99
103
  }
100
104
 
@@ -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
@@ -20,7 +20,7 @@ export function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): Worksp
20
20
  return policy.binding?.type === "channel" ? "shared" : "inherit";
21
21
  }
22
22
 
23
- export interface ManagedSpawnContext {
23
+ interface ManagedSpawnContext {
24
24
  createdBy: string;
25
25
  requestedAt?: number;
26
26
  }
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/notify.ts CHANGED
@@ -2,7 +2,7 @@ import { sendMessage } from "./db";
2
2
  import { emitNewMessage } from "./sse";
3
3
  import type { Message, MessageKind } from "./types";
4
4
 
5
- export interface SystemNotifyOptions {
5
+ interface SystemNotifyOptions {
6
6
  subject?: string;
7
7
  body: string;
8
8
  payload?: Record<string, unknown>;
@@ -1,6 +1,6 @@
1
1
  import { listOrchestrators } from "./db";
2
2
 
3
- export interface ManagedAgentMatch {
3
+ interface ManagedAgentMatch {
4
4
  agentId?: string;
5
5
  sessionName?: string;
6
6
  tmuxSession?: string;
package/src/routes.ts CHANGED
@@ -160,10 +160,11 @@ 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";
166
- import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
167
+ import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER, isMechanicalMessageKind, isReservedAgentId } from "agent-relay-sdk";
167
168
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
168
169
  import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
169
170
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
@@ -1282,7 +1283,13 @@ const postAgent: Handler = async (req) => {
1282
1283
  const available = resolveQueuedPolicyMessages(policyName, agent.id);
1283
1284
  if (available.length) {
1284
1285
  emitMessageAvailable(policyName, agent.id, available);
1285
- for (const message of available) emitNewMessage(message);
1286
+ for (const message of available) {
1287
+ emitNewMessage(message);
1288
+ // queued → pending flips delivery_status; the dashboard dedups message.new
1289
+ // by id (the message already shows as "queued"), so without an explicit
1290
+ // delivery_updated the badge stays stale until the next poll (#265).
1291
+ emitMessageDeliveryUpdated(message);
1292
+ }
1286
1293
  }
1287
1294
  }
1288
1295
  }
@@ -1321,7 +1328,7 @@ const getAgents: Handler = (req) => {
1321
1328
  const tag = url.searchParams.get("tag") ?? undefined;
1322
1329
  const machine = url.searchParams.get("machine") ?? undefined;
1323
1330
  const status = url.searchParams.get("status") ?? undefined;
1324
- return json(listAgents({ tag, machine, status }));
1331
+ return json(attachBranchStates(listAgents({ tag, machine, status })));
1325
1332
  };
1326
1333
 
1327
1334
  const findAgents: Handler = (req) => {
@@ -1355,7 +1362,7 @@ const postRouteAdvice: Handler = async (req) => {
1355
1362
 
1356
1363
  const getAgentById: Handler = (_req, params) => {
1357
1364
  const agent = getAgent(params.id!);
1358
- return agent ? json(agent) : error("agent not found", 404);
1365
+ return agent ? json(attachBranchState(agent)) : error("agent not found", 404);
1359
1366
  };
1360
1367
 
1361
1368
  const getAgentReplyObligations: Handler = (_req, params) => {
@@ -4489,8 +4496,12 @@ const patchCommand: Handler = async (req, params) => {
4489
4496
  // Repoint the row so the next merge targets the live branch, not the deleted one.
4490
4497
  const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
4491
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 });
4492
4503
  if (newBranch) {
4493
- setWorkspaceBranch(workspaceId, newBranch, mergedSha);
4504
+ setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
4494
4505
  }
4495
4506
  // #239 — push the author a "your branch landed" notice (no polling). Only on a
4496
4507
  // real land; a no-op resolution (#230) merged nothing, so it earns no notice.
@@ -4500,6 +4511,7 @@ const patchCommand: Handler = async (req, params) => {
4500
4511
  mergedSha,
4501
4512
  subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
4502
4513
  newBranch,
4514
+ pushed: typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
4503
4515
  });
4504
4516
  }
4505
4517
  }
@@ -5181,9 +5193,20 @@ const postMessage: Handler = async (req) => {
5181
5193
  }
5182
5194
  applyReplyRouting(input);
5183
5195
  if (!input.to) return error("to is required (or provide replyTo to auto-route)");
5196
+ // Mechanical lifecycle/observability posts (system/control/session) addressed to a
5197
+ // reserved sink ("user"/"system") are the relay's own lane, not agent-directed
5198
+ // messaging. A managed token's recipient constraints (targets/policies/agents) gate
5199
+ // which *agents* it may message — they must NOT gate a session-mirror capture to the
5200
+ // reserved sink, or constrained tokens (telegram policy, codex steward) 403 → the
5201
+ // runner's outbox retries 12× and poisons the record → the dashboard silently loses
5202
+ // the turn (#284, same outbox-poison failure mode as #184). The message:send scope
5203
+ // and any channel constraint still apply; we only drop the target/agentId predicate.
5204
+ const reservedSinkPost = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
5184
5205
  const denied = authorizeRoute(req, {
5185
5206
  scope: "message:send",
5186
- resource: { target: input.to, channel: input.channel, agentId: input.from },
5207
+ resource: reservedSinkPost
5208
+ ? { channel: input.channel }
5209
+ : { target: input.to, channel: input.channel, agentId: input.from },
5187
5210
  });
5188
5211
  if (denied) return denied;
5189
5212
  // Resolve the target through the shared planner — the SAME matcher the MCP send tool
@@ -5198,8 +5221,7 @@ const postMessage: Handler = async (req) => {
5198
5221
  // "session" = observed assistant turn (Phase 1 live-session lane). It is captured
5199
5222
  // from the provider transcript and stored for the dashboard chat; it must persist
5200
5223
  // regardless of target liveness and never be re-delivered into a session.
5201
- const bypassKinds = ["system", "control", "session"];
5202
- if (!bypassKinds.includes(input.kind ?? "")) {
5224
+ if (!isMechanicalMessageKind(input.kind)) {
5203
5225
  const plan = planSend(input.to, listAgents());
5204
5226
  if (plan.kind === "ambiguous") return error(plan.message, 409);
5205
5227
  if (plan.kind !== "not_found") input.to = plan.to;
@@ -5249,7 +5271,7 @@ const postMessage: Handler = async (req) => {
5249
5271
  };
5250
5272
 
5251
5273
  function automaticMemoryTarget(message: { to: string; resolvedToAgent?: string; kind: string }): string | null {
5252
- if (message.kind === "system" || message.kind === "control" || message.kind === "session") return null;
5274
+ if (isMechanicalMessageKind(message.kind)) return null;
5253
5275
  const target = message.resolvedToAgent ?? message.to;
5254
5276
  if (!isDirectTarget(target)) return null;
5255
5277
  const agent = getAgent(target);