agent-relay-server 0.78.9 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/docs/openapi.json +153 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-NWZehv71.js → MessageBubble-C-3IcqgU.js} +2 -2
  4. package/public/assets/{MessageBubble-NWZehv71.js.map → MessageBubble-C-3IcqgU.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-Dt3AZz4B.js → PlanGraphPanel-o3u36Del.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-Dt3AZz4B.js.map → PlanGraphPanel-o3u36Del.js.map} +1 -1
  7. package/public/assets/{activity-Bo5DgiCq.js → activity-L3POfhGa.js} +2 -2
  8. package/public/assets/{activity-Bo5DgiCq.js.map → activity-L3POfhGa.js.map} +1 -1
  9. package/public/assets/{agent-profiles-_cAkMjLb.js → agent-profiles-CeoqyRis.js} +2 -2
  10. package/public/assets/{agent-profiles-_cAkMjLb.js.map → agent-profiles-CeoqyRis.js.map} +1 -1
  11. package/public/assets/{agents-DWSugBVH.js → agents-DAdDtQb6.js} +2 -2
  12. package/public/assets/{agents-DWSugBVH.js.map → agents-DAdDtQb6.js.map} +1 -1
  13. package/public/assets/{analytics-DLt_3X2u.js → analytics-B6wGSTf3.js} +2 -2
  14. package/public/assets/{analytics-DLt_3X2u.js.map → analytics-B6wGSTf3.js.map} +1 -1
  15. package/public/assets/{automation-CeWlkojL.js → automation-kakk3xZ9.js} +2 -2
  16. package/public/assets/{automation-CeWlkojL.js.map → automation-kakk3xZ9.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DbJzvZw0.js → branch-state-badge-BSwkl9A8.js} +2 -2
  18. package/public/assets/{branch-state-badge-DbJzvZw0.js.map → branch-state-badge-BSwkl9A8.js.map} +1 -1
  19. package/public/assets/{channels-D8T7P8ui.js → channels-BzY-_4Zi.js} +2 -2
  20. package/public/assets/{channels-D8T7P8ui.js.map → channels-BzY-_4Zi.js.map} +1 -1
  21. package/public/assets/{chat-Bt1j5pmY.js → chat-DDlRjwGC.js} +2 -2
  22. package/public/assets/{chat-Bt1j5pmY.js.map → chat-DDlRjwGC.js.map} +1 -1
  23. package/public/assets/{connectors-BTjbCi5q.js → connectors-empwp8UF.js} +2 -2
  24. package/public/assets/{connectors-BTjbCi5q.js.map → connectors-empwp8UF.js.map} +1 -1
  25. package/public/assets/display-DHgdn4vN.js.map +1 -1
  26. package/public/assets/{formatted-body-impl-gaw1gO1I.js → formatted-body-impl-CEqSWNRL.js} +2 -2
  27. package/public/assets/{formatted-body-impl-gaw1gO1I.js.map → formatted-body-impl-CEqSWNRL.js.map} +1 -1
  28. package/public/assets/{index-CgHPA8OX.js → index-CtGaz-Yg.js} +6 -6
  29. package/public/assets/index-CtGaz-Yg.js.map +1 -0
  30. package/public/assets/{insights-CPQpfNUK.js → insights-mf5sd483.js} +2 -2
  31. package/public/assets/{insights-CPQpfNUK.js.map → insights-mf5sd483.js.map} +1 -1
  32. package/public/assets/{integrations-JEkQvc-S.js → integrations-4V9lOQ8b.js} +2 -2
  33. package/public/assets/{integrations-JEkQvc-S.js.map → integrations-4V9lOQ8b.js.map} +1 -1
  34. package/public/assets/{maintenance-D6c_hvMO.js → maintenance-CruMfiA7.js} +2 -2
  35. package/public/assets/{maintenance-D6c_hvMO.js.map → maintenance-CruMfiA7.js.map} +1 -1
  36. package/public/assets/{managed-agents-DmSmdhRe.js → managed-agents-fOhHGzNI.js} +2 -2
  37. package/public/assets/{managed-agents-DmSmdhRe.js.map → managed-agents-fOhHGzNI.js.map} +1 -1
  38. package/public/assets/{markdown-preview-impl-DbE1Axo2.js → markdown-preview-impl-g5-PTM1A.js} +2 -2
  39. package/public/assets/{markdown-preview-impl-DbE1Axo2.js.map → markdown-preview-impl-g5-PTM1A.js.map} +1 -1
  40. package/public/assets/{memory-B7SuFMg_.js → memory-CRyq2WNC.js} +2 -2
  41. package/public/assets/{memory-B7SuFMg_.js.map → memory-CRyq2WNC.js.map} +1 -1
  42. package/public/assets/{messages-C19vhh1D.js → messages-BqLp0X3y.js} +2 -2
  43. package/public/assets/{messages-C19vhh1D.js.map → messages-BqLp0X3y.js.map} +1 -1
  44. package/public/assets/{orchestrators-Dw2QF8-1.js → orchestrators-BD4nZcG3.js} +2 -2
  45. package/public/assets/{orchestrators-Dw2QF8-1.js.map → orchestrators-BD4nZcG3.js.map} +1 -1
  46. package/public/assets/{overview-DRDCQYId.js → overview-De-2GN06.js} +2 -2
  47. package/public/assets/{overview-DRDCQYId.js.map → overview-De-2GN06.js.map} +1 -1
  48. package/public/assets/{pairs-CeBqU3ev.js → pairs-DOqjBd0R.js} +2 -2
  49. package/public/assets/{pairs-CeBqU3ev.js.map → pairs-DOqjBd0R.js.map} +1 -1
  50. package/public/assets/{projects-CoJjeTUE.js → projects-Byp_gLoD.js} +2 -2
  51. package/public/assets/{projects-CoJjeTUE.js.map → projects-Byp_gLoD.js.map} +1 -1
  52. package/public/assets/{security-2o1oGQ9C.js → security-DtoRSpIi.js} +2 -2
  53. package/public/assets/{security-2o1oGQ9C.js.map → security-DtoRSpIi.js.map} +1 -1
  54. package/public/assets/{select-CYX4uPPQ.js → select-esP750zQ.js} +2 -2
  55. package/public/assets/{select-CYX4uPPQ.js.map → select-esP750zQ.js.map} +1 -1
  56. package/public/assets/{settings-Dk8JFQ4C.js → settings-U8788lYt.js} +2 -2
  57. package/public/assets/{settings-Dk8JFQ4C.js.map → settings-U8788lYt.js.map} +1 -1
  58. package/public/assets/{store-m7GK-CMC.js → store-Cjcd0egy.js} +2 -2
  59. package/public/assets/store-Cjcd0egy.js.map +1 -0
  60. package/public/assets/{tasks-ByWEnbEl.js → tasks-CtiwQy9I.js} +2 -2
  61. package/public/assets/{tasks-ByWEnbEl.js.map → tasks-CtiwQy9I.js.map} +1 -1
  62. package/public/assets/{teams-KsfHMaNk.js → teams-Cx29VUif.js} +2 -2
  63. package/public/assets/{teams-KsfHMaNk.js.map → teams-Cx29VUif.js.map} +1 -1
  64. package/public/assets/{terminal-viewer-impl-BAb6QYYF.js → terminal-viewer-impl-h9hvvB9S.js} +2 -2
  65. package/public/assets/{terminal-viewer-impl-BAb6QYYF.js.map → terminal-viewer-impl-h9hvvB9S.js.map} +1 -1
  66. package/public/assets/{work-queue-DI9rr47X.js → work-queue-C-bS6YmI.js} +2 -2
  67. package/public/assets/{work-queue-DI9rr47X.js.map → work-queue-C-bS6YmI.js.map} +1 -1
  68. package/public/assets/{workspaces-DAuU-uMo.js → workspaces-Bivhqw2N.js} +2 -2
  69. package/public/assets/{workspaces-DAuU-uMo.js.map → workspaces-Bivhqw2N.js.map} +1 -1
  70. package/public/index.html +2 -2
  71. package/src/cli/index.ts +1 -1
  72. package/src/cli/workspace.ts +26 -3
  73. package/src/config-store.ts +1 -1
  74. package/src/db/provider-quotas.ts +3 -1
  75. package/src/db/teams.ts +18 -0
  76. package/src/maintenance/jobs.ts +9 -1
  77. package/src/maintenance/teams.ts +122 -0
  78. package/src/notification-types.ts +2 -0
  79. package/src/quota.ts +1 -1
  80. package/src/routes/index.ts +6 -1
  81. package/src/routes/provider-quotas.ts +4 -1
  82. package/src/routes/teams-config.ts +15 -0
  83. package/src/routes/workspaces.ts +144 -1
  84. package/src/security.ts +3 -1
  85. package/src/settings/registry.ts +27 -0
  86. package/src/teams-config-store.ts +22 -0
  87. package/public/assets/index-CgHPA8OX.js.map +0 -1
  88. package/public/assets/store-m7GK-CMC.js.map +0 -1
package/src/db/teams.ts CHANGED
@@ -3,6 +3,7 @@ import { getAgent, setAgentTeam } from "./agents.ts";
3
3
  import { getDb, ValidationError } from "./connection.ts";
4
4
  import { rowToAgent } from "./mappers.ts";
5
5
  import { unpromotedBlackboardEntries, type UnpromotedBlackboardSummary } from "./project-entries.ts";
6
+ import { isProviderBlocked } from "../provider-state.ts";
6
7
  import type { AgentCard, Team } from "../types";
7
8
 
8
9
  interface TeamRow {
@@ -72,6 +73,11 @@ export interface TeamOutcomeAggregates {
72
73
  recent: TeamOutcomeRecent[];
73
74
  }
74
75
 
76
+ interface TeamBusyState {
77
+ busyMemberIds: string[];
78
+ lastBusyAt?: number;
79
+ }
80
+
75
81
  export function teamId(): string {
76
82
  return `team_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
77
83
  }
@@ -132,6 +138,18 @@ export function countLiveTeamMembers(teamId: string, cutoff: number): number {
132
138
  return row.n;
133
139
  }
134
140
 
141
+ export function getTeamBusyState(teamId: string, cutoff: number): TeamBusyState {
142
+ const rows = (getDb().query(
143
+ "SELECT * FROM agents WHERE team_id = ? AND status = 'busy' AND last_seen > ? ORDER BY last_seen DESC, id ASC",
144
+ ).all(teamId, cutoff) as any[])
145
+ .map(rowToAgent)
146
+ .filter((agent) => !isProviderBlocked(agent));
147
+ return {
148
+ busyMemberIds: rows.map((row) => row.id),
149
+ ...(rows[0] ? { lastBusyAt: rows[0].lastSeen } : {}),
150
+ };
151
+ }
152
+
135
153
  export function setTeamZeroMembersSince(teamId: string, ts: number): boolean {
136
154
  return getDb().query("UPDATE teams SET zero_members_since = ?, updated_at = ? WHERE id = ?").run(ts, ts, teamId).changes > 0;
137
155
  }
@@ -51,7 +51,7 @@ import {
51
51
  } from "./constants";
52
52
  import { emitCommand } from "./events";
53
53
  import { reapOrphanedSessions } from "./orphaned-sessions";
54
- import { reapZeroMemberTeams } from "./teams";
54
+ import { reapZeroMemberTeams, runTeamIdleWatchdog } from "./teams";
55
55
  import { scanWorkspaceConflicts, workspaceGC } from "./workspaces";
56
56
  import type { MaintenanceJobDefinition } from "./types";
57
57
 
@@ -208,6 +208,14 @@ export const definitions: MaintenanceJobDefinition[] = [
208
208
  runOnStart: false,
209
209
  handler: reapZeroMemberTeams,
210
210
  },
211
+ {
212
+ id: "team-idle-watchdog",
213
+ title: "Team idle watchdog",
214
+ description: "Gently ping a team's current coordinator once when the team has had no busy members for the configured threshold.",
215
+ intervalMs: REAP_INTERVAL_MS,
216
+ runOnStart: false,
217
+ handler: runTeamIdleWatchdog,
218
+ },
211
219
  {
212
220
  id: "orchestrator-reaper",
213
221
  title: "Orchestrator reaper",
@@ -2,12 +2,134 @@ import {
2
2
  clearTeamZeroMembersSince,
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
+ getTeamBusyState,
6
+ getTeamRoster,
5
7
  listActiveTeams,
6
8
  setTeamZeroMembersSince,
7
9
  } from "../db";
8
10
  import { STALE_TTL_MS } from "../config";
11
+ import { agentProviderState } from "../provider-state";
9
12
  import { dissolveTeamById } from "../services/team";
13
+ import { getTeamsConfig } from "../teams-config-store";
10
14
  import { teamReapGraceMs } from "./constants";
15
+ import { notifyTarget } from "./events";
16
+
17
+ interface TeamIdleTrackerEntry {
18
+ firstNoBusyAt: number;
19
+ pinged: boolean;
20
+ }
21
+
22
+ const teamIdleTracker = new Map<string, TeamIdleTrackerEntry>();
23
+
24
+ export function resetTeamIdleWatchdogForTests(): void {
25
+ teamIdleTracker.clear();
26
+ }
27
+
28
+ function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
29
+ const roster = getTeamRoster(teamId).map((member) => {
30
+ const providerState = agentProviderState(member);
31
+ return {
32
+ id: member.id,
33
+ name: member.name,
34
+ label: member.label,
35
+ status: member.status,
36
+ ready: member.ready,
37
+ lastSeen: member.lastSeen,
38
+ blocked: providerState?.state === "blocked",
39
+ providerState: providerState ?? undefined,
40
+ };
41
+ });
42
+ return {
43
+ kind: "team.idle",
44
+ teamId,
45
+ coordinatorId,
46
+ thresholdMinutes,
47
+ thresholdMs: thresholdMinutes * 60_000,
48
+ idleForMs,
49
+ firstNoBusyAt,
50
+ roster,
51
+ blockedMembers: roster.filter((member) => member.blocked).map((member) => ({
52
+ id: member.id,
53
+ providerState: member.providerState,
54
+ })),
55
+ };
56
+ }
57
+
58
+ function notifyTeamIdle(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number): string | null {
59
+ const roundedIdleMinutes = Math.max(thresholdMinutes, Math.floor(idleForMs / 60_000));
60
+ const payload = teamIdlePayload(teamId, coordinatorId, thresholdMinutes, idleForMs, firstNoBusyAt);
61
+ return notifyTarget(
62
+ "team.idle",
63
+ { teamId, agentId: coordinatorId },
64
+ coordinatorId,
65
+ "Team idle watchdog",
66
+ `Team \`${teamId}\` has had no busy members for ${roundedIdleMinutes} min. If the work is done, stand down. If not, check the team roster and restart unfinished work.`,
67
+ payload,
68
+ );
69
+ }
70
+
71
+ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string, unknown> {
72
+ const config = getTeamsConfig();
73
+ if (!config.enabled) {
74
+ const cleared = teamIdleTracker.size;
75
+ teamIdleTracker.clear();
76
+ return { enabled: false, clearedTrackedTeams: cleared };
77
+ }
78
+
79
+ const now = input.now ?? Date.now();
80
+ const cutoff = now - STALE_TTL_MS;
81
+ const thresholdMs = config.thresholdMinutes * 60_000;
82
+ const seen = new Set<string>();
83
+ const busyTeamIds: string[] = [];
84
+ const waitingTeamIds: string[] = [];
85
+ const pingedTeamIds: string[] = [];
86
+ const alreadyPingedTeamIds: string[] = [];
87
+ const missingCoordinatorTeamIds: string[] = [];
88
+
89
+ for (const team of listActiveTeams()) {
90
+ seen.add(team.id);
91
+ const busy = getTeamBusyState(team.id, cutoff);
92
+ if (busy.busyMemberIds.length > 0) {
93
+ teamIdleTracker.delete(team.id);
94
+ busyTeamIds.push(team.id);
95
+ continue;
96
+ }
97
+
98
+ const entry = teamIdleTracker.get(team.id) ?? { firstNoBusyAt: now, pinged: false };
99
+ teamIdleTracker.set(team.id, entry);
100
+ const idleForMs = now - entry.firstNoBusyAt;
101
+ if (idleForMs < thresholdMs) {
102
+ waitingTeamIds.push(team.id);
103
+ continue;
104
+ }
105
+
106
+ if (entry.pinged) {
107
+ alreadyPingedTeamIds.push(team.id);
108
+ continue;
109
+ }
110
+ if (!team.coordinatorId) {
111
+ missingCoordinatorTeamIds.push(team.id);
112
+ continue;
113
+ }
114
+
115
+ notifyTeamIdle(team.id, team.coordinatorId, config.thresholdMinutes, idleForMs, entry.firstNoBusyAt);
116
+ entry.pinged = true;
117
+ pingedTeamIds.push(team.id);
118
+ }
119
+
120
+ for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
121
+
122
+ return {
123
+ enabled: true,
124
+ thresholdMinutes: config.thresholdMinutes,
125
+ trackedTeams: teamIdleTracker.size,
126
+ busyTeamIds,
127
+ waitingTeamIds,
128
+ pingedTeamIds,
129
+ alreadyPingedTeamIds,
130
+ missingCoordinatorTeamIds,
131
+ };
132
+ }
11
133
 
12
134
  export function reapZeroMemberTeams(): Record<string, unknown> {
13
135
  const now = Date.now();
@@ -10,6 +10,7 @@ export type NotificationType =
10
10
  | "agent.resume_budget_exhausted"
11
11
  | "agent.transient_land_hold"
12
12
  | "agent.rate_limit_resume"
13
+ | "team.idle"
13
14
  | "plan.bound_agent_exited"
14
15
  | "workspace.steward_task"
15
16
  | "workspace.conflict"
@@ -60,6 +61,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
60
61
  "agent.resume_budget_exhausted": null,
61
62
  "agent.transient_land_hold": null,
62
63
  "agent.rate_limit_resume": 15 * MINUTES,
64
+ "team.idle": null,
63
65
  "plan.bound_agent_exited": null,
64
66
  "workspace.steward_task": null,
65
67
  "workspace.conflict": null,
package/src/quota.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
2
  import type { ProviderQuotaError, QuotaState, QuotaWindowName, QuotaSource, QuotaUnit } from "./types";
3
3
 
4
- const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest"]);
4
+ const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest", "statusline"]);
5
5
  const QUOTA_UNITS = new Set<QuotaUnit>(["tokens", "requests", "%"]);
6
6
  const QUOTA_WINDOWS = new Set<QuotaWindowName>(["five_hour", "seven_day", "seven_day_sonnet", "seven_day_opus", "primary", "secondary"]);
7
7
 
@@ -7,6 +7,7 @@ import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomati
7
7
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
8
8
  import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
9
9
  import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
10
+ import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
10
11
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
11
12
  import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute, patchInboxThreadState, postChatHistoryImportRoute, putInboxDraft } from "./inbox";
12
13
  import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
@@ -14,7 +15,7 @@ import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRo
14
15
  import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
15
16
  import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
16
17
  import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
17
- import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim } from "./workspaces";
18
+ import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
18
19
  import { error, type Handler } from "./_shared";
19
20
  import { getActivityEvents, postActivityEvent } from "./activity";
20
21
  import { getAnalyticsRoute, getHealthRoute, getMaintenanceJobs, getStatsRoute, postMaintenanceJobRun, postSystemReap } from "./stats";
@@ -142,6 +143,8 @@ const routes: Route[] = [
142
143
  // Static segments before :id so "/workspaces/orphans" isn't captured as an id.
143
144
  route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
144
145
  route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
146
+ route("GET", "/api/workspaces/recovery-branches", getWorkspaceRecoveryBranches),
147
+ route("POST", "/api/workspaces/recovery-branches/discard", postWorkspaceRecoveryBranchDiscard),
145
148
  route("GET", "/api/workspaces/stewards", getWorkspaceStewards),
146
149
  route("POST", "/api/workspaces/actions/cleanup-stale", postWorkspaceCleanupStale),
147
150
  route("GET", "/api/workspaces/:id", getWorkspaceById),
@@ -159,6 +162,8 @@ const routes: Route[] = [
159
162
  route("GET", "/api/merges/pauses/:id", getMergePauseById),
160
163
 
161
164
  route("POST", "/api/system/broadcast", postSystemBroadcast),
165
+ route("GET", "/api/teams/config", getTeamsConfigRoute),
166
+ route("PUT", "/api/teams/config", putTeamsConfigRoute),
162
167
  route("GET", "/api/teams", getTeamsRoute),
163
168
  route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),
164
169
  route("GET", "/api/teams/me", getMyTeamRoute),
@@ -1,5 +1,6 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
2
  import { acquireProviderQuotaLease, getOrchestrator, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
3
+ import { emitRelayEvent } from "../events";
3
4
  import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
4
5
  import { cleanString } from "../validation";
5
6
  import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
@@ -44,7 +45,9 @@ export const postProviderQuotaRoute: Handler = async (req) => {
44
45
  if (!parsed.ok) return error(parsed.error, parsed.status);
45
46
  const input = cleanProviderQuotaUpdate(parsed.body);
46
47
  if (typeof input === "string") return error(input);
47
- return json(upsertProviderQuota(input), 201);
48
+ const record = upsertProviderQuota(input);
49
+ emitRelayEvent({ type: "provider-quota.updated", source: "server", subject: `${record.provider}:${record.accountKey}`, data: {} });
50
+ return json(record, 201);
48
51
  };
49
52
 
50
53
  function cleanLeaseBody(body: unknown): {
@@ -0,0 +1,15 @@
1
+ import { getTeamsConfig, setTeamsConfig } from "../teams-config-store";
2
+ import { cleanString } from "../validation";
3
+ import { error, json, parseBody, type Handler } from "./_shared";
4
+ import { isRecord } from "agent-relay-sdk";
5
+
6
+ export const getTeamsConfigRoute: Handler = () => {
7
+ return json(getTeamsConfig());
8
+ };
9
+
10
+ export const putTeamsConfigRoute: Handler = async (req) => {
11
+ const parsed = await parseBody<unknown>(req);
12
+ if (!parsed.ok) return error(parsed.error, parsed.status);
13
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
14
+ return json(setTeamsConfig(parsed.body, { updatedBy }));
15
+ };
@@ -10,7 +10,7 @@ import { createCommand } from "../commands-db";
10
10
  import { LAND_STRATEGIES, isOwnerAlive, withOwnerOnline } from "../workspace-merge";
11
11
  import { isPathWithinBase } from "../utils";
12
12
  import { resolve } from "node:path";
13
- import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
13
+ import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecoveryBranch, type WorkspaceRecord, type WorkspaceStatus } from "../types";
14
14
  import { AUTO_MERGE_POLICIES } from "../workspace-merge";
15
15
  import { workspaceActiveClaim } from "../workspace-claim";
16
16
  import { relayToken } from "../config";
@@ -82,6 +82,149 @@ export const getWorkspaceOrphans: Handler = async () => {
82
82
  return json(reason ? { orphans, missingWorktrees, reason } : { orphans, missingWorktrees });
83
83
  };
84
84
 
85
+ function workspaceCleanupResult(workspace: WorkspaceRecord): Record<string, unknown> | undefined {
86
+ const result = workspace.metadata.cleanupResult;
87
+ return isRecord(result) ? result : undefined;
88
+ }
89
+
90
+ function workspaceBranchKey(repoRoot: string, branch: string): string {
91
+ return `${repoRoot}\0${branch}`;
92
+ }
93
+
94
+ function latestWorkspaceByBranch(workspaces: WorkspaceRecord[]): Map<string, WorkspaceRecord> {
95
+ const byBranch = new Map<string, WorkspaceRecord>();
96
+ for (const workspace of [...workspaces].sort((a, b) => b.updatedAt - a.updatedAt)) {
97
+ if (!workspace.branch) continue;
98
+ const key = workspaceBranchKey(workspace.repoRoot, workspace.branch);
99
+ if (byBranch.has(key)) continue;
100
+ byBranch.set(key, workspace);
101
+ }
102
+ return byBranch;
103
+ }
104
+
105
+ function owningOrchestratorForRepo(repoRoot: string) {
106
+ return listOrchestrators().find((candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
107
+ }
108
+
109
+ async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?: string; baseSha?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
110
+ const orch = owningOrchestratorForRepo(repoRoot);
111
+ if (!orch?.apiUrl) return { branches: [], unavailable: "owning orchestrator offline" };
112
+ const query = new URLSearchParams({ repoRoot });
113
+ if (opts.baseRef) query.set("baseRef", opts.baseRef);
114
+ if (opts.baseSha) query.set("baseSha", opts.baseSha);
115
+ const headers: Record<string, string> = {};
116
+ const token = relayToken();
117
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
118
+ try {
119
+ const res = await fetch(`${orch.apiUrl}/api/workspace/recovery-branches?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
120
+ if (!res.ok) return { branches: [], unavailable: `host returned ${res.status}` };
121
+ const payload = await res.json() as unknown;
122
+ if (!isRecord(payload) || !Array.isArray(payload.branches)) return { branches: [], unavailable: "host returned malformed recovery branch list" };
123
+ return { branches: payload.branches.filter(isRecord) as unknown as WorkspaceRecoveryBranch[] };
124
+ } catch (e) {
125
+ return { branches: [], unavailable: `orchestrator unreachable: ${(e as Error).message}` };
126
+ }
127
+ }
128
+
129
+ export const getWorkspaceRecoveryBranches: Handler = async (req) => {
130
+ try {
131
+ const url = new URL(req.url);
132
+ const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
133
+ const baseRef = cleanString(url.searchParams.get("baseRef") ?? undefined, "baseRef", { max: 240 });
134
+ const baseSha = cleanString(url.searchParams.get("baseSha") ?? undefined, "baseSha", { max: 80 });
135
+ const includeActive = url.searchParams.get("includeActive") === "1";
136
+ const allWorkspaces = listWorkspaces(repoRoot ? { repoRoot } : {});
137
+ const repoRoots = repoRoot ? [repoRoot] : [...new Set(allWorkspaces.map((workspace) => workspace.repoRoot))];
138
+ const byBranch = latestWorkspaceByBranch(allWorkspaces);
139
+ const branches: WorkspaceRecoveryBranch[] = [];
140
+ const unavailable: Record<string, string> = {};
141
+
142
+ for (const repo of repoRoots) {
143
+ const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha });
144
+ if (fetched.unavailable) {
145
+ unavailable[repo] = fetched.unavailable;
146
+ continue;
147
+ }
148
+ for (const branch of fetched.branches) {
149
+ const workspace = byBranch.get(workspaceBranchKey(repo, branch.branch));
150
+ const activeWorkspace = Boolean(workspace && !TERMINAL_WORKSPACE_STATUSES.has(workspace.status));
151
+ if (activeWorkspace && !includeActive) continue;
152
+ const owner = workspace?.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
153
+ const cleanupResult = workspace ? workspaceCleanupResult(workspace) : undefined;
154
+ const cleanupPreservedReason = cleanString(cleanupResult?.branchPreservedReason, "branchPreservedReason", { max: 400 });
155
+ const cleanupCommandId = cleanString(workspace?.metadata.cleanupCommandId, "cleanupCommandId", { max: 160 });
156
+ branches.push({
157
+ ...branch,
158
+ ...(workspace ? { workspaceId: workspace.id } : {}),
159
+ ...(workspace?.ownerAgentId ? { ownerAgentId: workspace.ownerAgentId } : {}),
160
+ ...(owner?.status ? { ownerStatus: owner.status } : {}),
161
+ ...(cleanupCommandId ? { cleanupCommandId } : {}),
162
+ ...(cleanupPreservedReason ? { cleanupPreservedReason, preservedByCleanup: true } : {}),
163
+ ...(activeWorkspace ? { activeWorkspace: true } : {}),
164
+ baseRef: workspace?.baseRef ?? branch.baseRef,
165
+ baseSha: workspace?.baseSha ?? branch.baseSha,
166
+ });
167
+ }
168
+ }
169
+
170
+ branches.sort((a, b) => (b.ageMs ?? 0) - (a.ageMs ?? 0) || a.branch.localeCompare(b.branch));
171
+ return json({ repoRoot, scannedRepos: repoRoots, branches, ...(Object.keys(unavailable).length ? { unavailable } : {}) });
172
+ } catch (e) {
173
+ if (e instanceof ValidationError) return error(e.message, 400);
174
+ throw e;
175
+ }
176
+ };
177
+
178
+ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
179
+ const denied = authorizeRoute(req, { scope: "command:write" });
180
+ if (denied) return denied;
181
+ const parsed = await parseBody<unknown>(req);
182
+ if (!parsed.ok) return error(parsed.error, parsed.status);
183
+ try {
184
+ if (!isRecord(parsed.body)) return error("body required");
185
+ const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
186
+ const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
187
+ const baseRef = cleanString(parsed.body.baseRef, "baseRef", { max: 240 });
188
+ const baseSha = cleanString(parsed.body.baseSha, "baseSha", { max: 80 });
189
+ const reason = cleanString(parsed.body.reason, "reason", { max: 1000 });
190
+ const force = parsed.body.force === true;
191
+ if (!repoRoot || !branch) return error("repoRoot and branch required", 400);
192
+ if (!branch.startsWith("agent/")) return error("only agent/* branches can be discarded through recovery cleanup", 400);
193
+ if (force && !reason) return error("reason required for forced recovery branch discard", 400);
194
+ const orch = owningOrchestratorForRepo(repoRoot);
195
+ if (!orch?.agentId) return error("no online orchestrator owns this repo", 409);
196
+ const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha });
197
+ if (fetched.unavailable) return error(fetched.unavailable, 409);
198
+ const target = fetched.branches.find((item) => item.branch === branch);
199
+ if (!target) return error("recovery branch not found", 404);
200
+ if (target.safeToDelete !== true && !force) {
201
+ return error(`branch holds work or cannot be proven safe (${target.preserveReason ?? `${target.unmergedAhead ?? target.ahead ?? "?"} unlanded commit(s)`}); land it first or pass force:true with reason to discard`, 409);
202
+ }
203
+ const command = createCommand({
204
+ type: "workspace.recovery-branch-discard",
205
+ source: "system",
206
+ target: orch.agentId,
207
+ correlationId: branch,
208
+ params: { action: "recovery-branch-discard", repoRoot, branch, baseRef: baseRef ?? target.baseRef, baseSha: baseSha ?? target.baseSha, force, reason, requestedBy: "steward", requestedAt: Date.now() },
209
+ });
210
+ emitCommand(command);
211
+ auditEvent({
212
+ clientId: `workspace-recovery-branch-discard-${Date.now()}`,
213
+ kind: "state",
214
+ title: "Recovery branch discard requested",
215
+ body: force ? `${branch} — forced discard after review: ${reason}` : `${branch} — confirmed empty/superseded`,
216
+ meta: repoRoot,
217
+ icon: "ti-trash",
218
+ view: "orchestrators",
219
+ metadata: { repoRoot, branch, commandId: command.id, force, reason, safeToDelete: target.safeToDelete, preserveReason: target.preserveReason, ...authAuditMetadata(req) },
220
+ });
221
+ return json({ command, branch: target }, 202);
222
+ } catch (e) {
223
+ if (e instanceof ValidationError) return error(e.message, 400);
224
+ throw e;
225
+ }
226
+ };
227
+
85
228
  export const postWorkspaceOrphanReclaim: Handler = async (req) => {
86
229
  const denied = authorizeRoute(req, { scope: "command:write" });
87
230
  if (denied) return denied;
package/src/security.ts CHANGED
@@ -194,6 +194,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
194
194
  if (pathname.startsWith("/api/maintenance")) return "system:admin";
195
195
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
196
196
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
197
+ if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
197
198
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
198
199
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
199
200
  if (pathname.startsWith("/api/issue-associations")) return method === "GET" ? "issue:read" : "issue:write";
@@ -280,6 +281,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
280
281
  if (pathname === "/api/providers/quota-config" || pathname.match(/^\/api\/providers\/[^/]+\/quota-config$/)) {
281
282
  return method === "GET" ? "agent:read" : "settings:write:provider";
282
283
  }
284
+ if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
283
285
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
284
286
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
285
287
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
@@ -304,7 +306,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
304
306
  return "system:admin";
305
307
  }
306
308
 
307
- const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider"]);
309
+ const CONFIG_SETTINGS_NAMESPACES = new Set(["steward", "insights", "notifications", "workspace", "landing", "provider", "teams"]);
308
310
 
309
311
  function requiredSettingsScopeFor(method: string, pathname: string): string | null | undefined {
310
312
  if (pathname === "/api/settings") return method === "GET" ? "settings:read" : null;
@@ -20,11 +20,14 @@ import {
20
20
  APPROVAL_MODES,
21
21
  errMessage,
22
22
  SPAWN_PROVIDERS,
23
+ TEAM_IDLE_THRESHOLD_MINUTES_MAX,
24
+ TEAM_IDLE_THRESHOLD_MINUTES_MIN,
23
25
  VALID_EFFORTS,
24
26
  type JSONSchema,
25
27
  type SettingDescriptor,
26
28
  type SettingScope,
27
29
  } from "agent-relay-sdk";
30
+ import { DEFAULT_TEAMS_CONFIG } from "agent-relay-sdk/teams-config";
28
31
 
29
32
  /** Fail-closed default scope — a descriptor that omits scope requires admin to
30
33
  * read and write (#385 owns the scope grammar; this layer only declares it). */
@@ -215,6 +218,20 @@ const WORKSPACE_SCHEMA: JSONSchema = {
215
218
  },
216
219
  };
217
220
 
221
+ const TEAMS_SCHEMA: JSONSchema = {
222
+ type: "object",
223
+ additionalProperties: false,
224
+ required: ["enabled", "thresholdMinutes"],
225
+ properties: {
226
+ enabled: { type: "boolean" },
227
+ thresholdMinutes: {
228
+ type: "integer",
229
+ minimum: TEAM_IDLE_THRESHOLD_MINUTES_MIN,
230
+ maximum: TEAM_IDLE_THRESHOLD_MINUTES_MAX,
231
+ },
232
+ },
233
+ };
234
+
218
235
  const LANDING_SCHEMA: JSONSchema = {
219
236
  type: "object",
220
237
  additionalProperties: false,
@@ -347,6 +364,16 @@ function seedBuiltInSettings(): void {
347
364
  display: { group: "Workspace", label: "Landing strategy", description: "Default land strategy for workspace branches.", order: 41 },
348
365
  });
349
366
 
367
+ registerSetting({
368
+ namespace: "teams",
369
+ key: "default",
370
+ schema: TEAMS_SCHEMA,
371
+ defaults: DEFAULT_TEAMS_CONFIG,
372
+ scope: { read: "settings:read:teams", write: "settings:write:teams" },
373
+ tier: "server-runtime",
374
+ display: { group: "Teams", label: "Team idle watchdog", description: "Coordinator nudge when a team has no busy members.", order: 45 },
375
+ });
376
+
350
377
  registerSetting({
351
378
  namespace: "provider",
352
379
  key: "chatCaptureMode",
@@ -0,0 +1,22 @@
1
+ import { getConfig, setConfig } from "./config-store";
2
+ import { emitConfigChanged } from "./sse";
3
+ import {
4
+ DEFAULT_TEAMS_CONFIG,
5
+ normalizeTeamsConfig,
6
+ type TeamsConfig,
7
+ } from "agent-relay-sdk";
8
+
9
+ const TEAMS_CONFIG_NAMESPACE = "teams";
10
+ const TEAMS_CONFIG_KEY = "default";
11
+
12
+ export function getTeamsConfig(): TeamsConfig {
13
+ const entry = getConfig<TeamsConfig>(TEAMS_CONFIG_NAMESPACE, TEAMS_CONFIG_KEY);
14
+ return entry ? normalizeTeamsConfig(entry.value) : { ...DEFAULT_TEAMS_CONFIG };
15
+ }
16
+
17
+ export function setTeamsConfig(value: unknown, opts: { updatedBy?: string } = {}): TeamsConfig {
18
+ const normalized = normalizeTeamsConfig(value);
19
+ const entry = setConfig(TEAMS_CONFIG_NAMESPACE, TEAMS_CONFIG_KEY, normalized, opts.updatedBy);
20
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
21
+ return normalized;
22
+ }