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
@@ -29,6 +29,15 @@ function taskDetailOr404(id: number) {
29
29
  return detail ? json(detail) : error("deliverable task not found", 404);
30
30
  }
31
31
 
32
+ function requireTaskMutationAuth(req: Request, id: number): Response | null {
33
+ if (!getTaskEntity(id)) return error("deliverable task not found", 404);
34
+ if (isFullReachRequest(req)) return null;
35
+ const component = getComponentAuth(req);
36
+ const agentId = component ? agentIdFromComponent(component) : undefined;
37
+ if (!isTaskReadAuthorized(id, agentId, component?.scope ?? [])) return error("forbidden", 403);
38
+ return null;
39
+ }
40
+
32
41
  function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
33
42
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
34
43
  const status = optionalEnum(body.status, "status", VALID_TASK_STATUSES);
@@ -173,6 +182,8 @@ export const getTaskHistoryRoute: Handler = (req, params) => {
173
182
  export const patchTaskEntity: Handler = async (req, params) => {
174
183
  const id = parseId(params.id);
175
184
  if (id === null) return error("invalid task id");
185
+ const forbidden = requireTaskMutationAuth(req, id);
186
+ if (forbidden) return forbidden;
176
187
  const parsed = await parseBody<Record<string, unknown>>(req);
177
188
  if (!parsed.ok) return error(parsed.error, parsed.status);
178
189
  const body = parsed.body;
@@ -200,6 +211,8 @@ export const patchTaskEntity: Handler = async (req, params) => {
200
211
  export const postTaskEntityStatus: Handler = async (req, params) => {
201
212
  const id = parseId(params.id);
202
213
  if (id === null) return error("invalid task id");
214
+ const forbidden = requireTaskMutationAuth(req, id);
215
+ if (forbidden) return forbidden;
203
216
  const parsed = await parseBody<Record<string, unknown>>(req);
204
217
  if (!parsed.ok) return error(parsed.error, parsed.status);
205
218
  const body = parsed.body;
@@ -220,6 +233,8 @@ export const postTaskEntityStatus: Handler = async (req, params) => {
220
233
  export const postTaskEntityStage: Handler = async (req, params) => {
221
234
  const id = parseId(params.id);
222
235
  if (id === null) return error("invalid task id");
236
+ const forbidden = requireTaskMutationAuth(req, id);
237
+ if (forbidden) return forbidden;
223
238
  const parsed = await parseBody<Record<string, unknown>>(req);
224
239
  if (!parsed.ok) return error(parsed.error, parsed.status);
225
240
  const body = parsed.body;
@@ -240,6 +255,8 @@ export const postTaskEntityStage: Handler = async (req, params) => {
240
255
  export const postTaskEntityGate: Handler = async (req, params) => {
241
256
  const id = parseId(params.id);
242
257
  if (id === null) return error("invalid task id");
258
+ const forbidden = requireTaskMutationAuth(req, id);
259
+ if (forbidden) return forbidden;
243
260
  const parsed = await parseBody<Record<string, unknown>>(req);
244
261
  if (!parsed.ok) return error(parsed.error, parsed.status);
245
262
  const body = parsed.body;
@@ -261,6 +278,8 @@ export const postTaskEntityGate: Handler = async (req, params) => {
261
278
  export const postTaskDependency: Handler = async (req, params) => {
262
279
  const id = parseId(params.id);
263
280
  if (id === null) return error("invalid task id");
281
+ const forbidden = requireTaskMutationAuth(req, id);
282
+ if (forbidden) return forbidden;
264
283
  const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
265
284
  if (!parsed.ok) return error(parsed.error, parsed.status);
266
285
  const dep = parseId(String(parsed.body?.dependsOnTaskId));
@@ -276,6 +295,8 @@ export const postTaskDependency: Handler = async (req, params) => {
276
295
  export const deleteTaskDependency: Handler = async (req, params) => {
277
296
  const id = parseId(params.id);
278
297
  if (id === null) return error("invalid task id");
298
+ const forbidden = requireTaskMutationAuth(req, id);
299
+ if (forbidden) return forbidden;
279
300
  const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
280
301
  if (!parsed.ok) return error(parsed.error, parsed.status);
281
302
  const dep = parseId(String(parsed.body?.dependsOnTaskId));
@@ -287,6 +308,8 @@ export const deleteTaskDependency: Handler = async (req, params) => {
287
308
  export const postTaskRef: Handler = async (req, params) => {
288
309
  const id = parseId(params.id);
289
310
  if (id === null) return error("invalid task id");
311
+ const forbidden = requireTaskMutationAuth(req, id);
312
+ if (forbidden) return forbidden;
290
313
  const parsed = await parseBody<Record<string, unknown>>(req);
291
314
  if (!parsed.ok) return error(parsed.error, parsed.status);
292
315
  const body = parsed.body;
@@ -308,6 +331,8 @@ export const postTaskRef: Handler = async (req, params) => {
308
331
  export const deleteTaskRef: Handler = async (req, params) => {
309
332
  const id = parseId(params.id);
310
333
  if (id === null) return error("invalid task id");
334
+ const forbidden = requireTaskMutationAuth(req, id);
335
+ if (forbidden) return forbidden;
311
336
  const parsed = await parseBody<Record<string, unknown>>(req);
312
337
  if (!parsed.ok) return error(parsed.error, parsed.status);
313
338
  const body = parsed.body;
@@ -328,6 +353,8 @@ export const deleteTaskRef: Handler = async (req, params) => {
328
353
  export const postTaskAssign: Handler = async (req, params) => {
329
354
  const id = parseId(params.id);
330
355
  if (id === null) return error("invalid task id");
356
+ const forbidden = requireTaskMutationAuth(req, id);
357
+ if (forbidden) return forbidden;
331
358
  const parsed = await parseBody<Record<string, unknown>>(req);
332
359
  if (!parsed.ok) return error(parsed.error, parsed.status);
333
360
  const body = parsed.body;
@@ -345,6 +372,8 @@ export const postTaskAssign: Handler = async (req, params) => {
345
372
  export const postTaskComment: Handler = async (req, params) => {
346
373
  const id = parseId(params.id);
347
374
  if (id === null) return error("invalid task id");
375
+ const forbidden = requireTaskMutationAuth(req, id);
376
+ if (forbidden) return forbidden;
348
377
  const parsed = await parseBody<Record<string, unknown>>(req);
349
378
  if (!parsed.ok) return error(parsed.error, parsed.status);
350
379
  const body = parsed.body;
@@ -16,6 +16,7 @@ import { workspaceActiveClaim } from "../workspace-claim";
16
16
  import { resolveCoordinatorAgentId } from "../steward-identity";
17
17
  import { relayToken } from "../config";
18
18
  import { workspaceOwnerOrchestrator } from "../workspace-host";
19
+ import { refreshWorkspaceGitState } from "../workspace-probe";
19
20
 
20
21
  export const getWorkspaces: Handler = (req) => {
21
22
  try {
@@ -423,7 +424,7 @@ export const postWorkspaceAction: Handler = async (req, params) => {
423
424
  if (!parsed.ok) return error(parsed.error, parsed.status);
424
425
  try {
425
426
  if (!isRecord(parsed.body)) return error("body required");
426
- const workspace = getWorkspace(params.id!);
427
+ let workspace = getWorkspace(params.id!);
427
428
  if (!workspace) return error("workspace not found", 404);
428
429
  const action = optionalEnum(parsed.body.action, "action", WORKSPACE_ACTIONS);
429
430
  if (!action) return error("action required", 400);
@@ -464,7 +465,13 @@ export const postWorkspaceAction: Handler = async (req, params) => {
464
465
  // Everything else delegates to the shared core (one home, shared with the
465
466
  // relay_workspace_* MCP tools); the core self-audits and returns any command to
466
467
  // emit, mirroring requestWorkspaceMerge's "caller emits" contract.
467
- const result = applyWorkspaceAction(workspace, {
468
+ if (action === "ready" || action === "request-review") {
469
+ await refreshWorkspaceGitState(workspace);
470
+ const refreshed = getWorkspace(workspace.id);
471
+ if (refreshed) workspace = refreshed;
472
+ }
473
+
474
+ const result = await applyWorkspaceAction(workspace, {
468
475
  action,
469
476
  agentId,
470
477
  detail: cleanString(parsed.body.detail, "detail", { max: 4000 }),
@@ -82,6 +82,8 @@ export function issueOrchestratorRuntimeToken(input: {
82
82
  cwdPrefixes: [input.baseDir],
83
83
  logsRead: true,
84
84
  terminalAttach: true,
85
+ terminalRead: true,
86
+ terminalWrite: true,
85
87
  },
86
88
  createdBy: input.createdBy ?? "runtime",
87
89
  });
@@ -190,6 +192,8 @@ export function issueInteractiveRunnerRuntimeToken(input: {
190
192
  agents: [agentId],
191
193
  cwdPrefixes: [input.cwd],
192
194
  terminalAttach: false,
195
+ terminalRead: false,
196
+ terminalWrite: false,
193
197
  logsRead: false,
194
198
  canDelegate: false,
195
199
  },
package/src/security.ts CHANGED
@@ -104,6 +104,9 @@ type AuthorizationResource = {
104
104
  spawnRequestId?: string;
105
105
  integrationName?: string;
106
106
  terminalAttach?: boolean;
107
+ terminalRead?: boolean;
108
+ terminalWrite?: boolean;
109
+ terminalOwner?: boolean;
107
110
  logsRead?: boolean;
108
111
  };
109
112
 
@@ -147,7 +150,7 @@ export function isRequestAuthorizedFor(req: Request, check: AuthorizationCheck):
147
150
  }
148
151
 
149
152
  export function isComponentAuthorizedFor(auth: ComponentToken, check: AuthorizationCheck): boolean {
150
- return hasComponentScope(auth, check.scope) && constraintsAllow(auth.constraints, check.resource);
153
+ return hasComponentScope(auth, check.scope) && constraintsAllow(auth, check.resource);
151
154
  }
152
155
 
153
156
  export function requiredScopeFor(method: string, pathname: string): string | null {
@@ -160,7 +163,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
160
163
  if (pathname === "/api/orchestrators/bootstrap/exchange") return "token:write";
161
164
  if (pathname === "/api/orchestrators/bootstrap") return "token:write";
162
165
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
163
- if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:attach";
166
+ if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\/[^/]+\/input$/)) return "terminal:write";
167
+ if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:read";
164
168
  const settingScope = requiredSettingsScopeFor(method, pathname);
165
169
  if (settingScope !== undefined) return settingScope;
166
170
  if (pathname.startsWith("/api/artifacts")) {
@@ -259,7 +263,8 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
259
263
  if (pathname === "/api/events") return "message:read";
260
264
  if (pathname === "/api/mcp") return "mcp:use";
261
265
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
262
- if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:attach";
266
+ if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\/[^/]+\/input$/)) return "terminal:write";
267
+ if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:read";
263
268
  const settingScope = requiredSettingsScopeFor(method, pathname);
264
269
  if (settingScope !== undefined) return settingScope ?? "system:admin";
265
270
  if (pathname.startsWith("/api/commands")) {
@@ -519,10 +524,12 @@ export function resolveTeamLineage(constraints: TokenConstraints | undefined): s
519
524
  return constraints?.teamId;
520
525
  }
521
526
 
522
- function constraintsAllow(constraints: TokenConstraints | undefined, resource: AuthorizationResource | undefined): boolean {
523
- if (!constraints || !resource) return true;
527
+ function constraintsAllow(auth: ComponentToken, resource: AuthorizationResource | undefined): boolean {
528
+ const constraints = auth.constraints;
529
+ if (!resource) return true;
530
+ if (!constraints) return resource.terminalOwner ? terminalOwnerAllowed(auth, undefined, resource) : true;
524
531
  const hasConstraints = Object.keys(constraints).length > 0;
525
- if (!hasConstraints) return true;
532
+ if (!hasConstraints && !resource.terminalOwner) return true;
526
533
  if (resource.agentId && !listAllows(constraints.agents, resource.agentId)) return false;
527
534
  if (resource.policyName && !listAllows(constraints.policies, resource.policyName)) return false;
528
535
  if (resource.channel && !listAllows(constraints.channels, resource.channel)) return false;
@@ -533,10 +540,27 @@ function constraintsAllow(constraints: TokenConstraints | undefined, resource: A
533
540
  if (resource.cwd && !cwdAllows(constraints, resource.cwd)) return false;
534
541
  if (resource.target && !targetAllows(constraints, resource.target)) return false;
535
542
  if (resource.terminalAttach && constraints.terminalAttach !== true) return false;
543
+ if (resource.terminalRead && constraints.terminalRead !== true) return false;
544
+ if (resource.terminalWrite && constraints.terminalWrite !== true) return false;
536
545
  if (resource.logsRead && constraints.logsRead !== true) return false;
546
+ if (resource.terminalOwner && !terminalOwnerAllowed(auth, constraints, resource)) return false;
537
547
  return true;
538
548
  }
539
549
 
550
+ function terminalOwnerAllowed(auth: ComponentToken, constraints: TokenConstraints | undefined, resource: AuthorizationResource): boolean {
551
+ if (hasScope(auth.scope, "system:admin")) return true;
552
+ if (auth.role === "orchestrator") {
553
+ return !resource.orchestratorId || listAllows(constraints?.orchestrators, resource.orchestratorId);
554
+ }
555
+ if (!constraints) return false;
556
+ if (resource.agentId && constraints.agents?.includes(resource.agentId)) return true;
557
+ if (resource.policyName && constraints.policies?.includes(resource.policyName)) return true;
558
+ if (resource.spawnRequestId && constraints.spawnRequestIds?.includes(resource.spawnRequestId)) return true;
559
+ if (resource.agentId && constraints.targets?.some((target) => target === resource.agentId || target === `agent:${resource.agentId}`)) return true;
560
+ if (resource.policyName && constraints.targets?.includes(`policy:${resource.policyName}`)) return true;
561
+ return false;
562
+ }
563
+
540
564
  function targetAllows(constraints: TokenConstraints, target: string): boolean {
541
565
  const hasTargetConstraints = Boolean(constraints.targets?.length || constraints.policies?.length || constraints.agents?.length);
542
566
  if (constraints.targets?.includes(target)) return true;
@@ -564,7 +588,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
564
588
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
565
589
  const record = value as Record<string, unknown>;
566
590
  for (const [key, item] of Object.entries(record)) {
567
- if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
591
+ if (["terminalAttach", "terminalRead", "terminalWrite", "logsRead", "canDelegate"].includes(key)) {
568
592
  if (typeof item !== "boolean") return false;
569
593
  } else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId" || key === "profile" || key === "tenantId") {
570
594
  if (typeof item !== "string") return false;
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import { startPlanLiveBindings } from "./services/plan-live-bindings";
15
15
  import { startRelayScheduler } from "./services/scheduler";
16
16
  import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
17
17
  import { onWorkspaceCreate } from "./db";
18
+ import { isTerminalSessionRequestAuthorized } from "./terminal-authz";
18
19
  import { resolve, sep } from "path";
19
20
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
20
21
  import {
@@ -35,7 +36,6 @@ import {
35
36
  isAuthorized,
36
37
  isScopedRequestAuthorized,
37
38
  isOriginAllowed,
38
- isRequestAuthorizedFor,
39
39
  unauthorized,
40
40
  } from "./security";
41
41
  import { startMaintenanceScheduler } from "./maintenance";
@@ -93,20 +93,7 @@ export function startServer(): void {
93
93
  hostname: HOST,
94
94
  idleTimeout: IDLE_TIMEOUT_SECONDS,
95
95
  fetch: createFetchHandler({ publicDir: process.env.PUBLIC_DIR, logRequests: LOG_REQUESTS }),
96
- websocket: {
97
- open(ws) {
98
- if (ws.data?.kind === "terminal-proxy") return openTerminalProxy(ws as TerminalProxySocket);
99
- return busHandleOpen(ws as any);
100
- },
101
- message(ws, data) {
102
- if (ws.data?.kind === "terminal-proxy") return sendTerminalProxyFrame(ws as TerminalProxySocket, data);
103
- return busHandleMessage(ws as any, data);
104
- },
105
- close(ws) {
106
- if (ws.data?.kind === "terminal-proxy") return closeTerminalProxy(ws as TerminalProxySocket);
107
- return busHandleClose(ws as any);
108
- },
109
- },
96
+ websocket: createWebSocketHandlers(),
110
97
  });
111
98
 
112
99
  console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
@@ -117,6 +104,7 @@ interface TerminalProxySocketData {
117
104
  upstreamUrl: string;
118
105
  upstreamToken?: string;
119
106
  upstream?: WebSocket;
107
+ writeAuthorized: boolean;
120
108
  pending: Array<string | Buffer>;
121
109
  }
122
110
 
@@ -128,6 +116,23 @@ type RelaySocketData = {
128
116
 
129
117
  type TerminalProxySocket = ServerWebSocket<TerminalProxySocketData>;
130
118
 
119
+ export function createWebSocketHandlers() {
120
+ return {
121
+ open(ws: ServerWebSocket<RelaySocketData>) {
122
+ if (ws.data?.kind === "terminal-proxy") return openTerminalProxy(ws as TerminalProxySocket);
123
+ return busHandleOpen(ws as any);
124
+ },
125
+ message(ws: ServerWebSocket<RelaySocketData>, data: string | Buffer) {
126
+ if (ws.data?.kind === "terminal-proxy") return sendTerminalProxyFrame(ws as TerminalProxySocket, data);
127
+ return busHandleMessage(ws as any, data);
128
+ },
129
+ close(ws: ServerWebSocket<RelaySocketData>) {
130
+ if (ws.data?.kind === "terminal-proxy") return closeTerminalProxy(ws as TerminalProxySocket);
131
+ return busHandleClose(ws as any);
132
+ },
133
+ };
134
+ }
135
+
131
136
  function emitAutomationDispatches(results: AutomationDispatchResult[]): void {
132
137
  for (const result of results) {
133
138
  if (result.command) emitAutomationCommand(result.command);
@@ -195,6 +200,10 @@ function openTerminalProxy(ws: TerminalProxySocket): void {
195
200
  }
196
201
 
197
202
  function sendTerminalProxyFrame(ws: TerminalProxySocket, data: string | Buffer): void {
203
+ if (!ws.data.writeAuthorized && isTerminalWriteFrame(data)) {
204
+ ws.send(JSON.stringify({ type: "error", error: "terminal write forbidden" }));
205
+ return;
206
+ }
198
207
  const upstream = ws.data.upstream;
199
208
  if (!upstream || upstream.readyState === WebSocket.CONNECTING) {
200
209
  ws.data.pending.push(data);
@@ -204,10 +213,22 @@ function sendTerminalProxyFrame(ws: TerminalProxySocket, data: string | Buffer):
204
213
  upstream.send(data);
205
214
  }
206
215
 
216
+ function isTerminalWriteFrame(data: string | Buffer): boolean {
217
+ let payload: unknown;
218
+ try {
219
+ payload = JSON.parse(typeof data === "string" ? data : data.toString("utf8"));
220
+ } catch {
221
+ return false;
222
+ }
223
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
224
+ const frame = payload as Record<string, unknown>;
225
+ return frame.type === "input" || (frame.type === "interactive" && frame.interactive === true);
226
+ }
227
+
207
228
  function closeTerminalProxy(ws: TerminalProxySocket): void {
208
229
  const upstream = ws.data.upstream;
209
230
  ws.data.pending = [];
210
- if (upstream && upstream.readyState === WebSocket.OPEN) upstream.close();
231
+ if (upstream && upstream.readyState !== WebSocket.CLOSED) upstream.close();
211
232
  }
212
233
 
213
234
  export function createFetchHandler(
@@ -235,9 +256,10 @@ export function createFetchHandler(
235
256
  if (!isAuthorized(req)) return unauthorized(req);
236
257
  const orchestratorId = decodeURIComponent(terminalStreamMatch[1]!);
237
258
  const session = decodeURIComponent(terminalStreamMatch[2]!);
238
- if (!isRequestAuthorizedFor(req, { scope: "terminal:attach", resource: { orchestratorId, terminalAttach: true } })) {
259
+ if (!isTerminalSessionRequestAuthorized(req, orchestratorId, session, "read")) {
239
260
  return forbidden(req);
240
261
  }
262
+ const writeAuthorized = isTerminalSessionRequestAuthorized(req, orchestratorId, session, "write");
241
263
  const orch = getOrchestrator(orchestratorId);
242
264
  if (!orch) return Response.json({ error: "orchestrator not found" }, { status: 404 });
243
265
  if (!orch.apiUrl) return Response.json({ error: "orchestrator does not expose an API" }, { status: 422 });
@@ -248,7 +270,7 @@ export function createFetchHandler(
248
270
  // access/proxy logs (#149). The orchestrator accepts both, but headers are safer.
249
271
  const token = relayToken();
250
272
  const upgraded = server.upgrade(req, {
251
- data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, pending: [] },
273
+ data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, writeAuthorized, pending: [] },
252
274
  });
253
275
  if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
254
276
  return undefined;
@@ -18,6 +18,26 @@ export function parseOwnerRepoFromRemote(url: string | undefined): string | unde
18
18
  let remoteRepoCache: Map<string, string | undefined> | undefined;
19
19
  let remoteUrlCache: Map<string, string | undefined> | undefined;
20
20
 
21
+ export async function remoteUrlFromGitRemoteAsync(repoRoot: string | undefined): Promise<string | undefined> {
22
+ if (!repoRoot) return undefined;
23
+ if (!remoteUrlCache) remoteUrlCache = new Map();
24
+ if (remoteUrlCache.has(repoRoot)) return remoteUrlCache.get(repoRoot);
25
+ let url: string | undefined;
26
+ try {
27
+ const proc = Bun.spawn(["git", "-C", repoRoot, "remote", "get-url", "origin"], {
28
+ stdin: "ignore",
29
+ stdout: "pipe",
30
+ stderr: "ignore",
31
+ });
32
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
33
+ if (exitCode === 0) url = stdout.trim() || undefined;
34
+ } catch {
35
+ url = undefined;
36
+ }
37
+ remoteUrlCache.set(repoRoot, url);
38
+ return url;
39
+ }
40
+
21
41
  export function remoteUrlFromGitRemote(repoRoot: string | undefined): string | undefined {
22
42
  if (!repoRoot) return undefined;
23
43
  if (!remoteUrlCache) remoteUrlCache = new Map();
@@ -37,6 +57,15 @@ export function remoteUrlFromGitRemote(repoRoot: string | undefined): string | u
37
57
  return url;
38
58
  }
39
59
 
60
+ export async function repoFromGitRemoteAsync(repoRoot: string | undefined): Promise<string | undefined> {
61
+ if (!repoRoot) return undefined;
62
+ if (!remoteRepoCache) remoteRepoCache = new Map();
63
+ if (remoteRepoCache.has(repoRoot)) return remoteRepoCache.get(repoRoot);
64
+ const repo = parseOwnerRepoFromRemote(await remoteUrlFromGitRemoteAsync(repoRoot));
65
+ remoteRepoCache.set(repoRoot, repo);
66
+ return repo;
67
+ }
68
+
40
69
  /**
41
70
  * `owner/repo` from a repo root's `origin` remote, cached per root. Works from any
42
71
  * subdirectory of the repo (`git -C` walks up). Returns undefined when the dir isn't a
@@ -13,7 +13,7 @@ import { defaultIssueRepo } from "../config";
13
13
  // `repoFromGitRemote` / `parseOwnerRepoFromRemote` live in the shared ./git-remote module
14
14
  // (#533) so project auto-seed reuses the exact same logic. Re-exported so this module's
15
15
  // existing importers and tests keep their entry point.
16
- import { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests } from "./git-remote";
16
+ import { parseOwnerRepoFromRemote, repoFromGitRemote, repoFromGitRemoteAsync, __resetRemoteRepoCacheForTests } from "./git-remote";
17
17
  export { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests };
18
18
  import type { IssueTrackerAdapter, IssueTrackerIssueSummary } from "../issue-tracker";
19
19
  import { recordAndEmitIssueActivity } from "./issue-activity";
@@ -90,8 +90,8 @@ function logFailure(opts: IssueLifecycleOptions, message: string): void {
90
90
  * from its own git remote so a single relay serving many repos closes the RIGHT repo's issue;
91
91
  * env (`AGENT_RELAY_DEFAULT_ISSUE_REPO`/`GITHUB_REPOSITORY`) is an override/fallback.
92
92
  */
93
- function landDefaultRepo(workspace: LifecycleWorkspace): string | undefined {
94
- return repoFromGitRemote(workspace.repoRoot) ?? defaultIssueRepo();
93
+ async function landDefaultRepo(workspace: LifecycleWorkspace): Promise<string | undefined> {
94
+ return (await repoFromGitRemoteAsync(workspace.repoRoot)) ?? defaultIssueRepo();
95
95
  }
96
96
 
97
97
  function cleanText(value: string | undefined): string | undefined {
@@ -197,10 +197,10 @@ export function parseBacklinkIssueRefs(text: string | undefined, fallbackDefault
197
197
  return issueRefMap(refs);
198
198
  }
199
199
 
200
- function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): { source: LandIssueRefSource; refs: IssueRef[] } {
200
+ async function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): Promise<{ source: LandIssueRefSource; refs: IssueRef[] }> {
201
201
  const associated = associatedIssueRefs(input.workspace);
202
202
  if (associated.length > 0) return { source: "association", refs: associated };
203
- const fallbackDefaultRepo = landDefaultRepo(input.workspace);
203
+ const fallbackDefaultRepo = await landDefaultRepo(input.workspace);
204
204
  const subjects = landedSubjectTexts(input);
205
205
  const keyworded = issueRefMap(subjects.flatMap((subject) => parseClosingIssueRefs(subject, fallbackDefaultRepo)));
206
206
  if (keyworded.length > 0) return { source: "commit", refs: keyworded };
@@ -399,7 +399,7 @@ export async function handleLandedIssueLifecycle(input: LandedIssueLifecycleInpu
399
399
  skipped: string[];
400
400
  }> {
401
401
  if ((input.workspace.baseRef ?? "main") !== "main") return { source: "commit", closed: [], reopened: [], skipped: ["non-default-branch"] };
402
- const resolved = resolveIssueRefsForLand(input);
402
+ const resolved = await resolveIssueRefsForLand(input);
403
403
  const closed: CloseIssueLifecycleResult[] = [];
404
404
  for (const ref of resolved.refs) {
405
405
  closed.push(await closeIssueRefForLand(ref, resolved.source, input, opts));
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { addProjectRoot, ensureProject, resolveProjectForCwd, rootsForProject, setProjectIssueRepo } from "../db";
4
4
  import { ingestClaudeMd } from "../project-ingest";
5
- import { remoteUrlFromGitRemote, repoFromGitRemote } from "./git-remote";
5
+ import { remoteUrlFromGitRemote, remoteUrlFromGitRemoteAsync, repoFromGitRemote, repoFromGitRemoteAsync } from "./git-remote";
6
6
  import type { Project, ProjectRoot } from "../types";
7
7
 
8
8
  interface EnsureProjectRootInput {
@@ -56,6 +56,26 @@ export function ensureProjectRoot(input: EnsureProjectRootInput): EnsureProjectR
56
56
  return { project, roots };
57
57
  }
58
58
 
59
+ export async function ensureProjectRootAsync(input: EnsureProjectRootInput): Promise<EnsureProjectRootResult> {
60
+ const project = ensureProject({
61
+ slug: input.slug,
62
+ ...(input.title !== undefined ? { title: input.title } : {}),
63
+ ...(input.description !== undefined ? { description: input.description } : {}),
64
+ ...(input.tenantId !== undefined ? { tenantId: input.tenantId } : {}),
65
+ ...(input.issueRepo !== undefined ? { issueRepo: input.issueRepo } : {}),
66
+ });
67
+ const remoteUrl = input.remoteUrl ?? await remoteUrlFromGitRemoteAsync(input.rootPath);
68
+ const roots = addProjectRoot(project.id, input.rootPath, { remoteUrl, ref: input.ref });
69
+ if (input.issueRepo === undefined && !project.issueRepo) {
70
+ const derived = await repoFromGitRemoteAsync(input.rootPath);
71
+ if (derived) {
72
+ const updated = setProjectIssueRepo(project.id, derived, { onlyIfNull: true });
73
+ if (updated) return { project: updated, roots };
74
+ }
75
+ }
76
+ return { project, roots };
77
+ }
78
+
59
79
  export function ingestProjectClaudeMd(projectId: string, authorId: string): ClaudeMdIngestResult {
60
80
  return ingestClaudeMd(projectId, authorId);
61
81
  }
@@ -112,3 +132,29 @@ export function autoSeedProjectForCwd(cwd: string, authorId: string): AutoSeedPr
112
132
  return { project, repoRoot, roots, ingestError: err instanceof Error ? err : new Error(String(err)) };
113
133
  }
114
134
  }
135
+
136
+ export async function autoSeedProjectForCwdAsync(cwd: string, authorId: string): Promise<AutoSeedProjectResult | null> {
137
+ const existing = resolveProjectForCwd(cwd);
138
+ if (existing) {
139
+ const repoRoot = findGitRepoRoot(cwd) ?? cwd;
140
+ let project = existing;
141
+ if (!existing.issueRepo) {
142
+ const derived = await repoFromGitRemoteAsync(repoRoot);
143
+ const updated = derived ? setProjectIssueRepo(existing.id, derived, { onlyIfNull: true }) : null;
144
+ if (updated) project = updated;
145
+ }
146
+ return { project, repoRoot, roots: rootsForProject(existing.id) };
147
+ }
148
+
149
+ const repoRoot = findGitRepoRoot(cwd);
150
+ if (!repoRoot) return null;
151
+ const slug = basename(repoRoot).trim();
152
+ if (!slug) return null;
153
+
154
+ const { project, roots } = await ensureProjectRootAsync({ slug, rootPath: repoRoot });
155
+ try {
156
+ return { project, repoRoot, roots, ingest: ingestProjectClaudeMd(project.id, authorId) };
157
+ } catch (err) {
158
+ return { project, repoRoot, roots, ingestError: err instanceof Error ? err : new Error(String(err)) };
159
+ }
160
+ }
@@ -30,7 +30,6 @@ import {
30
30
  promoteLineageCapturedResponse,
31
31
  sendMessageWithResult,
32
32
  channelObligationAnsweredBy,
33
- spawnObligationAnsweredBy,
34
33
  } from "../db";
35
34
  import { planSend, type DeliveryReceipt } from "../agent-ref";
36
35
  import { emitMessageQueued, emitNewMessage } from "../sse";
@@ -178,12 +177,11 @@ export function sendMessageService(
178
177
  ): SendMessageResult {
179
178
  const receipt = validateSendMessageAuthorization(input, ctx, opts);
180
179
  // Reply-obligation linking: some real answers are ordinary sends without replyTo/thread linkage.
181
- // Channel bridge replies and spawn report-backs both have a single unambiguous open obligation case;
180
+ // Channel bridge replies have a single unambiguous open obligation case;
182
181
  // link those at send time so the obligation clears with one message and the Stop hook does not nag
183
182
  // the agent into a redundant `/reply`.
184
183
  if (!input.replyTo && !isMechanicalMessageKind(input.kind)) {
185
- const answered = channelObligationAnsweredBy(input.from, input.to)
186
- ?? spawnObligationAnsweredBy(input.from, input.to);
184
+ const answered = channelObligationAnsweredBy(input.from, input.to);
187
185
  if (answered !== null) input.replyTo = answered;
188
186
  }
189
187
  const suppressedParent = suppressResultsOnlyAck(input);
@@ -39,7 +39,7 @@ import type { ProjectMcpPolicy } from "../project-mcp";
39
39
  import type { ProjectSettingsPolicy } from "../project-settings";
40
40
  import type { ProjectSkillsPolicy } from "../project-skills";
41
41
  import { applyDefaultSharedMcp, type SharedMcpSpawnOverride } from "../shared-mcp";
42
- import { autoSeedProjectForCwd } from "./project";
42
+ import { autoSeedProjectForCwdAsync } from "./project";
43
43
  import { hydrateTaskContext, projectTaskHydration } from "./task-context-hydration";
44
44
  import { createTaskEntity, recordTaskEntityDispatchDecision } from "./task-entity";
45
45
  import { autoSubscribeTaskCoordinator } from "./task-semantic-events";
@@ -414,7 +414,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
414
414
  // stays filterable by project.
415
415
  let callerProject = resolveProjectForCwd(resolvedCwd);
416
416
  try {
417
- const seeded = autoSeedProjectForCwd(resolvedCwd, requestedBy);
417
+ const seeded = await autoSeedProjectForCwdAsync(resolvedCwd, requestedBy);
418
418
  callerProject = seeded?.project ?? callerProject;
419
419
  if (seeded?.ingestError) {
420
420
  console.warn(`project auto-seed failed to ingest CLAUDE.md for ${resolvedCwd}: ${seeded.ingestError.message}`);
@@ -100,20 +100,21 @@ export function __setDriveTaskOnLandForTests(resolver: DriveTaskResolver | undef
100
100
  driveTaskOnLandForTests = resolver;
101
101
  }
102
102
 
103
- function landedDiffSummary(input: LandedTaskLifecycleInput, log: LogFn): string | undefined {
103
+ async function landedDiffSummary(input: LandedTaskLifecycleInput, log: LogFn): Promise<string | undefined> {
104
104
  if (diffSummaryResolverForTests) return diffSummaryResolverForTests(input);
105
105
  const repoRoot = input.workspace.repoRoot?.trim();
106
106
  const baseSha = input.workspace.baseSha?.trim();
107
107
  const tipSha = input.mergedSha?.trim();
108
108
  if (!repoRoot || !baseSha || !tipSha || baseSha === tipSha) return undefined;
109
109
  try {
110
- const proc = Bun.spawnSync(["git", "-C", repoRoot, "diff", "--shortstat", `${baseSha}..${tipSha}`], {
110
+ const proc = Bun.spawn(["git", "-C", repoRoot, "diff", "--shortstat", `${baseSha}..${tipSha}`], {
111
111
  stdin: "ignore",
112
112
  stdout: "pipe",
113
113
  stderr: "pipe",
114
114
  });
115
- if (proc.exitCode !== 0) return undefined;
116
- const out = Buffer.from(proc.stdout).toString("utf8").trim();
115
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
116
+ if (exitCode !== 0) return undefined;
117
+ const out = stdout.trim();
117
118
  return out || undefined;
118
119
  } catch (error) {
119
120
  log(`task lifecycle diff summary failed for ${input.workspace.id}: ${errMessage(error)}`);
@@ -454,7 +455,7 @@ export async function handleLandedTaskLifecycle(
454
455
  const taskIds = boundTaskIdsForWorkspace(input.workspace);
455
456
  if (taskIds.length === 0) return { handled: false, reason: "no-bound-task", tasks: [] };
456
457
 
457
- const diffSummary = landedDiffSummary(input, log);
458
+ const diffSummary = await landedDiffSummary(input, log);
458
459
  const outcomes: TaskLandOutcome[] = [];
459
460
  for (const taskId of taskIds) {
460
461
  try {
@@ -238,10 +238,10 @@ const DISPLAY_PREFS_SCHEMA: JSONSchema = {
238
238
  // sticky-bottom intent follow them across devices instead of dying in per-browser module state.
239
239
  // The value is a MAP keyed by conversation/agent id — one user-settings row holds every
240
240
  // conversation's anchor, so this is ONE descriptor (not a per-agent descriptor explosion).
241
- // Each entry is a LOGICAL anchor, never a pixel scrollTop: `topKey` is the timeline entry key
242
- // (`msg-<id>` — a server-stable id), `offset` the within-row pixel offset, `stick` the
243
- // follow-the-bottom intent. `maxProperties` bounds unbounded conversation growth; the dashboard
244
- // store evicts oldest-touched before it can be hit.
241
+ // Each entry is a LOGICAL anchor, never a pixel scrollTop: `anchorMessageId` is the durable
242
+ // message-id anchor, `topKey` is the compatibility timeline entry key, `offset` the within-row
243
+ // pixel offset, `stick` the follow-the-bottom intent. `maxProperties` bounds unbounded
244
+ // conversation growth; the dashboard store evicts oldest-touched before it can be hit.
245
245
  const CHAT_SCROLL_SCHEMA: JSONSchema = {
246
246
  type: "object",
247
247
  maxProperties: 200,
@@ -252,6 +252,8 @@ const CHAT_SCROLL_SCHEMA: JSONSchema = {
252
252
  properties: {
253
253
  stick: { type: "boolean" },
254
254
  topKey: { type: ["string", "null"], maxLength: 200 },
255
+ anchorMessageId: { type: "number", minimum: 0, maximum: 10_000_000_000 },
256
+ anchorEndMessageId: { type: "number", minimum: 0, maximum: 10_000_000_000 },
255
257
  offset: { type: "number", minimum: 0, maximum: 10_000_000 },
256
258
  },
257
259
  },
@@ -277,7 +277,7 @@ function spawnQuotaWindow(
277
277
  ? Math.max(0, burnWindow.earlyWallMs) / timeToResetMs
278
278
  : null;
279
279
  const weight = burnWindow.role === "7d" ? 3 : 1;
280
- const hardBackstop = providerCfg?.hardBackstopUtilization ?? 0.99;
280
+ const hardBackstop = providerCfg?.hardBackstopUtilization;
281
281
  // #760 — floor default is now 0 (off). The projection/pace gate (earlyFraction≈0 when the
282
282
  // recency-weighted rate projects no early wall) replaces the blunt utilization floor, which
283
283
  // used to hide a legitimate low-util-high-burst alarm. Operators may still opt back in.
@@ -289,7 +289,7 @@ function spawnQuotaWindow(
289
289
  // over-pace penalty and ≥ preferSlackAt projected headroom at reset.
290
290
  const target = providerCfg?.targetUtilization ?? DEFAULT_QUOTA_TARGET_UTILIZATION;
291
291
  const preferSlackAt = providerCfg?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
292
- const blockedNow = quotaWindow.utilization >= hardBackstop;
292
+ const blockedNow = hardBackstop === 0 ? false : quotaWindow.utilization >= (hardBackstop ?? 0.99);
293
293
  let burnRatio = burnWindow.burnRatio;
294
294
  let projectedUtilAtReset = burnWindow.projectedUtilAtReset;
295
295
  let slack = burnWindow.slack;