agent-relay-server 0.27.2 → 0.29.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/docs/openapi.json +14 -1
- package/package.json +2 -2
- package/public/index.html +146 -9
- package/runner/src/adapter.ts +14 -1
- package/src/agent-ref.ts +3 -3
- package/src/branch-landed.ts +2 -0
- package/src/cli.ts +3 -3
- package/src/contracts.ts +1 -1
- package/src/db.ts +20 -7
- package/src/http-body.ts +1 -1
- package/src/insights-db.ts +2 -2
- package/src/lifecycle-manager.ts +4 -0
- package/src/managed-policy.ts +1 -1
- package/src/mcp.ts +51 -10
- package/src/notify.ts +9 -1
- package/src/orchestrator-lookup.ts +1 -1
- package/src/routes.ts +26 -6
- package/src/runtime-tokens.ts +2 -2
- package/src/security.ts +8 -0
- package/src/spawn-command.ts +2 -2
- package/src/token-db.ts +3 -3
- package/src/upgrade.ts +1 -1
- package/src/workspace-actions.ts +5 -5
- package/src/workspace-claim.ts +2 -2
- package/src/workspace-merge.ts +2 -2
- package/src/workspace-orphans.ts +1 -1
- package/src/workspace-phase.ts +2 -2
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.27.2",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
|
@@ -7399,6 +7399,9 @@
|
|
|
7399
7399
|
"claimable": {
|
|
7400
7400
|
"type": "string"
|
|
7401
7401
|
},
|
|
7402
|
+
"replyExpected": {
|
|
7403
|
+
"type": "string"
|
|
7404
|
+
},
|
|
7402
7405
|
"from": {
|
|
7403
7406
|
"type": "string"
|
|
7404
7407
|
},
|
|
@@ -7460,6 +7463,16 @@
|
|
|
7460
7463
|
}
|
|
7461
7464
|
}
|
|
7462
7465
|
},
|
|
7466
|
+
"409": {
|
|
7467
|
+
"description": "Conflict",
|
|
7468
|
+
"content": {
|
|
7469
|
+
"application/json": {
|
|
7470
|
+
"schema": {
|
|
7471
|
+
"$ref": "#/components/schemas/Error"
|
|
7472
|
+
}
|
|
7473
|
+
}
|
|
7474
|
+
}
|
|
7475
|
+
},
|
|
7463
7476
|
"422": {
|
|
7464
7477
|
"description": "Unprocessable entity",
|
|
7465
7478
|
"content": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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.18"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"prepack": "bun run build:dashboard:bundle >&2",
|
package/public/index.html
CHANGED
|
@@ -10454,12 +10454,28 @@ function spaceDigits(frac) {
|
|
|
10454
10454
|
return frac.split("").join(" ");
|
|
10455
10455
|
}
|
|
10456
10456
|
/**
|
|
10457
|
+
* Spell a token one character at a time so the engine reads each symbol on its
|
|
10458
|
+
* own: digits stay as digits, letters are upper-cased so they're heard as the
|
|
10459
|
+
* letter name ("e" → "E" = "ee") rather than a syllable. Used for commit hashes
|
|
10460
|
+
* ("834543e" → "8 3 4 5 4 3 E") and acronyms ("id" → "I D").
|
|
10461
|
+
*/
|
|
10462
|
+
function spellOut(token) {
|
|
10463
|
+
return token.split("").map((c) => /[0-9]/.test(c) ? c : c.toUpperCase()).join(" ");
|
|
10464
|
+
}
|
|
10465
|
+
/**
|
|
10457
10466
|
* Ordered normalization rules. ORDER IS LOAD-BEARING:
|
|
10458
10467
|
* - URLs/paths first, before anything mangles their slashes/dots.
|
|
10459
10468
|
* - `~` → "approximately" before number rules consume the digits after it.
|
|
10460
10469
|
* - number ranges ("5-7") before unit expansion, so the unit attaches once: "5 to 7 seconds".
|
|
10470
|
+
* - versions ("0.27.0") before decimals, so all dots become "dot" uniformly
|
|
10471
|
+
* instead of the decimal rule eating only the first pair and stranding ".0".
|
|
10461
10472
|
* - decimals before unit expansion and before the sentence splitter sees the dot.
|
|
10462
|
-
* -
|
|
10473
|
+
* - commit-hash spelling before nothing in particular, but after decimals so a
|
|
10474
|
+
* real number is never mistaken for a hash.
|
|
10475
|
+
* - "id" acronym expansion (camelCase suffix before the standalone word).
|
|
10476
|
+
* - unit expansion among the number rules.
|
|
10477
|
+
* - interior-dot LAST, so it only mops up dots the structured rules left behind
|
|
10478
|
+
* (identifiers like "branch.landed", "router.ts") — never a decimal or version.
|
|
10463
10479
|
*/
|
|
10464
10480
|
var SPEECH_RULES = [
|
|
10465
10481
|
{
|
|
@@ -10472,6 +10488,11 @@ var SPEECH_RULES = [
|
|
|
10472
10488
|
pattern: /(?:\/[A-Za-z0-9._-]+){2,}\/?/g,
|
|
10473
10489
|
replace: (m) => " " + m.replace(/[/._-]+/g, " ").trim() + " "
|
|
10474
10490
|
},
|
|
10491
|
+
{
|
|
10492
|
+
name: "slash",
|
|
10493
|
+
pattern: /(?<=[A-Za-z0-9])\/(?=[A-Za-z0-9])/g,
|
|
10494
|
+
replace: " "
|
|
10495
|
+
},
|
|
10475
10496
|
{
|
|
10476
10497
|
name: "func-call",
|
|
10477
10498
|
pattern: /\b([A-Za-z_$][\w$]*)\(\)/g,
|
|
@@ -10487,11 +10508,31 @@ var SPEECH_RULES = [
|
|
|
10487
10508
|
pattern: /(\d)\s*[-–—]\s*(?=\d)/g,
|
|
10488
10509
|
replace: "$1 to "
|
|
10489
10510
|
},
|
|
10511
|
+
{
|
|
10512
|
+
name: "version",
|
|
10513
|
+
pattern: /\b(v?)(\d+(?:\.\d+){2,})\b/gi,
|
|
10514
|
+
replace: (_m, v, nums) => `${v ? "version " : ""}${nums.replace(/\./g, " dot ")}`
|
|
10515
|
+
},
|
|
10490
10516
|
{
|
|
10491
10517
|
name: "decimal",
|
|
10492
10518
|
pattern: /\b(\d+)\.(\d+)\b/g,
|
|
10493
10519
|
replace: (_m, i, f) => `${i} point ${spaceDigits(f)}`
|
|
10494
10520
|
},
|
|
10521
|
+
{
|
|
10522
|
+
name: "hash",
|
|
10523
|
+
pattern: /\b(?=[0-9a-f]{7,40}\b)(?=[0-9a-f]*[a-f])(?=[0-9a-f]*\d)[0-9a-f]{7,40}\b/gi,
|
|
10524
|
+
replace: (m) => spellOut(m)
|
|
10525
|
+
},
|
|
10526
|
+
{
|
|
10527
|
+
name: "id-camel",
|
|
10528
|
+
pattern: /([a-z])(Id|ID)(s)?\b/g,
|
|
10529
|
+
replace: (_m, pre, _acr, plural) => `${pre} I D${plural ? "s" : ""}`
|
|
10530
|
+
},
|
|
10531
|
+
{
|
|
10532
|
+
name: "id-word",
|
|
10533
|
+
pattern: /\b(?:id|Id|ID)(s)?\b/g,
|
|
10534
|
+
replace: (_m, plural) => `I D${plural ? "s" : ""}`
|
|
10535
|
+
},
|
|
10495
10536
|
{
|
|
10496
10537
|
name: "byte-unit",
|
|
10497
10538
|
pattern: /\b(\d+)\s?(kb|mb|gb|tb)\b/gi,
|
|
@@ -10506,6 +10547,16 @@ var SPEECH_RULES = [
|
|
|
10506
10547
|
name: "s-unit",
|
|
10507
10548
|
pattern: /\b(\d{1,3})\s?s\b/g,
|
|
10508
10549
|
replace: (_m, n) => `${n} ${UNIT_WORDS.s}`
|
|
10550
|
+
},
|
|
10551
|
+
{
|
|
10552
|
+
name: "live-adjective",
|
|
10553
|
+
pattern: /\b(is|are|was|were|be|been|being|now|and|stays?|staying|went|go|goes|going|deployed|it's|that's|here's|there's|we're|you're|they're)\s+live\b/gi,
|
|
10554
|
+
replace: (_m, cue) => `${cue} lyve`
|
|
10555
|
+
},
|
|
10556
|
+
{
|
|
10557
|
+
name: "interior-dot",
|
|
10558
|
+
pattern: /\.(?=[A-Za-z0-9])/g,
|
|
10559
|
+
replace: " dot "
|
|
10509
10560
|
}
|
|
10510
10561
|
];
|
|
10511
10562
|
/**
|
|
@@ -12255,6 +12306,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
|
|
|
12255
12306
|
view: "overview",
|
|
12256
12307
|
showOffline: false,
|
|
12257
12308
|
showBuiltIns: false,
|
|
12309
|
+
showReasoning: false,
|
|
12258
12310
|
autoRefresh: true,
|
|
12259
12311
|
voiceTtsEnabled: false,
|
|
12260
12312
|
voiceTtsLang: "en-US",
|
|
@@ -14610,6 +14662,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
|
|
|
14610
14662
|
view: state.view,
|
|
14611
14663
|
showOffline: state.showOffline,
|
|
14612
14664
|
showBuiltIns: state.showBuiltIns,
|
|
14665
|
+
showReasoning: state.showReasoning,
|
|
14613
14666
|
autoRefresh: state.autoRefresh,
|
|
14614
14667
|
voiceTtsEnabled: state.voiceTtsEnabled,
|
|
14615
14668
|
voiceTtsLang: state.voiceTtsLang,
|
|
@@ -128013,15 +128066,54 @@ function BusyIndicator({ blockedLabel, onInterrupt }) {
|
|
|
128013
128066
|
})
|
|
128014
128067
|
});
|
|
128015
128068
|
}
|
|
128016
|
-
|
|
128017
|
-
|
|
128018
|
-
|
|
128019
|
-
|
|
128069
|
+
var HIDE_TOOLS_KEY = "agent-relay:chat-hide-tools";
|
|
128070
|
+
var hideToolsStore = (() => {
|
|
128071
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
128072
|
+
let value = (() => {
|
|
128073
|
+
try {
|
|
128074
|
+
return window.localStorage.getItem(HIDE_TOOLS_KEY) === "on";
|
|
128075
|
+
} catch {
|
|
128076
|
+
return false;
|
|
128077
|
+
}
|
|
128078
|
+
})();
|
|
128079
|
+
const emit = () => listeners.forEach((l) => l());
|
|
128080
|
+
if (typeof window !== "undefined") window.addEventListener("storage", (e) => {
|
|
128081
|
+
if (e.key !== HIDE_TOOLS_KEY) return;
|
|
128082
|
+
value = e.newValue === "on";
|
|
128083
|
+
emit();
|
|
128084
|
+
});
|
|
128085
|
+
return {
|
|
128086
|
+
subscribe(cb) {
|
|
128087
|
+
listeners.add(cb);
|
|
128088
|
+
return () => listeners.delete(cb);
|
|
128089
|
+
},
|
|
128090
|
+
get: () => value,
|
|
128091
|
+
toggle() {
|
|
128092
|
+
value = !value;
|
|
128093
|
+
try {
|
|
128094
|
+
window.localStorage.setItem(HIDE_TOOLS_KEY, value ? "on" : "off");
|
|
128095
|
+
} catch {}
|
|
128096
|
+
emit();
|
|
128097
|
+
}
|
|
128098
|
+
};
|
|
128099
|
+
})();
|
|
128100
|
+
function useHideTools() {
|
|
128101
|
+
return [(0, import_react.useSyncExternalStore)(hideToolsStore.subscribe, hideToolsStore.get, () => false), hideToolsStore.toggle];
|
|
128102
|
+
}
|
|
128103
|
+
function ActivityTrace({ steps, showReasoning }) {
|
|
128104
|
+
const [hideTools, toggleHideTools] = useHideTools();
|
|
128105
|
+
const visible = showReasoning ? steps : steps.filter((s) => s.kind !== "reasoning");
|
|
128106
|
+
if (!visible.length) return null;
|
|
128107
|
+
const toolCount = visible.filter((s) => s.kind === "tool").length;
|
|
128020
128108
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
128021
128109
|
className: "flex justify-start mb-2",
|
|
128022
128110
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
128023
128111
|
className: "max-w-[85%] md:max-w-[75%] min-w-0 space-y-1.5",
|
|
128024
|
-
children: [
|
|
128112
|
+
children: [visible.map((step) => {
|
|
128113
|
+
if (step.kind === "narration") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
128114
|
+
className: "text-sm leading-relaxed whitespace-pre-wrap break-words text-foreground/90 min-w-0",
|
|
128115
|
+
children: step.text
|
|
128116
|
+
}, step.id);
|
|
128025
128117
|
if (step.kind === "reasoning") return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
128026
128118
|
className: "flex items-start gap-1.5 text-xs leading-relaxed text-muted-foreground/80",
|
|
128027
128119
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Brain, { className: "w-3.5 h-3.5 mt-0.5 shrink-0 opacity-70" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
|
|
@@ -128044,7 +128136,7 @@ function ActivityTrace({ steps }) {
|
|
|
128044
128136
|
})]
|
|
128045
128137
|
}, step.id);
|
|
128046
128138
|
}), toolCount > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", {
|
|
128047
|
-
onClick:
|
|
128139
|
+
onClick: toggleHideTools,
|
|
128048
128140
|
className: "flex items-center gap-1 text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors text-left",
|
|
128049
128141
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChevronRight, { className: cn$2("w-3 h-3 shrink-0 transition-transform", !hideTools && "rotate-90") }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: hideTools ? `show ${toolCount} tool step${toolCount === 1 ? "" : "s"}` : "hide tool steps" })]
|
|
128050
128142
|
})]
|
|
@@ -128826,7 +128918,7 @@ var MessageBubble = (0, import_react.memo)(function MessageBubble({ msg, peer, o
|
|
|
128826
128918
|
function sessionActivityStep(msg) {
|
|
128827
128919
|
if (msg.kind !== "session") return null;
|
|
128828
128920
|
const s = msg.payload?.session;
|
|
128829
|
-
if (!s || s.type !== "reasoning" && s.type !== "tool") return null;
|
|
128921
|
+
if (!s || s.type !== "narration" && s.type !== "reasoning" && s.type !== "tool") return null;
|
|
128830
128922
|
return {
|
|
128831
128923
|
id: msg.id,
|
|
128832
128924
|
kind: s.type,
|
|
@@ -128911,10 +129003,17 @@ function buildTimeline(messages, statusEvents, createdAt, importedHistory = [])
|
|
|
128911
129003
|
}
|
|
128912
129004
|
});
|
|
128913
129005
|
}
|
|
129006
|
+
const responseKeys = /* @__PURE__ */ new Set();
|
|
129007
|
+
for (const msg of messages) {
|
|
129008
|
+
if (msg.kind !== "session") continue;
|
|
129009
|
+
const s = msg.payload?.session;
|
|
129010
|
+
if (s?.type === "response" && typeof s.turnId === "string") responseKeys.add(`${s.turnId} ${msg.body.trim()}`);
|
|
129011
|
+
}
|
|
128914
129012
|
for (const msg of messages) {
|
|
128915
129013
|
if (isReactionEvent(msg)) continue;
|
|
128916
129014
|
const step = sessionActivityStep(msg);
|
|
128917
129015
|
if (step) {
|
|
129016
|
+
if (step.kind === "narration" && step.turnId && responseKeys.has(`${step.turnId} ${step.text.trim()}`)) continue;
|
|
128918
129017
|
raw.push({
|
|
128919
129018
|
ts: step.ts,
|
|
128920
129019
|
entry: {
|
|
@@ -129112,6 +129211,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
|
|
|
129112
129211
|
const showError = useRelayStore((s) => s.showError);
|
|
129113
129212
|
const orchestrators = useRelayStore((s) => s.orchestrators);
|
|
129114
129213
|
const fetchOrchestrators = useRelayStore((s) => s.fetchOrchestrators);
|
|
129214
|
+
const showReasoning = useRelayStore((s) => s.showReasoning);
|
|
129115
129215
|
const voiceTtsEnabled = useRelayStore((s) => s.voiceTtsEnabled);
|
|
129116
129216
|
const setVoiceTtsEnabled = useRelayStore((s) => s.setVoiceTtsEnabled);
|
|
129117
129217
|
const voiceInputMode = useRelayStore((s) => s.voiceInputMode);
|
|
@@ -129689,7 +129789,10 @@ function ChatPanel({ threads, onBack, showBackButton }) {
|
|
|
129689
129789
|
onPreviewReferencedPath: previewReferencedFile,
|
|
129690
129790
|
onPreviewReferencedPathEnd: scheduleCloseReferencedFilePreview
|
|
129691
129791
|
}, entry.msg.id);
|
|
129692
|
-
if (entry.type === "activity") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ActivityTrace, {
|
|
129792
|
+
if (entry.type === "activity") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ActivityTrace, {
|
|
129793
|
+
steps: entry.steps,
|
|
129794
|
+
showReasoning
|
|
129795
|
+
}, `act-${entry.steps[0]?.id ?? entry.timestamp}`);
|
|
129693
129796
|
if (entry.type === "import-boundary") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ImportedHistoryMarker, { history: entry.history }, `import-${entry.history.id}`);
|
|
129694
129797
|
if (entry.type === "imported-message") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ImportedMessageBubble, {
|
|
129695
129798
|
entry: entry.entry,
|
|
@@ -156013,6 +156116,7 @@ function SettingsView() {
|
|
|
156013
156116
|
]
|
|
156014
156117
|
})]
|
|
156015
156118
|
}),
|
|
156119
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChatDisplaySettings, {}),
|
|
156016
156120
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(WorkspaceSettings, {}),
|
|
156017
156121
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(StewardSettings, {}),
|
|
156018
156122
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(VoiceSettings, {})
|
|
@@ -156087,6 +156191,29 @@ function WorkspaceSettings() {
|
|
|
156087
156191
|
]
|
|
156088
156192
|
});
|
|
156089
156193
|
}
|
|
156194
|
+
function ChatDisplaySettings() {
|
|
156195
|
+
const showReasoning = useRelayStore((s) => s.showReasoning);
|
|
156196
|
+
const set = useRelayStore((s) => s.set);
|
|
156197
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("section", {
|
|
156198
|
+
className: "space-y-3 rounded-lg border p-4",
|
|
156199
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
156200
|
+
className: "flex items-center justify-between gap-2",
|
|
156201
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
156202
|
+
className: "flex items-center gap-2",
|
|
156203
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Brain, { className: "w-4 h-4" }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
|
|
156204
|
+
className: "text-sm font-semibold",
|
|
156205
|
+
children: "Show reasoning details"
|
|
156206
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
|
|
156207
|
+
className: "text-xs text-muted-foreground",
|
|
156208
|
+
children: "Show the agent's internal thinking inline in chat, alongside its narration and tool steps. Off by default — narration is always shown either way."
|
|
156209
|
+
})] })]
|
|
156210
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Switch, {
|
|
156211
|
+
checked: showReasoning,
|
|
156212
|
+
onCheckedChange: (v) => set({ showReasoning: v })
|
|
156213
|
+
})]
|
|
156214
|
+
})
|
|
156215
|
+
});
|
|
156216
|
+
}
|
|
156090
156217
|
function VoiceSettings() {
|
|
156091
156218
|
const voiceTtsEnabled = useRelayStore((s) => s.voiceTtsEnabled);
|
|
156092
156219
|
const setVoiceTtsEnabled = useRelayStore((s) => s.setVoiceTtsEnabled);
|
|
@@ -164034,6 +164161,16 @@ if ("serviceWorker" in navigator) {
|
|
|
164034
164161
|
}
|
|
164035
164162
|
}
|
|
164036
164163
|
|
|
164164
|
+
.text-foreground\/90 {
|
|
164165
|
+
color: var(--foreground);
|
|
164166
|
+
}
|
|
164167
|
+
|
|
164168
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
164169
|
+
.text-foreground\/90 {
|
|
164170
|
+
color: color-mix(in oklab, var(--foreground) 90%, transparent);
|
|
164171
|
+
}
|
|
164172
|
+
}
|
|
164173
|
+
|
|
164037
164174
|
.text-green-400 {
|
|
164038
164175
|
color: var(--color-green-400);
|
|
164039
164176
|
}
|
package/runner/src/adapter.ts
CHANGED
|
@@ -211,9 +211,19 @@ function isPersistedRelayMessage(message: Message): boolean {
|
|
|
211
211
|
return Number.isSafeInteger(message.id) && message.id > 0;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// #283 — one-line nudge that replaces the reply-scaffold footer for notification-class
|
|
215
|
+
// (replyExpected:false) messages. Deliberately tiny so a bloated context can't drown the
|
|
216
|
+
// no-reply rule established at session start. Shared with the Claude delivery path.
|
|
217
|
+
export const NOTIFICATION_NUDGE = "↪ Notification — no reply needed.";
|
|
218
|
+
|
|
219
|
+
// A notification is a persisted message the server marked replyExpected:false.
|
|
220
|
+
export function isNotificationMessage(message: Message): boolean {
|
|
221
|
+
return isPersistedRelayMessage(message) && message.replyExpected === false;
|
|
222
|
+
}
|
|
223
|
+
|
|
214
224
|
function latestReplyableMessage(messages: Message[]): Message | undefined {
|
|
215
225
|
return messages
|
|
216
|
-
.filter((message) => isPersistedRelayMessage(message) && !isMemoryInjection(message) && !isReactionNotification(message))
|
|
226
|
+
.filter((message) => isPersistedRelayMessage(message) && !isMemoryInjection(message) && !isReactionNotification(message) && message.replyExpected !== false)
|
|
217
227
|
.at(-1);
|
|
218
228
|
}
|
|
219
229
|
|
|
@@ -316,6 +326,9 @@ export function providerMessageText(messages: Message[]): string {
|
|
|
316
326
|
"If you already delivered the useful response through Relay, do not send a separate status-only confirmation.",
|
|
317
327
|
"If multiple messages arrived together, cover them in one reply instead of answering each line separately.",
|
|
318
328
|
].join("\n"));
|
|
329
|
+
} else if (messages.some(isNotificationMessage)) {
|
|
330
|
+
// #283 — pure notification batch: no scaffold, just the one-line no-reply nudge.
|
|
331
|
+
sections.push(NOTIFICATION_NUDGE);
|
|
319
332
|
}
|
|
320
333
|
return sections.join("\n\n");
|
|
321
334
|
}
|
package/src/agent-ref.ts
CHANGED
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
import { STALE_TTL_MS } from "./config";
|
|
15
15
|
import type { AgentCard } from "./types";
|
|
16
16
|
|
|
17
|
-
|
|
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
|
-
|
|
24
|
+
type ResolveResult =
|
|
25
25
|
| { status: "resolved"; agent: AgentCard }
|
|
26
26
|
| { status: "ambiguous"; candidates: AgentCard[] }
|
|
27
27
|
| { status: "not_found"; offlineMatches: AgentCard[] };
|
|
@@ -136,7 +136,7 @@ export interface DeliveryReceipt {
|
|
|
136
136
|
reason?: string;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
type SendPlan =
|
|
140
140
|
// resolved/fan-out/passthrough carry the (possibly rewritten) canonical `to`
|
|
141
141
|
| { kind: "direct" | "fanout" | "passthrough"; to: string; receipt: DeliveryReceipt }
|
|
142
142
|
| { kind: "not_found"; message: string }
|
package/src/branch-landed.ts
CHANGED
|
@@ -78,6 +78,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
78
78
|
subject: "Your branch landed",
|
|
79
79
|
body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${continueLabel}`,
|
|
80
80
|
payload,
|
|
81
|
+
replyExpected: false,
|
|
81
82
|
});
|
|
82
83
|
}
|
|
83
84
|
|
|
@@ -93,6 +94,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
|
|
|
93
94
|
subject: `Merged to ${base}`,
|
|
94
95
|
body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.`,
|
|
95
96
|
payload,
|
|
97
|
+
replyExpected: false,
|
|
96
98
|
});
|
|
97
99
|
}
|
|
98
100
|
}
|
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 {
|
|
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
|
|
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
|
|
721
|
+
...(stateDir ? ["--state-dir", shellQuote(stateDir)] : []),
|
|
722
722
|
].join(" ");
|
|
723
723
|
console.log(command);
|
|
724
724
|
return;
|
package/src/contracts.ts
CHANGED
package/src/db.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
import {
|
|
@@ -226,6 +226,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
226
226
|
body TEXT NOT NULL,
|
|
227
227
|
thread_id INTEGER,
|
|
228
228
|
reply_to INTEGER REFERENCES messages(id),
|
|
229
|
+
reply_expected INTEGER NOT NULL DEFAULT 1,
|
|
229
230
|
claimable INTEGER NOT NULL DEFAULT 0,
|
|
230
231
|
claimed_by TEXT,
|
|
231
232
|
claimed_at INTEGER,
|
|
@@ -857,6 +858,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
857
858
|
db.run("ALTER TABLE messages ADD COLUMN thread_id INTEGER");
|
|
858
859
|
db.run("ALTER TABLE messages ADD COLUMN reply_to INTEGER REFERENCES messages(id)");
|
|
859
860
|
}
|
|
861
|
+
if (!colNames.includes("reply_expected")) {
|
|
862
|
+
db.run("ALTER TABLE messages ADD COLUMN reply_expected INTEGER NOT NULL DEFAULT 1");
|
|
863
|
+
}
|
|
860
864
|
if (!colNames.includes("claimable")) {
|
|
861
865
|
db.run("ALTER TABLE messages ADD COLUMN claimable INTEGER NOT NULL DEFAULT 0");
|
|
862
866
|
db.run("ALTER TABLE messages ADD COLUMN claimed_by TEXT");
|
|
@@ -1292,6 +1296,9 @@ function rowToMessage(row: any): Message {
|
|
|
1292
1296
|
body: row.body,
|
|
1293
1297
|
threadId: row.thread_id ?? undefined,
|
|
1294
1298
|
replyTo: row.reply_to ?? undefined,
|
|
1299
|
+
// Default (true) stays absent to match the `claimable` idiom and keep notification-free
|
|
1300
|
+
// messages byte-identical on the wire; only an explicit notification surfaces false (#283).
|
|
1301
|
+
replyExpected: row.reply_expected === 0 ? false : undefined,
|
|
1295
1302
|
claimable: row.claimable === 1 ? true : undefined,
|
|
1296
1303
|
claimedBy: row.claimed_by ?? undefined,
|
|
1297
1304
|
claimedAt: row.claimed_at ?? undefined,
|
|
@@ -3794,12 +3801,12 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
3794
3801
|
|
|
3795
3802
|
const insert = db.query(`
|
|
3796
3803
|
INSERT INTO messages (
|
|
3797
|
-
from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, claimable,
|
|
3804
|
+
from_agent, to_target, kind, channel, subject, body, thread_id, reply_to, reply_expected, claimable,
|
|
3798
3805
|
idempotency_key, delivery_status, queued_at, max_age_seconds, resolved_to_agent,
|
|
3799
3806
|
payload, meta, created_at, occurred_at
|
|
3800
3807
|
)
|
|
3801
3808
|
VALUES (
|
|
3802
|
-
$from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $claimable,
|
|
3809
|
+
$from, $to, $kind, $channel, $subject, $body, $threadId, $replyTo, $replyExpected, $claimable,
|
|
3803
3810
|
$idempotencyKey, $deliveryStatus, $queuedAt, $maxAgeSeconds, $resolvedToAgent,
|
|
3804
3811
|
$payload, $meta, $now, $occurredAt
|
|
3805
3812
|
)
|
|
@@ -3833,6 +3840,9 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
|
|
|
3833
3840
|
$body: input.body,
|
|
3834
3841
|
$threadId: threadId,
|
|
3835
3842
|
$replyTo: input.replyTo ?? null,
|
|
3843
|
+
// Server-owned reply obligation (#283): true by default; only an explicit false marks
|
|
3844
|
+
// a notification. Stored 0/1 so the footer renderer + reply tracker key off one column.
|
|
3845
|
+
$replyExpected: input.replyExpected === false ? 0 : 1,
|
|
3836
3846
|
$claimable: claimable ? 1 : 0,
|
|
3837
3847
|
$idempotencyKey: input.idempotencyKey ?? null,
|
|
3838
3848
|
$deliveryStatus: deliveryStatus,
|
|
@@ -4318,7 +4328,10 @@ export function pollMessages(query: PollQuery): Message[] {
|
|
|
4318
4328
|
}
|
|
4319
4329
|
|
|
4320
4330
|
function messageRequiresReply(message: Message): boolean {
|
|
4321
|
-
|
|
4331
|
+
// Server-owned notification flag (#283) wins over every kind/sender heuristic below: an
|
|
4332
|
+
// explicit replyExpected:false is a fire-and-forget message that must never become an obligation.
|
|
4333
|
+
if (message.replyExpected === false) return false;
|
|
4334
|
+
if (isMechanicalMessageKind(message.kind)) return false;
|
|
4322
4335
|
if (message.from === "user") return true;
|
|
4323
4336
|
if (message.kind === "task" || message.kind === "channel.event") return true;
|
|
4324
4337
|
return Boolean(message.payload?.source);
|
|
@@ -4780,7 +4793,7 @@ export type AnalyticsPeriod = keyof typeof ANALYTICS_PERIODS;
|
|
|
4780
4793
|
// Message → category, the ONE place this mapping lives (server-side SQL). Order is
|
|
4781
4794
|
// significant: a claimable/system/pair/channel message is classified as such even
|
|
4782
4795
|
// when it is also a reply; only an otherwise-plain reply counts as "Replies".
|
|
4783
|
-
|
|
4796
|
+
const ANALYTICS_CATEGORIES = ["Messages", "Replies", "Work items", "System", "Pair", "Channel"] as const;
|
|
4784
4797
|
export type AnalyticsCategory = (typeof ANALYTICS_CATEGORIES)[number];
|
|
4785
4798
|
const ANALYTICS_CATEGORY_SQL = `
|
|
4786
4799
|
CASE
|
|
@@ -5440,7 +5453,7 @@ export function setWorkspaceBranch(id: string, branch: string, baseSha?: string)
|
|
|
5440
5453
|
// of these is a candidate steward; the repo is worth coordinating.
|
|
5441
5454
|
const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
|
|
5442
5455
|
|
|
5443
|
-
|
|
5456
|
+
interface RepoStewardRecord {
|
|
5444
5457
|
repoRoot: string;
|
|
5445
5458
|
stewardAgentId?: string;
|
|
5446
5459
|
lastStewardAgentId?: string;
|
|
@@ -5557,7 +5570,7 @@ function electWorkspaceStewardsForAgent(agentId: string): void {
|
|
|
5557
5570
|
|
|
5558
5571
|
// --- Per-repo merge serialization lease (issue #157) -----------------------
|
|
5559
5572
|
|
|
5560
|
-
|
|
5573
|
+
interface MergeLeaseRecord {
|
|
5561
5574
|
repoRoot: string;
|
|
5562
5575
|
workspaceId: string;
|
|
5563
5576
|
commandId?: string;
|
package/src/http-body.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Concatenate body chunks into a single contiguous Uint8Array. */
|
|
2
|
-
|
|
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;
|
package/src/insights-db.ts
CHANGED
|
@@ -46,7 +46,7 @@ function rowToObservation(row: ObservationRow): InsightObservation {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
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
|
-
|
|
102
|
+
interface ListObservationsQuery {
|
|
103
103
|
project?: string;
|
|
104
104
|
signal?: string;
|
|
105
105
|
sessionId?: string;
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -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
|
|
package/src/managed-policy.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function effectiveManagedPolicyWorkspaceMode(policy: SpawnPolicy): Worksp
|
|
|
20
20
|
return policy.binding?.type === "channel" ? "shared" : "inherit";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
interface ManagedSpawnContext {
|
|
24
24
|
createdBy: string;
|
|
25
25
|
requestedAt?: number;
|
|
26
26
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -247,19 +247,19 @@ const TOOLS: ToolDefinition[] = [
|
|
|
247
247
|
},
|
|
248
248
|
{
|
|
249
249
|
name: "relay_spawn_agent",
|
|
250
|
-
description: "Spawn a long-living provider agent through Relay's orchestrator. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
|
|
250
|
+
description: "Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via `prompt` in the same call. Defaults to your own host (override with orchestratorId) and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
|
|
251
251
|
requiredScopes: ["command:spawn"],
|
|
252
252
|
inputSchema: {
|
|
253
253
|
type: "object",
|
|
254
254
|
properties: {
|
|
255
255
|
provider: { type: "string", enum: SPAWN_PROVIDERS },
|
|
256
|
-
orchestratorId: { type: "string" },
|
|
257
|
-
cwd: { type: "string" },
|
|
256
|
+
orchestratorId: { type: "string", description: "Target host. Defaults to the host that owns cwd, else YOUR OWN host — only set it to spawn onto a different machine." },
|
|
257
|
+
cwd: { type: "string", description: "Working directory for the agent. Must resolve within the target orchestrator's base directory (enforced server-side)." },
|
|
258
258
|
label: { type: "string" },
|
|
259
259
|
model: { type: "string" },
|
|
260
260
|
effort: { type: "string", enum: VALID_EFFORTS },
|
|
261
261
|
approvalMode: { type: "string", enum: APPROVAL_MODES },
|
|
262
|
-
prompt: { type: "string" },
|
|
262
|
+
prompt: { type: "string", description: "Initial task/message delivered to the agent on launch — spawn and hand it its first instruction in one call (no separate follow-up message needed)." },
|
|
263
263
|
systemPromptAppend: { type: "string" },
|
|
264
264
|
profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
|
|
265
265
|
tags: { type: "array", items: { type: "string" } },
|
|
@@ -267,6 +267,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
267
267
|
providerArgs: { type: "array", items: { type: "string" } },
|
|
268
268
|
policyName: { type: "string" },
|
|
269
269
|
spawnRequestId: { type: "string" },
|
|
270
|
+
waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
|
|
270
271
|
},
|
|
271
272
|
required: ["provider"],
|
|
272
273
|
additionalProperties: false,
|
|
@@ -485,7 +486,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
485
486
|
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
486
487
|
else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
|
|
487
488
|
else if (name === "relay_whoami") result = relayWhoami(auth);
|
|
488
|
-
else if (name === "relay_spawn_agent") result = relaySpawnAgent(auth, args);
|
|
489
|
+
else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
|
|
489
490
|
else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
|
|
490
491
|
else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
|
|
491
492
|
else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
|
|
@@ -763,10 +764,12 @@ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
763
764
|
return { agents, count: agents.length };
|
|
764
765
|
}
|
|
765
766
|
|
|
766
|
-
function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown
|
|
767
|
+
async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
767
768
|
const provider = enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider;
|
|
768
769
|
const cwd = optionalString(args.cwd, "cwd", 500);
|
|
769
|
-
const
|
|
770
|
+
const callerId = callerAgentId(auth);
|
|
771
|
+
const preferHost = callerId ? getAgent(callerId)?.machine : undefined;
|
|
772
|
+
const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd, preferHost);
|
|
770
773
|
const resolvedCwd = cwd || orchestrator.baseDir;
|
|
771
774
|
if (cwd && !isPathWithinBase(cwd, orchestrator.baseDir)) {
|
|
772
775
|
throw new ValidationError(`cwd must be within orchestrator base directory: ${orchestrator.baseDir}`);
|
|
@@ -781,7 +784,6 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
781
784
|
// #221 runtime gate (belt; the coarse `command:spawn` scope is enforced in callTool, and is
|
|
782
785
|
// granted only to agents whose profile sets maxSpawnedAgents>0 and never to children).
|
|
783
786
|
// Server/admin tokens have no caller identity → unrestricted by design.
|
|
784
|
-
const callerId = callerAgentId(auth);
|
|
785
787
|
if (callerId) {
|
|
786
788
|
const me = getAgent(callerId);
|
|
787
789
|
if (me?.spawnedBy) {
|
|
@@ -841,7 +843,27 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
841
843
|
}),
|
|
842
844
|
});
|
|
843
845
|
emitCommand(command);
|
|
844
|
-
|
|
846
|
+
|
|
847
|
+
// #255: resolve the spawned agent id once it registers. Spawn is a fire-and-forget command
|
|
848
|
+
// over the bus; the child registers back to THIS relay (same DB) with meta.spawnRequestId set,
|
|
849
|
+
// so a bounded poll links the request to the agent without a separate relay_find_agents round
|
|
850
|
+
// trip. waitForRegistrationMs:0 opts out (pure fire-and-forget); the default is short because
|
|
851
|
+
// isolated-worktree spawns register near-instantly (symlinked deps).
|
|
852
|
+
const waitMs = Math.min(optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000, 30000);
|
|
853
|
+
const agentId = waitMs > 0 ? await waitForSpawnedAgent(spawnRequestId, waitMs) : null;
|
|
854
|
+
return { ok: true, spawnRequestId, orchestratorId: orchestrator.id, provider, agentId, registered: agentId !== null, command };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Poll the agents table for the child that registers with this spawnRequestId (#255). Returns
|
|
858
|
+
// the resolved agent id, or null on timeout (the caller still has spawnRequestId to poll later).
|
|
859
|
+
async function waitForSpawnedAgent(spawnRequestId: string, timeoutMs: number, pollMs = 300): Promise<string | null> {
|
|
860
|
+
const deadline = Date.now() + timeoutMs;
|
|
861
|
+
for (;;) {
|
|
862
|
+
const match = listAgents().find((a) => a.meta?.spawnRequestId === spawnRequestId);
|
|
863
|
+
if (match) return match.id;
|
|
864
|
+
if (Date.now() >= deadline) return null;
|
|
865
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
|
|
866
|
+
}
|
|
845
867
|
}
|
|
846
868
|
|
|
847
869
|
function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -1062,7 +1084,12 @@ function policyStatusPayload(policy: NonNullable<ReturnType<typeof getSpawnPolic
|
|
|
1062
1084
|
};
|
|
1063
1085
|
}
|
|
1064
1086
|
|
|
1065
|
-
function selectSpawnOrchestrator(
|
|
1087
|
+
function selectSpawnOrchestrator(
|
|
1088
|
+
provider: SpawnProvider,
|
|
1089
|
+
orchestratorId?: string,
|
|
1090
|
+
cwd?: string,
|
|
1091
|
+
preferHost?: string,
|
|
1092
|
+
): NonNullable<ReturnType<typeof getOrchestrator>> {
|
|
1066
1093
|
if (orchestratorId) {
|
|
1067
1094
|
const orchestrator = getOrchestrator(orchestratorId);
|
|
1068
1095
|
if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
|
|
@@ -1075,6 +1102,14 @@ function selectSpawnOrchestrator(provider: SpawnProvider, orchestratorId?: strin
|
|
|
1075
1102
|
const match = candidates.find((item) => isPathWithinBase(cwd, item.baseDir));
|
|
1076
1103
|
if (match) return match;
|
|
1077
1104
|
}
|
|
1105
|
+
// #255: with neither an explicit id nor a cwd to pin the host, default to the CALLER's own
|
|
1106
|
+
// host instead of silently grabbing candidates[0] (a foreign host whose baseDir would then
|
|
1107
|
+
// reject the caller's cwd — the footgun the spawn recipe warned about). An agent's `machine`
|
|
1108
|
+
// is its OS hostname; match it against the orchestrator hostname (or id, defensively).
|
|
1109
|
+
if (preferHost) {
|
|
1110
|
+
const own = candidates.find((item) => item.hostname === preferHost || item.id === preferHost);
|
|
1111
|
+
if (own) return own;
|
|
1112
|
+
}
|
|
1078
1113
|
const orchestrator = candidates[0];
|
|
1079
1114
|
if (!orchestrator) throw new McpNotFoundError(`no orchestrator available for provider: ${provider}`);
|
|
1080
1115
|
return orchestrator;
|
|
@@ -1327,6 +1362,12 @@ function optionalPositiveInt(value: unknown, field: string): number | undefined
|
|
|
1327
1362
|
return value;
|
|
1328
1363
|
}
|
|
1329
1364
|
|
|
1365
|
+
function optionalNonNegativeInt(value: unknown, field: string): number | undefined {
|
|
1366
|
+
if (value === undefined || value === null) return undefined;
|
|
1367
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${field} must be a non-negative integer`);
|
|
1368
|
+
return value;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1330
1371
|
function optionalFutureTimestamp(value: unknown, field: string): number | undefined {
|
|
1331
1372
|
const timestamp = optionalPositiveInt(value, field);
|
|
1332
1373
|
if (timestamp !== undefined && timestamp <= Date.now()) throw new ValidationError(`${field} must be a future unix timestamp in milliseconds`);
|
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
|
-
|
|
5
|
+
interface SystemNotifyOptions {
|
|
6
6
|
subject?: string;
|
|
7
7
|
body: string;
|
|
8
8
|
payload?: Record<string, unknown>;
|
|
@@ -10,6 +10,13 @@ export interface SystemNotifyOptions {
|
|
|
10
10
|
kind?: MessageKind;
|
|
11
11
|
/** Sender id; defaults to "system". */
|
|
12
12
|
from?: string;
|
|
13
|
+
/**
|
|
14
|
+
* #283 — set false for a fire-and-forget notification (merge notice, lifecycle event): the
|
|
15
|
+
* server suppresses the reply-scaffold footer and the reply-obligation tracker skips it.
|
|
16
|
+
* Omit (default true) for system messages that genuinely want the agent to act/answer
|
|
17
|
+
* (steward task assignments, conflict handoffs).
|
|
18
|
+
*/
|
|
19
|
+
replyExpected?: boolean;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
/**
|
|
@@ -25,6 +32,7 @@ export function notifySystemMessage(to: string, opts: SystemNotifyOptions): Mess
|
|
|
25
32
|
subject: opts.subject,
|
|
26
33
|
body: opts.body,
|
|
27
34
|
payload: opts.payload,
|
|
35
|
+
replyExpected: opts.replyExpected,
|
|
28
36
|
});
|
|
29
37
|
emitNewMessage(msg);
|
|
30
38
|
return msg;
|
package/src/routes.ts
CHANGED
|
@@ -163,7 +163,7 @@ import { planSend } from "./agent-ref";
|
|
|
163
163
|
import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
|
|
164
164
|
import type { ProviderConfig } from "../runner/src/adapter";
|
|
165
165
|
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";
|
|
166
|
+
import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER, isMechanicalMessageKind, isReservedAgentId } from "agent-relay-sdk";
|
|
167
167
|
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
168
168
|
import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
|
|
169
169
|
import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
|
|
@@ -520,6 +520,9 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
520
520
|
if (body.claimable !== undefined && typeof body.claimable !== "boolean") {
|
|
521
521
|
throw new ValidationError("claimable must be a boolean");
|
|
522
522
|
}
|
|
523
|
+
if (body.replyExpected !== undefined && typeof body.replyExpected !== "boolean") {
|
|
524
|
+
throw new ValidationError("replyExpected must be a boolean");
|
|
525
|
+
}
|
|
523
526
|
|
|
524
527
|
const input: SendMessageInput = {
|
|
525
528
|
from: cleanString(body.from, "from", { required: true, max: 200 })!,
|
|
@@ -527,6 +530,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
|
|
|
527
530
|
body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
|
|
528
531
|
kind: kind as SendMessageInput["kind"] | undefined,
|
|
529
532
|
replyTo: cleanPositiveId(body.replyTo, "replyTo"),
|
|
533
|
+
replyExpected: body.replyExpected as boolean | undefined,
|
|
530
534
|
claimable: body.claimable as boolean | undefined,
|
|
531
535
|
idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
|
|
532
536
|
};
|
|
@@ -1278,7 +1282,13 @@ const postAgent: Handler = async (req) => {
|
|
|
1278
1282
|
const available = resolveQueuedPolicyMessages(policyName, agent.id);
|
|
1279
1283
|
if (available.length) {
|
|
1280
1284
|
emitMessageAvailable(policyName, agent.id, available);
|
|
1281
|
-
for (const message of available)
|
|
1285
|
+
for (const message of available) {
|
|
1286
|
+
emitNewMessage(message);
|
|
1287
|
+
// queued → pending flips delivery_status; the dashboard dedups message.new
|
|
1288
|
+
// by id (the message already shows as "queued"), so without an explicit
|
|
1289
|
+
// delivery_updated the badge stays stale until the next poll (#265).
|
|
1290
|
+
emitMessageDeliveryUpdated(message);
|
|
1291
|
+
}
|
|
1282
1292
|
}
|
|
1283
1293
|
}
|
|
1284
1294
|
}
|
|
@@ -5177,9 +5187,20 @@ const postMessage: Handler = async (req) => {
|
|
|
5177
5187
|
}
|
|
5178
5188
|
applyReplyRouting(input);
|
|
5179
5189
|
if (!input.to) return error("to is required (or provide replyTo to auto-route)");
|
|
5190
|
+
// Mechanical lifecycle/observability posts (system/control/session) addressed to a
|
|
5191
|
+
// reserved sink ("user"/"system") are the relay's own lane, not agent-directed
|
|
5192
|
+
// messaging. A managed token's recipient constraints (targets/policies/agents) gate
|
|
5193
|
+
// which *agents* it may message — they must NOT gate a session-mirror capture to the
|
|
5194
|
+
// reserved sink, or constrained tokens (telegram policy, codex steward) 403 → the
|
|
5195
|
+
// runner's outbox retries 12× and poisons the record → the dashboard silently loses
|
|
5196
|
+
// the turn (#284, same outbox-poison failure mode as #184). The message:send scope
|
|
5197
|
+
// and any channel constraint still apply; we only drop the target/agentId predicate.
|
|
5198
|
+
const reservedSinkPost = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
|
|
5180
5199
|
const denied = authorizeRoute(req, {
|
|
5181
5200
|
scope: "message:send",
|
|
5182
|
-
resource:
|
|
5201
|
+
resource: reservedSinkPost
|
|
5202
|
+
? { channel: input.channel }
|
|
5203
|
+
: { target: input.to, channel: input.channel, agentId: input.from },
|
|
5183
5204
|
});
|
|
5184
5205
|
if (denied) return denied;
|
|
5185
5206
|
// Resolve the target through the shared planner — the SAME matcher the MCP send tool
|
|
@@ -5194,8 +5215,7 @@ const postMessage: Handler = async (req) => {
|
|
|
5194
5215
|
// "session" = observed assistant turn (Phase 1 live-session lane). It is captured
|
|
5195
5216
|
// from the provider transcript and stored for the dashboard chat; it must persist
|
|
5196
5217
|
// regardless of target liveness and never be re-delivered into a session.
|
|
5197
|
-
|
|
5198
|
-
if (!bypassKinds.includes(input.kind ?? "")) {
|
|
5218
|
+
if (!isMechanicalMessageKind(input.kind)) {
|
|
5199
5219
|
const plan = planSend(input.to, listAgents());
|
|
5200
5220
|
if (plan.kind === "ambiguous") return error(plan.message, 409);
|
|
5201
5221
|
if (plan.kind !== "not_found") input.to = plan.to;
|
|
@@ -5245,7 +5265,7 @@ const postMessage: Handler = async (req) => {
|
|
|
5245
5265
|
};
|
|
5246
5266
|
|
|
5247
5267
|
function automaticMemoryTarget(message: { to: string; resolvedToAgent?: string; kind: string }): string | null {
|
|
5248
|
-
if (message.kind
|
|
5268
|
+
if (isMechanicalMessageKind(message.kind)) return null;
|
|
5249
5269
|
const target = message.resolvedToAgent ?? message.to;
|
|
5250
5270
|
if (!isDirectTarget(target)) return null;
|
|
5251
5271
|
const agent = getAgent(target);
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -118,7 +118,7 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
function issueChildRunnerRuntimeToken(input: {
|
|
122
122
|
parentAgentId: string;
|
|
123
123
|
orchestratorId: string;
|
|
124
124
|
cwd: string;
|
|
@@ -239,7 +239,7 @@ export function runnerRuntimeTokenEnv(input: {
|
|
|
239
239
|
};
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
function childRunnerRuntimeTokenEnv(input: {
|
|
243
243
|
parentAgentId: string;
|
|
244
244
|
orchestratorId: string;
|
|
245
245
|
cwd: string;
|
package/src/security.ts
CHANGED
|
@@ -200,6 +200,9 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
200
200
|
if (pathname.startsWith("/api/maintenance")) return "system:admin";
|
|
201
201
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
202
202
|
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
|
203
|
+
// Insights config (the feature toggle) stays admin-only via the default; only the
|
|
204
|
+
// mechanical observation feed is writable by lower-privilege callers.
|
|
205
|
+
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
203
206
|
if (pathname.startsWith("/api/system/")) return "system:admin";
|
|
204
207
|
return null;
|
|
205
208
|
}
|
|
@@ -268,6 +271,11 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
268
271
|
if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
|
|
269
272
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
270
273
|
if (pathname.startsWith("/api/orchestrators")) return method === "GET" ? "agent:read" : "command:write";
|
|
274
|
+
// The Runner posts the #184 context-gathering signal here (source:"server") via its
|
|
275
|
+
// provider token. Without this case the path fell through to the system:admin default
|
|
276
|
+
// below and every observation was 403-dropped — the whole Insights feed stayed empty.
|
|
277
|
+
// Config (the feature toggle) intentionally keeps falling through to system:admin.
|
|
278
|
+
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
271
279
|
if (pathname.startsWith("/api/system/")) return "system:admin";
|
|
272
280
|
return "system:admin";
|
|
273
281
|
}
|
package/src/spawn-command.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function generateSpawnRequestId(): string {
|
|
|
17
17
|
return `sp_${randomUUID()}`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
interface ResolveSpawnModelParamsOptions {
|
|
21
21
|
/**
|
|
22
22
|
* What to do when provider-catalog resolution throws (e.g. unknown model, or
|
|
23
23
|
* effort without a model):
|
|
@@ -69,7 +69,7 @@ export function resolveSpawnModelParams(
|
|
|
69
69
|
* Every field that can appear in a spawn command-bus payload.
|
|
70
70
|
* Optional fields are omitted from the result when undefined.
|
|
71
71
|
*/
|
|
72
|
-
|
|
72
|
+
interface BuildSpawnCommandOptions {
|
|
73
73
|
provider: SpawnProvider | string;
|
|
74
74
|
cwd: string;
|
|
75
75
|
/** Correlation id; omitted from the payload when absent (e.g. automation spawns use automationRunId instead). */
|
package/src/token-db.ts
CHANGED
|
@@ -57,7 +57,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
57
57
|
name: "Provider Agent",
|
|
58
58
|
description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
|
|
59
59
|
role: "provider",
|
|
60
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
|
|
60
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
|
|
61
61
|
ttlSeconds: 24 * 60 * 60,
|
|
62
62
|
builtIn: true,
|
|
63
63
|
createdBy: "system",
|
|
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
67
67
|
name: "Provider Child Agent",
|
|
68
68
|
description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
|
|
69
69
|
role: "provider",
|
|
70
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
|
|
70
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
|
|
71
71
|
constraints: { canDelegate: false },
|
|
72
72
|
ttlSeconds: 2 * 60 * 60,
|
|
73
73
|
builtIn: true,
|
|
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
78
78
|
name: "Provider Interactive Agent",
|
|
79
79
|
description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
|
|
80
80
|
role: "provider",
|
|
81
|
-
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
|
|
81
|
+
scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "insights:write"],
|
|
82
82
|
constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
|
|
83
83
|
ttlSeconds: 30 * 24 * 60 * 60,
|
|
84
84
|
builtIn: true,
|
package/src/upgrade.ts
CHANGED
|
@@ -249,7 +249,7 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
|
|
|
249
249
|
};
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
type ExecuteUpgradeOptions = {
|
|
253
253
|
dryRun?: boolean;
|
|
254
254
|
runner?: Runner;
|
|
255
255
|
/** Re-register grace window for post-restart version checks (default 30s). */
|
package/src/workspace-actions.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const WORKSPACE_ACTIONS = [
|
|
|
41
41
|
] as const;
|
|
42
42
|
export type WorkspaceAction = (typeof WORKSPACE_ACTIONS)[number];
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
interface ApplyWorkspaceActionInput {
|
|
45
45
|
action: WorkspaceAction;
|
|
46
46
|
agentId?: string;
|
|
47
47
|
detail?: string;
|
|
@@ -62,7 +62,7 @@ export interface ApplyWorkspaceActionInput {
|
|
|
62
62
|
auditMetadata?: Record<string, unknown>;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
type WorkspaceActionResult =
|
|
66
66
|
| {
|
|
67
67
|
ok: true;
|
|
68
68
|
httpStatus: number;
|
|
@@ -320,10 +320,10 @@ export function buildWorkspaceDepsRefreshCommand(
|
|
|
320
320
|
return { ok: true, command };
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
const DEFAULT_WORKSPACE_WAIT_MS = 300_000;
|
|
324
|
+
const MAX_WORKSPACE_WAIT_MS = 600_000;
|
|
325
325
|
|
|
326
|
-
|
|
326
|
+
interface WaitForWorkspaceResult {
|
|
327
327
|
workspace: WorkspaceRecord | null;
|
|
328
328
|
/** The status when the wait began. */
|
|
329
329
|
fromStatus?: WorkspaceStatus;
|
package/src/workspace-claim.ts
CHANGED
|
@@ -4,9 +4,9 @@ import type { WorkspaceRecord } from "./types";
|
|
|
4
4
|
// auto-merge (Layer 0) doesn't race it (#208 / steward report §1). The claim is a
|
|
5
5
|
// TTL'd lease stored in row metadata, so a dead steward can't block the workspace
|
|
6
6
|
// forever — it expires and auto-merge resumes. Renew by re-claiming.
|
|
7
|
-
|
|
7
|
+
const STEWARD_CLAIM_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS) || 15 * 60_000;
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
interface WorkspaceClaim {
|
|
10
10
|
by?: string;
|
|
11
11
|
purpose?: string;
|
|
12
12
|
claimedAt?: number;
|
package/src/workspace-merge.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
|
|
11
11
|
import { isPathWithinBase } from "./utils";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
interface RequestWorkspaceMergeOptions {
|
|
14
14
|
/** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
|
|
15
15
|
requestedBy: string;
|
|
16
16
|
/** Merge strategy; "auto" lets the host pick pr-vs-rebase-ff. Defaults to "auto". */
|
|
@@ -26,7 +26,7 @@ export interface RequestWorkspaceMergeOptions {
|
|
|
26
26
|
metadata?: Record<string, unknown>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
type RequestWorkspaceMergeResult =
|
|
30
30
|
| { ok: true; command: Command; workspace: WorkspaceRecord }
|
|
31
31
|
| { ok: false; status: number; error: string };
|
|
32
32
|
|
package/src/workspace-orphans.ts
CHANGED
|
@@ -141,7 +141,7 @@ function knownRepoRoots(workspaces: WorkspaceRecord[]): string[] {
|
|
|
141
141
|
return [...new Set(workspaces.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
interface CollectOrphansResult {
|
|
145
145
|
orphans: WorkspaceOrphan[];
|
|
146
146
|
/** Live isolated rows whose worktree is missing on disk (DB→disk drift). */
|
|
147
147
|
missingWorktrees: Array<{
|
package/src/workspace-phase.ts
CHANGED
|
@@ -71,7 +71,7 @@ export function worktreeReapable(state: WorktreeReapState | null | undefined): b
|
|
|
71
71
|
// instead of the old behavior where it looked healthy for 90 minutes.
|
|
72
72
|
export const LAND_PENDING_STALL_MS = 15 * 60 * 1000;
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
type WorkspacePhase =
|
|
75
75
|
| "working" // active — your turn: commit, then mark ready
|
|
76
76
|
| "land-pending" // ready | review_requested — handed off; auto-merge will land it
|
|
77
77
|
| "landing" // merge_planned — merge dispatched, in progress
|
|
@@ -79,7 +79,7 @@ export type WorkspacePhase =
|
|
|
79
79
|
| "landed" // merged — on the base; a fresh rebased branch is coming
|
|
80
80
|
| "closed"; // abandoned | cleanup_requested | cleaned — torn down
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
interface WorkspaceNextAction {
|
|
83
83
|
/** MCP tool to call (when the agent is on the MCP surface). */
|
|
84
84
|
tool?: string;
|
|
85
85
|
/** Equivalent CLI invocation (when the agent is on the shell surface). */
|