agent-relay-server 0.23.0 → 0.25.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.23.0",
3
+ "version": "0.25.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.13"
36
+ "agent-relay-sdk": "0.2.14"
37
37
  },
38
38
  "scripts": {
39
39
  "prepack": "bun run build:dashboard:bundle >&2",
package/public/index.html CHANGED
@@ -10168,6 +10168,8 @@ function parseSseFrame(frame) {
10168
10168
  }
10169
10169
  //#endregion
10170
10170
  //#region src/lib/api.ts
10171
+ var API_TIMEOUT_MS = 2e4;
10172
+ var SSE_STALE_MS = 35e3;
10171
10173
  var authToken = "";
10172
10174
  function setAuthToken(token) {
10173
10175
  authToken = token;
@@ -10211,11 +10213,11 @@ function openTerminalWebSocket(orchestratorId, session) {
10211
10213
  return new WebSocket(url);
10212
10214
  }
10213
10215
  function openRelayEventStream(token, handlers) {
10214
- const abort = new AbortController();
10215
10216
  const eventUrl = new URL("api/events", baseUrl()).toString();
10216
10217
  let closed = false;
10217
10218
  let retryMs = 5e3;
10218
10219
  let reconnectTimer = null;
10220
+ let activeAbort = null;
10219
10221
  const scheduleReconnect = () => {
10220
10222
  if (closed) return;
10221
10223
  reconnectTimer = setTimeout(connect, retryMs);
@@ -10226,6 +10228,11 @@ function openRelayEventStream(token, handlers) {
10226
10228
  if (data.length > 0) handlers.message(event, data.join("\n"));
10227
10229
  };
10228
10230
  const connect = async () => {
10231
+ if (closed) return;
10232
+ const ac = new AbortController();
10233
+ activeAbort = ac;
10234
+ let lastFrameAt = Date.now();
10235
+ let staleTimer = null;
10229
10236
  try {
10230
10237
  const headers = { Accept: "text/event-stream" };
10231
10238
  const effectiveToken = token || getAuthToken();
@@ -10233,10 +10240,14 @@ function openRelayEventStream(token, handlers) {
10233
10240
  const response = await fetch(eventUrl, {
10234
10241
  headers,
10235
10242
  cache: "no-store",
10236
- signal: abort.signal
10243
+ signal: ac.signal
10237
10244
  });
10238
10245
  if (!response.ok || !response.body) throw new Error(`SSE failed: ${response.status}`);
10239
10246
  handlers.connected?.();
10247
+ lastFrameAt = Date.now();
10248
+ staleTimer = setInterval(() => {
10249
+ if (Date.now() - lastFrameAt > SSE_STALE_MS) ac.abort();
10250
+ }, 5e3);
10240
10251
  const reader = response.body.getReader();
10241
10252
  const decoder = new TextDecoder();
10242
10253
  let buffer = "";
@@ -10246,6 +10257,7 @@ function openRelayEventStream(token, handlers) {
10246
10257
  buffer += decoder.decode();
10247
10258
  break;
10248
10259
  }
10260
+ lastFrameAt = Date.now();
10249
10261
  buffer += decoder.decode(value, { stream: true });
10250
10262
  let frameEnd = buffer.indexOf("\n\n");
10251
10263
  while (frameEnd >= 0) {
@@ -10256,6 +10268,7 @@ function openRelayEventStream(token, handlers) {
10256
10268
  }
10257
10269
  }
10258
10270
  } catch {} finally {
10271
+ if (staleTimer) clearInterval(staleTimer);
10259
10272
  if (closed) return;
10260
10273
  handlers.disconnected?.();
10261
10274
  scheduleReconnect();
@@ -10265,13 +10278,14 @@ function openRelayEventStream(token, handlers) {
10265
10278
  return { close() {
10266
10279
  closed = true;
10267
10280
  if (reconnectTimer) clearTimeout(reconnectTimer);
10268
- abort.abort();
10281
+ activeAbort?.abort();
10269
10282
  } };
10270
10283
  }
10271
10284
  async function api(method, path, body) {
10272
10285
  const opts = {
10273
10286
  method,
10274
- headers: {}
10287
+ headers: {},
10288
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
10275
10289
  };
10276
10290
  const headers = opts.headers;
10277
10291
  if (authToken) headers["X-Agent-Relay-Token"] = authToken;
@@ -11391,6 +11405,14 @@ function agentPresence(now, agent, attention, pair) {
11391
11405
  reconnecting: false,
11392
11406
  badges
11393
11407
  };
11408
+ if (lifecycleAction.startsWith("finalizing-")) return {
11409
+ label: `wrapping up (${lifecycleAction.slice(11)})`,
11410
+ tone: "warning",
11411
+ icon: "Hourglass",
11412
+ stale,
11413
+ reconnecting,
11414
+ badges
11415
+ };
11394
11416
  if (lifecycleAction === "shutting-down") return {
11395
11417
  label: "shutting down",
11396
11418
  tone: "warning",
@@ -11764,6 +11786,7 @@ function toneToColor(tone) {
11764
11786
  function statusDotColor(agent, now) {
11765
11787
  if (!agent) return "bg-zinc-500 opacity-50";
11766
11788
  if (agent.status === "offline") return "bg-zinc-500 opacity-50";
11789
+ if (typeof agent.meta?.lifecycleAction === "string" && agent.meta.lifecycleAction.startsWith("finalizing-")) return "bg-amber-500 animate-pulse";
11767
11790
  if (agent.meta?.lifecycleAction === "shutting-down" || agent.meta?.lifecycleAction === "restarting") return "bg-yellow-500 animate-pulse";
11768
11791
  if (agent.meta?.lifecycleAction === "killing") return "bg-red-500 animate-pulse";
11769
11792
  if (providerBlockedState(agent)) return "bg-red-500 animate-pulse";
@@ -12982,10 +13005,13 @@ var useRelayStore = create$1()(persist((set, get) => ({
12982
13005
  connectSSE() {
12983
13006
  get().disconnectSSE();
12984
13007
  set({ _es: openRelayEventStream(get().authToken, {
12985
- connected: () => set(get().connectionError ? {
12986
- connected: true,
12987
- connectionError: false
12988
- } : { connected: true }),
13008
+ connected: () => {
13009
+ set(get().connectionError ? {
13010
+ connected: true,
13011
+ connectionError: false
13012
+ } : { connected: true });
13013
+ get().refreshLiveData();
13014
+ },
12989
13015
  disconnected: () => set({ connected: false }),
12990
13016
  message: (event, data) => {
12991
13017
  if (event === "connected") return;
@@ -77597,6 +77623,49 @@ function OverviewView() {
77597
77623
  });
77598
77624
  }
77599
77625
  //#endregion
77626
+ //#region src/hooks/use-agent-terminal.ts
77627
+ /**
77628
+ * Open/close lifecycle for an agent's terminal session. Shared by the agent
77629
+ * card and the chat header — both POST a terminal-session, track the target,
77630
+ * and DELETE guest sessions on close. Pass null/undefined to no-op (e.g. when
77631
+ * no agent is selected).
77632
+ */
77633
+ function useAgentTerminal(agentId) {
77634
+ const showError = useRelayStore((s) => s.showError);
77635
+ const [terminalOpen, setTerminalOpen] = (0, import_react.useState)(false);
77636
+ const [terminalTarget, setTerminalTarget] = (0, import_react.useState)(null);
77637
+ const [terminalOpening, setTerminalOpening] = (0, import_react.useState)(false);
77638
+ async function openTerminal() {
77639
+ if (!agentId || terminalOpening) return;
77640
+ setTerminalOpening(true);
77641
+ try {
77642
+ setTerminalTarget(await api("POST", "/agents/" + encodeURIComponent(agentId) + "/terminal-session"));
77643
+ setTerminalOpen(true);
77644
+ } catch (e) {
77645
+ showError("Terminal Failed", e.message);
77646
+ } finally {
77647
+ setTerminalOpening(false);
77648
+ }
77649
+ }
77650
+ function closeTerminal(open) {
77651
+ if (open) {
77652
+ setTerminalOpen(true);
77653
+ return;
77654
+ }
77655
+ setTerminalOpen(false);
77656
+ const target = terminalTarget;
77657
+ setTerminalTarget(null);
77658
+ if (agentId && target?.mode === "guest") api("DELETE", "/agents/" + encodeURIComponent(agentId) + "/terminal-session/" + encodeURIComponent(target.session)).catch(() => {});
77659
+ }
77660
+ return {
77661
+ terminalOpen,
77662
+ terminalTarget,
77663
+ terminalOpening,
77664
+ openTerminal,
77665
+ closeTerminal
77666
+ };
77667
+ }
77668
+ //#endregion
77600
77669
  //#region node_modules/comma-separated-tokens/index.js
77601
77670
  /**
77602
77671
  * Serialize an array of strings or numbers to comma-separated tokens.
@@ -127279,9 +127348,6 @@ function ChatPanel({ threads, onBack, showBackButton }) {
127279
127348
  const pendingAttachmentsRef = (0, import_react.useRef)([]);
127280
127349
  const [pendingAttachments, setPendingAttachments] = (0, import_react.useState)([]);
127281
127350
  const [dragActive, setDragActive] = (0, import_react.useState)(false);
127282
- const [terminalOpen, setTerminalOpen] = (0, import_react.useState)(false);
127283
- const [terminalTarget, setTerminalTarget] = (0, import_react.useState)(null);
127284
- const [terminalOpening, setTerminalOpening] = (0, import_react.useState)(false);
127285
127351
  const [filePreview, setFilePreview] = (0, import_react.useState)(null);
127286
127352
  const [floatingPreview, setFloatingPreview] = (0, import_react.useState)(null);
127287
127353
  const [filePreviewError, setFilePreviewError] = (0, import_react.useState)("");
@@ -127302,6 +127368,8 @@ function ChatPanel({ threads, onBack, showBackButton }) {
127302
127368
  selectedInboxThread
127303
127369
  ]);
127304
127370
  const agent = agentsById[selectedInboxThread] || null;
127371
+ const agentFinalizing = typeof agent?.meta?.lifecycleAction === "string" && agent.meta.lifecycleAction.startsWith("finalizing-");
127372
+ const { terminalOpen, terminalTarget, terminalOpening, openTerminal: handleOpenTerminal, closeTerminal: handleCloseTerminal } = useAgentTerminal(agent?.id);
127305
127373
  const agentSpawnRequestId = typeof agent?.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : "";
127306
127374
  const importedHistory = (0, import_react.useMemo)(() => {
127307
127375
  return chatHistoryImports.filter((history) => history.targetAgentId === selectedInboxThread || Boolean(agentSpawnRequestId && history.targetSpawnRequestId === agentSpawnRequestId));
@@ -127382,18 +127450,6 @@ function ChatPanel({ threads, onBack, showBackButton }) {
127382
127450
  agent?.createdAt,
127383
127451
  importedHistory
127384
127452
  ]);
127385
- async function handleOpenTerminal() {
127386
- if (!agent || terminalOpening) return;
127387
- setTerminalOpening(true);
127388
- try {
127389
- setTerminalTarget(await api("POST", "/agents/" + encodeURIComponent(agent.id) + "/terminal-session"));
127390
- setTerminalOpen(true);
127391
- } catch (e) {
127392
- showError("Terminal Failed", e.message);
127393
- } finally {
127394
- setTerminalOpening(false);
127395
- }
127396
- }
127397
127453
  const clearFilePreviewCloseTimer = (0, import_react.useCallback)(() => {
127398
127454
  if (filePreviewCloseTimer.current === null) return;
127399
127455
  window.clearTimeout(filePreviewCloseTimer.current);
@@ -127475,16 +127531,6 @@ function ChatPanel({ threads, onBack, showBackButton }) {
127475
127531
  setFilePreview(null);
127476
127532
  setFilePreviewError("");
127477
127533
  }
127478
- function handleCloseTerminal(open) {
127479
- if (open) {
127480
- setTerminalOpen(true);
127481
- return;
127482
- }
127483
- setTerminalOpen(false);
127484
- const target = terminalTarget;
127485
- setTerminalTarget(null);
127486
- if (agent && target?.mode === "guest") api("DELETE", "/agents/" + encodeURIComponent(agent.id) + "/terminal-session/" + encodeURIComponent(target.session)).catch(() => {});
127487
- }
127488
127534
  async function uploadFiles(files) {
127489
127535
  const list = Array.from(files).filter((file) => file.size > 0);
127490
127536
  if (!list.length) return;
@@ -127530,7 +127576,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
127530
127576
  setPendingAttachments([]);
127531
127577
  }
127532
127578
  function handleSend() {
127533
- if (!draft.trim() && readyAttachments.length === 0 || !selectedInboxThread || chatSending || hasPendingUploads) return;
127579
+ if (!draft.trim() && readyAttachments.length === 0 || !selectedInboxThread || chatSending || hasPendingUploads || agentFinalizing) return;
127534
127580
  const attachments = readyAttachments.map((item) => ({
127535
127581
  artifactId: item.artifact.id,
127536
127582
  kind: item.artifact.kind,
@@ -127988,7 +128034,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
127988
128034
  }),
127989
128035
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
127990
128036
  size: "icon",
127991
- disabled: !draft.trim() && readyAttachments.length === 0 || hasPendingUploads || chatSending,
128037
+ disabled: !draft.trim() && readyAttachments.length === 0 || hasPendingUploads || chatSending || agentFinalizing,
127992
128038
  onClick: handleSend,
127993
128039
  className: "shrink-0 mb-0.5 rounded-xl h-[42px] w-[42px]",
127994
128040
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Send, { className: "w-4 h-4" })
@@ -128038,7 +128084,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
128038
128084
  })]
128039
128085
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
128040
128086
  size: "icon",
128041
- disabled: !draft.trim() && readyAttachments.length === 0 || hasPendingUploads || chatSending,
128087
+ disabled: !draft.trim() && readyAttachments.length === 0 || hasPendingUploads || chatSending || agentFinalizing,
128042
128088
  onClick: handleSend,
128043
128089
  className: "rounded-xl h-9 w-9",
128044
128090
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Send, { className: "w-4 h-4" })
@@ -128047,7 +128093,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
128047
128093
  }),
128048
128094
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
128049
128095
  className: "text-xs text-muted-foreground mt-1.5 hidden md:block",
128050
- children: "Enter to send · Shift+Enter for newline"
128096
+ children: agentFinalizing ? `${agent ? displayName(agent) : "Agent"} is wrapping up (${(agent?.meta?.lifecycleAction).slice(11)}) — messaging paused` : "Enter to send · Shift+Enter for newline"
128051
128097
  })
128052
128098
  ]
128053
128099
  })
@@ -128176,13 +128222,10 @@ function AgentCard({ agent }) {
128176
128222
  const openPairInvite = useRelayStore((s) => s.openPairInvite);
128177
128223
  const openRename = useRelayStore((s) => s.openRename);
128178
128224
  const openConfirm = useRelayStore((s) => s.openConfirm);
128179
- const showError = useRelayStore((s) => s.showError);
128180
128225
  const doAgentAction = useRelayStore((s) => s.doAgentAction);
128181
128226
  const doDeleteAgent = useRelayStore((s) => s.doDeleteAgent);
128182
128227
  const openFilesForAgent = useRelayStore((s) => s.openFilesForAgent);
128183
- const [terminalOpen, setTerminalOpen] = (0, import_react.useState)(false);
128184
- const [terminalTarget, setTerminalTarget] = (0, import_react.useState)(null);
128185
- const [terminalOpening, setTerminalOpening] = (0, import_react.useState)(false);
128228
+ const { terminalOpen, terminalTarget, terminalOpening, openTerminal: handleOpenTerminal, closeTerminal: handleCloseTerminal } = useAgentTerminal(agent.id);
128186
128229
  const pairsMap = usePairsByAgentId();
128187
128230
  const rawAttention = useAgentAttention(agent);
128188
128231
  const pair = pairsMap[agent.id] || null;
@@ -128196,28 +128239,6 @@ function AgentCard({ agent }) {
128196
128239
  const canOpenTerminal = Boolean(agent.providerCapabilities?.terminal?.live?.read || agent.providerCapabilities?.terminal?.attach?.create);
128197
128240
  const canWriteTerminal = Boolean(agent.providerCapabilities?.terminal?.live?.write || agent.providerCapabilities?.model?.provider === "claude");
128198
128241
  const canForget = agentCanBeForgotten(agent);
128199
- async function handleOpenTerminal() {
128200
- if (terminalOpening) return;
128201
- setTerminalOpening(true);
128202
- try {
128203
- setTerminalTarget(await api("POST", "/agents/" + encodeURIComponent(agent.id) + "/terminal-session"));
128204
- setTerminalOpen(true);
128205
- } catch (e) {
128206
- showError("Terminal Failed", e.message);
128207
- } finally {
128208
- setTerminalOpening(false);
128209
- }
128210
- }
128211
- function handleCloseTerminal(open) {
128212
- if (open) {
128213
- setTerminalOpen(true);
128214
- return;
128215
- }
128216
- setTerminalOpen(false);
128217
- const target = terminalTarget;
128218
- setTerminalTarget(null);
128219
- if (target?.mode === "guest") api("DELETE", "/agents/" + encodeURIComponent(agent.id) + "/terminal-session/" + encodeURIComponent(target.session)).catch(() => {});
128220
- }
128221
128242
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Card, {
128222
128243
  className: "group hover:border-zinc-600 transition-colors cursor-pointer",
128223
128244
  onClick: () => openAgentDetail(agent),
@@ -1,5 +1,6 @@
1
1
  import type { AgentProfile, Message } from "agent-relay-sdk";
2
2
  import { isRecord } from "agent-relay-sdk";
3
+ import type { SessionEvent } from "./session-insights";
3
4
 
4
5
  export type SemanticStatus = "idle" | "busy" | "offline" | "error";
5
6
  type ProviderWorkKind = "provider-turn" | "subagent";
@@ -133,6 +134,15 @@ export interface ProviderAdapter {
133
134
  shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
134
135
  compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
135
136
  clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
137
+ // Normalize the session so far into the provider-agnostic SessionEvent stream the
138
+ // Insights context-ratio signal (#183/#184) reduces. Called by the runner's
139
+ // pre-session-destroy seam before any compact/clear/restart/shutdown. The runner owns
140
+ // the per-segment cursor (it slices events since the last capture), so this returns the
141
+ // full ordered event list for the current process lifetime. `ctx.transcriptPath` is
142
+ // supplied for transcript-backed providers (Claude); event-stream providers (Codex)
143
+ // ignore it and return their accumulated log. Return null when there is nothing to
144
+ // measure. Best-effort: may be omitted by providers without a session view yet.
145
+ collectSessionEvents?(process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null>;
136
146
  // Interrupt the in-flight turn without ending the session (ESC for Claude's
137
147
  // tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
138
148
  // the runner boundary; each adapter does what its provider actually supports.
@@ -0,0 +1,77 @@
1
+ import { emitRelayEvent } from "./events";
2
+ import { getNotificationsConfig } from "./config-store";
3
+ import { notifySystemMessage } from "./notify";
4
+ import type { WorkspaceRecord } from "./types";
5
+
6
+ export interface BranchLandedInput {
7
+ /**
8
+ * The workspace as it was AT land time — `branch` must be the branch that landed,
9
+ * captured before any land-and-continue recycle repoints the row (#206). `ownerAgentId`
10
+ * is the author the "landed" notice is pushed to.
11
+ */
12
+ workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
13
+ /** SHA the base now points at after the land. */
14
+ mergedSha?: string;
15
+ /** Subject line of the landed commit, when the orchestrator reported it. */
16
+ subject?: string;
17
+ /** Fresh branch the worktree was recycled onto (land-and-continue), if any. */
18
+ newBranch?: string;
19
+ }
20
+
21
+ /**
22
+ * #239 — turn an authoritative land completion into a relay-driven push so the author
23
+ * stops polling to learn it merged. Always emits the durable `branch.landed` event (the
24
+ * rest of the bus does the same); only the agent-facing push is gated, since it wakes the
25
+ * recipient. Offline authors get it on next poll via store-ahead (#234).
26
+ *
27
+ * Agents-on-main fan-out (the second #239 recipient class) lands in a follow-up commit.
28
+ */
29
+ export function notifyBranchLanded(input: BranchLandedInput): void {
30
+ const { workspace } = input;
31
+ const base = workspace.baseRef ?? "base";
32
+ const landedBranch = workspace.branch;
33
+ const shortSha = input.mergedSha ? input.mergedSha.slice(0, 12) : undefined;
34
+
35
+ emitRelayEvent({
36
+ type: "branch.landed",
37
+ source: "server",
38
+ subject: workspace.id,
39
+ data: {
40
+ workspaceId: workspace.id,
41
+ repoRoot: workspace.repoRoot,
42
+ branch: landedBranch,
43
+ base,
44
+ sha: input.mergedSha,
45
+ subject: input.subject,
46
+ author: workspace.ownerAgentId,
47
+ newBranch: input.newBranch,
48
+ },
49
+ });
50
+
51
+ const config = getNotificationsConfig();
52
+ if (!config.enabled || !config.branchLanded) return;
53
+
54
+ const author = workspace.ownerAgentId;
55
+ if (!author) return;
56
+
57
+ const branchLabel = landedBranch ? `\`${landedBranch}\`` : "Your branch";
58
+ const shaLabel = shortSha ? ` as \`${shortSha}\`` : "";
59
+ const subjectLabel = input.subject ? ` — "${input.subject}"` : "";
60
+ const continueLabel = input.newBranch
61
+ ? ` You're now on \`${input.newBranch}\` — keep working there.`
62
+ : " Worktree reclaimed.";
63
+
64
+ notifySystemMessage(author, {
65
+ subject: "Your branch landed",
66
+ body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${continueLabel}`,
67
+ payload: {
68
+ kind: "branch.landed",
69
+ workspaceId: workspace.id,
70
+ repoRoot: workspace.repoRoot,
71
+ branch: landedBranch,
72
+ base,
73
+ sha: input.mergedSha,
74
+ newBranch: input.newBranch,
75
+ },
76
+ });
77
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  InsightsConfig,
11
11
  ManagedAgentState,
12
12
  ManagedAgentStatus,
13
+ NotificationsConfig,
13
14
  SpawnApprovalMode,
14
15
  SpawnPolicy,
15
16
  SpawnProvider,
@@ -24,6 +25,8 @@ const STEWARD_NAMESPACE = "steward";
24
25
  const STEWARD_KEY = "default";
25
26
  const INSIGHTS_NAMESPACE = "insights";
26
27
  const INSIGHTS_KEY = "default";
28
+ const NOTIFICATIONS_NAMESPACE = "notifications";
29
+ const NOTIFICATIONS_KEY = "default";
27
30
  const WORKSPACE_NAMESPACE = "workspace";
28
31
  const WORKSPACE_KEY = "default";
29
32
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
@@ -460,6 +463,26 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
460
463
  };
461
464
  }
462
465
 
466
+ // Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
467
+ // operator can flip the master switch or individual events off via the generic
468
+ // config route. Push messages wake recipients, so they must be suppressible.
469
+ const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
470
+ enabled: true,
471
+ branchLanded: true,
472
+ };
473
+
474
+ function validateNotificationsConfig(value: unknown): NotificationsConfig {
475
+ if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
476
+ return {
477
+ enabled: value.enabled === undefined
478
+ ? NOTIFICATIONS_CONFIG_DEFAULTS.enabled
479
+ : cleanBoolean(value.enabled, "enabled"),
480
+ branchLanded: value.branchLanded === undefined
481
+ ? NOTIFICATIONS_CONFIG_DEFAULTS.branchLanded
482
+ : cleanBoolean(value.branchLanded, "branchLanded"),
483
+ };
484
+ }
485
+
463
486
  // Global workspace provisioning config for isolated worktrees (#159 follow-up).
464
487
  // Defaults seed the two untracked paths an isolated agent almost always needs:
465
488
  // the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
@@ -487,6 +510,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
487
510
  if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
488
511
  if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
489
512
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
513
+ if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
490
514
  if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
491
515
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
492
516
  return value;
@@ -620,6 +644,13 @@ export function getInsightsConfigEntry(): ConfigEntry<InsightsConfig> {
620
644
  };
621
645
  }
622
646
 
647
+ /** Lifecycle-notification config (#239), merged over defaults (always usable). */
648
+ export function getNotificationsConfig(): NotificationsConfig {
649
+ const entry = getConfig<Partial<NotificationsConfig>>(NOTIFICATIONS_NAMESPACE, NOTIFICATIONS_KEY);
650
+ if (!entry) return { ...NOTIFICATIONS_CONFIG_DEFAULTS };
651
+ return validateNotificationsConfig({ ...NOTIFICATIONS_CONFIG_DEFAULTS, ...entry.value });
652
+ }
653
+
623
654
  export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEntry<InsightsConfig> {
624
655
  return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
625
656
  }
package/src/connectors.ts CHANGED
@@ -243,10 +243,46 @@ async function refreshConnectorStatus(id: string): Promise<void> {
243
243
  }
244
244
 
245
245
  export function startConnectorStatusPoller(): void {
246
- void refreshAllConnectorStatuses();
246
+ // Relaunch connectors that were running before this process restarted (e.g. a
247
+ // deploy that took down the relay/orchestrator tree) BEFORE the first status
248
+ // poll overwrites their persisted "running" flag with the now-dead reality.
249
+ void reconcileConnectorsOnBoot().finally(() => void refreshAllConnectorStatuses());
247
250
  setInterval(() => void refreshAllConnectorStatuses(), STATUS_POLL_INTERVAL_MS);
248
251
  }
249
252
 
253
+ // On boot, bring back any connector whose last persisted state says it was
254
+ // running. `start` is idempotent — a daemon that survived the restart reports
255
+ // "already running"; one that died with the process tree is relaunched. A
256
+ // connector the operator deliberately stopped has running:false persisted, so
257
+ // it stays down. Best-effort: failures are swallowed; the poll reflects truth.
258
+ export async function reconcileConnectorsOnBoot(): Promise<void> {
259
+ for (const connector of listConnectors()) {
260
+ const wasRunning = connector.state?.running === true;
261
+ const canStart = Boolean(connector.manifest.commands.start?.length);
262
+ if (!wasRunning || !canStart) continue;
263
+ try {
264
+ await runConnectorAction(connector.id, "start");
265
+ } catch { /* boot relaunch is best-effort; status poll will surface failures */ }
266
+ }
267
+ }
268
+
269
+ // Flip a connector to running:false when the relay proves its advertised
270
+ // endpoint is unreachable (e.g. the daemon died but state.json still claims
271
+ // running:true). Stops the dashboard from lying until the 60s status poll runs.
272
+ export function markConnectorUnreachable(id: string, detail: string): void {
273
+ const path = join(connectorDir(id), "state.json");
274
+ const current = readRecordFile(path);
275
+ if (!current || current.running === false) return;
276
+ const next = {
277
+ ...current,
278
+ status: "error",
279
+ detail,
280
+ running: false,
281
+ updatedAt: new Date().toISOString(),
282
+ };
283
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
284
+ }
285
+
250
286
  async function refreshAllConnectorStatuses(): Promise<void> {
251
287
  for (const connector of listConnectors()) {
252
288
  try {
@@ -268,6 +268,7 @@ export class LifecycleManager {
268
268
  graceful,
269
269
  timeoutMs: 10_000,
270
270
  reason,
271
+ orchestratorId: orch.id,
271
272
  requestedBy: "lifecycle-manager",
272
273
  requestedAt: this.now(),
273
274
  },
@@ -27,14 +27,13 @@ import {
27
27
  releaseExpiredMergeLeases,
28
28
  releaseOrphanedTasks,
29
29
  runDbMaintenance,
30
- sendMessage,
31
30
  sweepArtifacts,
32
31
  updateWorkspaceStatus,
33
32
  } from "./db";
34
33
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
34
  import { requestWorkspaceMerge } from "./workspace-merge";
36
35
  import { workspaceActiveClaim } from "./workspace-claim";
37
- import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
36
+ import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
38
37
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
39
38
  import { getStewardConfig } from "./config-store";
40
39
  import { ensureRepoSteward } from "./steward";
@@ -46,11 +45,11 @@ import {
46
45
  emitAgentStatus,
47
46
  emitMessageClaimReleased,
48
47
  emitMessageExpired,
49
- emitNewMessage,
50
48
  emitOrchestratorStatus,
51
49
  emitPoolBindingChanged,
52
50
  emitTaskChanged,
53
51
  } from "./sse";
52
+ import { notifySystemMessage } from "./notify";
54
53
  import { pruneExpiredTokenRecords } from "./token-db";
55
54
  import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
56
55
 
@@ -83,7 +82,10 @@ const STEWARD_WAKE_COOLDOWN_MS = Number(process.env.AGENT_RELAY_STEWARD_WAKE_COO
83
82
  const stewardEscalationMs = () => Number(process.env.AGENT_RELAY_WORKSPACE_STEWARD_ESCALATION_MS) || 60 * 60 * 1000;
84
83
  const stewardFallbackTarget = () => (process.env.AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK || "").trim();
85
84
  // Statuses that need an owner — a stranded one of these is what escalation rescues.
86
- const STRANDABLE_STATUSES = new Set<WorkspaceStatus>(["review_requested", "conflict"]);
85
+ // Derived from the shared ready-to-land set (#242) plus `conflict`, so a stranded
86
+ // `ready` worktree (no online steward) escalates to the fallback target instead of
87
+ // rotting silently — same gap that left the original #242 branch parked.
88
+ const STRANDABLE_STATUSES = new Set<WorkspaceStatus>([...READY_TO_LAND_STATUSES, "conflict"]);
87
89
  // Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
88
90
  // in-flight (cleanup_requested) states are skipped.
89
91
  const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
@@ -394,7 +396,7 @@ const definitions: MaintenanceJobDefinition[] = [
394
396
  {
395
397
  id: "workspace-auto-merge",
396
398
  title: "Workspace auto-merge",
397
- description: "Auto-merge any non-conflicting review_requested worktree into base under the per-repo lease (rebasing when the base moved on); only real or unknown conflicts are left for the steward.",
399
+ description: "Auto-merge any non-conflicting ready/review_requested worktree into base under the per-repo lease (rebasing when the base moved on); only real or unknown conflicts are left for the steward.",
398
400
  intervalMs: WORKSPACE_AUTO_MERGE_INTERVAL_MS,
399
401
  runOnStart: false,
400
402
  timeoutMs: 60 * 1000,
@@ -532,15 +534,11 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
532
534
  const policyName = ensureRepoSteward(ws.repoRoot);
533
535
  if (!policyName) return null;
534
536
  try {
535
- const msg = sendMessage({
536
- from: "system",
537
- to: `policy:${policyName}`,
538
- kind: "system",
537
+ notifySystemMessage(`policy:${policyName}`, {
539
538
  subject: `Steward: ${ws.status} workspace needs attention`,
540
539
  body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
541
540
  payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
542
541
  });
543
- emitNewMessage(msg);
544
542
  getLifecycleManager().onMessageForPolicy(policyName);
545
543
  patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
546
544
  return policyName;
@@ -631,15 +629,11 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
631
629
  if (woke) notifiedStewards.push(woke);
632
630
  } else if (ws.stewardAgentId) {
633
631
  try {
634
- const msg = sendMessage({
635
- from: "system",
636
- to: ws.stewardAgentId,
637
- kind: "system",
632
+ notifySystemMessage(ws.stewardAgentId, {
638
633
  subject: "Workspace merge conflict",
639
634
  body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
640
635
  payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
641
636
  });
642
- emitNewMessage(msg);
643
637
  notifiedStewards.push(ws.stewardAgentId);
644
638
  } catch {
645
639
  // Steward unregistered/stale — the activity event still records it.
@@ -657,9 +651,11 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
657
651
  return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards };
658
652
  }
659
653
 
660
- // Deterministic auto-land (Layer 0, issue #167 / #207). Walk the "ready to land"
661
- // queue (`review_requested` isolated worktrees) and land any whose merge is
662
- // predicted conflict-free, via the shared lease-serialized merge helper — even
654
+ // Deterministic auto-land (Layer 0, issue #167 / #207 / #242). Walk the "ready to
655
+ // land" queue (isolated worktrees in any READY_TO_LAND status — `ready` from
656
+ // `relay_workspace_ready`, or `review_requested` from a failed-merge retry) and
657
+ // land any whose merge is predicted conflict-free, via the shared lease-serialized
658
+ // merge helper — even
663
659
  // when the base moved on (behind>0): mergeRebaseFf rebases onto the current base
664
660
  // before fast-forwarding. Only a predicted conflict or an unknown merge state is
665
661
  // left for the steward; clean parallel work lands with no agent in the loop.
@@ -669,7 +665,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
669
665
  if (!orchestrators.length) return { scanned: 0, skipped: "no online orchestrators" };
670
666
 
671
667
  const candidates = listWorkspaces().filter(
672
- (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && ws.status === "review_requested",
668
+ (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && READY_TO_LAND_STATUSES.has(ws.status),
673
669
  );
674
670
  const stewardEnabled = getStewardConfig().enabled;
675
671
  const merged: string[] = [];
@@ -738,7 +734,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
738
734
  function notifyTarget(target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
739
735
  if (!target) return null;
740
736
  try {
741
- emitNewMessage(sendMessage({ from: "system", to: target, kind: "system", subject, body, payload }));
737
+ notifySystemMessage(target, { subject, body, payload });
742
738
  return target;
743
739
  } catch {
744
740
  return null;
@@ -32,6 +32,7 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
32
32
  const grant = spawnGrantForProfile(policy.profile);
33
33
  return buildSpawnCommand({
34
34
  provider: policy.provider,
35
+ orchestratorId: policy.orchestratorId,
35
36
  cwd: policy.cwd,
36
37
  workspaceMode: effectiveManagedPolicyWorkspaceMode(policy),
37
38
  rig: policy.rig || undefined,