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
@@ -0,0 +1,206 @@
1
+ import {
2
+ TASK_DELIVERY_DISPOSITIONS,
3
+ TASK_GATE_STATUSES,
4
+ type TaskCommitRef,
5
+ type TaskBlocker,
6
+ type TaskDeliverableEventMetadata,
7
+ type TaskDeliveryDisposition,
8
+ type TaskDeliveryGateReports,
9
+ type TaskDeliveryResult,
10
+ type TaskDeliverySelfAcceptance,
11
+ type TaskEvent,
12
+ type TaskGateStatus,
13
+ } from "../types";
14
+ import { parseJson } from "../utils";
15
+ import { getDb, ValidationError } from "./connection.ts";
16
+ import { rowToTaskEvent } from "./mappers.ts";
17
+ import { DELIVERABLE_TASK_KIND, insertTaskEvent } from "./task-core.ts";
18
+
19
+ function requireText(value: unknown, field: string, max: number): string {
20
+ if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${field} required`);
21
+ const cleaned = value.trim();
22
+ if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
23
+ return cleaned;
24
+ }
25
+
26
+ function optionalText(value: unknown, field: string, max: number): string | undefined {
27
+ if (value === undefined || value === null) return undefined;
28
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
29
+ const cleaned = value.trim();
30
+ if (!cleaned) return undefined;
31
+ if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
32
+ return cleaned;
33
+ }
34
+
35
+ function isObject(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ function taskCommitRows(id: number): TaskCommitRef[] {
40
+ const row = getDb().query("SELECT kind, commits FROM tasks WHERE id = ?").get(id) as { kind: string; commits: string | null } | undefined;
41
+ if (!row) throw new ValidationError(`task ${id} not found`);
42
+ if (row.kind !== DELIVERABLE_TASK_KIND) throw new ValidationError(`task ${id} is not a deliverable task`);
43
+ return parseJson<TaskCommitRef[]>(row.commits, []);
44
+ }
45
+
46
+ function appendCommitRow(id: number, commit: TaskCommitRef): void {
47
+ const sha = requireText(commit.sha, "commit.sha", 80);
48
+ const subject = optionalText(commit.subject, "commit.subject", 1000);
49
+ const commits = taskCommitRows(id);
50
+ if (commits.some((c) => c.sha === sha)) return;
51
+ commits.push({ sha, ...(subject ? { subject } : {}) });
52
+ getDb().query("UPDATE tasks SET commits = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(commits), Date.now(), id);
53
+ }
54
+
55
+ function cleanCommitRefs(value: unknown): TaskCommitRef[] | undefined {
56
+ if (value === undefined || value === null) return undefined;
57
+ if (!Array.isArray(value)) throw new ValidationError("commits must be an array");
58
+ const commits = value.map((entry, index) => {
59
+ if (!isObject(entry)) throw new ValidationError(`commits[${index}] must be an object`);
60
+ return {
61
+ sha: requireText(entry.sha, `commits[${index}].sha`, 80),
62
+ ...(optionalText(entry.subject, `commits[${index}].subject`, 1000) ? { subject: optionalText(entry.subject, `commits[${index}].subject`, 1000)! } : {}),
63
+ };
64
+ });
65
+ return commits.length > 0 ? commits : undefined;
66
+ }
67
+
68
+ function cleanGateReport(value: unknown, field: string): TaskDeliveryGateReports["typecheck"] {
69
+ if (value === undefined || value === null) return undefined;
70
+ if (!isObject(value)) throw new ValidationError(`${field} must be an object`);
71
+ if (!TASK_GATE_STATUSES.includes(value.status as TaskGateStatus)) throw new ValidationError(`${field}.status must be one of: ${TASK_GATE_STATUSES.join(", ")}`);
72
+ const count = value.count;
73
+ if (count !== undefined && (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0)) {
74
+ throw new ValidationError(`${field}.count must be a non-negative integer`);
75
+ }
76
+ return {
77
+ status: value.status as TaskGateStatus,
78
+ ...(optionalText(value.detail, `${field}.detail`, 4000) ? { detail: optionalText(value.detail, `${field}.detail`, 4000)! } : {}),
79
+ ...(typeof count === "number" ? { count } : {}),
80
+ ...(optionalText(value.runId, `${field}.runId`, 240) ? { runId: optionalText(value.runId, `${field}.runId`, 240)! } : {}),
81
+ };
82
+ }
83
+
84
+ function cleanGateReports(value: unknown): TaskDeliveryGateReports | undefined {
85
+ if (value === undefined || value === null) return undefined;
86
+ if (!isObject(value)) throw new ValidationError("gates must be an object");
87
+ const out: TaskDeliveryGateReports = {};
88
+ const typecheck = cleanGateReport(value.typecheck, "gates.typecheck");
89
+ const test = cleanGateReport(value.test, "gates.test");
90
+ if (typecheck) out.typecheck = typecheck;
91
+ if (test) out.test = test;
92
+ if (value.other !== undefined) {
93
+ if (!Array.isArray(value.other)) throw new ValidationError("gates.other must be an array");
94
+ const other = value.other.map((entry, index) => {
95
+ if (!isObject(entry)) throw new ValidationError(`gates.other[${index}] must be an object`);
96
+ const gate = cleanGateReport(entry, `gates.other[${index}]`)!;
97
+ return { name: requireText(entry.name, `gates.other[${index}].name`, 120), ...gate };
98
+ });
99
+ if (other.length > 0) out.other = other;
100
+ }
101
+ return out.typecheck || out.test || out.other?.length ? out : undefined;
102
+ }
103
+
104
+ function cleanRisks(value: unknown): string[] | undefined {
105
+ if (value === undefined || value === null) return undefined;
106
+ if (!Array.isArray(value)) throw new ValidationError("risks must be an array");
107
+ const risks = value.map((entry, index) => requireText(entry, `risks[${index}]`, 4000));
108
+ return risks.length > 0 ? risks : undefined;
109
+ }
110
+
111
+ function cleanBlockedBy(value: unknown): TaskBlocker[] | undefined {
112
+ if (value === undefined || value === null) return undefined;
113
+ if (!Array.isArray(value)) throw new ValidationError("blockedBy must be an array");
114
+ const entries = value.map((entry, index) => {
115
+ if (!isObject(entry)) throw new ValidationError(`blockedBy[${index}] must be an object`);
116
+ const type = entry.type as TaskBlocker["type"];
117
+ if (type !== "dep" && type !== "gate" && type !== "decision" && type !== "external") {
118
+ throw new ValidationError(`blockedBy[${index}].type must be one of: dep, gate, decision, external`);
119
+ }
120
+ return {
121
+ type,
122
+ ...(optionalText(entry.ref, `blockedBy[${index}].ref`, 240) ? { ref: optionalText(entry.ref, `blockedBy[${index}].ref`, 240)! } : {}),
123
+ ...(optionalText(entry.note, `blockedBy[${index}].note`, 2000) ? { note: optionalText(entry.note, `blockedBy[${index}].note`, 2000)! } : {}),
124
+ };
125
+ });
126
+ return entries.length > 0 ? entries : undefined;
127
+ }
128
+
129
+ function cleanSelfAcceptance(value: unknown): TaskDeliverySelfAcceptance[] | undefined {
130
+ if (value === undefined || value === null) return undefined;
131
+ if (!Array.isArray(value)) throw new ValidationError("selfAcceptance must be an array");
132
+ const entries = value.map((entry, index) => {
133
+ if (!isObject(entry)) throw new ValidationError(`selfAcceptance[${index}] must be an object`);
134
+ if (entry.status !== "met" && entry.status !== "unmet") throw new ValidationError(`selfAcceptance[${index}].status must be met or unmet`);
135
+ return {
136
+ criterion: requireText(entry.criterion, `selfAcceptance[${index}].criterion`, 1000),
137
+ status: entry.status as TaskDeliverySelfAcceptance["status"],
138
+ ...(optionalText(entry.note, `selfAcceptance[${index}].note`, 4000) ? { note: optionalText(entry.note, `selfAcceptance[${index}].note`, 4000)! } : {}),
139
+ };
140
+ });
141
+ return entries.length > 0 ? entries : undefined;
142
+ }
143
+
144
+ export interface RecordTaskDeliverableInput extends TaskDeliveryResult {
145
+ actor: string;
146
+ workspaceId?: string;
147
+ idempotencyKey?: string;
148
+ land?: TaskDeliverableEventMetadata["land"];
149
+ }
150
+
151
+ export function recordTaskDeliverable(id: number, input: RecordTaskDeliverableInput): { event: TaskEvent; created: boolean } {
152
+ taskCommitRows(id);
153
+ const actor = requireText(input.actor, "actor", 200);
154
+ const idempotencyKey = optionalText(input.idempotencyKey, "idempotencyKey", 240);
155
+ if (idempotencyKey) {
156
+ const existing = getDb()
157
+ .query("SELECT * FROM task_events WHERE task_id = ? AND type = 'deliverable' AND json_extract(metadata, '$.idempotencyKey') = ? LIMIT 1")
158
+ .get(id, idempotencyKey) as any;
159
+ if (existing) return { event: rowToTaskEvent(existing), created: false };
160
+ }
161
+ if (!TASK_DELIVERY_DISPOSITIONS.includes(input.disposition as TaskDeliveryDisposition)) {
162
+ throw new ValidationError(`disposition must be one of: ${TASK_DELIVERY_DISPOSITIONS.join(", ")}`);
163
+ }
164
+ const commits = cleanCommitRefs(input.commits);
165
+ const gates = cleanGateReports(input.gates);
166
+ const reason = optionalText(input.reason, "reason", 64_000);
167
+ const blockedBy = cleanBlockedBy(input.blockedBy);
168
+ const filesChanged = input.filesChanged;
169
+ if (filesChanged !== undefined && (typeof filesChanged !== "number" || !Number.isSafeInteger(filesChanged) || filesChanged < 0)) {
170
+ throw new ValidationError("filesChanged must be a non-negative integer");
171
+ }
172
+ const now = Date.now();
173
+ const metadata: TaskDeliverableEventMetadata = {
174
+ v: 1,
175
+ disposition: input.disposition,
176
+ summary: requireText(input.summary, "summary", 1_000_000),
177
+ actor,
178
+ ...(reason ? { reason } : {}),
179
+ ...(blockedBy ? { blockedBy } : {}),
180
+ ...(commits ? { commits } : {}),
181
+ ...(gates ? { gates } : {}),
182
+ ...(typeof filesChanged === "number" ? { filesChanged } : {}),
183
+ ...(optionalText(input.rca, "rca", 64_000) ? { rca: optionalText(input.rca, "rca", 64_000)! } : {}),
184
+ ...(optionalText(input.testCoverage, "testCoverage", 64_000) ? { testCoverage: optionalText(input.testCoverage, "testCoverage", 64_000)! } : {}),
185
+ ...(cleanRisks(input.risks) ? { risks: cleanRisks(input.risks)! } : {}),
186
+ ...(cleanSelfAcceptance(input.selfAcceptance) ? { selfAcceptance: cleanSelfAcceptance(input.selfAcceptance)! } : {}),
187
+ ...(optionalText(input.workspaceId, "workspaceId", 240) ? { workspaceId: optionalText(input.workspaceId, "workspaceId", 240)! } : {}),
188
+ ...(idempotencyKey ? { idempotencyKey } : {}),
189
+ ...(input.land ? { land: input.land } : {}),
190
+ };
191
+ const event = getDb().transaction(() => {
192
+ if (commits) {
193
+ for (const commit of commits) appendCommitRow(id, commit);
194
+ }
195
+ getDb().query("UPDATE tasks SET updated_at = ?, last_seen_at = ? WHERE id = ?").run(now, now, id);
196
+ return insertTaskEvent(id, {
197
+ source: actor,
198
+ type: "deliverable",
199
+ severity: input.disposition === "failed" || input.disposition === "blocked" ? "warning" : "info",
200
+ title: `Delivery result: ${input.disposition}`,
201
+ body: metadata.summary,
202
+ metadata: metadata as unknown as Record<string, unknown>,
203
+ }, now);
204
+ })();
205
+ return { event, created: true };
206
+ }
@@ -322,14 +322,7 @@ export function setTaskStage(id: number, input: SetTaskStageInput): TaskDetail {
322
322
  return getTaskDetail(id)!;
323
323
  }
324
324
 
325
- export interface RecordGateInput {
326
- gate: string;
327
- status: TaskGateStatus;
328
- detail?: string;
329
- commitSha?: string;
330
- runId?: string;
331
- actor?: string;
332
- }
325
+ export interface RecordGateInput { gate: string; status: TaskGateStatus; detail?: string; count?: number; commitSha?: string; runId?: string; actor?: string; }
333
326
 
334
327
  // Quality-gate ledger write (#834). The append-only `task_events` IS the ledger (no second
335
328
  // system of record). Idempotent by runId: the SAME run reported twice does not double-append,
@@ -341,6 +334,7 @@ export function recordGate(id: number, input: RecordGateInput): TaskGateState {
341
334
  if (!TASK_GATE_STATUSES.includes(input.status)) {
342
335
  throw new ValidationError(`gate status must be one of: ${TASK_GATE_STATUSES.join(", ")}`);
343
336
  }
337
+ if (input.count !== undefined && (!Number.isSafeInteger(input.count) || input.count < 0)) throw new ValidationError("count must be a non-negative integer");
344
338
  const type = `${GATE_EVENT_PREFIX}${gate}`;
345
339
  const runId = optionalText(input.runId, "runId", 240);
346
340
  if (runId) {
@@ -360,6 +354,7 @@ export function recordGate(id: number, input: RecordGateInput): TaskGateState {
360
354
  gate,
361
355
  status: input.status,
362
356
  ...(input.detail ? { detail: input.detail } : {}),
357
+ ...(typeof input.count === "number" ? { count: input.count } : {}),
363
358
  ...(input.commitSha ? { commitSha: input.commitSha } : {}),
364
359
  ...(runId ? { runId } : {}),
365
360
  },
@@ -373,6 +368,7 @@ function gateStateFromEvent(row: { type: string; created_at: number; metadata: s
373
368
  gate: typeof meta.gate === "string" ? meta.gate : row.type.slice(GATE_EVENT_PREFIX.length),
374
369
  status: (meta.status as TaskGateStatus) ?? "fail",
375
370
  ...(typeof meta.detail === "string" ? { detail: meta.detail } : {}),
371
+ ...(typeof meta.count === "number" ? { count: meta.count } : {}),
376
372
  ...(typeof meta.commitSha === "string" ? { commitSha: meta.commitSha } : {}),
377
373
  ...(typeof meta.runId === "string" ? { runId: meta.runId } : {}),
378
374
  ranAt: row.created_at,
package/src/mcp/index.ts CHANGED
@@ -6,6 +6,7 @@ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
6
6
  import { createActivityEvent, ValidationError } from "../db";
7
7
  import { PLAN_TOOLS, relayPlanTool } from "../mcp-plan-tools";
8
8
  import { TASK_TOOLS, relayTaskTool } from "../mcp-task-tools";
9
+ import { TASK_DELIVER_TOOLS, relayTaskDeliverTool } from "../mcp-task-deliver-tools";
9
10
  import { TASK_MEMORY_TOOLS, relayTaskMemoryTool } from "../mcp-task-memory-tools";
10
11
  import { DISPATCH_TOOLS, relayDispatchTool } from "../mcp-dispatch-tools";
11
12
  import { TASK_WEAVE_TOOLS, relayTaskWeaveTool } from "../mcp-task-weave-tools";
@@ -37,7 +38,7 @@ const TOOLS: ToolDefinition[] = [
37
38
  AGENT_ACTION_TOOL,
38
39
  ...WORKSPACE_TOOLS,
39
40
  ...MERGE_TOOLS, ...TEAM_TOOLS,
40
- ...PLAN_TOOLS, ...TASK_TOOLS, ...TASK_MEMORY_TOOLS, ...DISPATCH_TOOLS, ...TASK_WEAVE_TOOLS, ...RELEASE_TOOLS, ...ISSUE_REF_TOOLS, ...ISSUE_ASSOCIATION_TOOLS, ...ISSUE_ACTIVITY_TOOLS, ...ISSUE_LIFECYCLE_TOOLS,
41
+ ...PLAN_TOOLS, ...TASK_TOOLS, ...TASK_DELIVER_TOOLS, ...TASK_MEMORY_TOOLS, ...DISPATCH_TOOLS, ...TASK_WEAVE_TOOLS, ...RELEASE_TOOLS, ...ISSUE_REF_TOOLS, ...ISSUE_ASSOCIATION_TOOLS, ...ISSUE_ACTIVITY_TOOLS, ...ISSUE_LIFECYCLE_TOOLS,
41
42
  ...BLACKBOARD_TOOLS,
42
43
  ...PROJECT_TOOLS,
43
44
  ...MEMORY_TOOLS,
@@ -52,7 +53,7 @@ export const RELAY_TOOL_GROUPS: Record<string, readonly string[]> = {
52
53
  core: ["relay_whoami", "relay_send_message", "relay_reply", "relay_get_message", "relay_get_thread", "relay_find_agents"],
53
54
  memory: ["relay_recall", "relay_compact_and_resume", "relay_memory_write", "relay_memory_promote", "relay_memory_retract", "relay_memory_read", "relay_memory_search"],
54
55
  artifacts: ["relay_upload_artifact", "relay_attach_artifact"],
55
- workspace: ["relay_workspace_status", "relay_workspace_ready", "relay_workspace_deps", "relay_workspace_list", "relay_workspace_claim", "relay_workspace_release", "relay_workspace_land", "relay_merge_pause", "relay_merge_pauses", "relay_merge_renew", "relay_merge_resume"],
56
+ workspace: ["relay_workspace_status", "relay_workspace_deps", "relay_workspace_list", "relay_workspace_claim", "relay_workspace_release", "relay_task_deliver", "relay_merge_pause", "relay_merge_pauses", "relay_merge_renew", "relay_merge_resume"],
56
57
  "spawn-lifecycle": ["relay_spawn_agent", "relay_spawn_targets", "relay_my_agents", "relay_shutdown_agent", "relay_agent_action", "relay_agent_status", "relay_agent_crash_report"],
57
58
  schedule: ["relay_schedule_timer", "relay_list_timers", "relay_cancel_timer"],
58
59
  plans: ["relay_plan_create", "relay_plan_add_node", "relay_plan_update_node", "relay_plan_add_edge", "relay_plan_remove_node", "relay_plan_remove_edge", "relay_plan_archive", "relay_plan_delete", "relay_plan_get"],
@@ -167,13 +168,12 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
167
168
  else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
168
169
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
169
170
  else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
170
- else if (name === "relay_workspace_ready") result = await relayWorkspaceMutation(auth, "ready", args);
171
171
  else if (name === "relay_workspace_deps") result = await relayWorkspaceMutation(auth, "deps-refresh", args);
172
172
  else if (name === "relay_workspace_claim") result = await relayWorkspaceMutation(auth, "claim", args);
173
173
  else if (name === "relay_workspace_release") result = await relayWorkspaceMutation(auth, "release-claim", args);
174
- else if (name === "relay_workspace_land") result = await relayWorkspaceMutation(auth, "merge", args);
175
174
  else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
176
175
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
176
+ else if (name === "relay_task_deliver") result = await relayTaskDeliverTool(auth, name, args);
177
177
  else if (name.startsWith("relay_task_memory_")) result = relayTaskMemoryTool(auth, name, args);
178
178
  else if (name === "relay_task_dispatch") result = relayDispatchTool(auth, name, args);
179
179
  else if (name === "relay_task_comment" || name === "relay_task_close_bridge") result = await relayTaskWeaveTool(auth, name, args);
@@ -15,33 +15,19 @@ import { optionalBoolean, optionalPositiveInt, optionalString } from "./validati
15
15
  export const WORKSPACE_TOOLS: ToolDefinition[] = [
16
16
  {
17
17
  name: "relay_workspace_status",
18
- description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes; do not use wait:true after relay_workspace_ready unless explicitly debugging. Normal branch workers mark ready, stop their turn, and are woken later by landed-success or landed-failure notifications. Defaults to your own workspace; pass workspaceId to target another.",
18
+ description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes; do not use wait:true after relay_task_deliver unless explicitly debugging. Normal branch workers deliver the Task, stop their turn, and are woken later by landed-success or landed-failure notifications. Defaults to your own workspace; pass workspaceId to target another.",
19
19
  requiredScopes: ["agents:read", "agent:read", "agent:write"],
20
20
  alwaysLoad: true,
21
21
  inputSchema: {
22
22
  type: "object",
23
23
  properties: {
24
24
  workspaceId: { type: "string", description: "Defaults to your own isolated workspace (resolved from your identity)." },
25
- wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout. Do not use this after relay_workspace_ready in the normal worker flow; stop and wait for landed-success/landed-failure instead." },
25
+ wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout. Do not use this after relay_task_deliver in the normal worker flow; stop and wait for landed-success/landed-failure instead." },
26
26
  timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Max seconds to wait when wait:true (default 300, max 600). Returns early on any transition." },
27
27
  },
28
28
  additionalProperties: false,
29
29
  },
30
30
  },
31
- {
32
- name: "relay_workspace_ready",
33
- description: "Signal that your branch work is finished and ready to merge back to main. This kicks off the auto-merge-back: a clean merge lands immediately; on conflict a steward agent reconciles and lands it (escalating if it can't). After this tool returns, stop your turn and go idle; Relay wakes you later with landed-success when the merge lands and your branch refreshes, or landed-failure if landing fatally fails. Defaults to your own workspace.",
34
- requiredScopes: ["agent:write"],
35
- alwaysLoad: true,
36
- inputSchema: {
37
- type: "object",
38
- properties: {
39
- workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
40
- detail: { type: "string", description: "Optional note recorded on the activity feed." },
41
- },
42
- additionalProperties: false,
43
- },
44
- },
45
31
  {
46
32
  name: "relay_workspace_deps",
47
33
  description: "Re-provision your worktree's dependencies when the shared symlinked node_modules has gone stale (e.g. typecheck reports a missing module). checkOnly:true just reports staleness without changing anything. Self-service — acts only on your own worktree.",
@@ -98,25 +84,6 @@ export const WORKSPACE_TOOLS: ToolDefinition[] = [
98
84
  additionalProperties: false,
99
85
  },
100
86
  },
101
- {
102
- name: "relay_workspace_land",
103
- description: "Dispatch a base merge for your workspace directly (lease-serialized per repo, same path the auto-merge job uses). Most branch agents should use relay_workspace_ready instead and let the relay land it; this is the explicit/steward path and requires the command:write scope.",
104
- requiredScopes: ["command:write"],
105
- inputSchema: {
106
- type: "object",
107
- properties: {
108
- workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
109
- strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto). Falls back to the instance landing.strategy config when unset." },
110
- deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
111
- prTitle: { type: "string" },
112
- prBody: { type: "string" },
113
- autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "Auto-merge policy for PR-strategy lands. Defaults to 'on-green' when strategy resolves to 'pr'. Ignored for rebase-ff/auto strategies." },
114
- reviewer: { type: "string", description: "GitHub login or team slug to request as a reviewer on the opened PR (pr strategy only)." },
115
- detail: { type: "string" },
116
- },
117
- additionalProperties: false,
118
- },
119
- },
120
87
  ];
121
88
 
122
89
  function isWorkspaceAdmin(auth: McpAuthContext): boolean {
@@ -219,7 +186,7 @@ export async function relayWorkspaceMutation(auth: McpAuthContext, action: Works
219
186
  if (action === "ready") {
220
187
  payload.contract = readyContract(result.workspace);
221
188
  payload.guidance = describeWorkspacePhase(result.workspace);
222
- // #1084 relay_workspace_ready is an explicit, platform-owned completion signal: the caller
189
+ // Legacy workspace-ready route/CLI path is an explicit, platform-owned completion signal: the caller
223
190
  // is declaring its work window closed. Close it, forwarding only the held final group.
224
191
  markAgentWorkFinal(result.workspace.ownerAgentId ?? callerAgentId(auth), "workspace-ready");
225
192
  }
@@ -0,0 +1,143 @@
1
+ import { McpAuthError } from "./mcp-errors";
2
+ import { ValidationError } from "./db";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { deliverTask } from "./services/task-delivery";
5
+ import { ServiceAuthError } from "./services/errors";
6
+ import { cleanString, optionalEnum } from "./validation";
7
+ import {
8
+ TASK_DELIVERY_DISPOSITIONS,
9
+ TASK_BLOCKER_TYPES,
10
+ TASK_GATE_STATUSES,
11
+ isRecord,
12
+ type TaskDeliveryGateReports,
13
+ type TaskDeliveryResult,
14
+ } from "./types";
15
+
16
+ const TASK_ID_PROP = { oneOf: [{ type: "integer", minimum: 1 }, { type: "string" }], description: "Task id. Optional when the caller is bound to exactly one Task." } as const;
17
+ const COMMIT_SCHEMA = {
18
+ type: "object",
19
+ properties: { sha: { type: "string" }, subject: { type: "string" } },
20
+ required: ["sha"],
21
+ additionalProperties: false,
22
+ } as const;
23
+ const GATE_RESULT_SCHEMA = {
24
+ type: "object",
25
+ properties: {
26
+ status: { type: "string", enum: TASK_GATE_STATUSES },
27
+ detail: { type: "string" },
28
+ count: { type: "integer", minimum: 0 },
29
+ runId: { type: "string" },
30
+ },
31
+ required: ["status"],
32
+ additionalProperties: false,
33
+ } as const;
34
+
35
+ export const TASK_DELIVER_TOOLS = [
36
+ {
37
+ name: "relay_task_deliver",
38
+ description: "Submit a structured Delivery Result for a durable Task. Writes a typed deliverable event to the Task thread before any side effect. disposition:'complete' requires passing typecheck+test gates and auto-triggers the caller's task-bound isolated workspace handoff to land.",
39
+ requiredScopes: ["task:write"],
40
+ alwaysLoad: true,
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ taskId: TASK_ID_PROP,
45
+ disposition: { type: "string", enum: TASK_DELIVERY_DISPOSITIONS },
46
+ summary: { type: "string" },
47
+ reason: { type: "string", description: "Required for blocked/failed deliveries." },
48
+ blockedBy: {
49
+ type: "array",
50
+ description: "Required for blocked deliveries; records the blocker set on the Task.",
51
+ items: {
52
+ type: "object",
53
+ properties: { type: { type: "string", enum: TASK_BLOCKER_TYPES }, ref: { type: "string" }, note: { type: "string" } },
54
+ required: ["type"],
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ commits: { type: "array", items: COMMIT_SCHEMA },
59
+ gates: {
60
+ type: "object",
61
+ properties: {
62
+ typecheck: GATE_RESULT_SCHEMA,
63
+ test: GATE_RESULT_SCHEMA,
64
+ other: {
65
+ type: "array",
66
+ items: {
67
+ type: "object",
68
+ properties: { name: { type: "string" }, status: { type: "string", enum: TASK_GATE_STATUSES }, detail: { type: "string" }, count: { type: "integer", minimum: 0 }, runId: { type: "string" } },
69
+ required: ["name", "status"],
70
+ additionalProperties: false,
71
+ },
72
+ },
73
+ },
74
+ additionalProperties: false,
75
+ },
76
+ filesChanged: { type: "integer", minimum: 0 },
77
+ rca: { type: "string" },
78
+ testCoverage: { type: "string" },
79
+ risks: { type: "array", items: { type: "string" } },
80
+ selfAcceptance: {
81
+ type: "array",
82
+ items: {
83
+ type: "object",
84
+ properties: { criterion: { type: "string" }, status: { type: "string", enum: ["met", "unmet"] }, note: { type: "string" } },
85
+ required: ["criterion", "status"],
86
+ additionalProperties: false,
87
+ },
88
+ },
89
+ workspaceId: { type: "string", description: "Defaults to the caller's isolated workspace." },
90
+ idempotencyKey: { type: "string", description: "Dedupes retry delivery and prevents double-land on replay." },
91
+ },
92
+ required: ["disposition", "summary"],
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown>; alwaysLoad?: boolean }>;
97
+
98
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
99
+
100
+ function optionalPositiveInt(value: unknown, field: string): number | undefined {
101
+ if (value === undefined || value === null) return undefined;
102
+ const n = typeof value === "string" ? Number(value) : value;
103
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) throw new ValidationError(`${field} must be a positive integer`);
104
+ return n;
105
+ }
106
+
107
+ function cleanDeliveryGates(value: unknown): TaskDeliveryGateReports | undefined {
108
+ if (value === undefined || value === null) return undefined;
109
+ if (!isRecord(value)) throw new ValidationError("gates must be an object");
110
+ return value as TaskDeliveryGateReports;
111
+ }
112
+
113
+ function cleanDeliveryResult(args: Record<string, unknown>): TaskDeliveryResult {
114
+ return {
115
+ disposition: optionalEnum(args.disposition, "disposition", TASK_DELIVERY_DISPOSITIONS)!,
116
+ summary: cleanString(args.summary, "summary", { required: true, max: 1_000_000 })!,
117
+ ...(args.reason !== undefined ? { reason: cleanString(args.reason, "reason", { max: 64_000 }) } : {}),
118
+ ...(Array.isArray(args.blockedBy) ? { blockedBy: args.blockedBy as TaskDeliveryResult["blockedBy"] } : {}),
119
+ ...(Array.isArray(args.commits) ? { commits: args.commits as TaskDeliveryResult["commits"] } : {}),
120
+ ...(args.gates !== undefined ? { gates: cleanDeliveryGates(args.gates) } : {}),
121
+ ...(typeof args.filesChanged === "number" ? { filesChanged: args.filesChanged } : {}),
122
+ ...(args.rca !== undefined ? { rca: cleanString(args.rca, "rca", { max: 64_000 }) } : {}),
123
+ ...(args.testCoverage !== undefined ? { testCoverage: cleanString(args.testCoverage, "testCoverage", { max: 64_000 }) } : {}),
124
+ ...(Array.isArray(args.risks) ? { risks: args.risks.filter((risk): risk is string => typeof risk === "string") } : {}),
125
+ ...(Array.isArray(args.selfAcceptance) ? { selfAcceptance: args.selfAcceptance as TaskDeliveryResult["selfAcceptance"] } : {}),
126
+ };
127
+ }
128
+
129
+ export async function relayTaskDeliverTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
130
+ if (name !== "relay_task_deliver") throw new ValidationError(`unknown tool: ${name}`);
131
+ if (!isRecord(args)) throw new ValidationError("arguments must be an object");
132
+ try {
133
+ return await deliverTask({
134
+ ...cleanDeliveryResult(args),
135
+ taskId: optionalPositiveInt(args.taskId, "taskId"),
136
+ workspaceId: cleanString(args.workspaceId, "workspaceId", { max: 240 }),
137
+ idempotencyKey: cleanString(args.idempotencyKey, "idempotencyKey", { max: 240 }),
138
+ }, authContextFromMcp(auth));
139
+ } catch (error) {
140
+ if (error instanceof ServiceAuthError) throw new McpAuthError(error.message);
141
+ throw error;
142
+ }
143
+ }
@@ -274,6 +274,7 @@ registerDefaultAudienceRules("message.new", [
274
274
  ]);
275
275
 
276
276
  for (const type of [
277
+ "task.deliverable",
277
278
  "task.created",
278
279
  "task.started",
279
280
  "task.blocked",
@@ -2,6 +2,7 @@ export type NotificationType =
2
2
  | "message.new"
3
3
  | "landed-failure"
4
4
  | "branch.landed"
5
+ | "task.deliverable"
5
6
  | "task.deliverable_landed"
6
7
  | "task.created"
7
8
  | "task.started"
@@ -99,6 +100,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
99
100
  "message.new": null,
100
101
  "landed-failure": null,
101
102
  "branch.landed": null,
103
+ "task.deliverable": null,
102
104
  // The #831 auto-driven "task deliverable landed" receipt to the coordinator. Durable (null):
103
105
  // it carries the auto-actions the coordinator confirms/exception-handles, so it must survive
104
106
  // the coordinator being briefly offline rather than expiring as an ephemeral bystander ping.
@@ -289,7 +289,7 @@ const DEFAULT_PROMPT_TEMPLATES = [
289
289
  template: lines(
290
290
  "You are in an isolated git worktree on branch {{branch}}, based on {{baseRef}} — NOT the main checkout. {{baseRef}} moves under you as other agents land in parallel; that's expected.",
291
291
  "Your shell's working directory is already this worktree — run commands directly without prefixing `cd`.",
292
- "Changes reach the base via: commit your work, then call `relay_workspace_ready`. Relay rebases onto the latest base, lands, and pushes for you.",
292
+ "Changes reach the base via: commit your work, then call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates. Relay records the typed delivery on the Task, then rebases onto the latest base, lands, and pushes for you.",
293
293
  "Do NOT push, rebase, merge, resolve conflicts, or `cd` into the main checkout — Relay (and a steward, spawned only if a clean auto-merge isn't possible) own all of that.",
294
294
  "After `ready` the status is `ready` (a normal, healthy hand-off state, not a stall). Stop your turn and go idle; Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails.",
295
295
  "Call `relay_workspace_status` anytime to see where you are and the exact next step.",
@@ -297,7 +297,7 @@ const DEFAULT_PROMPT_TEMPLATES = [
297
297
  defaultTemplate: lines(
298
298
  "You are in an isolated git worktree on branch {{branch}}, based on {{baseRef}} — NOT the main checkout. {{baseRef}} moves under you as other agents land in parallel; that's expected.",
299
299
  "Your shell's working directory is already this worktree — run commands directly without prefixing `cd`.",
300
- "Changes reach the base via: commit your work, then call `relay_workspace_ready`. Relay rebases onto the latest base, lands, and pushes for you.",
300
+ "Changes reach the base via: commit your work, then call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates. Relay records the typed delivery on the Task, then rebases onto the latest base, lands, and pushes for you.",
301
301
  "Do NOT push, rebase, merge, resolve conflicts, or `cd` into the main checkout — Relay (and a steward, spawned only if a clean auto-merge isn't possible) own all of that.",
302
302
  "After `ready` the status is `ready` (a normal, healthy hand-off state, not a stall). Stop your turn and go idle; Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails.",
303
303
  "Call `relay_workspace_status` anytime to see where you are and the exact next step.",
@@ -423,8 +423,8 @@ const DEFAULT_PROMPT_TEMPLATES = [
423
423
  category: "workspace-phase",
424
424
  title: "Workspace Active Hint",
425
425
  description: "Workspace status hint for active isolated work.",
426
- template: "Commit your work in this worktree, then mark it ready. Relay rebases onto the latest base, lands, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
427
- defaultTemplate: "Commit your work in this worktree, then mark it ready. Relay rebases onto the latest base, lands, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
426
+ template: "Commit your work in this worktree, then deliver the Task with `relay_task_deliver` using disposition `complete` and passing `typecheck` + `test` gates. Relay records the typed delivery, lands the branch, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
427
+ defaultTemplate: "Commit your work in this worktree, then deliver the Task with `relay_task_deliver` using disposition `complete` and passing `typecheck` + `test` gates. Relay records the typed delivery, lands the branch, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
428
428
  variables: [],
429
429
  },
430
430
  {
@@ -432,8 +432,8 @@ const DEFAULT_PROMPT_TEMPLATES = [
432
432
  category: "workspace-phase",
433
433
  title: "Workspace Ready Headline",
434
434
  description: "Workspace status headline for healthy handoff states.",
435
- template: "Handed off — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-ready state (not an escalation).",
436
- defaultTemplate: "Handed off — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-ready state (not an escalation).",
435
+ template: "Delivered — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-delivery state (not an escalation).",
436
+ defaultTemplate: "Delivered — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-delivery state (not an escalation).",
437
437
  variables: [],
438
438
  },
439
439
  {
@@ -2,7 +2,7 @@
2
2
  import { RELAY_TOKEN_HEADER, isRecord, isSpawnProvider } from "agent-relay-sdk";
3
3
  import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, canViewAgentRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, requestUserId, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { arbitrateDrive, currentDriver, drivingMessage, releaseDrive, type DriveArbitration } from "../agent-drive-arbiter";
5
- import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, readSpawnCallerSystemPromptAppend, resolveAgentOwnership, sendMessage, withResolvedProvisioning } from "../db";
5
+ import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
6
6
  import { announcePresence, viewersForAgent } from "../dashboard-presence";
7
7
  import { buildManagedSpawnParams } from "../managed-policy";
8
8
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -19,8 +19,8 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
19
19
  import { type AgentCard, type SpawnApprovalMode } from "../types";
20
20
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
21
21
  import { getManifest } from "agent-relay-providers";
22
- import { composeProfileSpawnInstructions } from "../project-instructions";
23
22
  import { relayToken } from "../config";
23
+ import { restartInstructionParamsForAgent } from "../services/restart-instructions";
24
24
 
25
25
  // 409 when a second human tries to drive an agent another user is already driving (#394). The
26
26
  // blocked driver learns who holds the wheel so the UI can show "X is driving" + offer "request".
@@ -133,6 +133,7 @@ export function restartSpawnParamsForAgent(
133
133
  const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
134
134
  const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
135
135
  const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
136
+ const projectedInstructions = restartInstructionParamsForAgent(agent, { spawnRequestId, cwd }) ?? {};
136
137
  // #1052-v2 — a dashboard-spawned agent has NO spawn policy, so its restart skips the
137
138
  // `buildManagedSpawnParams` policy branch above and lands here. Re-project the profile role
138
139
  // charter / project knowledge / scoped memory (the SAME projection a fresh spawn gets) so the
@@ -152,8 +153,6 @@ export function restartSpawnParamsForAgent(
152
153
  // params — the SAME field a fresh agent.spawn stashes it in, see spawn-agent.ts). The reader then
153
154
  // recovers it from either the original agent.spawn row OR a later agent.restart row, so each
154
155
  // generation has its own recoverable copy and the brief follows the agent across every rotation.
155
- const callerAppend = readSpawnCallerSystemPromptAppend(spawnRequestId);
156
- const composed = composeProfileSpawnInstructions({ cwd, operatorId: resolveAgentOwnership(agent.id).ownerUserId, ...(profileName ? { profileName } : {}), ...(agentProfile ? { profile: agentProfile } : {}), ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}) });
157
156
  const params = buildSpawnCommand({
158
157
  provider,
159
158
  modelParams: resolvedModel,
@@ -168,12 +167,12 @@ export function restartSpawnParamsForAgent(
168
167
  approvalMode: approvalMode ?? "guarded",
169
168
  permissionMode: approvalMode ?? "guarded",
170
169
  providerArgs: providerArgs.length ? providerArgs : undefined,
171
- ...(composed.systemPromptAppend ? { systemPromptAppend: composed.systemPromptAppend } : {}),
170
+ ...(typeof projectedInstructions.systemPromptAppend === "string" ? { systemPromptAppend: projectedInstructions.systemPromptAppend } : {}),
172
171
  // #1067 — re-persist the RAW brief (pre-composition) under this restart's fresh spawnRequestId so
173
172
  // the NEXT restart, whose predecessor id is this fresh id, can recover it. Without this the brief
174
173
  // survives only one restart (the original agent.spawn row is keyed on the very first id).
175
- ...(callerAppend ? { callerSystemPromptAppend: callerAppend } : {}),
176
- ...(composed.relayInjectionEvents.length ? { relayInjectionEvents: composed.relayInjectionEvents } : {}),
174
+ ...(typeof projectedInstructions.callerSystemPromptAppend === "string" ? { callerSystemPromptAppend: projectedInstructions.callerSystemPromptAppend } : {}),
175
+ ...(Array.isArray(projectedInstructions.relayInjectionEvents) ? { relayInjectionEvents: projectedInstructions.relayInjectionEvents } : {}),
177
176
  policyName: policyName || undefined,
178
177
  headless: true,
179
178
  spawnRequestId: requestId,
@@ -298,6 +297,9 @@ export const postAgentAction: Handler = async (req, params) => {
298
297
  const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
299
298
  const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
300
299
  const resumeId = action === "resume" ? latestResumeIdForAgent(agent) : undefined;
300
+ const restartInstructions = action === "restart" || action === "resume"
301
+ ? restartInstructionParamsForAgent(agent, { spawnRequestId: metaSpawnRequestId, cwd: metaString(agent.meta, "cwd") ?? orchestrator?.baseDir })
302
+ : undefined;
301
303
  if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
302
304
  if (action === "resume" && !resumeId) return error("no resume id recorded for agent", 422);
303
305
  const denied = authorizeRoute(req, {
@@ -319,6 +321,7 @@ export const postAgentAction: Handler = async (req, params) => {
319
321
  ...(metaPolicyName ? { policyName: metaPolicyName } : {}),
320
322
  ...(metaSpawnRequestId ? { spawnRequestId: metaSpawnRequestId } : {}),
321
323
  ...(action === "restart" || action === "resume" ? { restartSpawn: restartSpawnParamsForAgent(agent, orchestrator, metaPolicyName, metaSpawnRequestId, { resumeId }) } : {}),
324
+ ...(restartInstructions ?? {}),
322
325
  ...(action === "compact" ? compactMemoryParams(agent) : {}),
323
326
  ...(resumeId ? { claudeResumeId: resumeId } : {}),
324
327
  requestedBy: "dashboard",