agent-relay-server 0.120.1 → 0.120.2

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 (126) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/MessageBubble-DazQ9Rn-.js +2 -0
  4. package/public/assets/{MessageBubble-jpshgh3K.js.map → MessageBubble-DazQ9Rn-.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CjVG-Jsq.js → PlanGraphPanel-DpTiGCkz.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CjVG-Jsq.js.map → PlanGraphPanel-DpTiGCkz.js.map} +1 -1
  7. package/public/assets/{activity-DLTFF8Dz.js → activity-DReCE2mK.js} +2 -2
  8. package/public/assets/{activity-DLTFF8Dz.js.map → activity-DReCE2mK.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BcCLIrON.js → agent-profiles-D3ba8Xpe.js} +2 -2
  10. package/public/assets/{agent-profiles-BcCLIrON.js.map → agent-profiles-D3ba8Xpe.js.map} +1 -1
  11. package/public/assets/agents-BcsIxadm.js +2 -0
  12. package/public/assets/{agents-QemvuxKO.js.map → agents-BcsIxadm.js.map} +1 -1
  13. package/public/assets/{analytics-DMLoMwta.js → analytics-0lOlkaib.js} +2 -2
  14. package/public/assets/{analytics-DMLoMwta.js.map → analytics-0lOlkaib.js.map} +1 -1
  15. package/public/assets/{automation-DACSOffp.js → automation-D66mthBn.js} +2 -2
  16. package/public/assets/{automation-DACSOffp.js.map → automation-D66mthBn.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-NH-l2Eh3.js} +2 -2
  18. package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-NH-l2Eh3.js.map} +1 -1
  19. package/public/assets/{channels-3SlDQL3j.js → channels-BlHLMQVk.js} +2 -2
  20. package/public/assets/{channels-3SlDQL3j.js.map → channels-BlHLMQVk.js.map} +1 -1
  21. package/public/assets/chat-rwffRsPX.js +2 -0
  22. package/public/assets/chat-rwffRsPX.js.map +1 -0
  23. package/public/assets/{connectors-CrEW591Q.js → connectors-CdsiaLD3.js} +2 -2
  24. package/public/assets/{connectors-CrEW591Q.js.map → connectors-CdsiaLD3.js.map} +1 -1
  25. package/public/assets/{formatted-body-Dg9R3RD7.js → formatted-body-C1AprxyU.js} +3 -3
  26. package/public/assets/{formatted-body-Dg9R3RD7.js.map → formatted-body-C1AprxyU.js.map} +1 -1
  27. package/public/assets/formatted-body-impl-D_ZJQVNk.js +3 -0
  28. package/public/assets/formatted-body-impl-D_ZJQVNk.js.map +1 -0
  29. package/public/assets/{index-D_fNJnsI.js → index-DfPouNiV.js} +10 -10
  30. package/public/assets/index-DfPouNiV.js.map +1 -0
  31. package/public/assets/{insights-D48n2ojF.js → insights-BJf1vwrf.js} +2 -2
  32. package/public/assets/{insights-D48n2ojF.js.map → insights-BJf1vwrf.js.map} +1 -1
  33. package/public/assets/{integrations-B-bdliwU.js → integrations-BZSHasnY.js} +2 -2
  34. package/public/assets/{integrations-B-bdliwU.js.map → integrations-BZSHasnY.js.map} +1 -1
  35. package/public/assets/{maintenance-LQ_U3Nqy.js → maintenance-uEI1lArh.js} +2 -2
  36. package/public/assets/{maintenance-LQ_U3Nqy.js.map → maintenance-uEI1lArh.js.map} +1 -1
  37. package/public/assets/{managed-agents-Cfh8FvY8.js → managed-agents-mX3hHpOP.js} +4 -4
  38. package/public/assets/{managed-agents-Cfh8FvY8.js.map → managed-agents-mX3hHpOP.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-BSa7zYx1.js → markdown-preview-impl-ClNwMC48.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-BSa7zYx1.js.map → markdown-preview-impl-ClNwMC48.js.map} +1 -1
  41. package/public/assets/{memory-Dr1iYKkC.js → memory-OfusEzGh.js} +2 -2
  42. package/public/assets/{memory-Dr1iYKkC.js.map → memory-OfusEzGh.js.map} +1 -1
  43. package/public/assets/messages-DsQGj0gU.js +2 -0
  44. package/public/assets/{messages-DRKulS-2.js.map → messages-DsQGj0gU.js.map} +1 -1
  45. package/public/assets/{orchestrators-BguyeViG.js → orchestrators-BwWyl4mB.js} +2 -2
  46. package/public/assets/{orchestrators-BguyeViG.js.map → orchestrators-BwWyl4mB.js.map} +1 -1
  47. package/public/assets/overview-CED1SeTY.js +2 -0
  48. package/public/assets/{overview-Dbq2lr7Y.js.map → overview-CED1SeTY.js.map} +1 -1
  49. package/public/assets/{pairs-DD7i2scF.js → pairs-BNu6ZWg4.js} +2 -2
  50. package/public/assets/{pairs-DD7i2scF.js.map → pairs-BNu6ZWg4.js.map} +1 -1
  51. package/public/assets/{projects-DEn85LQb.js → projects-BmopRiUD.js} +2 -2
  52. package/public/assets/{projects-DEn85LQb.js.map → projects-BmopRiUD.js.map} +1 -1
  53. package/public/assets/{prompt-settings-DwgrPKY3.js → prompt-settings-Ci2n1cFt.js} +2 -2
  54. package/public/assets/{prompt-settings-DwgrPKY3.js.map → prompt-settings-Ci2n1cFt.js.map} +1 -1
  55. package/public/assets/{registry-DCR3xFpe.js → registry-DwXnDIql.js} +2 -2
  56. package/public/assets/{registry-DCR3xFpe.js.map → registry-DwXnDIql.js.map} +1 -1
  57. package/public/assets/security-AW5EbdOm.js +3 -0
  58. package/public/assets/security-AW5EbdOm.js.map +1 -0
  59. package/public/assets/{select-CpyCRBK3.js → select-CEeWX4hp.js} +2 -2
  60. package/public/assets/{select-CpyCRBK3.js.map → select-CEeWX4hp.js.map} +1 -1
  61. package/public/assets/settings-DYUdGz4G.js +6 -0
  62. package/public/assets/{settings-DYNFGC2-.js.map → settings-DYUdGz4G.js.map} +1 -1
  63. package/public/assets/store-BaslPLIP.js +9 -0
  64. package/public/assets/store-BaslPLIP.js.map +1 -0
  65. package/public/assets/{tasks-rdy9Nsbf.js → tasks-DxCsrY4g.js} +2 -2
  66. package/public/assets/{tasks-rdy9Nsbf.js.map → tasks-DxCsrY4g.js.map} +1 -1
  67. package/public/assets/{teams-DHloBagJ.js → teams-CF7vi-oM.js} +2 -2
  68. package/public/assets/{teams-DHloBagJ.js.map → teams-CF7vi-oM.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DWTuNejR.js → terminal-viewer-impl-Dy3vqOnW.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-DWTuNejR.js.map → terminal-viewer-impl-Dy3vqOnW.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-CShT7nTC.js → work-queue-BPaGXrCE.js} +2 -2
  73. package/public/assets/{work-queue-CShT7nTC.js.map → work-queue-BPaGXrCE.js.map} +1 -1
  74. package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-BLEXgnlj.js} +2 -2
  75. package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-BLEXgnlj.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/src/automations/reconcile.ts +69 -4
  79. package/src/branch-landed.ts +18 -7
  80. package/src/bus.ts +23 -16
  81. package/src/db/connection.ts +29 -1
  82. package/src/db/maintenance-worker.ts +25 -0
  83. package/src/db/message-reads.ts +69 -44
  84. package/src/db/migrations.ts +1 -0
  85. package/src/db/provider-quotas.ts +6 -7
  86. package/src/db/schema.ts +2 -1
  87. package/src/maintenance/jobs.ts +3 -3
  88. package/src/mcp/index.ts +5 -5
  89. package/src/mcp/tools-workspace.ts +8 -3
  90. package/src/routes/orchestrator-proxy.ts +7 -6
  91. package/src/routes/tasks.ts +29 -0
  92. package/src/routes/workspaces.ts +9 -2
  93. package/src/runtime-tokens.ts +4 -0
  94. package/src/security.ts +31 -7
  95. package/src/server.ts +40 -18
  96. package/src/services/git-remote.ts +29 -0
  97. package/src/services/issue-lifecycle.ts +6 -6
  98. package/src/services/project.ts +47 -1
  99. package/src/services/send-message.ts +2 -4
  100. package/src/services/spawn-agent.ts +2 -2
  101. package/src/services/task-lifecycle.ts +6 -5
  102. package/src/settings/registry.ts +6 -4
  103. package/src/spawn-targets.ts +2 -2
  104. package/src/sse.ts +37 -18
  105. package/src/steward.ts +10 -6
  106. package/src/terminal-authz.ts +50 -0
  107. package/src/token-db.ts +3 -3
  108. package/src/validation.ts +1 -1
  109. package/src/workspace-actions.ts +46 -4
  110. package/src/workspace-auto-merge.ts +120 -13
  111. package/src/workspace-orphans.ts +1 -1
  112. package/src/workspace-probe.ts +19 -10
  113. package/public/assets/MessageBubble-jpshgh3K.js +0 -2
  114. package/public/assets/agents-QemvuxKO.js +0 -2
  115. package/public/assets/chat-xKXCafN9.js +0 -2
  116. package/public/assets/chat-xKXCafN9.js.map +0 -1
  117. package/public/assets/formatted-body-impl-DlasTD8X.js +0 -3
  118. package/public/assets/formatted-body-impl-DlasTD8X.js.map +0 -1
  119. package/public/assets/index-D_fNJnsI.js.map +0 -1
  120. package/public/assets/messages-DRKulS-2.js +0 -2
  121. package/public/assets/overview-Dbq2lr7Y.js +0 -2
  122. package/public/assets/security-BH8kTdA-.js +0 -3
  123. package/public/assets/security-BH8kTdA-.js.map +0 -1
  124. package/public/assets/settings-DYNFGC2-.js +0 -6
  125. package/public/assets/store-BuuJUn70.js +0 -9
  126. package/public/assets/store-BuuJUn70.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { createCommand } from "../commands-db";
2
- import { getAgent, getTask, listOrchestrators, sendMessage, updateTaskStatus } from "../db";
2
+ import { getAgent, getTask, listAgents, listOrchestrators, sendMessage, updateTaskStatus } from "../db";
3
3
  import { renderTemplate } from "../prompt-resolver";
4
4
  import { CLOSED_TASK_STATUS } from "./constants";
5
5
  import {
@@ -11,7 +11,7 @@ import {
11
11
  db,
12
12
  } from "./storage";
13
13
  import type { AutomationReconcileResult } from "./types";
14
- import type { AutomationRun, AutomationRunStatus, AutomationRuntimeBudget, Command, Message, Task } from "../types";
14
+ import type { AgentCard, AutomationRun, AutomationRunStatus, AutomationRuntimeBudget, Command, Message, Task } from "../types";
15
15
 
16
16
  export function reconcileAutomationRuns(now = Date.now()): AutomationReconcileResult[] {
17
17
  ensureAutomationTables();
@@ -41,6 +41,11 @@ export function reconcileAutomationRuns(now = Date.now()): AutomationReconcileRe
41
41
  if (!CLOSED_TASK_STATUS.has(task.status)) {
42
42
  const updated = getAutomationRun(run.id);
43
43
  if (!updated) continue;
44
+ const neverReadyOffline = maybeFailNeverReadyOfflineTarget(updated, task, now);
45
+ if (neverReadyOffline) {
46
+ results.push(neverReadyOffline);
47
+ continue;
48
+ }
44
49
  const timedOut = maybeTimeoutRuntimeBudget(updated, task, now);
45
50
  if (timedOut) {
46
51
  results.push(timedOut);
@@ -68,9 +73,8 @@ export function reconcileAutomationRuns(now = Date.now()): AutomationReconcileRe
68
73
  }
69
74
 
70
75
  function automationRunRuntimeMeta(run: AutomationRun, task: Task): Record<string, unknown> {
71
- const agentId = task.claimedBy ?? run.targetAgentId ?? run.spawnedAgentId;
76
+ const { agentId, agent } = resolveAutomationRunAgent(run, task);
72
77
  if (!agentId) return {};
73
- const agent = getAgent(agentId);
74
78
  const managed = listOrchestrators()
75
79
  .flatMap((orchestrator) => orchestrator.managedAgents.map((managedAgent) => ({ orchestrator, managedAgent })))
76
80
  .find(({ managedAgent }) => managedAgent.agentId === agentId);
@@ -103,6 +107,23 @@ function hasMetaChanges(current: Record<string, unknown>, patch: Record<string,
103
107
  return Object.entries(patch).some(([key, value]) => current[key] !== value);
104
108
  }
105
109
 
110
+ function resolveAutomationRunAgent(run: AutomationRun, task: Task): { agentId?: string; agent?: AgentCard } {
111
+ const explicitAgentId = task.claimedBy ?? run.targetAgentId ?? run.spawnedAgentId ?? stringMeta(run.meta, "runtimeAgentId");
112
+ if (explicitAgentId) return { agentId: explicitAgentId, agent: getAgent(explicitAgentId) ?? undefined };
113
+
114
+ const onDemandLabel = stringMeta(run.meta, "onDemandLabel");
115
+ const agent = listAgents().find((candidate) =>
116
+ (onDemandLabel !== undefined && candidate.label === onDemandLabel) ||
117
+ stringMeta(candidate.meta, "automationRunId") === run.id
118
+ );
119
+ return agent ? { agentId: agent.id, agent } : {};
120
+ }
121
+
122
+ function isOnDemandAutomationRun(run: AutomationRun): boolean {
123
+ if (run.meta.targetMode === "on_demand_agent") return true;
124
+ return getAutomation(run.automationId)?.targetPolicy.mode === "on_demand_agent";
125
+ }
126
+
106
127
  function runtimeBudgetForRun(run: AutomationRun): AutomationRuntimeBudget | undefined {
107
128
  const automation = getAutomation(run.automationId);
108
129
  if (!automation || automation.targetPolicy.mode !== "on_demand_agent") return undefined;
@@ -163,6 +184,50 @@ function maybeWarnRuntimeBudget(run: AutomationRun, task: Task, now: number): Me
163
184
  return message;
164
185
  }
165
186
 
187
+ function maybeFailNeverReadyOfflineTarget(run: AutomationRun, task: Task, now: number): AutomationReconcileResult | undefined {
188
+ if (!isOnDemandAutomationRun(run) || task.claimedBy) return undefined;
189
+ const { agentId, agent } = resolveAutomationRunAgent(run, task);
190
+ if (!agentId || !agent || agent.status !== "offline" || agent.ready) return undefined;
191
+
192
+ const reason = "Automation target went offline before becoming ready";
193
+ const taskResult = updateTaskStatus(task.id, {
194
+ status: "canceled",
195
+ agentId,
196
+ result: reason,
197
+ body: reason,
198
+ metadata: {
199
+ automationId: run.automationId,
200
+ automationRunId: run.id,
201
+ automationTargetOffline: true,
202
+ automationTargetNeverReady: true,
203
+ agentId,
204
+ },
205
+ });
206
+ const updatedTask = taskResult.task ?? getTask(task.id) ?? task;
207
+ updateRun(run.id, {
208
+ status: "failed",
209
+ finishedAt: now,
210
+ error: reason,
211
+ result: {
212
+ taskStatus: updatedTask.status,
213
+ automationTargetOffline: true,
214
+ automationTargetNeverReady: true,
215
+ agentId,
216
+ },
217
+ targetAgentId: agentId,
218
+ spawnedAgentId: run.spawnedAgentId ?? agentId,
219
+ meta: {
220
+ ...run.meta,
221
+ runtimeAgentId: agentId,
222
+ runtimeAgentStatus: agent.status,
223
+ runtimeAgentReady: agent.ready,
224
+ runtimeTargetOfflineAt: now,
225
+ runtimeTargetNeverReady: true,
226
+ },
227
+ }, now);
228
+ return { run: getAutomationRun(run.id)!, task: getTask(task.id) ?? updatedTask };
229
+ }
230
+
166
231
  function maybeTimeoutRuntimeBudget(run: AutomationRun, task: Task, now: number): AutomationReconcileResult | undefined {
167
232
  const budget = runtimeBudgetForRun(run);
168
233
  if (!budget) return undefined;
@@ -46,7 +46,7 @@ export interface BranchLandedInput {
46
46
  }
47
47
 
48
48
  type BranchLandedLogFn = (message: string) => void;
49
- type LandedCommitSubjectResolver = (input: BranchLandedInput, log: BranchLandedLogFn) => string[] | undefined;
49
+ type LandedCommitSubjectResolver = (input: BranchLandedInput, log: BranchLandedLogFn) => string[] | undefined | Promise<string[] | undefined>;
50
50
 
51
51
  let landedCommitSubjectResolverForTests: LandedCommitSubjectResolver | undefined;
52
52
 
@@ -59,7 +59,7 @@ function cleanRef(value: string | undefined): string | undefined {
59
59
  return cleaned || undefined;
60
60
  }
61
61
 
62
- function landedCommitSubjects(input: BranchLandedInput, log: BranchLandedLogFn): string[] | undefined {
62
+ async function landedCommitSubjects(input: BranchLandedInput, log: BranchLandedLogFn): Promise<string[] | undefined> {
63
63
  if (landedCommitSubjectResolverForTests) return landedCommitSubjectResolverForTests(input, log);
64
64
  if (input.prLand) return undefined;
65
65
  const repoRoot = cleanRef(input.workspace.repoRoot);
@@ -67,17 +67,18 @@ function landedCommitSubjects(input: BranchLandedInput, log: BranchLandedLogFn):
67
67
  const tipSha = cleanRef(input.mergedSha);
68
68
  if (!repoRoot || !baseSha || !tipSha || baseSha === tipSha) return undefined;
69
69
  try {
70
- const proc = Bun.spawnSync(["git", "-C", repoRoot, "log", "--format=%B%x00", `${baseSha}..${tipSha}`], {
70
+ const proc = Bun.spawn(["git", "-C", repoRoot, "log", "--format=%B%x00", `${baseSha}..${tipSha}`], {
71
71
  stdin: "ignore",
72
72
  stdout: "pipe",
73
73
  stderr: "pipe",
74
74
  });
75
- if (proc.exitCode !== 0) {
76
- const error = Buffer.from(proc.stderr).toString("utf8").trim() || `exit ${proc.exitCode}`;
75
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
76
+ if (exitCode !== 0) {
77
+ const error = stderr.trim() || `exit ${exitCode}`;
77
78
  log(`issue lifecycle commit range scan failed for ${input.workspace.id} (${baseSha.slice(0, 12)}..${tipSha.slice(0, 12)}): ${error}; falling back to tip subject`);
78
79
  return undefined;
79
80
  }
80
- const subjects = Buffer.from(proc.stdout).toString("utf8").split("\x00").map((msg) => msg.trim()).filter(Boolean);
81
+ const subjects = stdout.split("\x00").map((msg) => msg.trim()).filter(Boolean);
81
82
  return subjects.length > 0 ? subjects : undefined;
82
83
  } catch (error) {
83
84
  log(`issue lifecycle commit range scan failed for ${input.workspace.id} (${baseSha.slice(0, 12)}..${tipSha.slice(0, 12)}): ${errMessage(error)}; falling back to tip subject`);
@@ -94,6 +95,13 @@ function resolveLandedTask(workspace: Pick<WorkspaceRecord, "branch" | "ownerAge
94
95
  return task ? { taskId: task.id, taskLabel: task.title } : undefined;
95
96
  }
96
97
 
98
+ function landedNotificationIdempotencyKey(input: BranchLandedInput, landedTask: { taskId: number } | undefined, base: string): string | undefined {
99
+ const sha = cleanRef(input.mergedSha);
100
+ if (sha) return `branch-landed:${sha}`;
101
+ if (landedTask) return `branch-landed:task:${landedTask.taskId}:${base}`;
102
+ return undefined;
103
+ }
104
+
97
105
  /**
98
106
  * #239 — turn an authoritative land completion into a relay-driven push so the author
99
107
  * stops polling to learn it merged. Always emits the durable `branch.landed` event (the
@@ -108,8 +116,8 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
108
116
  const landedBranch = workspace.branch;
109
117
  const shortSha = input.mergedSha ? input.mergedSha.slice(0, 12) : undefined;
110
118
  const lifecycleLog = (message: string) => console.warn(message);
111
- const commitSubjects = landedCommitSubjects(input, lifecycleLog);
112
119
  const landedTask = resolveLandedTask(workspace);
120
+ const idempotencyKey = landedNotificationIdempotencyKey(input, landedTask, base);
113
121
 
114
122
  emitRelayEvent({
115
123
  type: "branch.landed",
@@ -151,6 +159,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
151
159
  // receipt accurate. Both are best-effort + isolated — a throw in either can never fail the merge
152
160
  // (it already landed before notifyBranchLanded runs). A non-task branch makes the Task drive a no-op.
153
161
  void (async () => {
162
+ const commitSubjects = await landedCommitSubjects(input, lifecycleLog);
154
163
  try {
155
164
  await handleLandedIssueLifecycle({ ...input, commitSubjects }, { log: lifecycleLog });
156
165
  } catch (error) {
@@ -208,6 +217,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
208
217
  continueLabel,
209
218
  }),
210
219
  payload: { ...payload, notification: "landed-success" },
220
+ idempotencyKey,
211
221
  replyExpected: false,
212
222
  },
213
223
  });
@@ -231,6 +241,7 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
231
241
  subject: `Merged to ${base}`,
232
242
  body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
233
243
  payload: { ...payload, notification: "landed-success" },
244
+ idempotencyKey,
234
245
  replyExpected: false,
235
246
  },
236
247
  });
package/src/bus.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  } from "agent-relay-sdk/protocol";
32
32
  import { errMessage, isRecord } from "agent-relay-sdk";
33
33
  import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
34
- import type { Command, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
34
+ import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
35
35
 
36
36
  interface BusSocketData {
37
37
  kind: "bus";
@@ -503,30 +503,37 @@ function withRegistered(
503
503
  }
504
504
 
505
505
  function broadcastRelayEvent(event: RelayEvent): void {
506
+ const frame = JSON.stringify({
507
+ type: "event",
508
+ payload: {
509
+ seq: event.seq,
510
+ eventType: event.type,
511
+ source: event.source,
512
+ subject: event.subject,
513
+ data: event.data,
514
+ timestamp: event.timestamp,
515
+ },
516
+ });
517
+ const agentCache = new Map<string, AgentCard | null>();
506
518
  for (const conn of busConnections.values()) {
507
- if (!connectionWantsEvent(conn, event)) continue;
508
- send(conn.ws, {
509
- type: "event",
510
- payload: {
511
- seq: event.seq,
512
- eventType: event.type,
513
- source: event.source,
514
- subject: event.subject,
515
- data: event.data,
516
- timestamp: event.timestamp,
517
- },
518
- });
519
+ if (!connectionWantsEvent(conn, event, agentCache)) continue;
520
+ conn.ws.send(frame);
519
521
  }
520
522
  }
521
523
 
522
- function connectionWantsEvent(conn: BusConnection, event: RelayEvent): boolean {
524
+ function fanoutAgent(agentId: string, cache: Map<string, AgentCard | null>): AgentCard | null {
525
+ if (!cache.has(agentId)) cache.set(agentId, getAgent(agentId));
526
+ return cache.get(agentId) ?? null;
527
+ }
528
+
529
+ function connectionWantsEvent(conn: BusConnection, event: RelayEvent, agentCache: Map<string, AgentCard | null>): boolean {
523
530
  if (!matchesSubscription(conn.subscriptions, event.type)) return false;
524
531
  if (event.type === "message.new" && conn.agentId) {
525
- return messageMatchesAgent(event.data as unknown as Message, conn.agentId, getAgent(conn.agentId));
532
+ return messageMatchesAgent(event.data as unknown as Message, conn.agentId, fanoutAgent(conn.agentId, agentCache));
526
533
  }
527
534
  if (event.type.startsWith("task.") && conn.agentId) {
528
535
  const target = typeof event.data.target === "string" ? event.data.target : "";
529
- return targetMatchesAgent(target, conn.agentId, getAgent(conn.agentId));
536
+ return targetMatchesAgent(target, conn.agentId, fanoutAgent(conn.agentId, agentCache));
530
537
  }
531
538
  if (event.type.startsWith("command.")) {
532
539
  const command = isRecord(event.data.command) ? event.data.command : undefined;
@@ -1,12 +1,15 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { join } from "node:path";
2
3
  import { DB_SLOW_QUERY_MS } from "../config";
3
4
 
4
5
  // The shared SQLite handle. Set once by initDb() (schema.ts) and read everywhere
5
6
  // else through getDb(). Kept module-private so the only mutation path is setDb().
6
7
  let db: Database;
8
+ let dbPath: string | undefined;
7
9
 
8
- export function setDb(conn: Database): void {
10
+ export function setDb(conn: Database, path?: string): void {
9
11
  db = conn;
12
+ dbPath = path;
10
13
  }
11
14
 
12
15
  export function getDb(): Database {
@@ -14,6 +17,10 @@ export function getDb(): Database {
14
17
  return db;
15
18
  }
16
19
 
20
+ export function getDbPath(): string | undefined {
21
+ return dbPath;
22
+ }
23
+
17
24
  // Connection-scoped pragmas. journal_mode and auto_vacuum persist in the file;
18
25
  // the rest reset per connection, so they must be re-applied on every open.
19
26
  export function applyConnectionPragmas(conn: Database): void {
@@ -67,6 +74,27 @@ export function runDbMaintenance(options: { vacuum?: boolean } = {}): {
67
74
  return { analyzed: true, checkpointed: true, vacuumed };
68
75
  }
69
76
 
77
+ export async function runDbMaintenanceAsync(options: { vacuum?: boolean } = {}): Promise<{
78
+ analyzed: boolean;
79
+ checkpointed: boolean;
80
+ vacuumed: boolean;
81
+ offThread: boolean;
82
+ }> {
83
+ if (!dbPath || dbPath === ":memory:") return { ...runDbMaintenance(options), offThread: false };
84
+ const proc = Bun.spawn([process.execPath, join(import.meta.dir, "maintenance-worker.ts"), dbPath, options.vacuum ? "vacuum" : "no-vacuum"], {
85
+ stdin: "ignore",
86
+ stdout: "pipe",
87
+ stderr: "pipe",
88
+ });
89
+ const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
90
+ if (exitCode !== 0) {
91
+ const detail = stderr.trim() || stdout.trim() || `exit ${exitCode}`;
92
+ throw new Error(`db maintenance worker failed: ${detail}`);
93
+ }
94
+ const result = JSON.parse(stdout) as { analyzed: boolean; checkpointed: boolean; vacuumed: boolean };
95
+ return { ...result, offThread: true };
96
+ }
97
+
70
98
  // Shared error types thrown across query domains and unwrapped at call sites.
71
99
  // `ValidationError` now lives in the SDK (agent-relay-sdk) as the single canonical
72
100
  // home shared with the orchestrator package; re-exported here so the ~120 server
@@ -0,0 +1,25 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { applyConnectionPragmas } from "./connection";
3
+
4
+ const [, , dbPath, mode] = Bun.argv;
5
+
6
+ if (!dbPath) {
7
+ console.error("db path is required");
8
+ process.exit(2);
9
+ }
10
+
11
+ const db = new Database(dbPath, { create: true });
12
+ try {
13
+ applyConnectionPragmas(db);
14
+ db.run("ANALYZE");
15
+ db.run("PRAGMA optimize");
16
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
17
+ let vacuumed = false;
18
+ if (mode === "vacuum") {
19
+ db.run("VACUUM");
20
+ vacuumed = true;
21
+ }
22
+ console.log(JSON.stringify({ analyzed: true, checkpointed: true, vacuumed }));
23
+ } finally {
24
+ db.close();
25
+ }
@@ -15,7 +15,7 @@ import {
15
15
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { getAgent } from "./agents.ts";
18
- import { getMessage, sendMessageWithResult } from "./messages.ts";
18
+ import { sendMessageWithResult } from "./messages.ts";
19
19
  import { deleteArtifactLinksForEntity } from "./artifacts.ts";
20
20
  import { getDb, timedQuery } from "./connection.ts";
21
21
  import { legacyChannelTargets, isChannelAgentId } from "./delivery.ts";
@@ -393,25 +393,58 @@ function relayConversationId(message: Message): string | undefined {
393
393
  return stringField(replyContext?.conversationId) ?? stringField(conversation?.id);
394
394
  }
395
395
 
396
- function isCoveredByLaterAgentResponse(message: Message, agentId: string): boolean {
396
+ function messagesCoveredByLaterAgentResponses(messages: Message[], agentId: string): Set<number> {
397
+ if (messages.length === 0) return new Set();
397
398
  // Order by id, not created_at: ids are monotonic insertion order, so this is
398
399
  // robust when a reply lands in the same millisecond as the message it covers
399
400
  // (created_at > … strictly would miss it, leaving the message wrongly pending).
400
- const replies = (getDb().query(`
401
- ${MSG_SELECT}
402
- WHERE m.from_agent = ?
403
- AND m.id > ?
404
- ORDER BY m.id ASC
405
- LIMIT 200
406
- `).all(agentId, message.id) as any[]).map(rowToMessage);
407
-
408
- const conversationId = relayConversationId(message);
409
- return replies.some((reply) => {
410
- if (reply.replyTo === message.id) return true;
411
- if (message.threadId !== undefined && reply.threadId === message.threadId) return true;
412
- if (!message.channel || reply.channel !== message.channel || !conversationId) return false;
413
- return relayConversationId(reply) === conversationId;
414
- });
401
+ const minMessageId = Math.min(...messages.map((message) => message.id));
402
+ const replies = getDb().query(`
403
+ SELECT id, thread_id, channel, reply_to, payload
404
+ FROM messages INDEXED BY idx_msg_from_id
405
+ WHERE from_agent = ?
406
+ AND id > ?
407
+ ORDER BY id ASC
408
+ `).all(agentId, minMessageId) as Array<{ id: number; thread_id: number | null; channel: string | null; reply_to: number | null; payload: string }>;
409
+ if (replies.length === 0) return new Set();
410
+
411
+ const covered = new Set<number>();
412
+ const conversationsById = new Map<number, string | undefined>();
413
+ for (const message of messages) conversationsById.set(message.id, relayConversationId(message));
414
+
415
+ for (const message of messages) {
416
+ const conversationId = conversationsById.get(message.id);
417
+ for (const reply of replies) {
418
+ if (reply.id <= message.id) continue;
419
+ if (reply.reply_to === message.id) {
420
+ covered.add(message.id);
421
+ break;
422
+ }
423
+ if (message.threadId !== undefined && reply.thread_id === message.threadId) {
424
+ covered.add(message.id);
425
+ break;
426
+ }
427
+ if (!message.channel || reply.channel !== message.channel || !conversationId) continue;
428
+ if (relayConversationId({
429
+ id: reply.id,
430
+ from: agentId,
431
+ to: "",
432
+ body: "",
433
+ kind: "chat",
434
+ channel: reply.channel ?? undefined,
435
+ replyTo: reply.reply_to ?? undefined,
436
+ threadId: reply.thread_id ?? undefined,
437
+ payload: parseJson(reply.payload ?? "{}", {}),
438
+ meta: {},
439
+ readBy: [],
440
+ createdAt: 0,
441
+ }) === conversationId) {
442
+ covered.add(message.id);
443
+ break;
444
+ }
445
+ }
446
+ }
447
+ return covered;
415
448
  }
416
449
 
417
450
  function replyObligationFromMessage(message: Message, agentId: string): ReplyObligation {
@@ -433,11 +466,20 @@ function replyObligationFromMessage(message: Message, agentId: string): ReplyObl
433
466
  export function listPendingReplyObligations(agentId: string, limit = 20): ReplyObligation[] {
434
467
  const scanLimit = Math.max(limit * 5, 50);
435
468
  const rows = timedQuery("listPendingReplyObligations", () => getDb().query(`
436
- ${MSG_SELECT}
437
- WHERE EXISTS (
438
- SELECT 1 FROM message_reads mr
439
- WHERE mr.message_id = m.id AND mr.agent_id = ?
440
- )
469
+ SELECT m.*, (
470
+ SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
471
+ ) AS read_by_agents, (
472
+ SELECT json_group_array(json_object(
473
+ 'messageId', message_id,
474
+ 'actorId', actor_id,
475
+ 'emoji', emoji,
476
+ 'createdAt', created_at,
477
+ 'updatedAt', updated_at
478
+ )) FROM message_reactions WHERE message_id = m.id
479
+ ) AS reactions_json
480
+ FROM message_reads mr INDEXED BY idx_mr_agent
481
+ JOIN messages m ON m.id = mr.message_id
482
+ WHERE mr.agent_id = ?
441
483
  AND m.from_agent != ?
442
484
  AND NOT EXISTS (
443
485
  SELECT 1 FROM messages reply
@@ -447,10 +489,12 @@ export function listPendingReplyObligations(agentId: string, limit = 20): ReplyO
447
489
  ORDER BY m.created_at ASC
448
490
  LIMIT ?
449
491
  `).all(agentId, agentId, agentId, scanLimit) as any[]);
450
- return rows
492
+ const candidates = rows
451
493
  .map(rowToMessage)
452
- .filter(messageRequiresReply)
453
- .filter((message) => !isCoveredByLaterAgentResponse(message, agentId))
494
+ .filter(messageRequiresReply);
495
+ const covered = messagesCoveredByLaterAgentResponses(candidates, agentId);
496
+ return candidates
497
+ .filter((message) => !covered.has(message.id))
454
498
  .slice(0, limit)
455
499
  .map((message) => replyObligationFromMessage(message, agentId));
456
500
  }
@@ -484,25 +528,6 @@ export function channelObligationAnsweredBy(
484
528
  return open.length === 1 ? open[0]!.messageId : null;
485
529
  }
486
530
 
487
- /**
488
- * Spawn report-back linking (#492): a spawned worker's actual task is delivered as the provider
489
- * prompt, while `seedSpawnReplyObligation` creates a short Stop-hook contract message so the worker
490
- * cannot go idle without reporting back. Workers commonly send their natural completion report
491
- * directly to the spawning coordinator; when there is exactly one open spawn obligation from that
492
- * coordinator, that send is the answer. Link it to the obligation at send time so the Stop hook does
493
- * not nag the worker into re-sending the same verdict through `/reply`.
494
- */
495
- export function spawnObligationAnsweredBy(
496
- agentId: string,
497
- spawnerAgentId: string,
498
- ): number | null {
499
- const open = listPendingReplyObligations(agentId, 50).filter((obligation) => {
500
- if (obligation.from !== spawnerAgentId) return false;
501
- return getMessage(obligation.messageId)?.payload?.source === "spawn-obligation";
502
- });
503
- return open.length === 1 ? open[0]!.messageId : null;
504
- }
505
-
506
531
  /**
507
532
  * #573 — spawned children no longer get a report-back reply obligation. Their turn-final
508
533
  * response is promoted server-side to the spawn-parent by lineage, so creating a separate
@@ -411,6 +411,7 @@ export function applyMigrations(): void {
411
411
  getDb().run("ALTER TABLE messages ADD COLUMN payload TEXT NOT NULL DEFAULT '{}'");
412
412
  }
413
413
  getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_kind ON messages(kind)");
414
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_from_id ON messages(from_agent, id)");
414
415
  getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_from_kind_id ON messages(from_agent, kind, id)"); getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_analytics_created ON messages(created_at, kind, from_agent, claimable, reply_to)"); getDb().run("CREATE INDEX IF NOT EXISTS idx_msg_session_analytics ON messages(kind, created_at, json_extract(payload, '$.session.type'), json_extract(payload, '$.session.label'), json_extract(payload, '$.session.turnId'))");
415
416
  // (from_agent, idempotency_key) powers findMessageByIdempotencyKey — the retry-dedup lookup
416
417
  // AND the session-step in-place upsert (Codex tool running→completed / streamed reasoning),
@@ -125,10 +125,15 @@ function supersededAccountKeys(provider: string, accountKey: string, rawAccountK
125
125
  return [...candidates];
126
126
  }
127
127
 
128
+ // Source priority for quota precedence: "ingest" (WHAM/API) beats "probe" (agent self-report).
129
+ const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0, emulated: -1 };
130
+ function quotaSourcePriority(source: string): number { return QUOTA_SOURCE_PRIORITY[source] ?? 1; }
131
+
128
132
  function newestQuotaState(rows: ProviderQuotaRow[]): string | null {
133
+ const priority = (candidate: ProviderQuotaRow) => { const source = parseJson<Partial<QuotaState>>(candidate.quota_state, { source: "probe" }).source; return quotaSourcePriority(typeof source === "string" ? source : "probe"); };
129
134
  const row = rows
130
135
  .filter((candidate) => candidate.quota_state)
131
- .sort((a, b) => (b.last_updated_at ?? 0) - (a.last_updated_at ?? 0))[0];
136
+ .sort((a, b) => priority(b) - priority(a) || (b.last_updated_at ?? 0) - (a.last_updated_at ?? 0))[0];
132
137
  return row?.quota_state ?? null;
133
138
  }
134
139
 
@@ -285,12 +290,6 @@ function storedQuotaWindowSampleTimes(quota: StoredQuotaState): Map<string, numb
285
290
  return times;
286
291
  }
287
292
 
288
- // Source priority for per-window precedence: "ingest" (WHAM/API) beats "probe" (agent self-report).
289
- const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0, emulated: -1 };
290
- function quotaSourcePriority(source: string): number {
291
- return QUOTA_SOURCE_PRIORITY[source] ?? 1;
292
- }
293
-
294
293
  function storedQuotaWindowSources(quota: StoredQuotaState): Map<string, string> {
295
294
  const sources = new Map<string, string>();
296
295
  const rawSources = quota._windowSource && typeof quota._windowSource === "object" ? quota._windowSource : {};
package/src/db/schema.ts CHANGED
@@ -91,7 +91,7 @@ import type {
91
91
  export function initDb(path: string = "agent-relay.db"): Database {
92
92
  const conn = new Database(path, { create: true });
93
93
  applyConnectionPragmas(conn);
94
- setDb(conn);
94
+ setDb(conn, path);
95
95
 
96
96
  getDb().run(`
97
97
  CREATE TABLE IF NOT EXISTS agents (
@@ -165,6 +165,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
165
165
  -- resolved_to_agent ALTER — never inline here (same hazard as #483/#559).
166
166
  -- idx_msg_reply_to is also migration-owned because reply_to was added by ALTER.
167
167
  CREATE INDEX IF NOT EXISTS idx_msg_to_id ON messages(to_target, id);
168
+ CREATE INDEX IF NOT EXISTS idx_msg_from_id ON messages(from_agent, id);
168
169
  CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at);
169
170
  CREATE INDEX IF NOT EXISTS idx_msg_channel ON messages(channel);
170
171
  CREATE TABLE IF NOT EXISTS scheduled_timers (
@@ -20,7 +20,7 @@ import {
20
20
  releaseExpiredClaims,
21
21
  releaseExpiredDriveLeases,
22
22
  releaseOrphanedTasks,
23
- runDbMaintenance,
23
+ runDbMaintenanceAsync,
24
24
  sweepArtifacts,
25
25
  } from "../db";
26
26
  import { isAgentUnavailable } from "../db/agent-predicates";
@@ -385,10 +385,10 @@ export const definitions: MaintenanceJobDefinition[] = [
385
385
  intervalMs: DB_MAINTENANCE_INTERVAL_MS,
386
386
  runOnStart: false,
387
387
  timeoutMs: 5 * 60 * 1000,
388
- handler() {
388
+ async handler() {
389
389
  dbMaintenanceRuns += 1;
390
390
  const vacuum = DB_VACUUM_EVERY > 0 && dbMaintenanceRuns % DB_VACUUM_EVERY === 0;
391
- return runDbMaintenance({ vacuum });
391
+ return runDbMaintenanceAsync({ vacuum });
392
392
  },
393
393
  },
394
394
  {
package/src/mcp/index.ts CHANGED
@@ -167,11 +167,11 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
167
167
  else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
168
168
  else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
169
169
  else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
170
- else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
171
- else if (name === "relay_workspace_deps") result = relayWorkspaceMutation(auth, "deps-refresh", args);
172
- else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
173
- else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
174
- else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
170
+ else if (name === "relay_workspace_ready") result = await relayWorkspaceMutation(auth, "ready", args);
171
+ else if (name === "relay_workspace_deps") result = await relayWorkspaceMutation(auth, "deps-refresh", args);
172
+ else if (name === "relay_workspace_claim") result = await relayWorkspaceMutation(auth, "claim", args);
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
175
  else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
176
176
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
177
177
  else if (name.startsWith("relay_task_memory_")) result = relayTaskMemoryTool(auth, name, args);
@@ -4,6 +4,7 @@ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
4
  import { AUTO_MERGE_POLICIES } from "../workspace-merge";
5
5
  import { describeWorkspacePhase, landReceipt, readyContract } from "../workspace-phase";
6
6
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "../workspace-actions";
7
+ import { refreshWorkspaceGitState } from "../workspace-probe";
7
8
  import { optionalEnum } from "../validation";
8
9
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord } from "../types";
9
10
  import type { McpAuthContext, ToolDefinition } from "./types";
@@ -185,9 +186,13 @@ export function relayWorkspaceList(auth: McpAuthContext, args: Record<string, un
185
186
  return { workspaces, count: workspaces.length };
186
187
  }
187
188
 
188
- export function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Record<string, unknown> {
189
- const ws = resolveWorkspaceForCaller(auth, args);
190
- const result = applyWorkspaceAction(ws, {
189
+ export async function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Promise<Record<string, unknown>> {
190
+ let ws = resolveWorkspaceForCaller(auth, args);
191
+ if (action === "ready" || action === "request-review") {
192
+ await refreshWorkspaceGitState(ws);
193
+ ws = getWorkspace(ws.id) ?? ws;
194
+ }
195
+ const result = await applyWorkspaceAction(ws, {
191
196
  action,
192
197
  agentId: callerAgentId(auth) ?? auth.actor,
193
198
  detail: optionalString(args.detail, "detail", 4000),
@@ -6,6 +6,7 @@ import { cleanStringArray } from "../validation";
6
6
  import { emitOrchestratorStatus } from "../sse";
7
7
  import { type OrchestratorRuntimeInput, type SpawnProvider } from "../types";
8
8
  import { relayToken } from "../config";
9
+ import { isTerminalSessionRequestAuthorized } from "../terminal-authz";
9
10
 
10
11
  export const getOrchestratorDirectories: Handler = async (req, params) => {
11
12
  const orch = getOrchestrator(params.id!);
@@ -146,16 +147,16 @@ export const getOrchestratorLogs: Handler = (req, params) => {
146
147
  };
147
148
 
148
149
  export const getOrchestratorTerminal: Handler = (req, params) => {
149
- const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
150
- return denied ?? proxyOrchestratorGet(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}`);
150
+ if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "read")) return error("forbidden", 403);
151
+ return proxyOrchestratorGet(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}`);
151
152
  };
152
153
 
153
154
  export const postOrchestratorTerminalInput: Handler = (req, params) => {
154
- const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
155
- return denied ?? proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/input`);
155
+ if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "write")) return error("forbidden", 403);
156
+ return proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/input`);
156
157
  };
157
158
 
158
159
  export const postOrchestratorTerminalResize: Handler = (req, params) => {
159
- const denied = authorizeRoute(req, { scope: "terminal:attach", resource: { orchestratorId: params.id!, terminalAttach: true } });
160
- return denied ?? proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/resize`);
160
+ if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "read")) return error("forbidden", 403);
161
+ return proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/resize`);
161
162
  };