agent-relay-server 0.125.0 → 0.127.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 (156) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +9 -5
  3. package/public/assets/{MessageBubble-BYeYFteG.js → MessageBubble-D2cnxyTy.js} +2 -2
  4. package/public/assets/{MessageBubble-BYeYFteG.js.map → MessageBubble-D2cnxyTy.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-iAXvkZKW.js → PlanGraphPanel-Cnxt-x7R.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-iAXvkZKW.js.map → PlanGraphPanel-Cnxt-x7R.js.map} +1 -1
  7. package/public/assets/{activity-Du9SRJt5.js → activity-BEm1BQB1.js} +2 -2
  8. package/public/assets/{activity-Du9SRJt5.js.map → activity-BEm1BQB1.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DXDQnrlH.js → agent-profiles-C8V_oCiM.js} +2 -2
  10. package/public/assets/{agent-profiles-DXDQnrlH.js.map → agent-profiles-C8V_oCiM.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-B3i0rndK.js → agent-type-icon-BmBR-9CH.js} +2 -2
  12. package/public/assets/{agent-type-icon-B3i0rndK.js.map → agent-type-icon-BmBR-9CH.js.map} +1 -1
  13. package/public/assets/{agents-CWOdKrxP.js → agents-D8mc9X1j.js} +2 -2
  14. package/public/assets/{agents-CWOdKrxP.js.map → agents-D8mc9X1j.js.map} +1 -1
  15. package/public/assets/{analytics-YcY59Hv9.js → analytics-BZtRycyL.js} +2 -2
  16. package/public/assets/{analytics-YcY59Hv9.js.map → analytics-BZtRycyL.js.map} +1 -1
  17. package/public/assets/{automation-CItTse7F.js → automation-BEhJZkNQ.js} +2 -2
  18. package/public/assets/{automation-CItTse7F.js.map → automation-BEhJZkNQ.js.map} +1 -1
  19. package/public/assets/{badge-XEru6wto.js → badge-BCvp7LK3.js} +2 -2
  20. package/public/assets/{badge-XEru6wto.js.map → badge-BCvp7LK3.js.map} +1 -1
  21. package/public/assets/{branch-state-badge-QjTktTYj.js → branch-state-badge-Cscjqf-E.js} +2 -2
  22. package/public/assets/{branch-state-badge-QjTktTYj.js.map → branch-state-badge-Cscjqf-E.js.map} +1 -1
  23. package/public/assets/{button-C3CQSr4p.js → button-DyHjsHY3.js} +2 -2
  24. package/public/assets/{button-C3CQSr4p.js.map → button-DyHjsHY3.js.map} +1 -1
  25. package/public/assets/{card-C5XTIPvq.js → card-BoqYxpVW.js} +2 -2
  26. package/public/assets/{card-C5XTIPvq.js.map → card-BoqYxpVW.js.map} +1 -1
  27. package/public/assets/{channels-BSFTsbhT.js → channels-DAtOnA6o.js} +2 -2
  28. package/public/assets/{channels-BSFTsbhT.js.map → channels-DAtOnA6o.js.map} +1 -1
  29. package/public/assets/chat-BAH9bEPx.js +2 -0
  30. package/public/assets/chat-BAH9bEPx.js.map +1 -0
  31. package/public/assets/{connectors-C7d5dlYz.js → connectors-B2grC-hl.js} +2 -2
  32. package/public/assets/{connectors-C7d5dlYz.js.map → connectors-B2grC-hl.js.map} +1 -1
  33. package/public/assets/{copy-button-Bil2CjzT.js → copy-button-C67FUG3E.js} +2 -2
  34. package/public/assets/{copy-button-Bil2CjzT.js.map → copy-button-C67FUG3E.js.map} +1 -1
  35. package/public/assets/{dist-V3wu3BHw.js → dist-BD4Xc13v.js} +2 -2
  36. package/public/assets/{dist-V3wu3BHw.js.map → dist-BD4Xc13v.js.map} +1 -1
  37. package/public/assets/{dist-C5z05Vkz.js → dist-BFDfbfNC.js} +2 -2
  38. package/public/assets/{dist-C5z05Vkz.js.map → dist-BFDfbfNC.js.map} +1 -1
  39. package/public/assets/{dist-CjyfQThq.js → dist-BJKxqIQd.js} +2 -2
  40. package/public/assets/{dist-CjyfQThq.js.map → dist-BJKxqIQd.js.map} +1 -1
  41. package/public/assets/{dist-B8tD9ZcR.js → dist-BNB3fTO-.js} +2 -2
  42. package/public/assets/{dist-B8tD9ZcR.js.map → dist-BNB3fTO-.js.map} +1 -1
  43. package/public/assets/{dist-CFtFIhu3.js → dist-CHKra2JV.js} +2 -2
  44. package/public/assets/{dist-CFtFIhu3.js.map → dist-CHKra2JV.js.map} +1 -1
  45. package/public/assets/{dist-r8wyUTkK.js → dist-CQMQ5iku.js} +2 -2
  46. package/public/assets/{dist-r8wyUTkK.js.map → dist-CQMQ5iku.js.map} +1 -1
  47. package/public/assets/{dist-DKmTXfpP.js → dist-C_VOBSaK.js} +2 -2
  48. package/public/assets/{dist-DKmTXfpP.js.map → dist-C_VOBSaK.js.map} +1 -1
  49. package/public/assets/{dist-CGaHpxnN.js → dist-D5w04z0l.js} +2 -2
  50. package/public/assets/{dist-CGaHpxnN.js.map → dist-D5w04z0l.js.map} +1 -1
  51. package/public/assets/{dist-4VwhnLtj.js → dist-DG75bvPp.js} +2 -2
  52. package/public/assets/{dist-4VwhnLtj.js.map → dist-DG75bvPp.js.map} +1 -1
  53. package/public/assets/{dist-CrUl2aQp.js → dist-G8YBD-wp.js} +2 -2
  54. package/public/assets/{dist-CrUl2aQp.js.map → dist-G8YBD-wp.js.map} +1 -1
  55. package/public/assets/{dist-BJaV2BD4.js → dist-f6MW74eX.js} +2 -2
  56. package/public/assets/{dist-BJaV2BD4.js.map → dist-f6MW74eX.js.map} +1 -1
  57. package/public/assets/{es2015-5DTJe35n.js → es2015-a6fysEVz.js} +2 -2
  58. package/public/assets/{es2015-5DTJe35n.js.map → es2015-a6fysEVz.js.map} +1 -1
  59. package/public/assets/formatted-body-Rv9RDPsQ.js +3 -0
  60. package/public/assets/{formatted-body-BuLfVMeD.js.map → formatted-body-Rv9RDPsQ.js.map} +1 -1
  61. package/public/assets/{formatted-body-impl-DO-8giS6.js → formatted-body-impl-XFU4tPeT.js} +2 -2
  62. package/public/assets/{formatted-body-impl-DO-8giS6.js.map → formatted-body-impl-XFU4tPeT.js.map} +1 -1
  63. package/public/assets/{index-De1tR7J4.js → index-Bhy_jnX4.js} +6 -6
  64. package/public/assets/{index-De1tR7J4.js.map → index-Bhy_jnX4.js.map} +1 -1
  65. package/public/assets/index-DHzXsQot.css +2 -0
  66. package/public/assets/{input-CXMbJ848.js → input-Rc4oWv4q.js} +2 -2
  67. package/public/assets/{input-CXMbJ848.js.map → input-Rc4oWv4q.js.map} +1 -1
  68. package/public/assets/{insights-Ci3VuA5Y.js → insights-DpXPAEwH.js} +2 -2
  69. package/public/assets/{insights-Ci3VuA5Y.js.map → insights-DpXPAEwH.js.map} +1 -1
  70. package/public/assets/{integrations-BboxWQQD.js → integrations-9c6IdRU4.js} +2 -2
  71. package/public/assets/{integrations-BboxWQQD.js.map → integrations-9c6IdRU4.js.map} +1 -1
  72. package/public/assets/{lib-BcUW1l-W.js → lib-BZ6WPg2t.js} +2 -2
  73. package/public/assets/{lib-BcUW1l-W.js.map → lib-BZ6WPg2t.js.map} +1 -1
  74. package/public/assets/lucide-react-NXHPIMsX.js +10 -0
  75. package/public/assets/lucide-react-NXHPIMsX.js.map +1 -0
  76. package/public/assets/{maintenance-CO1miJIJ.js → maintenance-BrkqfGwx.js} +2 -2
  77. package/public/assets/{maintenance-CO1miJIJ.js.map → maintenance-BrkqfGwx.js.map} +1 -1
  78. package/public/assets/{managed-agents-r4oYK3Fm.js → managed-agents-CLANxOAN.js} +2 -2
  79. package/public/assets/{managed-agents-r4oYK3Fm.js.map → managed-agents-CLANxOAN.js.map} +1 -1
  80. package/public/assets/{markdown-preview-impl-5CCM9iyL.js → markdown-preview-impl-CcZFkjn-.js} +2 -2
  81. package/public/assets/{markdown-preview-impl-5CCM9iyL.js.map → markdown-preview-impl-CcZFkjn-.js.map} +1 -1
  82. package/public/assets/{memory-BJJS07W-.js → memory-BoQw8q-i.js} +2 -2
  83. package/public/assets/{memory-BJJS07W-.js.map → memory-BoQw8q-i.js.map} +1 -1
  84. package/public/assets/{messages-RT_dfEsL.js → messages-DfKNuph2.js} +2 -2
  85. package/public/assets/{messages-RT_dfEsL.js.map → messages-DfKNuph2.js.map} +1 -1
  86. package/public/assets/{orchestrators-8hyl8hW1.js → orchestrators-sQh8e7-V.js} +2 -2
  87. package/public/assets/{orchestrators-8hyl8hW1.js.map → orchestrators-sQh8e7-V.js.map} +1 -1
  88. package/public/assets/{overview-CUJ9QmW_.js → overview-B0z8ZNtp.js} +2 -2
  89. package/public/assets/{overview-CUJ9QmW_.js.map → overview-B0z8ZNtp.js.map} +1 -1
  90. package/public/assets/{pairs-CzkzMGZq.js → pairs-B3FhGmn-.js} +2 -2
  91. package/public/assets/{pairs-CzkzMGZq.js.map → pairs-B3FhGmn-.js.map} +1 -1
  92. package/public/assets/{projects-CAuLWBU5.js → projects-iDkxAGBW.js} +2 -2
  93. package/public/assets/{projects-CAuLWBU5.js.map → projects-iDkxAGBW.js.map} +1 -1
  94. package/public/assets/{prompt-settings-BnB90R7F.js → prompt-settings-CCj_s008.js} +2 -2
  95. package/public/assets/{prompt-settings-BnB90R7F.js.map → prompt-settings-CCj_s008.js.map} +1 -1
  96. package/public/assets/{react-dom-BQICbUuF.js → react-dom-8yaD9iJG.js} +2 -2
  97. package/public/assets/{react-dom-BQICbUuF.js.map → react-dom-8yaD9iJG.js.map} +1 -1
  98. package/public/assets/{registry-B8TInqru.js → registry-mD_y06Ru.js} +2 -2
  99. package/public/assets/{registry-B8TInqru.js.map → registry-mD_y06Ru.js.map} +1 -1
  100. package/public/assets/{security-DG_VVZUW.js → security-CTSA9niL.js} +2 -2
  101. package/public/assets/{security-DG_VVZUW.js.map → security-CTSA9niL.js.map} +1 -1
  102. package/public/assets/{select-BJ8G38_V.js → select-gEQEHCIf.js} +2 -2
  103. package/public/assets/{select-BJ8G38_V.js.map → select-gEQEHCIf.js.map} +1 -1
  104. package/public/assets/{settings-CK_T9z5w.js → settings-ByAR-TYj.js} +2 -2
  105. package/public/assets/{settings-CK_T9z5w.js.map → settings-ByAR-TYj.js.map} +1 -1
  106. package/public/assets/store-D-Frk_EC.js +9 -0
  107. package/public/assets/store-D-Frk_EC.js.map +1 -0
  108. package/public/assets/{task-chip-BHM-e3I8.js → task-chip-CeujtXYT.js} +2 -2
  109. package/public/assets/{task-chip-BHM-e3I8.js.map → task-chip-CeujtXYT.js.map} +1 -1
  110. package/public/assets/{tasks-Briui9Mp.js → tasks-CZyJX881.js} +2 -2
  111. package/public/assets/{tasks-Briui9Mp.js.map → tasks-CZyJX881.js.map} +1 -1
  112. package/public/assets/{teams-Ck4EVV3R.js → teams-BrL4sEL7.js} +2 -2
  113. package/public/assets/{teams-Ck4EVV3R.js.map → teams-BrL4sEL7.js.map} +1 -1
  114. package/public/assets/{terminal-viewer-impl-66ZjVN1v.js → terminal-viewer-impl-LjIph8ql.js} +2 -2
  115. package/public/assets/{terminal-viewer-impl-66ZjVN1v.js.map → terminal-viewer-impl-LjIph8ql.js.map} +1 -1
  116. package/public/assets/types-BqeSkWrv.js.map +1 -1
  117. package/public/assets/{use-keyboard-viewport-CAa7IQTm.js → use-keyboard-viewport-cGEmrGE7.js} +2 -2
  118. package/public/assets/{use-keyboard-viewport-CAa7IQTm.js.map → use-keyboard-viewport-cGEmrGE7.js.map} +1 -1
  119. package/public/assets/{user-avatar-BOy9o2xK.js → user-avatar-qbo-B3dQ.js} +2 -2
  120. package/public/assets/{user-avatar-BOy9o2xK.js.map → user-avatar-qbo-B3dQ.js.map} +1 -1
  121. package/public/assets/{work-queue-CnghFRG2.js → work-queue-CSu5kRsw.js} +2 -2
  122. package/public/assets/{work-queue-CnghFRG2.js.map → work-queue-CSu5kRsw.js.map} +1 -1
  123. package/public/assets/{workspaces-BzmQnzSm.js → workspaces-CoDz-iZd.js} +2 -2
  124. package/public/assets/{workspaces-BzmQnzSm.js.map → workspaces-CoDz-iZd.js.map} +1 -1
  125. package/public/index.html +24 -24
  126. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  127. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +2 -2
  128. package/runner/src/adapters/codex-agent-message-capture.ts +38 -8
  129. package/runner/src/relay-mcp-proxy.ts +1 -3
  130. package/runner/src/runner-core.ts +33 -13
  131. package/runner/src/runner-helpers.ts +31 -0
  132. package/src/db/index.ts +1 -0
  133. package/src/db/messages.ts +38 -2
  134. package/src/db/task-deliverables.ts +206 -0
  135. package/src/db/task-entity.ts +4 -8
  136. package/src/mcp/index.ts +4 -4
  137. package/src/mcp/tools-workspace.ts +3 -36
  138. package/src/mcp-task-deliver-tools.ts +143 -0
  139. package/src/notification-rules.ts +1 -0
  140. package/src/notification-types.ts +2 -0
  141. package/src/prompt-resolver.ts +6 -6
  142. package/src/routes/agent-sessions.ts +10 -7
  143. package/src/routes/orchestrator.ts +5 -2
  144. package/src/routes/settings.ts +31 -2
  145. package/src/services/restart-instructions.ts +36 -0
  146. package/src/services/task-delivery.ts +275 -0
  147. package/src/settings/registry.ts +14 -3
  148. package/src/workspace-phase.ts +5 -5
  149. package/public/assets/chat-CRzFSFWe.js +0 -2
  150. package/public/assets/chat-CRzFSFWe.js.map +0 -1
  151. package/public/assets/formatted-body-BuLfVMeD.js +0 -3
  152. package/public/assets/index-C9X_0QBt.css +0 -2
  153. package/public/assets/lucide-react-CoU_Xts2.js +0 -10
  154. package/public/assets/lucide-react-CoU_Xts2.js.map +0 -1
  155. package/public/assets/store-Dl9Z8YUS.js +0 -9
  156. package/public/assets/store-Dl9Z8YUS.js.map +0 -1
@@ -4,7 +4,7 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
4
4
  import { notifyAgentOffline } from "../agent-lifecycle-events";
5
5
  import { diagnoseAgentDeath } from "../agent-death-diagnosis";
6
6
  import { VERSION } from "../config";
7
- import { ValidationError, deleteOrchestrator, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
7
+ import { ValidationError, deleteOrchestrator, getAgent, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
8
8
  import { auditEvent, authAuditMetadata, authorizeRoute, cleanJsonArray, cleanSafeNumber, dashboardAttribution, emitCommand, error, isRootCredentialRequest, json, parseBody, spawnRequestId, type Handler } from "./_shared";
9
9
  import { cleanMeta, cleanString, cleanStringArray, cleanWorkspaceMetadata, optionalEnum } from "../validation";
10
10
  import { createCommand, updateCommand } from "../commands-db";
@@ -13,6 +13,7 @@ import { getLifecycleManager } from "../lifecycle-manager";
13
13
  import { issueOrchestratorRuntimeToken } from "../runtime-tokens";
14
14
  import { isOrchestratorRegistrationAuthorized } from "../security";
15
15
  import { type ManagedAgent, type ManagedSessionExitDiagnostics, type OrchestratorRuntimeInput, type RegisterOrchestratorInput, type SpawnApprovalMode, type SpawnProvider } from "../types";
16
+ import { restartSpawnParamsForAgent } from "./agent-sessions";
16
17
 
17
18
  function cleanProtocolVersion(value: unknown): number | undefined {
18
19
  if (value === undefined) return undefined;
@@ -533,13 +534,15 @@ export const postOrchestratorAction: Handler = async (req, params) => {
533
534
  resource: { orchestratorId: orch.id, agentId, policyName, spawnRequestId },
534
535
  });
535
536
  if (denied) return denied;
537
+ const agent = action === "restart" && agentId ? getAgent(agentId) : undefined;
538
+ const restartSpawn = agent ? restartSpawnParamsForAgent(agent, orch, policyName, spawnRequestId) : undefined;
536
539
 
537
540
  const command = createCommand({
538
541
  type: action === "restart" ? "agent.restart" : "agent.shutdown",
539
542
  source: "system",
540
543
  target: orch.agentId,
541
544
  correlationId: spawnRequestId,
542
- params: { action, agentId, policyName, spawnRequestId, sessionName, tmuxSession, reason, requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
545
+ params: { action, agentId, policyName, spawnRequestId, sessionName, tmuxSession, reason, ...(restartSpawn ? { restartSpawn } : {}), requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
543
546
  });
544
547
  emitCommand(command);
545
548
  auditEvent({
@@ -10,7 +10,7 @@ import { isRequestAuthorizedFor } from "../security";
10
10
  import { cleanString } from "../validation";
11
11
  import { emitConfigChanged } from "../sse";
12
12
  import { error, json, normalizeConfigPathParam, parseBody, requestUserId, type Handler } from "./_shared";
13
- import { DEFAULT_USER_ID, isRecord, type ConfigEntry, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
13
+ import { DEFAULT_USER_ID, isRecord, type ConfigEntry, type JSONSchema, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
14
14
 
15
15
  type SettingEnvelope = SettingEntry;
16
16
 
@@ -91,7 +91,10 @@ async function putSetting(req: Request, namespaceRaw: string | undefined, keyRaw
91
91
  if (!validation.valid) return json({ error: "invalid setting value", details: validation.errors }, 400);
92
92
  if (descriptor.tier === "user") {
93
93
  const userId = requestUserId(req);
94
- const userEntry = setUserSetting(userId, namespace, key, parsed.body.value, updatedBy);
94
+ const valueToStore = descriptor.mergeMapKeys && isRecord(parsed.body.value)
95
+ ? mergeMapKeysIntoExisting(getUserSetting(userId, namespace, key)?.value, parsed.body.value, descriptor.schema)
96
+ : parsed.body.value;
97
+ const userEntry = setUserSetting(userId, namespace, key, valueToStore, updatedBy);
95
98
  emitConfigChanged(userEntry.namespace, userEntry.key, userEntry.version);
96
99
  return json(settingEnvelope(req, descriptor, userEntry), userEntry.version === 1 ? 201 : 200);
97
100
  }
@@ -156,3 +159,29 @@ function mergeSettingDefaults(defaults: unknown, value: unknown): unknown {
156
159
  }
157
160
  return merged;
158
161
  }
162
+
163
+ // A `mergeMapKeys` descriptor (#1124) is a dictionary keyed by caller-chosen ids (e.g. one row
164
+ // per conversation). A PUT only ever knows its own device's changed keys, never every other
165
+ // device's, so overwriting the whole stored value like a normal setting would erase them — this
166
+ // merges just the incoming keys on top of whatever is already stored instead. The merged result
167
+ // is then capped at the schema's `maxProperties` (when set) so per-key merges from unboundedly
168
+ // many devices can't grow the row forever; the entry with the OLDEST `updatedAt` is evicted first
169
+ // (missing/non-numeric `updatedAt`, e.g. a pre-#1124 row, sorts as oldest: 0).
170
+ function mergeMapKeysIntoExisting(existing: unknown, incoming: Record<string, unknown>, schema: JSONSchema): Record<string, unknown> {
171
+ const merged: Record<string, unknown> = isRecord(existing) ? { ...existing } : {};
172
+ for (const [key, item] of Object.entries(incoming)) merged[key] = item;
173
+
174
+ const cap = typeof schema.maxProperties === "number" ? schema.maxProperties : undefined;
175
+ const entries = Object.entries(merged);
176
+ if (cap !== undefined && entries.length > cap) {
177
+ entries.sort((a, b) => entryUpdatedAt(a[1]) - entryUpdatedAt(b[1]));
178
+ const overflow = entries.length - cap;
179
+ for (let i = 0; i < overflow; i += 1) delete merged[entries[i]![0]];
180
+ }
181
+ return merged;
182
+ }
183
+
184
+ function entryUpdatedAt(value: unknown): number {
185
+ if (isRecord(value) && typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt)) return value.updatedAt;
186
+ return 0;
187
+ }
@@ -0,0 +1,36 @@
1
+ import { isSpawnProvider } from "agent-relay-sdk";
2
+ import { getAgentProfile } from "../config-store";
3
+ import { readSpawnCallerSystemPromptAppend, resolveAgentOwnership, withResolvedProvisioning } from "../db";
4
+ import { composeProfileSpawnInstructions } from "../project-instructions";
5
+ import { applyDefaultSharedMcp } from "../shared-mcp";
6
+ import type { AgentCard } from "../types";
7
+
8
+ function metaString(meta: Record<string, unknown> | undefined, key: string): string | undefined {
9
+ const value = meta?.[key];
10
+ return typeof value === "string" ? value : undefined;
11
+ }
12
+
13
+ export function restartInstructionParamsForAgent(
14
+ agent: AgentCard,
15
+ opts: { spawnRequestId?: string; cwd?: string } = {},
16
+ ): Record<string, unknown> | undefined {
17
+ const provider = metaString(agent.meta, "provider");
18
+ if (!isSpawnProvider(provider)) return undefined;
19
+ const cwd = opts.cwd ?? metaString(agent.meta, "cwd");
20
+ const profileName = metaString(agent.meta, "profile");
21
+ const resolvedProfile = withResolvedProvisioning(profileName ? getAgentProfile(profileName)?.value : undefined, provider);
22
+ const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
23
+ const callerAppend = readSpawnCallerSystemPromptAppend(opts.spawnRequestId);
24
+ const composed = composeProfileSpawnInstructions({
25
+ ...(cwd ? { cwd } : {}),
26
+ operatorId: resolveAgentOwnership(agent.id).ownerUserId,
27
+ ...(profileName ? { profileName } : {}),
28
+ ...(agentProfile ? { profile: agentProfile } : {}),
29
+ ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
30
+ });
31
+ return {
32
+ ...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
33
+ ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
34
+ ...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
35
+ };
36
+ }
@@ -0,0 +1,275 @@
1
+ import {
2
+ getTaskDetail,
3
+ getWorkspace,
4
+ insertTaskEvent,
5
+ listTaskEvents,
6
+ ownedIsolatedWorkspace,
7
+ recordGate,
8
+ recordTaskDeliverable,
9
+ setTaskStatus,
10
+ ValidationError,
11
+ } from "../db";
12
+ import { emitRelayEvent } from "../events";
13
+ import { routeNotification } from "../notification-router";
14
+ import { emitTaskChanged, emitNewMessages } from "../sse";
15
+ import { agentBoundTaskIds, hasTaskReadAdminScope } from "../task-authz";
16
+ import { applyWorkspaceAction } from "../workspace-actions";
17
+ import { emitTaskSemanticNotification } from "./task-semantic-events";
18
+ import { ServiceAuthError } from "./errors";
19
+ import { resolveCoordinatorAgentId } from "../steward-identity";
20
+ import { markAgentWorkFinal } from "./work-finality";
21
+ import type { AuthContext } from "./auth-context";
22
+ import type { Command, TaskBlocker, TaskDeliverableEventMetadata, TaskDeliveryGateReports, TaskDeliveryResult, TaskGateStatus, TaskDetail, WorkspaceRecord } from "../types";
23
+
24
+ const REQUIRED_COMPLETE_GATES = ["typecheck", "test"] as const;
25
+
26
+ export interface DeliverTaskInput extends TaskDeliveryResult {
27
+ taskId?: number;
28
+ workspaceId?: string;
29
+ idempotencyKey?: string;
30
+ }
31
+
32
+ interface GateWrite {
33
+ gate: string;
34
+ status: TaskGateStatus;
35
+ detail?: string;
36
+ count?: number;
37
+ runId?: string;
38
+ }
39
+
40
+ function callerLabel(ctx: AuthContext): string {
41
+ return ctx.callerAgentId ?? ctx.actor.id;
42
+ }
43
+
44
+ function taskIdFrom(value: unknown): number | undefined {
45
+ if (value === undefined || value === null) return undefined;
46
+ const n = typeof value === "string" ? Number(value) : value;
47
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) throw new ValidationError("taskId must be a positive integer");
48
+ return n;
49
+ }
50
+
51
+ function resolveTaskId(ctx: AuthContext, explicit: unknown): number {
52
+ const taskId = taskIdFrom(explicit);
53
+ const caller = ctx.callerAgentId;
54
+ const bound = caller ? agentBoundTaskIds(caller) : new Set<number>();
55
+ const admin = hasTaskReadAdminScope(ctx.scopes);
56
+ if (taskId !== undefined) {
57
+ if (admin || bound.has(taskId)) return taskId;
58
+ throw new ServiceAuthError("taskId is not bound to the caller");
59
+ }
60
+ if (bound.size === 1) return [...bound][0]!;
61
+ if (bound.size === 0) throw new ServiceAuthError("caller is not bound to a Task");
62
+ throw new ValidationError("caller is bound to multiple Tasks; pass taskId explicitly");
63
+ }
64
+
65
+ function resolveWorkspace(ctx: AuthContext, input: { workspaceId?: string }, taskId: number): WorkspaceRecord | undefined {
66
+ const caller = ctx.callerAgentId;
67
+ const explicit = input.workspaceId?.trim();
68
+ const ws = explicit ? getWorkspace(explicit) : caller ? ownedIsolatedWorkspace(caller) : undefined;
69
+ if (!ws) return undefined;
70
+ if (!hasTaskReadAdminScope(ctx.scopes) && (!caller || ws.ownerAgentId !== caller)) {
71
+ throw new ServiceAuthError(`workspace ${ws.id} is not owned by the caller`);
72
+ }
73
+ const task = getTaskDetail(taskId);
74
+ if (!task) throw new ValidationError("task not found");
75
+ if (task.branch && ws.branch && task.branch !== ws.branch) {
76
+ throw new ValidationError(`task ${taskId} is bound to branch ${task.branch}, not workspace branch ${ws.branch}`);
77
+ }
78
+ return ws;
79
+ }
80
+
81
+ function gateWrites(gates: TaskDeliveryGateReports | undefined, deliveryKey: string): GateWrite[] {
82
+ const writes: GateWrite[] = [];
83
+ if (gates?.typecheck) writes.push({ gate: "typecheck", ...gatePayload(gates.typecheck, `${deliveryKey}:typecheck`) });
84
+ if (gates?.test) writes.push({ gate: "test", ...gatePayload(gates.test, `${deliveryKey}:test`) });
85
+ for (const gate of gates?.other ?? []) {
86
+ writes.push({ gate: gate.name, ...gatePayload(gate, `${deliveryKey}:other:${gate.name}`) });
87
+ }
88
+ return writes;
89
+ }
90
+
91
+ function gatePayload(input: { status: TaskGateStatus; detail?: string; count?: number; runId?: string }, fallbackRunId: string): Omit<GateWrite, "gate"> {
92
+ return {
93
+ status: input.status,
94
+ ...(input.detail ? { detail: input.detail } : {}),
95
+ ...(typeof input.count === "number" ? { count: input.count } : {}),
96
+ runId: input.runId ?? fallbackRunId,
97
+ };
98
+ }
99
+
100
+ function normalizedDoneWhen(task: TaskDetail): string {
101
+ return (task.doneWhen ?? "").toLowerCase();
102
+ }
103
+
104
+ function completeDeliveryLands(task: TaskDetail): boolean {
105
+ const dispatchType = task.dispatch?.type;
106
+ if (dispatchType === "review" || dispatchType === "research" || dispatchType === "ops") return false;
107
+ const acceptance = normalizedDoneWhen(task);
108
+ if (/\b(no[-\s]?branch|review(?:ed)?|verdict|report[-\s]?only|none)\b/.test(acceptance)) return false;
109
+ if (/\b(merged?|landed(?:-on-main)?|live[-\s]?verified|deployed|agent[-\s]?exit|reaped)\b/.test(acceptance)) return true;
110
+ return true;
111
+ }
112
+
113
+ function cleanReason(value: unknown, disposition: "blocked" | "failed"): string {
114
+ if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${disposition} delivery requires reason`);
115
+ const cleaned = value.trim();
116
+ if (cleaned.length > 64_000) throw new ValidationError("reason must be 64000 characters or fewer");
117
+ return cleaned;
118
+ }
119
+
120
+ function cleanBlockedBy(value: unknown): TaskBlocker[] {
121
+ if (!Array.isArray(value) || value.length === 0) throw new ValidationError("blocked delivery requires blockedBy");
122
+ return value as TaskBlocker[];
123
+ }
124
+
125
+ function completeGateProblem(gates: TaskDeliveryGateReports | undefined): string | undefined {
126
+ if (!gates) return "missing-gate-block";
127
+ const writes = gateWrites(gates, "check");
128
+ const byName = new Map(writes.map((gate) => [gate.gate, gate.status]));
129
+ const missing = REQUIRED_COMPLETE_GATES.filter((gate) => !byName.has(gate));
130
+ if (missing.length > 0) return `missing-gates:${missing.join("+")}`;
131
+ const failed = writes.filter((gate) => gate.status !== "pass").map((gate) => gate.gate);
132
+ if (failed.length > 0) return `failed-gates:${failed.join("+")}`;
133
+ return undefined;
134
+ }
135
+
136
+ function deliveryEvent(taskId: number, idempotencyKey: string | undefined): { id: number; metadata: TaskDeliverableEventMetadata } | undefined {
137
+ const events = listTaskEvents(taskId).filter((event) => event.type === "deliverable").reverse();
138
+ const event = idempotencyKey
139
+ ? events.find((item) => item.metadata.idempotencyKey === idempotencyKey)
140
+ : events[0];
141
+ if (!event) return undefined;
142
+ return { id: event.id, metadata: event.metadata as unknown as TaskDeliverableEventMetadata };
143
+ }
144
+
145
+ function notifyDelivery(taskId: number, eventId: number, metadata: TaskDeliverableEventMetadata): void {
146
+ const task = getTaskDetail(taskId);
147
+ if (!task) return;
148
+ emitRelayEvent({
149
+ type: "task.deliverable",
150
+ source: metadata.actor,
151
+ subject: String(taskId),
152
+ data: { taskId, eventId, disposition: metadata.disposition, summary: metadata.summary, workspaceId: metadata.workspaceId },
153
+ });
154
+ const coordinator = resolveCoordinatorAgentId(task.ownerAgentId ?? metadata.actor);
155
+ const recipients = [...new Set([coordinator, task.createdBy].filter((item): item is string => Boolean(item && item.trim())))];
156
+ const delivered = routeNotification({
157
+ type: "task.deliverable",
158
+ scope: {
159
+ taskId: String(taskId),
160
+ transition: "deliverable",
161
+ ...(task.ownerAgentId ? { agentId: task.ownerAgentId, author: task.ownerAgentId } : {}),
162
+ ...(task.createdBy ? { parent: task.createdBy } : {}),
163
+ ...(task.teamId ? { teamId: task.teamId } : {}),
164
+ ...(metadata.workspaceId ? { workspaceId: metadata.workspaceId } : {}),
165
+ },
166
+ recipients,
167
+ ttlMs: null,
168
+ message: {
169
+ subject: `Task T${taskId} delivery: ${metadata.disposition}`,
170
+ body: `Task T${taskId} delivery (${metadata.disposition}): ${metadata.summary}`,
171
+ payload: {
172
+ kind: "task.deliverable",
173
+ notification: "task-deliverable",
174
+ taskId,
175
+ eventId,
176
+ disposition: metadata.disposition,
177
+ summary: metadata.summary,
178
+ gates: metadata.gates,
179
+ risks: metadata.risks,
180
+ workspaceId: metadata.workspaceId,
181
+ },
182
+ idempotencyKey: metadata.idempotencyKey ? `task:${taskId}:deliverable:${metadata.idempotencyKey}` : `task:${taskId}:deliverable:event:${eventId}`,
183
+ replyExpected: false,
184
+ },
185
+ });
186
+ if (delivered.length > 0) emitNewMessages(delivered);
187
+ }
188
+
189
+ function emitCommand(command: Command): void {
190
+ emitRelayEvent({
191
+ type: command.status === "pending" ? "command.requested" : `command.${command.status}`,
192
+ source: command.source,
193
+ subject: command.id,
194
+ data: { command },
195
+ });
196
+ }
197
+
198
+ export async function deliverTask(input: DeliverTaskInput, ctx: AuthContext): Promise<Record<string, unknown>> {
199
+ const actor = callerLabel(ctx);
200
+ const taskId = resolveTaskId(ctx, input.taskId);
201
+ const before = getTaskDetail(taskId);
202
+ if (!before) throw new ValidationError("task not found");
203
+ if (before.kind !== "deliverable") throw new ValidationError("task not found");
204
+ const wantsLand = input.disposition === "complete" && completeDeliveryLands(before);
205
+ const isVerdictOnlyComplete = input.disposition === "complete" && !wantsLand;
206
+ const gateProblem = input.disposition === "complete" ? completeGateProblem(input.gates) : undefined;
207
+ if (gateProblem) throw new ValidationError(`complete delivery cannot land without passing gate block (${gateProblem})`);
208
+ const reason = input.disposition === "blocked" || input.disposition === "failed" ? cleanReason(input.reason, input.disposition) : undefined;
209
+ const blockedBy = input.disposition === "blocked" ? cleanBlockedBy(input.blockedBy) : input.blockedBy;
210
+ const workspace = wantsLand || input.workspaceId ? resolveWorkspace(ctx, input, taskId) : undefined;
211
+ if (wantsLand && (!workspace || workspace.mode !== "isolated" || !workspace.worktreePath)) {
212
+ throw new ValidationError("complete delivery requires a live isolated workspace");
213
+ }
214
+ const landMeta: TaskDeliverableEventMetadata["land"] | undefined = wantsLand && workspace
215
+ ? { requested: true, triggered: false, reason: "pending", workspaceId: workspace.id, workspaceStatus: workspace.status }
216
+ : undefined;
217
+ const delivery = recordTaskDeliverable(taskId, {
218
+ ...input,
219
+ actor,
220
+ ...(reason ? { reason } : {}),
221
+ ...(blockedBy ? { blockedBy } : {}),
222
+ ...(workspace ? { workspaceId: workspace.id } : {}),
223
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
224
+ ...(landMeta ? { land: landMeta } : {}),
225
+ });
226
+ const eventMeta = deliveryEvent(taskId, input.idempotencyKey)?.metadata;
227
+ if (delivery.created) {
228
+ const deliveryKey = input.idempotencyKey ?? `event:${delivery.event.id}`;
229
+ for (const gate of gateWrites(input.gates, deliveryKey)) {
230
+ recordGate(taskId, { ...gate, actor });
231
+ }
232
+ if (input.disposition === "blocked") {
233
+ setTaskStatus(taskId, { status: "blocked", blockedBy: blockedBy as TaskBlocker[], note: reason, actor });
234
+ } else if (input.disposition === "failed") {
235
+ setTaskStatus(taskId, { status: "failed", note: reason, actor });
236
+ } else if (isVerdictOnlyComplete) {
237
+ setTaskStatus(taskId, { status: "done", note: "complete delivery recorded without land", actor });
238
+ }
239
+ const detail = getTaskDetail(taskId);
240
+ if (detail) {
241
+ emitTaskChanged(detail, "task.deliverable");
242
+ notifyDelivery(taskId, delivery.event.id, eventMeta ?? (delivery.event.metadata as unknown as TaskDeliverableEventMetadata));
243
+ if (input.disposition === "failed") {
244
+ emitTaskSemanticNotification({ task: detail, transition: "gate-failed", actor, occurrenceKey: `event:${delivery.event.id}`, outcome: { disposition: "failed" } });
245
+ }
246
+ }
247
+ }
248
+
249
+ let land: Record<string, unknown> | undefined;
250
+ if (wantsLand && delivery.created && workspace) {
251
+ markAgentWorkFinal(actor, "task-deliver");
252
+ const result = await applyWorkspaceAction(workspace, {
253
+ action: "ready",
254
+ agentId: actor,
255
+ detail: `Task T${taskId} delivered complete`,
256
+ auditMetadata: { via: "task_deliver", actor, taskId, deliveryEventId: delivery.event.id },
257
+ });
258
+ if (!result.ok) {
259
+ land = { requested: true, triggered: false, error: result.error, workspaceId: workspace.id };
260
+ } else {
261
+ if (result.command) emitCommand(result.command);
262
+ land = { requested: true, triggered: true, workspace: result.workspace, command: result.command };
263
+ }
264
+ } else if (wantsLand && !delivery.created) {
265
+ land = { requested: true, triggered: false, reason: "duplicate-delivery" };
266
+ }
267
+
268
+ return {
269
+ taskId,
270
+ event: delivery.event,
271
+ created: delivery.created,
272
+ delivery: eventMeta ?? delivery.event.metadata,
273
+ ...(land ? { land } : {}),
274
+ };
275
+ }
@@ -99,6 +99,7 @@ export function registerSetting<T>(reg: SettingRegistration<T>): SettingDescript
99
99
  scope: resolveScope(reg.scope),
100
100
  tier: reg.tier,
101
101
  display: reg.display,
102
+ mergeMapKeys: reg.mergeMapKeys,
102
103
  };
103
104
  registry.set(id, { descriptor: descriptor as SettingDescriptor, validate });
104
105
  return descriptor;
@@ -235,14 +236,19 @@ const DISPLAY_PREFS_SCHEMA: JSONSchema = {
235
236
  },
236
237
  };
237
238
 
238
- // Per-conversation chat scroll memory (#759), tier:"user" so a human's reading position +
239
+ // Per-conversation chat scroll memory (#759/#1124), tier:"user" so a human's reading position +
239
240
  // sticky-bottom intent follow them across devices instead of dying in per-browser module state.
240
241
  // The value is a MAP keyed by conversation/agent id — one user-settings row holds every
241
242
  // conversation's anchor, so this is ONE descriptor (not a per-agent descriptor explosion).
242
243
  // Each entry is a LOGICAL anchor, never a pixel scrollTop: `anchorMessageId` is the durable
243
244
  // message-id anchor, `topKey` is the compatibility timeline entry key, `offset` the within-row
244
- // pixel offset, `stick` the follow-the-bottom intent. `maxProperties` bounds unbounded
245
- // conversation growth; the dashboard store evicts oldest-touched before it can be hit.
245
+ // pixel offset, `stick` the follow-the-bottom intent. `updatedAt` (epoch ms, #1124) is the
246
+ // per-entry write timestamp the client stamps on every write; it drives both the server's
247
+ // over-cap eviction (oldest-`updatedAt`-first, not oldest-key-first) and the client's
248
+ // newer-wins re-hydration merge, so it is intentionally NOT `required` — older rows written
249
+ // before #1124 lack it and must still validate (they just sort as oldest). `maxProperties`
250
+ // bounds unbounded conversation growth across every device that merges into this row (#1124
251
+ // `mergeMapKeys`); the dashboard store also evicts oldest-touched locally well under it.
246
252
  const CHAT_SCROLL_SCHEMA: JSONSchema = {
247
253
  type: "object",
248
254
  maxProperties: 200,
@@ -256,6 +262,7 @@ const CHAT_SCROLL_SCHEMA: JSONSchema = {
256
262
  anchorMessageId: { type: "number", minimum: 0, maximum: 10_000_000_000 },
257
263
  anchorEndMessageId: { type: "number", minimum: 0, maximum: 10_000_000_000 },
258
264
  offset: { type: "number", minimum: 0, maximum: 10_000_000 },
265
+ updatedAt: { type: "number", minimum: 0 },
259
266
  },
260
267
  },
261
268
  };
@@ -443,6 +450,9 @@ function seedBuiltInSettings(): void {
443
450
  });
444
451
 
445
452
  // Per-conversation chat scroll memory (#759) — one row per user, keyed internally by agent id.
453
+ // `mergeMapKeys` (#1124): a PUT merges only the conversation keys it carries into the stored
454
+ // map instead of replacing it, so one device's write can't clobber another device's write to a
455
+ // different conversation (the "desktop tab resurrects every conversation" bug in #1130 chain A2).
446
456
  registerSetting({
447
457
  namespace: "chat",
448
458
  key: "scroll",
@@ -450,6 +460,7 @@ function seedBuiltInSettings(): void {
450
460
  defaults: {},
451
461
  scope: { read: "settings:read:chat", write: "settings:write:chat" },
452
462
  tier: "user",
463
+ mergeMapKeys: true,
453
464
  display: { group: "Preferences", label: "Chat scroll", description: "Per-conversation scroll position and sticky-bottom flag (follows you across devices).", order: 72 },
454
465
  });
455
466
 
@@ -84,7 +84,7 @@ export const LAND_PENDING_STALL_MS = 15 * 60 * 1000;
84
84
  export const DIRTY_WORKTREE_LAND_SKIP_REASON = "worktree has uncommitted changes";
85
85
 
86
86
  type WorkspacePhase =
87
- | "working" // active — your turn: commit, then mark ready
87
+ | "working" // active — your turn: commit, then deliver
88
88
  | "land-pending" // ready | review_requested — handed off; auto-merge will land it
89
89
  | "landing" // merge_planned — merge dispatched, in progress
90
90
  | "steward" // conflict — auto-merge couldn't; a steward is reconciling (not your job)
@@ -122,13 +122,13 @@ const WAIT_ACTION: WorkspaceNextAction = {
122
122
  const CHECK_STATUS_ACTION: WorkspaceNextAction = {
123
123
  tool: "relay_workspace_status",
124
124
  cli: "agent-relay workspace status",
125
- when: "only if you are explicitly checking state later; do not poll or wait after marking ready",
125
+ when: "only if you are explicitly checking state later; do not poll or wait after delivering",
126
126
  };
127
127
 
128
128
  const READY_ACTION: WorkspaceNextAction = {
129
- tool: "relay_workspace_ready",
130
- cli: "agent-relay workspace ready",
131
- when: "after you commit your work in this worktree",
129
+ tool: "relay_task_deliver",
130
+ cli: "MCP relay_task_deliver",
131
+ when: "after you commit your work and have passing delivery gates",
132
132
  };
133
133
 
134
134
  // Map every WorkspaceStatus to the branch agent's mental model. Statuses that