agent-relay-server 0.123.2 → 0.125.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.
Files changed (119) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-NCggGKTA.js → MessageBubble-BYeYFteG.js} +2 -2
  4. package/public/assets/{MessageBubble-NCggGKTA.js.map → MessageBubble-BYeYFteG.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CSdszmGk.js → PlanGraphPanel-iAXvkZKW.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CSdszmGk.js.map → PlanGraphPanel-iAXvkZKW.js.map} +1 -1
  7. package/public/assets/{activity-Cyp2nolX.js → activity-Du9SRJt5.js} +2 -2
  8. package/public/assets/{activity-Cyp2nolX.js.map → activity-Du9SRJt5.js.map} +1 -1
  9. package/public/assets/agent-profiles-DXDQnrlH.js +2 -0
  10. package/public/assets/{agent-profiles-D5Izg0Vn.js.map → agent-profiles-DXDQnrlH.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-BxUopALO.js → agent-type-icon-B3i0rndK.js} +2 -2
  12. package/public/assets/{agent-type-icon-BxUopALO.js.map → agent-type-icon-B3i0rndK.js.map} +1 -1
  13. package/public/assets/{agents-DIOxuJ6M.js → agents-CWOdKrxP.js} +2 -2
  14. package/public/assets/{agents-DIOxuJ6M.js.map → agents-CWOdKrxP.js.map} +1 -1
  15. package/public/assets/{analytics-DqAuUAwX.js → analytics-YcY59Hv9.js} +2 -2
  16. package/public/assets/{analytics-DqAuUAwX.js.map → analytics-YcY59Hv9.js.map} +1 -1
  17. package/public/assets/{automation-Bn6mOLAR.js → automation-CItTse7F.js} +2 -2
  18. package/public/assets/{automation-Bn6mOLAR.js.map → automation-CItTse7F.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Mf0Ow77K.js → branch-state-badge-QjTktTYj.js} +2 -2
  20. package/public/assets/{branch-state-badge-Mf0Ow77K.js.map → branch-state-badge-QjTktTYj.js.map} +1 -1
  21. package/public/assets/{channels-DVRkVeky.js → channels-BSFTsbhT.js} +2 -2
  22. package/public/assets/{channels-DVRkVeky.js.map → channels-BSFTsbhT.js.map} +1 -1
  23. package/public/assets/chat-CRzFSFWe.js +2 -0
  24. package/public/assets/chat-CRzFSFWe.js.map +1 -0
  25. package/public/assets/{connectors-Cqubkw9C.js → connectors-C7d5dlYz.js} +2 -2
  26. package/public/assets/{connectors-Cqubkw9C.js.map → connectors-C7d5dlYz.js.map} +1 -1
  27. package/public/assets/{display-GFbGUG0O.js → display-aTnwsqQh.js} +3 -3
  28. package/public/assets/{display-GFbGUG0O.js.map → display-aTnwsqQh.js.map} +1 -1
  29. package/public/assets/{formatted-body-CvrQ2D1J.js → formatted-body-BuLfVMeD.js} +3 -3
  30. package/public/assets/{formatted-body-CvrQ2D1J.js.map → formatted-body-BuLfVMeD.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-BJ7jdbpR.js → formatted-body-impl-DO-8giS6.js} +2 -2
  32. package/public/assets/{formatted-body-impl-BJ7jdbpR.js.map → formatted-body-impl-DO-8giS6.js.map} +1 -1
  33. package/public/assets/index-C9X_0QBt.css +2 -0
  34. package/public/assets/index-De1tR7J4.js +26 -0
  35. package/public/assets/index-De1tR7J4.js.map +1 -0
  36. package/public/assets/{insights-CQGH5LhD.js → insights-Ci3VuA5Y.js} +2 -2
  37. package/public/assets/{insights-CQGH5LhD.js.map → insights-Ci3VuA5Y.js.map} +1 -1
  38. package/public/assets/{integrations-9G5TJnnU.js → integrations-BboxWQQD.js} +2 -2
  39. package/public/assets/{integrations-9G5TJnnU.js.map → integrations-BboxWQQD.js.map} +1 -1
  40. package/public/assets/{maintenance-7c7UvpAt.js → maintenance-CO1miJIJ.js} +2 -2
  41. package/public/assets/{maintenance-7c7UvpAt.js.map → maintenance-CO1miJIJ.js.map} +1 -1
  42. package/public/assets/{managed-agents-yh4HPO5a.js → managed-agents-r4oYK3Fm.js} +2 -2
  43. package/public/assets/{managed-agents-yh4HPO5a.js.map → managed-agents-r4oYK3Fm.js.map} +1 -1
  44. package/public/assets/{markdown-preview-impl-BuOn5-Uc.js → markdown-preview-impl-5CCM9iyL.js} +2 -2
  45. package/public/assets/{markdown-preview-impl-BuOn5-Uc.js.map → markdown-preview-impl-5CCM9iyL.js.map} +1 -1
  46. package/public/assets/{memory-Qv0JN8CE.js → memory-BJJS07W-.js} +2 -2
  47. package/public/assets/{memory-Qv0JN8CE.js.map → memory-BJJS07W-.js.map} +1 -1
  48. package/public/assets/{messages-CgjHJmc2.js → messages-RT_dfEsL.js} +2 -2
  49. package/public/assets/{messages-CgjHJmc2.js.map → messages-RT_dfEsL.js.map} +1 -1
  50. package/public/assets/{orchestrators-CgL4CFzb.js → orchestrators-8hyl8hW1.js} +2 -2
  51. package/public/assets/{orchestrators-CgL4CFzb.js.map → orchestrators-8hyl8hW1.js.map} +1 -1
  52. package/public/assets/{overview-Bri04eYE.js → overview-CUJ9QmW_.js} +2 -2
  53. package/public/assets/{overview-Bri04eYE.js.map → overview-CUJ9QmW_.js.map} +1 -1
  54. package/public/assets/{pairs-C78LwUH2.js → pairs-CzkzMGZq.js} +2 -2
  55. package/public/assets/{pairs-C78LwUH2.js.map → pairs-CzkzMGZq.js.map} +1 -1
  56. package/public/assets/{projects-CquHTMo1.js → projects-CAuLWBU5.js} +2 -2
  57. package/public/assets/{projects-CquHTMo1.js.map → projects-CAuLWBU5.js.map} +1 -1
  58. package/public/assets/{prompt-settings-DDEkMqPZ.js → prompt-settings-BnB90R7F.js} +2 -2
  59. package/public/assets/{prompt-settings-DDEkMqPZ.js.map → prompt-settings-BnB90R7F.js.map} +1 -1
  60. package/public/assets/registry-B8TInqru.js +4 -0
  61. package/public/assets/registry-B8TInqru.js.map +1 -0
  62. package/public/assets/{security-wvnRKFvm.js → security-DG_VVZUW.js} +2 -2
  63. package/public/assets/{security-wvnRKFvm.js.map → security-DG_VVZUW.js.map} +1 -1
  64. package/public/assets/{select-BIfbVmLf.js → select-BJ8G38_V.js} +2 -2
  65. package/public/assets/{select-BIfbVmLf.js.map → select-BJ8G38_V.js.map} +1 -1
  66. package/public/assets/{settings-B6Ntahx7.js → settings-CK_T9z5w.js} +4 -4
  67. package/public/assets/{settings-B6Ntahx7.js.map → settings-CK_T9z5w.js.map} +1 -1
  68. package/public/assets/{store-zFLzd0Jd.js → store-Dl9Z8YUS.js} +4 -4
  69. package/public/assets/{store-zFLzd0Jd.js.map → store-Dl9Z8YUS.js.map} +1 -1
  70. package/public/assets/{tasks-CUglafo5.js → tasks-Briui9Mp.js} +2 -2
  71. package/public/assets/{tasks-CUglafo5.js.map → tasks-Briui9Mp.js.map} +1 -1
  72. package/public/assets/{teams-EE8RHhHM.js → teams-Ck4EVV3R.js} +2 -2
  73. package/public/assets/{teams-EE8RHhHM.js.map → teams-Ck4EVV3R.js.map} +1 -1
  74. package/public/assets/{terminal-viewer-impl-DBRkdyag.js → terminal-viewer-impl-66ZjVN1v.js} +2 -2
  75. package/public/assets/{terminal-viewer-impl-DBRkdyag.js.map → terminal-viewer-impl-66ZjVN1v.js.map} +1 -1
  76. package/public/assets/{types-CRptbSYh.js → types-BqeSkWrv.js} +2 -2
  77. package/public/assets/{types-CRptbSYh.js.map → types-BqeSkWrv.js.map} +1 -1
  78. package/public/assets/{user-avatar-kYPfLgXp.js → user-avatar-BOy9o2xK.js} +2 -2
  79. package/public/assets/{user-avatar-kYPfLgXp.js.map → user-avatar-BOy9o2xK.js.map} +1 -1
  80. package/public/assets/{work-queue-BFQVVL4T.js → work-queue-CnghFRG2.js} +2 -2
  81. package/public/assets/{work-queue-BFQVVL4T.js.map → work-queue-CnghFRG2.js.map} +1 -1
  82. package/public/assets/{workspaces-CR6f7G0R.js → workspaces-BzmQnzSm.js} +2 -2
  83. package/public/assets/{workspaces-CR6f7G0R.js.map → workspaces-BzmQnzSm.js.map} +1 -1
  84. package/public/index.html +7 -7
  85. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  86. package/runner/plugins/claude/hooks/relay-status.sh +11 -1
  87. package/runner/plugins/claude/hooks/stop.sh +6 -1
  88. package/runner/plugins/claude/hooks/user-prompt-submit.sh +6 -1
  89. package/runner/src/adapter.ts +5 -1
  90. package/runner/src/adapters/claude-transcript.ts +52 -58
  91. package/runner/src/adapters/codex-agent-message-capture.ts +8 -36
  92. package/runner/src/adapters/codex.ts +30 -12
  93. package/runner/src/control-server.ts +9 -4
  94. package/runner/src/response-capture-report.ts +4 -1
  95. package/runner/src/runner-core.ts +75 -33
  96. package/src/agent-lifecycle-events.ts +8 -0
  97. package/src/bus.ts +0 -24
  98. package/src/db/agents.ts +15 -1
  99. package/src/db/messages.ts +114 -43
  100. package/src/db/provisioning-registry.ts +174 -1
  101. package/src/maintenance/jobs.ts +8 -2
  102. package/src/mcp/tools-workspace.ts +4 -0
  103. package/src/routes/agents.ts +0 -4
  104. package/src/routes/provisioning-registry.ts +3 -0
  105. package/src/routing-policy-store.ts +55 -0
  106. package/src/services/send-message.ts +7 -18
  107. package/src/services/shutdown-agent.ts +6 -0
  108. package/src/services/task-entity.ts +4 -0
  109. package/src/services/transient-agent-reaper.ts +40 -1
  110. package/src/services/work-finality.ts +25 -0
  111. package/src/spawn-targets.ts +130 -28
  112. package/public/assets/agent-profiles-D5Izg0Vn.js +0 -2
  113. package/public/assets/chat-eUaPz399.js +0 -2
  114. package/public/assets/chat-eUaPz399.js.map +0 -1
  115. package/public/assets/index-DcmG5gLR.css +0 -2
  116. package/public/assets/index-DnUo0Zqg.js +0 -25
  117. package/public/assets/index-DnUo0Zqg.js.map +0 -1
  118. package/public/assets/registry-5KhP5FE5.js +0 -2
  119. package/public/assets/registry-5KhP5FE5.js.map +0 -1
@@ -10,7 +10,12 @@ import { errMessage, isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
10
10
  import type {
11
11
  AgentProfileProvider,
12
12
  HookAssetDefinition,
13
+ McpApplicability,
13
14
  McpAssetDefinition,
15
+ McpEnvValue,
16
+ McpHealthProbe,
17
+ McpRunConfig,
18
+ McpSetupRecipe,
14
19
  PluginAssetDefinition,
15
20
  ProvisioningCapability,
16
21
  ProvisioningAsset,
@@ -41,6 +46,158 @@ const BUNDLED_SOURCE_TYPE = "bundled";
41
46
  // (src/db/provisioning-callmux.ts) when splitting literals from placeholders.
42
47
  export const SHARED_CALLMUX_CONFIG_ENV_ALLOWLIST: Readonly<Record<string, readonly string[]>> = { vent: ["VENT_REPO"] };
43
48
 
49
+ // #1030 slice 1 (#1110) — MCP setup recipe / run config validation.
50
+ const MCP_SETUP_KINDS = ["npm-global", "download", "bundled"] as const;
51
+ const MCP_TRANSPORTS = ["stdio", "ws", "http"] as const;
52
+ const MCP_HEALTH_PROBE_KINDS = ["tcp", "http", "none"] as const;
53
+ const MCP_LISTENER_MODES = ["shared", "per-agent"] as const;
54
+ // Exact pin only — no ranges ("^1.2.3"), tags ("latest", "next"), or partial versions
55
+ // ("1.2"). The same recipe must reproduce byte-for-byte on every host.
56
+ const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
57
+ const SHA256_HEX = /^[a-f0-9]{64}$/i;
58
+ // callmux's own secret-ref grammar (`env:NAME`, `file:/path`), plus `secret:<name>` for
59
+ // the relay-owned secret store a follow-on issue (#1111) resolves. A ref is stored and
60
+ // validated for shape only in this slice — no resolution (env lookup / file read /
61
+ // secret-store fetch) happens here.
62
+ const MCP_ENV_REF_SCHEMES = ["secret", "env", "file"] as const;
63
+ // Content-shaped credential detection for literal runConfig.env values (#1030 review
64
+ // steer): a value that looks like a real token/key must be rejected in favor of a
65
+ // `secret:<name>` reference — the registry never carries a resolved secret.
66
+ const INLINE_SECRET_CONTENT_PATTERNS: RegExp[] = [
67
+ /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i,
68
+ /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/,
69
+ /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/,
70
+ /\bsk-[A-Za-z0-9_-]{20,}\b/,
71
+ /\bAKIA[0-9A-Z]{16}\b/,
72
+ /^Bearer\s+\S{16,}$/i,
73
+ ];
74
+
75
+ function looksLikeInlineSecret(value: string): boolean {
76
+ if (INLINE_SECRET_CONTENT_PATTERNS.some((pattern) => pattern.test(value))) return true;
77
+ // Crude high-entropy heuristic: a long token-shaped string mixing upper/lower/digit.
78
+ return value.length >= 24 && /^[A-Za-z0-9_.-]+$/.test(value) && /[0-9]/.test(value) && /[A-Z]/.test(value) && /[a-z]/.test(value);
79
+ }
80
+
81
+ function assertMcpSetupRecipe(value: unknown): McpSetupRecipe {
82
+ if (!isRecord(value)) throw new ValidationError("mcp setup recipe must be an object");
83
+ const kind = value.kind;
84
+ if (kind === "npm-global") {
85
+ const pkg = value.pkg;
86
+ const version = value.version;
87
+ if (typeof pkg !== "string" || !pkg.trim()) throw new ValidationError("mcp setup npm-global requires pkg");
88
+ if (typeof version !== "string" || !EXACT_SEMVER.test(version)) {
89
+ throw new ValidationError("mcp setup npm-global requires an exact pinned version (e.g. 1.2.3), not a range or tag");
90
+ }
91
+ return { kind, pkg, version };
92
+ }
93
+ if (kind === "download") {
94
+ const url = value.url;
95
+ const sha256 = value.sha256;
96
+ const bin = value.bin;
97
+ if (typeof url !== "string" || !/^https:\/\//.test(url)) throw new ValidationError("mcp setup download requires an https url");
98
+ if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) throw new ValidationError("mcp setup download requires a sha256 checksum (64 hex chars)");
99
+ if (typeof bin !== "string" || !bin.trim()) throw new ValidationError("mcp setup download requires bin");
100
+ return { kind, url, sha256: sha256.toLowerCase(), bin };
101
+ }
102
+ if (kind === "bundled") return { kind: "bundled" };
103
+ throw new ValidationError(`mcp setup recipe kind must be one of: ${MCP_SETUP_KINDS.join(", ")}`);
104
+ }
105
+
106
+ // Splits a leading `<scheme>:<ref>` off an env value when scheme is one of the
107
+ // recognized MCP_ENV_REF_SCHEMES. Returns null for a bare literal (no colon, or a
108
+ // colon-containing literal like a Windows path whose prefix isn't a known scheme).
109
+ function parseMcpEnvRef(value: string): { scheme: (typeof MCP_ENV_REF_SCHEMES)[number]; ref: string } | null {
110
+ const colon = value.indexOf(":");
111
+ if (colon <= 0) return null;
112
+ const scheme = value.slice(0, colon);
113
+ if (!(MCP_ENV_REF_SCHEMES as readonly string[]).includes(scheme)) return null;
114
+ return { scheme: scheme as (typeof MCP_ENV_REF_SCHEMES)[number], ref: value.slice(colon + 1) };
115
+ }
116
+
117
+ function assertMcpRunConfigEnvValue(key: string, value: unknown): McpEnvValue {
118
+ if (!ENV_NAME.test(key)) throw new ValidationError(`mcp runConfig env key ${key} is not a valid env name`);
119
+ if (typeof value !== "string") throw new ValidationError(`mcp runConfig env ${key} must be a string (a literal, or a "secret:"/"env:"/"file:" reference)`);
120
+ const parsed = parseMcpEnvRef(value);
121
+ if (parsed) {
122
+ if (!parsed.ref.trim()) throw new ValidationError(`mcp runConfig env ${key} ${parsed.scheme}: reference must include a name/path`);
123
+ return value;
124
+ }
125
+ if (looksLikeInlineSecret(value) || (isSecretLikeEnvName(key) && !isEnvPlaceholder(value))) {
126
+ throw new ValidationError(`mcp runConfig env ${key} looks like an inline secret; use "secret:<name>" instead of a literal value`);
127
+ }
128
+ return value;
129
+ }
130
+
131
+ function assertMcpHealthProbe(value: unknown): McpHealthProbe {
132
+ if (!isRecord(value)) throw new ValidationError("mcp runConfig healthProbe must be an object");
133
+ const kind = value.kind;
134
+ if (kind !== "tcp" && kind !== "http" && kind !== "none") {
135
+ throw new ValidationError(`mcp runConfig healthProbe.kind must be one of: ${MCP_HEALTH_PROBE_KINDS.join(", ")}`);
136
+ }
137
+ const probe: McpHealthProbe = { kind };
138
+ if (value.path !== undefined) {
139
+ if (typeof value.path !== "string" || !value.path) throw new ValidationError("mcp runConfig healthProbe.path must be a non-empty string");
140
+ probe.path = value.path;
141
+ }
142
+ if (value.intervalMs !== undefined) {
143
+ if (typeof value.intervalMs !== "number" || !(value.intervalMs > 0)) throw new ValidationError("mcp runConfig healthProbe.intervalMs must be a positive number");
144
+ probe.intervalMs = value.intervalMs;
145
+ }
146
+ if (value.timeoutMs !== undefined) {
147
+ if (typeof value.timeoutMs !== "number" || !(value.timeoutMs > 0)) throw new ValidationError("mcp runConfig healthProbe.timeoutMs must be a positive number");
148
+ probe.timeoutMs = value.timeoutMs;
149
+ }
150
+ return probe;
151
+ }
152
+
153
+ function assertMcpRunConfig(value: unknown): McpRunConfig {
154
+ if (!isRecord(value)) throw new ValidationError("mcp runConfig must be an object");
155
+ const command = value.command;
156
+ if (typeof command !== "string" || !command.trim()) throw new ValidationError("mcp runConfig requires command");
157
+ const runConfig: McpRunConfig = { command, transport: "stdio", listenerMode: "shared" };
158
+ if (value.args !== undefined) {
159
+ if (!Array.isArray(value.args) || !value.args.every((arg) => typeof arg === "string")) throw new ValidationError("mcp runConfig args must be strings");
160
+ runConfig.args = value.args;
161
+ }
162
+ if (value.env !== undefined) {
163
+ if (!isRecord(value.env)) throw new ValidationError("mcp runConfig env must be an object");
164
+ const env: Record<string, McpEnvValue> = {};
165
+ for (const [key, raw] of Object.entries(value.env)) env[key] = assertMcpRunConfigEnvValue(key, raw);
166
+ runConfig.env = env;
167
+ }
168
+ const transport = value.transport;
169
+ if (transport !== "stdio" && transport !== "ws" && transport !== "http") {
170
+ throw new ValidationError(`mcp runConfig transport must be one of: ${MCP_TRANSPORTS.join(", ")}`);
171
+ }
172
+ runConfig.transport = transport;
173
+ if (value.healthProbe !== undefined) runConfig.healthProbe = assertMcpHealthProbe(value.healthProbe);
174
+ const listenerMode = value.listenerMode;
175
+ if (listenerMode !== "shared" && listenerMode !== "per-agent") {
176
+ throw new ValidationError(`mcp runConfig listenerMode must be one of: ${MCP_LISTENER_MODES.join(", ")}`);
177
+ }
178
+ runConfig.listenerMode = listenerMode;
179
+ return runConfig;
180
+ }
181
+
182
+ function assertMcpApplicability(value: unknown): McpApplicability {
183
+ if (!isRecord(value)) throw new ValidationError("mcp applicability must be an object");
184
+ const applicability: McpApplicability = {};
185
+ if (value.providers !== undefined) {
186
+ if (!Array.isArray(value.providers) || !value.providers.every((p) => typeof p === "string")) {
187
+ throw new ValidationError("mcp applicability.providers must be an array of strings");
188
+ }
189
+ for (const provider of value.providers) assertProvider(provider as AgentProfileProvider);
190
+ applicability.providers = value.providers as AgentProfileProvider[];
191
+ }
192
+ if (value.profiles !== undefined) {
193
+ if (!Array.isArray(value.profiles) || !value.profiles.every((p) => typeof p === "string" && p.trim())) {
194
+ throw new ValidationError("mcp applicability.profiles must be an array of non-empty strings");
195
+ }
196
+ applicability.profiles = value.profiles as string[];
197
+ }
198
+ return applicability;
199
+ }
200
+
44
201
  interface ProvisioningAssetRow {
45
202
  id: string;
46
203
  capability_id?: string | null;
@@ -179,6 +336,9 @@ export interface PatchProvisioningAssetVariantInput {
179
336
 
180
337
  export interface CreateMcpProvisioningVariantInput {
181
338
  server: unknown;
339
+ setup?: unknown;
340
+ runConfig?: unknown;
341
+ applicability?: unknown;
182
342
  enabled?: boolean;
183
343
  approvalStatus?: "approved" | "pending";
184
344
  versionConstraint?: string;
@@ -213,6 +373,9 @@ function assertDefinition(kind: ProvisioningAssetKind, definition: ResolvedAsset
213
373
  assertMcpSecretPlaceholders(def.server, def.name);
214
374
  const metadata = isRecord(def.metadata) ? def.metadata : {};
215
375
  if (isRecord(metadata.callmuxServer)) assertCallmuxServerPlaceholders(metadata.callmuxServer as ProvisioningMcpServer, def.name);
376
+ if (def.setup !== undefined) assertMcpSetupRecipe(def.setup);
377
+ if (def.runConfig !== undefined) assertMcpRunConfig(def.runConfig);
378
+ if (def.applicability !== undefined) assertMcpApplicability(def.applicability);
216
379
  }
217
380
  if (kind === "hook") {
218
381
  const def = definition as HookAssetDefinition;
@@ -873,11 +1036,21 @@ export function createMcpProvisioningVariant(
873
1036
  const capability = getProvisioningCapabilityRowById(capabilityId);
874
1037
  if (capability.kind !== "mcp") throw new ValidationError("MCP variants require an mcp capability");
875
1038
  const server = coerceMcpServer(input.server);
1039
+ const setup = input.setup !== undefined ? assertMcpSetupRecipe(input.setup) : undefined;
1040
+ const runConfig = input.runConfig !== undefined ? assertMcpRunConfig(input.runConfig) : undefined;
1041
+ const applicability = input.applicability !== undefined ? assertMcpApplicability(input.applicability) : undefined;
876
1042
  const asset = upsertProvisioningAsset({
877
1043
  kind: "mcp",
878
1044
  name: capability.slug,
879
1045
  provider: "any",
880
- definition: { kind: "mcp", name: capability.slug, server },
1046
+ definition: {
1047
+ kind: "mcp",
1048
+ name: capability.slug,
1049
+ server,
1050
+ ...(setup ? { setup } : {}),
1051
+ ...(runConfig ? { runConfig } : {}),
1052
+ ...(applicability ? { applicability } : {}),
1053
+ },
881
1054
  enabled: input.enabled,
882
1055
  });
883
1056
  const approvalStatus = input.approvalStatus ?? "approved";
@@ -48,6 +48,7 @@ import {
48
48
  import { routeNotification } from "../notification-router";
49
49
  import { pruneExpiredTokenRecords } from "../token-db";
50
50
  import { reapTransientAgents } from "../services/transient-agent-reaper";
51
+ import { markAgentWorkFinal } from "../services/work-finality";
51
52
  import { reapUnregisteredSpawnCommands } from "../services/spawn-registration-watchdog";
52
53
  import { resumeRateLimitedAgents } from "../services/rate-limit-resume";
53
54
  import { collectCodexWhamQuota } from "../codex-wham-quota";
@@ -334,7 +335,12 @@ export const definitions: MaintenanceJobDefinition[] = [
334
335
  intervalMs: REAP_INTERVAL_MS,
335
336
  runOnStart: false,
336
337
  handler() {
337
- const prunedAgentIds = pruneOfflineAgents(OFFLINE_PRUNE_MS);
338
+ // #1084 crash-final-loss fix-forward — defense-in-depth: `notifyAgentOffline` is the primary
339
+ // flush point when an agent first goes offline, so this is normally a no-op by the time an
340
+ // agent reaches prune horizon; kept here in case any offline transition ever bypasses it.
341
+ const prunedAgentIds = pruneOfflineAgents(OFFLINE_PRUNE_MS, {
342
+ beforeDelete: (id) => markAgentWorkFinal(id, "edge-fallback"),
343
+ });
338
344
  for (const id of prunedAgentIds) emitAgentRemoved(id);
339
345
  return { prunedAgentIds };
340
346
  },
@@ -361,7 +367,7 @@ export const definitions: MaintenanceJobDefinition[] = [
361
367
  runOnStart: false,
362
368
  handler: () => reapOrphanedSessions(),
363
369
  },
364
- { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
370
+ { id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed; also flushes a persistent agent's held work-final report once it has settled idle (#1084 HIGH fix-forward).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
365
371
  {
366
372
  id: "lineage-report-startup-sweep",
367
373
  title: "Lineage report startup sweep",
@@ -5,6 +5,7 @@ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
5
5
  import { describeWorkspacePhase, landReceipt, readyContract } from "../workspace-phase";
6
6
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "../workspace-actions";
7
7
  import { refreshWorkspaceGitState } from "../workspace-probe";
8
+ import { markAgentWorkFinal } from "../services/work-finality";
8
9
  import { optionalEnum } from "../validation";
9
10
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord } from "../types";
10
11
  import type { McpAuthContext, ToolDefinition } from "./types";
@@ -218,6 +219,9 @@ export async function relayWorkspaceMutation(auth: McpAuthContext, action: Works
218
219
  if (action === "ready") {
219
220
  payload.contract = readyContract(result.workspace);
220
221
  payload.guidance = describeWorkspacePhase(result.workspace);
222
+ // #1084 — relay_workspace_ready is an explicit, platform-owned completion signal: the caller
223
+ // is declaring its work window closed. Close it, forwarding only the held final group.
224
+ markAgentWorkFinal(result.workspace.ownerAgentId ?? callerAgentId(auth), "workspace-ready");
221
225
  }
222
226
  return payload;
223
227
  }
@@ -10,7 +10,6 @@ import { emitAgentRemoved, emitAgentStatus } from "../sse";
10
10
  import { notifyAgentReady } from "../agent-lifecycle-events";
11
11
  import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
12
12
  import { flushQueuedDirectMessages } from "../services/managed-running";
13
- import { flushIdleLineageReport } from "../services/send-message";
14
13
  import { isAgentUnavailable } from "../db/agent-predicates";
15
14
  import { getCompactionWatch } from "../compaction-watch";
16
15
  import { isRecord } from "agent-relay-sdk";
@@ -200,9 +199,6 @@ export const patchAgentStatus: Handler = async (req, params) => {
200
199
  const before = getAgent(params.id!);
201
200
  if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
202
201
  auditAgentStateTransition(params.id!, before, getAgent(params.id!));
203
- // #1040 — the HTTP status PATCH is also a busy→idle edge (a runner without a live bus socket).
204
- // Flush any held turn-final report-up so it reaches the spawner on this path too.
205
- if (before?.status === "busy" && body.status !== "busy") flushIdleLineageReport(params.id!);
206
202
  } catch (e) {
207
203
  if (e instanceof ValidationError) return error(e.message, 400);
208
204
  throw e;
@@ -134,6 +134,9 @@ export const postProvisioningVariantMcpRoute: Handler = async (req, params) => {
134
134
  const body = expectObject(parsed.body);
135
135
  const variant = createMcpProvisioningVariant(params.id!, {
136
136
  server: body.server ?? body,
137
+ setup: body.setup,
138
+ runConfig: body.runConfig,
139
+ applicability: body.applicability,
137
140
  enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
138
141
  approvalStatus: optionalEnum(body.approvalStatus, "approvalStatus", APPROVAL_STATUSES, "approved"),
139
142
  versionConstraint: cleanString(body.versionConstraint, "versionConstraint", { max: 120 }),
@@ -25,6 +25,8 @@ import type {
25
25
  DispatchRule,
26
26
  ProviderQuotaRoutingConfig,
27
27
  QuotaBandThresholds,
28
+ QuotaPaceBandThresholds,
29
+ QuotaPacingConfig,
28
30
  RoutingPolicy,
29
31
  RoutingRule,
30
32
  } from "agent-relay-sdk";
@@ -107,6 +109,7 @@ export function normalizeRoutingPolicy(value: unknown): RoutingPolicy {
107
109
  rules: normalizeRules(input.rules),
108
110
  ...(input.quotaBands !== undefined ? { quotaBands: normalizeQuotaBands(input.quotaBands) } : {}),
109
111
  ...(input.providerQuota !== undefined ? { providerQuota: normalizeProviderQuota(input.providerQuota) } : {}),
112
+ ...(input.quotaPacing !== undefined ? { quotaPacing: normalizeQuotaPacing(input.quotaPacing) } : {}),
110
113
  ...(input.dispatch !== undefined ? { dispatch: normalizeDispatchPolicy(input.dispatch) } : {}),
111
114
  };
112
115
  }
@@ -264,6 +267,58 @@ function normalizeQuotaBands(raw: unknown): Partial<QuotaBandThresholds> {
264
267
  return result;
265
268
  }
266
269
 
270
+ // #1098/#1105 — the budget-pacing controller's tunables. Same shape as normalizeQuotaBands: every
271
+ // field optional, so a partial PUT only overrides what it names and everything else keeps falling
272
+ // back to the provider-quota.ts defaults.
273
+ function normalizeQuotaPacing(raw: unknown): QuotaPacingConfig {
274
+ if (!isRecord(raw)) throw new ValidationError("quotaPacing must be an object");
275
+ const result: QuotaPacingConfig = {};
276
+ if (raw.delta !== undefined) {
277
+ if (typeof raw.delta !== "number" || !Number.isFinite(raw.delta) || raw.delta <= 0) {
278
+ throw new ValidationError("quotaPacing.delta must be a positive finite number");
279
+ }
280
+ result.delta = raw.delta;
281
+ }
282
+ if (raw.thresholds !== undefined) result.thresholds = normalizeQuotaPaceThresholds(raw.thresholds);
283
+ if (raw.windowLengthMs !== undefined) result.windowLengthMs = normalizeQuotaPaceWindowLengths(raw.windowLengthMs);
284
+ return result;
285
+ }
286
+
287
+ function normalizeQuotaPaceThresholds(raw: unknown): Partial<QuotaPaceBandThresholds> {
288
+ if (!isRecord(raw)) throw new ValidationError("quotaPacing.thresholds must be an object");
289
+ const result: Partial<QuotaPaceBandThresholds> = {};
290
+ if (raw.preferAt !== undefined) result.preferAt = cleanFraction(raw.preferAt, "quotaPacing.thresholds.preferAt");
291
+ if (raw.normalAt !== undefined) result.normalAt = cleanFraction(raw.normalAt, "quotaPacing.thresholds.normalAt");
292
+ if (raw.cautionAt !== undefined) result.cautionAt = cleanFraction(raw.cautionAt, "quotaPacing.thresholds.cautionAt");
293
+ // Descending: preferAt > normalAt > cautionAt (weight 1 = best/promote, 0 = worst/avoid).
294
+ const orderedPairs: Array<[string, number]> = [];
295
+ if (result.preferAt !== undefined) orderedPairs.push(["preferAt", result.preferAt]);
296
+ if (result.normalAt !== undefined) orderedPairs.push(["normalAt", result.normalAt]);
297
+ if (result.cautionAt !== undefined) orderedPairs.push(["cautionAt", result.cautionAt]);
298
+ for (let i = 0; i < orderedPairs.length - 1; i++) {
299
+ const cur = orderedPairs[i]!;
300
+ const next = orderedPairs[i + 1]!;
301
+ if (cur[1] <= next[1]) {
302
+ throw new ValidationError(`quotaPacing.thresholds.${cur[0]} must be greater than quotaPacing.thresholds.${next[0]}`);
303
+ }
304
+ }
305
+ return result;
306
+ }
307
+
308
+ function normalizeQuotaPaceWindowLengths(raw: unknown): Partial<Record<"5h" | "7d", number>> {
309
+ if (!isRecord(raw)) throw new ValidationError("quotaPacing.windowLengthMs must be an object");
310
+ const result: Partial<Record<"5h" | "7d", number>> = {};
311
+ for (const role of ["5h", "7d"] as const) {
312
+ const value = raw[role];
313
+ if (value === undefined) continue;
314
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
315
+ throw new ValidationError(`quotaPacing.windowLengthMs.${role} must be a positive finite number`);
316
+ }
317
+ result[role] = value;
318
+ }
319
+ return result;
320
+ }
321
+
267
322
  function normalizeProviderQuota(raw: unknown): Partial<Record<string, ProviderQuotaRoutingConfig>> {
268
323
  if (!isRecord(raw)) throw new ValidationError("providerQuota must be an object");
269
324
  const result: Partial<Record<string, ProviderQuotaRoutingConfig>> = {};
@@ -24,7 +24,6 @@
24
24
  import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId, isTurnFinalSession } from "agent-relay-sdk";
25
25
  import {
26
26
  ValidationError,
27
- flushDeferredLineageReport,
28
27
  getAgent,
29
28
  getMessage,
30
29
  latestInboundMessage,
@@ -228,28 +227,18 @@ function prepareSendMessage(input: SendMessageInput, ctx: AuthContext, opts: Sen
228
227
  return { receipt, suppressedParent: suppressResultsOnlyAck(input) };
229
228
  }
230
229
 
231
- /**
232
- * #1037 — the child transitioned out of busy (idle/awaiting-input). Deliver its deferred turn-final
233
- * report-up (a quiet child's last turn, held back so mid-work narration never reached the spawner)
234
- * and fan it out over SSE. No-op when nothing was deferred. Driven off the bus turn-end edge.
235
- */
236
- export function flushIdleLineageReport(childId: string): void {
237
- const promoted = flushDeferredLineageReport(childId);
238
- if (promoted?.created) emitNewMessages([promoted.message]);
239
- }
240
-
241
230
  /** How far back the startup sweep re-scans for held-but-lost turn-final captures (#1040). A report
242
- * is only ever deferred while its child is mid-turn, so any capture lost across a restart is recent;
231
+ * is only ever held while its work window is open, so any capture lost across a restart is recent;
243
232
  * a day is a generous ceiling that still bounds the scan. */
244
233
  const LINEAGE_STARTUP_SWEEP_WINDOW_MS = 24 * 60 * 60 * 1000;
245
234
 
246
235
  /**
247
- * #1040 — startup re-promotion sweep. `deferredLineageReports` is an in-memory Map, so a relay
248
- * restart drops every held turn-final report-up. The captures are persisted, though, so re-run
249
- * promotion for recent final response captures: an idle child whose report was lost gets it
250
- * delivered now, an already-delivered one is a no-op (idempotency key), and a still-busy child
251
- * re-defers (re-arming the in-memory hold so a later idle edge flushes it). Idempotent and safe to
252
- * run periodically.
236
+ * #1040/#1084 — startup re-promotion sweep. The held-turn-final group (db/messages.ts) is an
237
+ * in-memory Map, so a relay restart drops it. The captures are persisted, though, so re-run
238
+ * promotion for recent final response captures: this just REBUILDS the held group (an
239
+ * already-forwarded capture is a no-op via its idempotency key; a `verbose` child's capture is
240
+ * delivered immediately, as it always is). Delivery to the spawner still only ever happens via an
241
+ * explicit work-finality signal (markAgentWorkFinal) — this sweep never flushes on its own.
253
242
  */
254
243
  export function sweepDeferredLineageReportsOnStartup(now: number = Date.now()): { promoted: number; scanned: number } {
255
244
  const captures = listRecentFinalResponseCaptures(now - LINEAGE_STARTUP_SWEEP_WINDOW_MS);
@@ -37,6 +37,7 @@ import { createCommand } from "../commands-db";
37
37
  import { emitCommandEvent } from "../command-events";
38
38
  import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
39
39
  import { emitActivityEvent, emitAgentStatus } from "../sse";
40
+ import { markAgentWorkFinal } from "./work-finality";
40
41
  import { stringValue } from "agent-relay-sdk";
41
42
  import type { AuthContext } from "./auth-context";
42
43
  import type { AgentCard, Command } from "../types";
@@ -211,6 +212,11 @@ export function shutdownAgent(input: ShutdownAgentInput, ctx: AuthContext): Shut
211
212
  },
212
213
  });
213
214
  emitCommandEvent(command, "command.requested");
215
+ // #1084 — an explicit shutdown is a platform-owned teardown signal: close the target's work
216
+ // window now rather than let a held final group die silently with the process. `edge-fallback`
217
+ // is the closest fit in the closed source enum — there is no cleaner workspace-ready/task-done
218
+ // signal backing an arbitrary teardown (which may target an agent with neither).
219
+ if (targetAgentId) markAgentWorkFinal(targetAgentId, "edge-fallback");
214
220
 
215
221
  // Lifecycle indicator — uniform across transports. The MCP path previously skipped
216
222
  // this, so an MCP-initiated shutdown left the agent with no "shutting down" signal in
@@ -44,6 +44,7 @@ import { emitRelayEvent } from "../events";
44
44
  import { routeNotification } from "../notification-router";
45
45
  import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
46
46
  import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
47
+ import { markAgentWorkFinal } from "./work-finality";
47
48
 
48
49
  type CreateTaskEntityInput = CreateDeliverableTaskInput & {
49
50
  issueRefs?: string[];
@@ -140,6 +141,9 @@ export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): Task
140
141
  });
141
142
  }
142
143
  if (isTerminalTaskStatus(detail.status)) removeSubscriptionsForTask(detail.id);
144
+ // #1084 — a task reaching `done` is an explicit, platform-owned completion signal for its
145
+ // owning agent's work window: close it, forwarding only the held final group.
146
+ if (before?.status !== "done" && detail.status === "done") markAgentWorkFinal(detail.ownerAgentId, "task-done");
143
147
  return detail;
144
148
  }
145
149
 
@@ -8,6 +8,7 @@ import { relayToken } from "../config";
8
8
  import { workspaceOwnerOrchestrator } from "../workspace-host";
9
9
  import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
10
10
  import { finalizeEphemeralTasksOnReap } from "./task-entity";
11
+ import { markAgentWorkFinal } from "./work-finality";
11
12
  import type { AgentCard, Command, WorkspaceGitState, WorkspaceRecord } from "../types";
12
13
 
13
14
  // The single home for "dispose a transient/one-shot agent" — the canonical `agent.shutdown`
@@ -38,6 +39,10 @@ export function requestTransientAgentShutdown(agent: AgentCard, opts: TransientS
38
39
  });
39
40
  emitRelayEvent({ type: "command.requested", source: command.source, subject: command.id, data: { command } });
40
41
  emitRelayEvent({ type: "agent.exited", source: "server", subject: agent.id, data: { agentId: agent.id, parent: agent.spawnedBy, reason: opts.reason, workspaceId: opts.workspaceId, workspaceStatus: opts.workspaceStatus } });
42
+ // #1084 — reaping a transient agent always follows a verified-complete land/idle-grace check
43
+ // (the caller only reaches here once `land.complete`/gates-passed holds), so it is a genuine
44
+ // teardown completion signal: close its work window, forwarding only the held final group.
45
+ markAgentWorkFinal(agent.id, "edge-fallback");
41
46
  // #645 — persist a clean terminal marker so the #634 heartbeat-gap watchdog reads this benign
42
47
  // completion as a terminus (not a silent death) once the reaped worker stops heartbeating.
43
48
  recordTerminalEvent({ agentId: agent.id, reason: opts.reason, kind: "agent.exited", epoch: agent.epoch, createdAt: now });
@@ -67,6 +72,20 @@ export function resetTransientTrackerForTests(): void {
67
72
  transientTracker.clear();
68
73
  }
69
74
 
75
+ // #1084 HIGH crash-final-loss fix-forward — a persistent (or autonomous) worker never enters the
76
+ // transient reap path above (it keeps running; land-and-continue). Its held work-final ring would
77
+ // otherwise only ever flush on an eventual explicit teardown (shutdownAgent) — hours away, or never,
78
+ // for a long-lived agent that simply idles after reporting in. This mirrors the SAME idle-grace
79
+ // mechanics as the transient tracker (aligned on `transientGraceMs`, not trigger-happy: a coordinator
80
+ // steer resets the clock the moment the agent goes busy again, exactly like `transientTracker`), but
81
+ // only ever calls `markAgentWorkFinal` — it never shuts the agent down.
82
+ const persistentIdleFinalityMs = () => envMsOrDefault("AGENT_RELAY_PERSISTENT_IDLE_FINALITY_MS", 2 * 60 * 1000);
83
+ const persistentIdleTracker = new Map<string, { firstIdleAt: number; lastFlushedAt?: number }>();
84
+
85
+ export function resetPersistentIdleTrackerForTests(): void {
86
+ persistentIdleTracker.clear();
87
+ }
88
+
70
89
  // How many commits the agent's branch has ahead of its base. Returns null when
71
90
  // the check can't run (missing fields, host/probe failure) — callers treat null as
72
91
  // "unknown" and preserve the safe #614 hold-pending bias: only 0 is fast-reaped.
@@ -234,9 +253,28 @@ export async function reapTransientAgents(): Promise<Record<string, unknown>> {
234
253
  const now = Date.now();
235
254
  const grace = transientGraceMs();
236
255
  const maxLandWait = transientMaxLandWaitMs();
256
+ const persistentGrace = persistentIdleFinalityMs();
237
257
  const seen = new Set<string>();
258
+ const seenPersistent = new Set<string>();
259
+ const persistentFinalsFlushed: string[] = [];
238
260
  const reaped: string[] = [], held: string[] = [], waiting: string[] = [];
239
261
  for (const agent of listAgents()) {
262
+ if (agent.lifecycle === "persistent") {
263
+ // No parent means no held ring can ever be flushed to anyone (flushWorkFinalReports
264
+ // requires a spawner) — skip tracking it at all rather than churn the map every scan.
265
+ if (!agent.spawnedBy) continue;
266
+ seenPersistent.add(agent.id);
267
+ if (reapProtected(agent)) { persistentIdleTracker.delete(agent.id); continue; }
268
+ if (completedProviderTurns(agent) === 0) { persistentIdleTracker.delete(agent.id); continue; }
269
+ const persistentEntry = persistentIdleTracker.get(agent.id) ?? { firstIdleAt: now };
270
+ persistentIdleTracker.set(agent.id, persistentEntry);
271
+ const persistentIdleForMs = now - persistentEntry.firstIdleAt;
272
+ if (persistentIdleForMs < persistentGrace || persistentEntry.lastFlushedAt) continue;
273
+ persistentEntry.lastFlushedAt = now;
274
+ markAgentWorkFinal(agent.id, "edge-fallback");
275
+ persistentFinalsFlushed.push(agent.id);
276
+ continue;
277
+ }
240
278
  if (agent.lifecycle !== "transient") continue;
241
279
  seen.add(agent.id);
242
280
  // A held agent (rate-limit hold #286, or any provider-blocked state) reads as
@@ -282,5 +320,6 @@ export async function reapTransientAgents(): Promise<Record<string, unknown>> {
282
320
  reaped.push(agent.id);
283
321
  }
284
322
  for (const key of transientTracker.keys()) if (!seen.has(key)) transientTracker.delete(key);
285
- return { scanned: seen.size, waiting, held, reaped, tracked: transientTracker.size };
323
+ for (const key of persistentIdleTracker.keys()) if (!seenPersistent.has(key)) persistentIdleTracker.delete(key);
324
+ return { scanned: seen.size, waiting, held, reaped, tracked: transientTracker.size, persistentFinalsFlushed };
286
325
  }
@@ -0,0 +1,25 @@
1
+ // #1084 (Part D of #1079) — the work-window finality service. A "work-final" is a RELAY-LEVEL
2
+ // mark anchored to explicit completion signals the platform already owns: `relay_workspace_ready`
3
+ // (src/mcp/tools-workspace.ts), a task's execution status reaching `done` (src/services/task-entity.ts),
4
+ // and agent teardown/reap (src/services/shutdown-agent.ts). It is deliberately NOT anchored to a
5
+ // per-turn busy→idle bus edge — that heuristic is exactly what produced the false-final flood this
6
+ // issue supersedes (#1078/#1058/#1032/#976): a child idling briefly between two subtasks is
7
+ // indistinguishable, on the wire, from a child that just finished.
8
+ //
9
+ // markAgentWorkFinal is the single entry point every anchor calls. It closes the child's held
10
+ // turn-final group (db/messages.ts flushWorkFinalReports) as ONE work-final unit and fans the
11
+ // result out over SSE: newly-forwarded chat reports as `message.new` (the coordinator's inbox), and
12
+ // the original already-rendered session captures as `message.finalized` (so a bubble the dashboard
13
+ // showed as ordinary mid-turn narration can be retroactively upgraded once its window closes).
14
+ import type { MessageWorkFinalitySource } from "agent-relay-sdk";
15
+ import { flushWorkFinalReports, type WorkFinalFlushResult } from "../db/messages.ts";
16
+ import { emitMessageFinalized, emitNewMessages } from "../sse.ts";
17
+
18
+ export function markAgentWorkFinal(agentId: string | undefined | null, source: MessageWorkFinalitySource): WorkFinalFlushResult {
19
+ if (!agentId) return { forwarded: [], finalizedOriginals: [] };
20
+ const result = flushWorkFinalReports(agentId, source);
21
+ const created = result.forwarded.filter((entry) => entry.created).map((entry) => entry.message);
22
+ if (created.length > 0) emitNewMessages(created);
23
+ for (const original of result.finalizedOriginals) emitMessageFinalized(original);
24
+ return result;
25
+ }