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 +2 -2
- package/public/index.html +84 -63
- package/runner/src/adapter.ts +10 -0
- package/src/branch-landed.ts +77 -0
- package/src/config-store.ts +31 -0
- package/src/connectors.ts +37 -1
- package/src/lifecycle-manager.ts +1 -0
- package/src/maintenance.ts +16 -20
- package/src/managed-policy.ts +1 -0
- package/src/mcp.ts +9 -5
- package/src/memory-broker-base.ts +161 -0
- package/src/memory-command-broker.ts +10 -119
- package/src/memory-http-broker.ts +11 -141
- package/src/notify.ts +31 -0
- package/src/routes.ts +26 -9
- package/src/workspace-phase.ts +51 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "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.
|
|
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:
|
|
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
|
|
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: () =>
|
|
12986
|
-
|
|
12987
|
-
|
|
12988
|
-
|
|
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
|
|
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),
|
package/runner/src/adapter.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config-store.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/lifecycle-manager.ts
CHANGED
package/src/maintenance.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
661
|
-
// queue (
|
|
662
|
-
//
|
|
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
|
|
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
|
-
|
|
737
|
+
notifySystemMessage(target, { subject, body, payload });
|
|
742
738
|
return target;
|
|
743
739
|
} catch {
|
|
744
740
|
return null;
|
package/src/managed-policy.ts
CHANGED
|
@@ -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,
|