agent-relay-server 0.120.1 → 0.120.3

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 (181) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/MessageBubble-Cqi3XsZ7.js +2 -0
  4. package/public/assets/{MessageBubble-jpshgh3K.js.map → MessageBubble-Cqi3XsZ7.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CjVG-Jsq.js → PlanGraphPanel-D6Um05oW.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CjVG-Jsq.js.map → PlanGraphPanel-D6Um05oW.js.map} +1 -1
  7. package/public/assets/{activity-DLTFF8Dz.js → activity-B0LsQHEz.js} +2 -2
  8. package/public/assets/{activity-DLTFF8Dz.js.map → activity-B0LsQHEz.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BcCLIrON.js → agent-profiles-O--pe5Ic.js} +2 -2
  10. package/public/assets/{agent-profiles-BcCLIrON.js.map → agent-profiles-O--pe5Ic.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-BEz393ra.js → agent-type-icon-CeEoMouY.js} +2 -2
  12. package/public/assets/{agent-type-icon-BEz393ra.js.map → agent-type-icon-CeEoMouY.js.map} +1 -1
  13. package/public/assets/agents-Ii6XlVwR.js +2 -0
  14. package/public/assets/{agents-QemvuxKO.js.map → agents-Ii6XlVwR.js.map} +1 -1
  15. package/public/assets/{analytics-DMLoMwta.js → analytics-BCzdYWRT.js} +2 -2
  16. package/public/assets/{analytics-DMLoMwta.js.map → analytics-BCzdYWRT.js.map} +1 -1
  17. package/public/assets/{automation-DACSOffp.js → automation-Crr3Xzui.js} +2 -2
  18. package/public/assets/{automation-DACSOffp.js.map → automation-Crr3Xzui.js.map} +1 -1
  19. package/public/assets/{badge-BhUZ1cAb.js → badge-DCIT3Irj.js} +2 -2
  20. package/public/assets/{badge-BhUZ1cAb.js.map → badge-DCIT3Irj.js.map} +1 -1
  21. package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-Bhw2rloy.js} +2 -2
  22. package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-Bhw2rloy.js.map} +1 -1
  23. package/public/assets/{button-C-V4oij1.js → button-CzdTkRIf.js} +2 -2
  24. package/public/assets/{button-C-V4oij1.js.map → button-CzdTkRIf.js.map} +1 -1
  25. package/public/assets/{card-Br1JLjTH.js → card-C3BGuVC6.js} +2 -2
  26. package/public/assets/{card-Br1JLjTH.js.map → card-C3BGuVC6.js.map} +1 -1
  27. package/public/assets/{channels-3SlDQL3j.js → channels-CKs9rvsn.js} +2 -2
  28. package/public/assets/{channels-3SlDQL3j.js.map → channels-CKs9rvsn.js.map} +1 -1
  29. package/public/assets/chat-BCwEUj5R.js +2 -0
  30. package/public/assets/chat-BCwEUj5R.js.map +1 -0
  31. package/public/assets/{connectors-CrEW591Q.js → connectors-D_kQR_vw.js} +2 -2
  32. package/public/assets/{connectors-CrEW591Q.js.map → connectors-D_kQR_vw.js.map} +1 -1
  33. package/public/assets/{copy-button-BStzXgtd.js → copy-button-B3o1jpLu.js} +2 -2
  34. package/public/assets/{copy-button-BStzXgtd.js.map → copy-button-B3o1jpLu.js.map} +1 -1
  35. package/public/assets/{dist-ChDX_t12.js → dist-B-T7lg0a.js} +2 -2
  36. package/public/assets/{dist-ChDX_t12.js.map → dist-B-T7lg0a.js.map} +1 -1
  37. package/public/assets/{dist-DV83j9tm.js → dist-B3e4B9pg.js} +2 -2
  38. package/public/assets/{dist-DV83j9tm.js.map → dist-B3e4B9pg.js.map} +1 -1
  39. package/public/assets/{dist-y03IwOrd.js → dist-B6woQ9Tb.js} +2 -2
  40. package/public/assets/{dist-y03IwOrd.js.map → dist-B6woQ9Tb.js.map} +1 -1
  41. package/public/assets/{dist-CQ0Z-TDi.js → dist-B9G83DXv.js} +2 -2
  42. package/public/assets/{dist-CQ0Z-TDi.js.map → dist-B9G83DXv.js.map} +1 -1
  43. package/public/assets/{dist-Cs8X0P1d.js → dist-BIjlN-xB.js} +2 -2
  44. package/public/assets/{dist-Cs8X0P1d.js.map → dist-BIjlN-xB.js.map} +1 -1
  45. package/public/assets/{dist-DyNJNwpi.js → dist-BInckSJC.js} +2 -2
  46. package/public/assets/{dist-DyNJNwpi.js.map → dist-BInckSJC.js.map} +1 -1
  47. package/public/assets/{dist-CmFeEXMe.js → dist-BlzVGRhh.js} +2 -2
  48. package/public/assets/{dist-CmFeEXMe.js.map → dist-BlzVGRhh.js.map} +1 -1
  49. package/public/assets/{dist-CDZZxxJq.js → dist-C7aTlosb.js} +2 -2
  50. package/public/assets/{dist-CDZZxxJq.js.map → dist-C7aTlosb.js.map} +1 -1
  51. package/public/assets/{dist-D2jvo5oA.js → dist-DGfp5dLz.js} +2 -2
  52. package/public/assets/{dist-D2jvo5oA.js.map → dist-DGfp5dLz.js.map} +1 -1
  53. package/public/assets/{dist-mm7c7nBt.js → dist-DT-OV6sQ.js} +2 -2
  54. package/public/assets/{dist-mm7c7nBt.js.map → dist-DT-OV6sQ.js.map} +1 -1
  55. package/public/assets/{dist-CBMWGhWc.js → dist-OUPTEP6q.js} +2 -2
  56. package/public/assets/{dist-CBMWGhWc.js.map → dist-OUPTEP6q.js.map} +1 -1
  57. package/public/assets/{es2015-VnZ2kVIz.js → es2015-CnKXYKpf.js} +2 -2
  58. package/public/assets/{es2015-VnZ2kVIz.js.map → es2015-CnKXYKpf.js.map} +1 -1
  59. package/public/assets/formatted-body-impl-CSRGuMcM.js +3 -0
  60. package/public/assets/formatted-body-impl-CSRGuMcM.js.map +1 -0
  61. package/public/assets/formatted-body-q_qzt1L-.js +3 -0
  62. package/public/assets/{formatted-body-Dg9R3RD7.js.map → formatted-body-q_qzt1L-.js.map} +1 -1
  63. package/public/assets/{index-D_fNJnsI.js → index-Ceiwd02X.js} +10 -10
  64. package/public/assets/index-Ceiwd02X.js.map +1 -0
  65. package/public/assets/{input-CtLT7WXi.js → input-B6Q0NWCS.js} +2 -2
  66. package/public/assets/{input-CtLT7WXi.js.map → input-B6Q0NWCS.js.map} +1 -1
  67. package/public/assets/{insights-D48n2ojF.js → insights-DUhXqZTZ.js} +2 -2
  68. package/public/assets/{insights-D48n2ojF.js.map → insights-DUhXqZTZ.js.map} +1 -1
  69. package/public/assets/{integrations-B-bdliwU.js → integrations-lbg5CjtE.js} +2 -2
  70. package/public/assets/{integrations-B-bdliwU.js.map → integrations-lbg5CjtE.js.map} +1 -1
  71. package/public/assets/{lib-ByLQqcD1.js → lib-BHsWPMKB.js} +2 -2
  72. package/public/assets/{lib-ByLQqcD1.js.map → lib-BHsWPMKB.js.map} +1 -1
  73. package/public/assets/lucide-react-DuhlF2Sj.js +10 -0
  74. package/public/assets/lucide-react-DuhlF2Sj.js.map +1 -0
  75. package/public/assets/{maintenance-LQ_U3Nqy.js → maintenance-CQgneSVf.js} +2 -2
  76. package/public/assets/{maintenance-LQ_U3Nqy.js.map → maintenance-CQgneSVf.js.map} +1 -1
  77. package/public/assets/{managed-agents-Cfh8FvY8.js → managed-agents-DsnYf6fm.js} +4 -4
  78. package/public/assets/{managed-agents-Cfh8FvY8.js.map → managed-agents-DsnYf6fm.js.map} +1 -1
  79. package/public/assets/{markdown-preview-impl-BSa7zYx1.js → markdown-preview-impl-C1ZtGJp6.js} +2 -2
  80. package/public/assets/{markdown-preview-impl-BSa7zYx1.js.map → markdown-preview-impl-C1ZtGJp6.js.map} +1 -1
  81. package/public/assets/{memory-Dr1iYKkC.js → memory-SSLUTOI2.js} +2 -2
  82. package/public/assets/{memory-Dr1iYKkC.js.map → memory-SSLUTOI2.js.map} +1 -1
  83. package/public/assets/messages-BY26OP4K.js +2 -0
  84. package/public/assets/{messages-DRKulS-2.js.map → messages-BY26OP4K.js.map} +1 -1
  85. package/public/assets/{orchestrators-BguyeViG.js → orchestrators-OY7yPzVI.js} +2 -2
  86. package/public/assets/{orchestrators-BguyeViG.js.map → orchestrators-OY7yPzVI.js.map} +1 -1
  87. package/public/assets/overview-Dj0u1tPG.js +2 -0
  88. package/public/assets/{overview-Dbq2lr7Y.js.map → overview-Dj0u1tPG.js.map} +1 -1
  89. package/public/assets/{pairs-DD7i2scF.js → pairs-CrUR50ou.js} +2 -2
  90. package/public/assets/{pairs-DD7i2scF.js.map → pairs-CrUR50ou.js.map} +1 -1
  91. package/public/assets/{projects-DEn85LQb.js → projects-B_AtxHtH.js} +2 -2
  92. package/public/assets/{projects-DEn85LQb.js.map → projects-B_AtxHtH.js.map} +1 -1
  93. package/public/assets/{prompt-settings-DwgrPKY3.js → prompt-settings-CPtYSban.js} +2 -2
  94. package/public/assets/{prompt-settings-DwgrPKY3.js.map → prompt-settings-CPtYSban.js.map} +1 -1
  95. package/public/assets/{react-dom-cLkVFBsS.js → react-dom-DWBmcawP.js} +2 -2
  96. package/public/assets/{react-dom-cLkVFBsS.js.map → react-dom-DWBmcawP.js.map} +1 -1
  97. package/public/assets/{registry-DCR3xFpe.js → registry-BgUALWn5.js} +2 -2
  98. package/public/assets/{registry-DCR3xFpe.js.map → registry-BgUALWn5.js.map} +1 -1
  99. package/public/assets/security-5lHfGk7g.js +3 -0
  100. package/public/assets/security-5lHfGk7g.js.map +1 -0
  101. package/public/assets/{select-CpyCRBK3.js → select-Cgx4QSLj.js} +2 -2
  102. package/public/assets/{select-CpyCRBK3.js.map → select-Cgx4QSLj.js.map} +1 -1
  103. package/public/assets/settings-DFGgxyun.js +6 -0
  104. package/public/assets/{settings-DYNFGC2-.js.map → settings-DFGgxyun.js.map} +1 -1
  105. package/public/assets/store-BM4zM89B.js +9 -0
  106. package/public/assets/store-BM4zM89B.js.map +1 -0
  107. package/public/assets/{task-chip-kxk4eNm1.js → task-chip-DXfvXBDx.js} +2 -2
  108. package/public/assets/{task-chip-kxk4eNm1.js.map → task-chip-DXfvXBDx.js.map} +1 -1
  109. package/public/assets/{tasks-rdy9Nsbf.js → tasks-BvH5rluu.js} +2 -2
  110. package/public/assets/{tasks-rdy9Nsbf.js.map → tasks-BvH5rluu.js.map} +1 -1
  111. package/public/assets/{teams-DHloBagJ.js → teams-VZOIOdoE.js} +2 -2
  112. package/public/assets/{teams-DHloBagJ.js.map → teams-VZOIOdoE.js.map} +1 -1
  113. package/public/assets/{terminal-viewer-impl-DWTuNejR.js → terminal-viewer-impl-DYY3Wmoe.js} +2 -2
  114. package/public/assets/{terminal-viewer-impl-DWTuNejR.js.map → terminal-viewer-impl-DYY3Wmoe.js.map} +1 -1
  115. package/public/assets/types-uVOXgvMT.js.map +1 -1
  116. package/public/assets/{use-keyboard-viewport-B06XtRUZ.js → use-keyboard-viewport-CP109w9y.js} +2 -2
  117. package/public/assets/{use-keyboard-viewport-B06XtRUZ.js.map → use-keyboard-viewport-CP109w9y.js.map} +1 -1
  118. package/public/assets/{user-avatar-DuwCFrER.js → user-avatar-Dw6mzWhv.js} +2 -2
  119. package/public/assets/{user-avatar-DuwCFrER.js.map → user-avatar-Dw6mzWhv.js.map} +1 -1
  120. package/public/assets/{work-queue-CShT7nTC.js → work-queue-B6Xk7QNe.js} +2 -2
  121. package/public/assets/{work-queue-CShT7nTC.js.map → work-queue-B6Xk7QNe.js.map} +1 -1
  122. package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-CPIzAHAe.js} +2 -2
  123. package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-CPIzAHAe.js.map} +1 -1
  124. package/public/index.html +23 -23
  125. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  126. package/src/automations/reconcile.ts +69 -4
  127. package/src/branch-landed.ts +18 -11
  128. package/src/bus.ts +25 -18
  129. package/src/db/connection.ts +53 -1
  130. package/src/db/maintenance-worker.ts +37 -0
  131. package/src/db/message-reads.ts +86 -45
  132. package/src/db/migrations.ts +1 -0
  133. package/src/db/provider-quotas.ts +6 -7
  134. package/src/db/schema.ts +2 -1
  135. package/src/maintenance/jobs.ts +19 -13
  136. package/src/maintenance/scheduler.ts +8 -3
  137. package/src/maintenance/types.ts +5 -1
  138. package/src/mcp/index.ts +5 -5
  139. package/src/mcp/tools-workspace.ts +8 -3
  140. package/src/routes/orchestrator-proxy.ts +7 -6
  141. package/src/routes/sse.ts +2 -1
  142. package/src/routes/tasks.ts +29 -0
  143. package/src/routes/workspaces.ts +9 -2
  144. package/src/runtime-tokens.ts +4 -0
  145. package/src/security.ts +31 -7
  146. package/src/server.ts +40 -18
  147. package/src/services/git-process.ts +61 -0
  148. package/src/services/git-remote.ts +87 -7
  149. package/src/services/issue-lifecycle.ts +6 -6
  150. package/src/services/project.ts +47 -1
  151. package/src/services/send-message.ts +2 -4
  152. package/src/services/spawn-agent.ts +2 -2
  153. package/src/services/task-lifecycle.ts +6 -9
  154. package/src/settings/registry.ts +6 -4
  155. package/src/spawn-targets.ts +2 -2
  156. package/src/sse.ts +131 -46
  157. package/src/steward.ts +10 -6
  158. package/src/terminal-authz.ts +50 -0
  159. package/src/token-db.ts +3 -3
  160. package/src/validation.ts +1 -1
  161. package/src/workspace-actions.ts +46 -4
  162. package/src/workspace-auto-merge.ts +120 -13
  163. package/src/workspace-orphans.ts +1 -1
  164. package/src/workspace-probe.ts +19 -10
  165. package/public/assets/MessageBubble-jpshgh3K.js +0 -2
  166. package/public/assets/agents-QemvuxKO.js +0 -2
  167. package/public/assets/chat-xKXCafN9.js +0 -2
  168. package/public/assets/chat-xKXCafN9.js.map +0 -1
  169. package/public/assets/formatted-body-Dg9R3RD7.js +0 -3
  170. package/public/assets/formatted-body-impl-DlasTD8X.js +0 -3
  171. package/public/assets/formatted-body-impl-DlasTD8X.js.map +0 -1
  172. package/public/assets/index-D_fNJnsI.js.map +0 -1
  173. package/public/assets/lucide-react-Dc3ZwCBi.js +0 -9
  174. package/public/assets/lucide-react-Dc3ZwCBi.js.map +0 -1
  175. package/public/assets/messages-DRKulS-2.js +0 -2
  176. package/public/assets/overview-Dbq2lr7Y.js +0 -2
  177. package/public/assets/security-BH8kTdA-.js +0 -3
  178. package/public/assets/security-BH8kTdA-.js.map +0 -1
  179. package/public/assets/settings-DYNFGC2-.js +0 -6
  180. package/public/assets/store-BuuJUn70.js +0 -9
  181. package/public/assets/store-BuuJUn70.js.map +0 -1
package/src/sse.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getAgent, getOrchestrator, matchingDeliveryAgents, onWorkspaceChange, sendMessageWithResult, setChannelSubscriberProbe } from "./db";
2
+ import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
2
3
  import { projectAgentStatusData } from "./agent-card-projection";
3
4
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
4
5
  import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
@@ -11,20 +12,27 @@ interface Connection {
11
12
  agentId: string | null;
12
13
  controller: ReadableStreamDefaultController<Uint8Array>;
13
14
  keepalive: Timer;
14
- // Pending cross-tick backpressure re-check (#971 refinement). Set when a stream
15
- // dips below the queue floor during a synchronous fanout; cleared on the deferred
16
- // tick once we know whether it drained (healthy) or stayed stuck (stale drop).
15
+ queuedBytes: number;
16
+ // Pending backpressure re-check. Set when a stream crosses the byte floor; cleared
17
+ // once it drains or is closed after making no progress during the grace interval.
17
18
  drainCheck?: Timer;
19
+ drainCheckQueuedBytes?: number;
18
20
  }
19
21
 
20
22
  const connections = new Map<string, Connection>();
21
23
  const encoder = new TextEncoder();
22
24
  const SSE_KEEPALIVE_MS = 15_000;
23
25
  const SSE_RETRY_MS = 5_000;
24
- const SSE_MAX_QUEUED_CHUNKS = 128;
26
+ const SSE_MAX_QUEUED_BYTES = 1024 * 1024;
27
+ const SSE_DRAIN_GRACE_MS = 2_000;
25
28
 
26
29
  subscribeRelayEvents((event) => fanout(event));
27
30
 
31
+ function fanoutAgent(agentId: string, cache: Map<string, AgentCard | null>): AgentCard | null {
32
+ if (!cache.has(agentId)) cache.set(agentId, getAgent(agentId));
33
+ return cache.get(agentId) ?? null;
34
+ }
35
+
28
36
  // #797 — let the db layer derive outbound-channel liveness from live SSE subscriptions.
29
37
  // A channel agent is "subscribed" iff the channels-host daemon holds an open
30
38
  // GET /api/events?for=<channelAgentId> stream (createSSEStream stores `for` as agentId).
@@ -36,28 +44,36 @@ export function hasActiveSubscriber(agentId: string): boolean {
36
44
  }
37
45
  setChannelSubscriberProbe(hasActiveSubscriber);
38
46
 
39
- export function createSSEStream(agentId: string | null): Response {
47
+ export function createSSEStream(agentId: string | null, lastEventId = 0): Response {
40
48
  let connId: string;
41
49
 
42
- const stream = new ReadableStream<Uint8Array>({
43
- start(controller) {
44
- connId = crypto.randomUUID();
45
- const keepalive = setInterval(() => {
46
- const conn = connections.get(connId);
47
- if (!conn) return;
48
- sendEncoded(conn, encoder.encode(": keepalive\n\n"));
49
- }, SSE_KEEPALIVE_MS);
50
-
51
- connections.set(connId, { id: connId, agentId, controller, keepalive });
52
-
53
- controller.enqueue(encoder.encode(
54
- `retry: ${SSE_RETRY_MS}\nevent: connected\ndata: ${JSON.stringify({ connectionId: connId })}\n\n`
55
- ));
50
+ const stream = new ReadableStream<Uint8Array>(
51
+ {
52
+ start(controller) {
53
+ connId = crypto.randomUUID();
54
+ const keepalive = setInterval(() => {
55
+ const conn = connections.get(connId);
56
+ if (!conn) return;
57
+ sendEncoded(conn, encoder.encode(": keepalive\n\n"));
58
+ }, SSE_KEEPALIVE_MS);
59
+
60
+ connections.set(connId, { id: connId, agentId, controller, keepalive, queuedBytes: 0 });
61
+
62
+ const replay = replayPlan(lastEventId);
63
+ controller.enqueue(encoder.encode(
64
+ `retry: ${SSE_RETRY_MS}\nevent: connected\ndata: ${JSON.stringify({ connectionId: connId, replay })}\n\n`
65
+ ));
66
+ replayEventsToConnection(connections.get(connId), replay);
67
+ },
68
+ cancel() {
69
+ removeConnection(connId);
70
+ },
56
71
  },
57
- cancel() {
58
- removeConnection(connId);
72
+ {
73
+ highWaterMark: 0,
74
+ size: (chunk) => chunk?.byteLength ?? 0,
59
75
  },
60
- });
76
+ );
61
77
 
62
78
  return new Response(stream, {
63
79
  headers: {
@@ -85,12 +101,19 @@ function closeConnection(id: string) {
85
101
  try { conn?.controller.close(); } catch {}
86
102
  }
87
103
 
88
- function sseFrame(event: string, data: unknown): Uint8Array {
89
- return encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
104
+ function sseFrame(event: string, data: unknown, id?: number): Uint8Array {
105
+ const idLine = id !== undefined ? `id: ${id}\n` : "";
106
+ return encoder.encode(`${idLine}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
107
+ }
108
+
109
+ function bufferedBytes(conn: Connection): number {
110
+ const desiredSize = conn.controller.desiredSize;
111
+ if (desiredSize === null) return conn.queuedBytes;
112
+ return Math.max(0, -desiredSize);
90
113
  }
91
114
 
92
115
  function isBackpressured(conn: Connection): boolean {
93
- return conn.controller.desiredSize !== null && conn.controller.desiredSize < -SSE_MAX_QUEUED_CHUNKS;
116
+ return bufferedBytes(conn) >= SSE_MAX_QUEUED_BYTES;
94
117
  }
95
118
 
96
119
  function sendEncoded(conn: Connection, chunk: Uint8Array) {
@@ -101,59 +124,66 @@ function sendEncoded(conn: Connection, chunk: Uint8Array) {
101
124
  removeConnection(conn.id);
102
125
  return;
103
126
  }
104
- // #971 — the queue only drains between event-loop ticks, so within a single
105
- // synchronous fanout (a batch loop emitting >SSE_MAX_QUEUED_CHUNKS frames)
106
- // desiredSize falls on liveness, not staleness. Closing inline here would kill
107
- // healthy, actively-read dashboards mid-burst. Instead defer the verdict: mark
108
- // the stream and re-check on a later tick, after the runtime has had a chance to
109
- // flush to the socket. Recovered → keep it; still stuck across ticks → drop it.
127
+ conn.queuedBytes = bufferedBytes(conn);
110
128
  if (isBackpressured(conn)) scheduleDrainCheck(conn);
111
129
  }
112
130
 
113
131
  function scheduleDrainCheck(conn: Connection) {
114
132
  if (conn.drainCheck) return; // one deferred verdict per stream is enough
133
+ conn.drainCheckQueuedBytes = bufferedBytes(conn);
115
134
  conn.drainCheck = setTimeout(() => {
116
135
  conn.drainCheck = undefined;
117
- // The connection may already have been removed (cancel/close) between ticks
136
+ // The connection may already have been removed (cancel/close) during the grace
118
137
  // closeConnection is idempotent, but bail early so we never touch a stale ref.
119
138
  if (connections.get(conn.id) !== conn) return;
120
- // Drained across the tick → genuinely healthy-but-slow. Stayed backpressured →
121
- // genuinely stalled/stale; drop it (the original #971 protection).
139
+ const queuedBytes = bufferedBytes(conn);
140
+ conn.queuedBytes = queuedBytes;
141
+ if (queuedBytes < SSE_MAX_QUEUED_BYTES) {
142
+ conn.drainCheckQueuedBytes = undefined;
143
+ return;
144
+ }
145
+ const drained = conn.drainCheckQueuedBytes !== undefined && queuedBytes < conn.drainCheckQueuedBytes;
146
+ conn.drainCheckQueuedBytes = undefined;
147
+ if (drained) {
148
+ scheduleDrainCheck(conn);
149
+ return;
150
+ }
122
151
  if (isBackpressured(conn)) closeConnection(conn.id);
123
- }, 0);
152
+ }, SSE_DRAIN_GRACE_MS);
124
153
  }
125
154
 
126
- function send(conn: Connection, event: string, data: unknown) {
127
- sendEncoded(conn, sseFrame(event, data));
155
+ function send(conn: Connection, event: string, data: unknown, id?: number) {
156
+ sendEncoded(conn, sseFrame(event, data, id));
128
157
  }
129
158
 
130
159
  function fanout(event: RelayEvent): void {
131
160
  if (event.type === "message.new") {
132
- sendNewMessage(event.data as unknown as Message);
161
+ sendNewMessage(event.data as unknown as Message, event.seq);
133
162
  return;
134
163
  }
135
164
  if (event.type === "task.updated" || event.type === "task.created" || event.type === "task.claimed" || event.type === "task.status") {
136
- sendTaskChanged(event.data as unknown as Task, event.type);
165
+ sendTaskChanged(event.data as unknown as Task, event.type, event.seq);
137
166
  return;
138
167
  }
139
- const chunk = sseFrame(event.type, event.data);
168
+ const chunk = sseFrame(event.type, event.data, event.seq);
140
169
  for (const conn of connections.values()) {
141
170
  sendEncoded(conn, chunk);
142
171
  }
143
172
  }
144
173
 
145
- function sendNewMessage(msg: Message): void {
174
+ function sendNewMessage(msg: Message, seq?: number): void {
146
175
  const scopedBroadcastAgentIds = msg.to === "broadcast"
147
176
  ? new Set(matchingDeliveryAgents("broadcast", { from: msg.from, channel: msg.channel }).map((agent) => agent.id))
148
177
  : null;
149
- const chunk = sseFrame("message.new", msg);
178
+ const chunk = sseFrame("message.new", msg, seq);
179
+ const agentCache = new Map<string, AgentCard | null>();
150
180
  for (const conn of connections.values()) {
151
181
  if (!conn.agentId) {
152
182
  sendEncoded(conn, chunk);
153
183
  continue;
154
184
  }
155
185
  if (scopedBroadcastAgentIds && !scopedBroadcastAgentIds.has(conn.agentId)) continue;
156
- if (!messageMatchesAgent(msg, conn.agentId, getAgent(conn.agentId))) continue;
186
+ if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
157
187
  sendEncoded(conn, chunk);
158
188
  }
159
189
  }
@@ -261,14 +291,69 @@ export function emitMessageReactionUpdated(msg: Message) {
261
291
  emitRelayEvent({ type: "message.reaction_updated", source: "server", subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
262
292
  }
263
293
 
264
- function sendTaskChanged(task: Task, eventType = "task.updated"): void {
265
- const chunk = sseFrame(eventType, task);
294
+ function sendTaskChanged(task: Task, eventType = "task.updated", seq?: number): void {
295
+ const chunk = sseFrame(eventType, task, seq);
296
+ const agentCache = new Map<string, AgentCard | null>();
266
297
  for (const conn of connections.values()) {
267
- if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId, getAgent(conn.agentId))) continue;
298
+ if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
268
299
  sendEncoded(conn, chunk);
269
300
  }
270
301
  }
271
302
 
303
+ interface ReplayPlan {
304
+ requested: boolean;
305
+ since: number;
306
+ available: boolean;
307
+ gap: boolean;
308
+ }
309
+
310
+ function replayPlan(lastEventId: number): ReplayPlan {
311
+ const since = Number.isSafeInteger(lastEventId) && lastEventId > 0 ? lastEventId : 0;
312
+ if (since === 0) return { requested: false, since: 0, available: false, gap: false };
313
+ const oldest = getOldestOutboxCursor();
314
+ const gap = oldest > 0 && oldest > since + 1;
315
+ return { requested: true, since, available: !gap, gap };
316
+ }
317
+
318
+ function replayEventsToConnection(conn: Connection | undefined, plan: ReplayPlan): void {
319
+ if (!conn || !plan.requested) return;
320
+ if (plan.gap) {
321
+ send(conn, "replay.gap", { since: plan.since }, getOutboxCursor());
322
+ return;
323
+ }
324
+ const agentCache = new Map<string, AgentCard | null>();
325
+ for (const event of replayEvents(plan.since)) {
326
+ sendReplayEvent(conn, outboxToRelayEvent(event), agentCache);
327
+ }
328
+ }
329
+
330
+ function sendReplayEvent(conn: Connection, event: RelayEvent, agentCache: Map<string, AgentCard | null>): void {
331
+ if (event.type === "message.new" && conn.agentId) {
332
+ const msg = event.data as unknown as Message;
333
+ if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) return;
334
+ send(conn, "message.new", msg, event.seq);
335
+ return;
336
+ }
337
+ if (event.type.startsWith("task.") && conn.agentId) {
338
+ const task = event.data as unknown as Task;
339
+ if (!targetMatchesAgent(task.target, conn.agentId, fanoutAgent(conn.agentId, agentCache))) return;
340
+ send(conn, event.type, task, event.seq);
341
+ return;
342
+ }
343
+ send(conn, event.type, event.data, event.seq);
344
+ }
345
+
346
+ function outboxToRelayEvent(event: BusEvent): RelayEvent {
347
+ return {
348
+ seq: event.seq,
349
+ type: event.eventType,
350
+ source: event.source,
351
+ subject: event.subject,
352
+ data: event.data,
353
+ timestamp: event.timestamp,
354
+ };
355
+ }
356
+
272
357
  export function emitTaskChanged(task: Task, eventType = "task.updated") {
273
358
  emitRelayEvent({ type: eventType, source: task.source, subject: String(task.id), data: task as unknown as Record<string, unknown> });
274
359
  }
package/src/steward.ts CHANGED
@@ -12,10 +12,11 @@ import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
12
12
  import { isPathWithinBase } from "./utils";
13
13
 
14
14
  /** Stable, readable, collision-resistant policy name for a repo's steward. */
15
- export function repoStewardPolicyName(repoRoot: string): string {
15
+ export function repoStewardPolicyName(repoRoot: string, orchestratorId?: string): string {
16
16
  const slug = basename(repoRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
17
17
  const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 8);
18
- return `steward-${slug}-${hash}`;
18
+ const owner = orchestratorId?.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19
+ return owner ? `steward-${slug}-${hash}-${owner}` : `steward-${slug}-${hash}`;
19
20
  }
20
21
 
21
22
  /**
@@ -54,7 +55,7 @@ function desiredStewardPolicy(repoRoot: string, owner: Orchestrator, config: Ste
54
55
  const resolved = resolveInternalSpawnTarget("landing-steward", pin);
55
56
 
56
57
  return {
57
- name: repoStewardPolicyName(repoRoot),
58
+ name: repoStewardPolicyName(repoRoot, owner.id),
58
59
  description: `Autonomous repo steward for ${repoRoot} (issue #167).`,
59
60
  enabled: true,
60
61
  orchestratorId: owner.id,
@@ -118,13 +119,16 @@ function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): bool
118
119
  * owns the repo. Idempotent — refreshes the policy only when config-derived fields
119
120
  * change, so it can be called on every sweep without churn.
120
121
  */
121
- export function ensureRepoSteward(repoRoot: string): string | null {
122
+ export function ensureRepoSteward(repoRoot: string, owningOrchestrator?: Orchestrator): string | null {
122
123
  const config = getStewardConfig();
123
124
  if (!config.enabled) return null;
124
- const owner = listOrchestrators().find((orch) => isPathWithinBase(repoRoot, orch.baseDir));
125
+ const owner = owningOrchestrator ?? listOrchestrators().find((orch) =>
126
+ orch.status === "online" && isPathWithinBase(repoRoot, orch.baseDir)
127
+ );
128
+ if (owner && (owner.status !== "online" || !isPathWithinBase(repoRoot, owner.baseDir))) return null;
125
129
  if (!owner) return null;
126
130
 
127
- const name = repoStewardPolicyName(repoRoot);
131
+ const name = repoStewardPolicyName(repoRoot, owner.id);
128
132
  const desired = desiredStewardPolicy(repoRoot, owner, config);
129
133
  const existing = getSpawnPolicy(name);
130
134
  if (existing && !stewardPolicyDiffers(existing.value, desired)) return name;
@@ -0,0 +1,50 @@
1
+ import { getOrchestrator } from "./db";
2
+ import { getIntegrationAuth, isFullReachRequest, isRequestAuthorizedFor } from "./security";
3
+
4
+ type TerminalAccessMode = "read" | "write";
5
+
6
+ interface TerminalOwner {
7
+ agentId: string;
8
+ policyName?: string;
9
+ spawnRequestId?: string;
10
+ }
11
+
12
+ function hasPresentedCredential(req: Request): boolean {
13
+ return Boolean(req.headers.get("authorization") || req.headers.get("x-agent-relay-token") || new URL(req.url).searchParams.get("token"));
14
+ }
15
+
16
+ function terminalOwner(orchestratorId: string, session: string): TerminalOwner | null {
17
+ const orch = getOrchestrator(orchestratorId);
18
+ if (!orch) return null;
19
+ for (const agent of orch.managedAgents ?? []) {
20
+ const sessions = [agent.terminalSession, agent.tmuxSession, agent.sessionName].filter((value): value is string => typeof value === "string" && value.length > 0);
21
+ if (!sessions.includes(session)) continue;
22
+ if (!agent.agentId) return null;
23
+ return {
24
+ agentId: agent.agentId,
25
+ ...(agent.policyName ? { policyName: agent.policyName } : {}),
26
+ ...(agent.spawnRequestId ? { spawnRequestId: agent.spawnRequestId } : {}),
27
+ };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ export function isTerminalSessionRequestAuthorized(req: Request, orchestratorId: string, session: string, mode: TerminalAccessMode): boolean {
33
+ if (!hasPresentedCredential(req)) return true;
34
+ if (isFullReachRequest(req)) return true;
35
+ if (getIntegrationAuth(req)) return false;
36
+ const owner = terminalOwner(orchestratorId, session);
37
+ if (!owner) return false;
38
+ const terminalCapability = mode === "read" ? { terminalRead: true } : { terminalWrite: true };
39
+ return isRequestAuthorizedFor(req, {
40
+ scope: mode === "read" ? "terminal:read" : "terminal:write",
41
+ resource: {
42
+ orchestratorId,
43
+ agentId: owner.agentId,
44
+ policyName: owner.policyName,
45
+ spawnRequestId: owner.spawnRequestId,
46
+ terminalOwner: true,
47
+ ...terminalCapability,
48
+ },
49
+ });
50
+ }
package/src/token-db.ts CHANGED
@@ -67,8 +67,8 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
67
67
  name: "Orchestrator Host",
68
68
  description: "Host supervisor access for spawn, command, log, and terminal operations.",
69
69
  role: "orchestrator",
70
- scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach", "quota:write", "provisioning:read"],
71
- constraints: { logsRead: true, terminalAttach: true },
70
+ scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach", "terminal:read", "terminal:write", "quota:write", "provisioning:read"],
71
+ constraints: { logsRead: true, terminalAttach: true, terminalRead: true, terminalWrite: true },
72
72
  ttlSeconds: 30 * 24 * 60 * 60,
73
73
  builtIn: true,
74
74
  createdBy: "system",
@@ -100,7 +100,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
100
100
  description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
101
101
  role: "provider",
102
102
  scope: ["agent:read", "agent:write", "message:read", "message:send", "schedule:write", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "channel:egress", "insights:write", "quota:write"],
103
- constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
103
+ constraints: { terminalAttach: false, terminalRead: false, terminalWrite: false, logsRead: false, canDelegate: false },
104
104
  ttlSeconds: 30 * 24 * 60 * 60,
105
105
  builtIn: true,
106
106
  createdBy: "system",
package/src/validation.ts CHANGED
@@ -109,7 +109,7 @@ export function cleanTokenConstraints(value: unknown): TokenConstraints | undefi
109
109
  if (teamId) constraints.teamId = teamId;
110
110
  const tenantId = cleanString(value.tenantId, "constraints.tenantId", { max: 120 });
111
111
  if (tenantId) constraints.tenantId = tenantId;
112
- for (const key of ["terminalAttach", "logsRead", "canDelegate"] as const) {
112
+ for (const key of ["terminalAttach", "terminalRead", "terminalWrite", "logsRead", "canDelegate"] as const) {
113
113
  if (value[key] !== undefined) {
114
114
  if (typeof value[key] !== "boolean") throw new ValidationError(`constraints.${key} must be a boolean`);
115
115
  constraints[key] = value[key];
@@ -26,8 +26,9 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
26
26
  // tuple as the HTTP route without a second import hop (one source: workspace-merge, #304).
27
27
  export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
28
28
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
29
- import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
29
+ import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES, worktreeReapable } from "./workspace-phase";
30
30
  import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
31
+ import { fetchHostMergePreview } from "./workspace-probe";
31
32
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
32
33
 
33
34
  // Single source of truth for the action verb set. The route's `optionalEnum` and
@@ -129,7 +130,28 @@ function recordWorkspaceAudit(a: {
129
130
  }
130
131
  }
131
132
 
132
- export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWorkspaceActionInput): WorkspaceActionResult {
133
+ function metadataNumber(metadata: Record<string, unknown>, key: string): number | undefined {
134
+ const value = metadata[key];
135
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
136
+ }
137
+
138
+ async function cleanupWorkSafety(workspace: WorkspaceRecord): Promise<{ ok: true } | { ok: false; error: string }> {
139
+ const owner = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
140
+ if (!owner?.apiUrl) return { ok: false, error: "owning orchestrator unavailable for cleanup safety probe" };
141
+ const preview = await fetchHostMergePreview(owner.apiUrl, workspace, { checkPr: false });
142
+ if (!preview) return { ok: false, error: "cleanup safety probe unavailable" };
143
+ if ("available" in preview && preview.available === false) return { ok: false, error: "cleanup safety probe unavailable" };
144
+ const p = preview as Exclude<typeof preview, { available: false }>;
145
+ if (p.missing) return { ok: true };
146
+ if (p.error) return { ok: false, error: `cleanup safety probe error: ${p.error}` };
147
+ if (worktreeReapable({ landed: p.landed, ahead: p.ahead, unmergedAhead: p.unmergedAhead, dirtyCount: p.dirtyCount })) return { ok: true };
148
+ const dirtyCount = p.dirtyCount ?? 0;
149
+ if (dirtyCount > 0) return { ok: false, error: `${dirtyCount} uncommitted change(s)` };
150
+ const ahead = p.unmergedAhead ?? p.ahead ?? 0;
151
+ return { ok: false, error: `${ahead} unlanded commit(s)` };
152
+ }
153
+
154
+ export async function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWorkspaceActionInput): Promise<WorkspaceActionResult> {
133
155
  const { action } = input;
134
156
  const agentId = input.agentId;
135
157
  const detail = input.detail;
@@ -228,6 +250,17 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
228
250
  const nextStatus = STATUS_BY_ACTION[action];
229
251
  if (!nextStatus) return { ok: false, httpStatus: 400, error: `unsupported action: ${action}` };
230
252
 
253
+ if (READY_TO_LAND_STATUSES.has(nextStatus)) {
254
+ const dirtyCount = metadataNumber(workspace.metadata, "gitDirtyCount") ?? 0;
255
+ if (dirtyCount > 0) {
256
+ return {
257
+ ok: false,
258
+ httpStatus: 409,
259
+ error: `workspace ${workspace.id} cannot be marked ${nextStatus}: ${DIRTY_WORKTREE_LAND_SKIP_REASON} (${dirtyCount} dirty file${dirtyCount === 1 ? "" : "s"}); commit or stash first`,
260
+ };
261
+ }
262
+ }
263
+
231
264
  if (
232
265
  action === "cleanup" &&
233
266
  input.force !== true &&
@@ -245,7 +278,15 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
245
278
  let command: Command | undefined;
246
279
  if (requiresCommand) {
247
280
  // Only `cleanup` reaches here — `merge` returned early via the shared helper.
248
- const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard", { force: input.force === true });
281
+ const safety = await cleanupWorkSafety(workspace);
282
+ if (!safety.ok && !(input.force === true && Boolean(detail?.trim()))) {
283
+ return {
284
+ ok: false,
285
+ httpStatus: 409,
286
+ error: `workspace ${workspace.id} cannot be cleaned up safely: ${safety.error}; pass force:true with a detail reason only if intentionally discarding work`,
287
+ };
288
+ }
289
+ const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard", { force: input.force === true, reason: detail });
249
290
  if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
250
291
  command = built.command;
251
292
  }
@@ -287,7 +328,7 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
287
328
  export function buildWorkspaceCleanupCommand(
288
329
  workspace: WorkspaceRecord,
289
330
  requestedBy: string,
290
- opts: { force?: boolean } = {},
331
+ opts: { force?: boolean; reason?: string } = {},
291
332
  ): { ok: true; command: Command } | { ok: false; status: number; error: string } {
292
333
  if (
293
334
  opts.force !== true &&
@@ -319,6 +360,7 @@ export function buildWorkspaceCleanupCommand(
319
360
  requestedBy,
320
361
  requestedAt: Date.now(),
321
362
  deleteBranch: true,
363
+ ...(opts.force === true && opts.reason ? { force: true, reason: opts.reason } : {}),
322
364
  queued: false,
323
365
  },
324
366
  });