agent-relay-server 0.62.3 → 0.63.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +4 -2
  3. package/public/assets/{MessageBubble-DRrxfjyT.js → MessageBubble-Cl6ufPHw.js} +2 -2
  4. package/public/assets/MessageBubble-Cl6ufPHw.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-Dp2eYazz.js → PlanGraphPanel-BnfjmkQ8.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-Dp2eYazz.js.map → PlanGraphPanel-BnfjmkQ8.js.map} +1 -1
  7. package/public/assets/{activity--t0U2yzq.js → activity-aFxvGLtd.js} +2 -2
  8. package/public/assets/{activity--t0U2yzq.js.map → activity-aFxvGLtd.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BMtJYzxn.js → agent-profiles-B4mleUDz.js} +2 -2
  10. package/public/assets/{agent-profiles-BMtJYzxn.js.map → agent-profiles-B4mleUDz.js.map} +1 -1
  11. package/public/assets/{agents-Ck9AePlc.js → agents-DW5djToQ.js} +2 -2
  12. package/public/assets/{agents-Ck9AePlc.js.map → agents-DW5djToQ.js.map} +1 -1
  13. package/public/assets/{analytics-CzfK387L.js → analytics-CRi6thtg.js} +2 -2
  14. package/public/assets/{analytics-CzfK387L.js.map → analytics-CRi6thtg.js.map} +1 -1
  15. package/public/assets/{automation-Cgw8vtE8.js → automation-Do4OpauI.js} +2 -2
  16. package/public/assets/{automation-Cgw8vtE8.js.map → automation-Do4OpauI.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DnEEPtOP.js → branch-state-badge-D3W3w7zz.js} +2 -2
  18. package/public/assets/{branch-state-badge-DnEEPtOP.js.map → branch-state-badge-D3W3w7zz.js.map} +1 -1
  19. package/public/assets/{channels-Dc3L2MQs.js → channels-BytdR9NS.js} +2 -2
  20. package/public/assets/{channels-Dc3L2MQs.js.map → channels-BytdR9NS.js.map} +1 -1
  21. package/public/assets/{chat-Fc6iJ7sa.js → chat-CvaQ2czL.js} +2 -2
  22. package/public/assets/{chat-Fc6iJ7sa.js.map → chat-CvaQ2czL.js.map} +1 -1
  23. package/public/assets/{connectors-Dc2ttGWs.js → connectors-BLQDp8Bc.js} +2 -2
  24. package/public/assets/{connectors-Dc2ttGWs.js.map → connectors-BLQDp8Bc.js.map} +1 -1
  25. package/public/assets/{display-DzNvOu1j.js → display-B06I5sqa.js} +3 -3
  26. package/public/assets/display-B06I5sqa.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-Bg-gx7rG.js → formatted-body-impl-0Lf8e1Ic.js} +2 -2
  28. package/public/assets/{formatted-body-impl-Bg-gx7rG.js.map → formatted-body-impl-0Lf8e1Ic.js.map} +1 -1
  29. package/public/assets/{index-BeDncjN4.js → index-C3su8ezq.js} +6 -6
  30. package/public/assets/{index-BeDncjN4.js.map → index-C3su8ezq.js.map} +1 -1
  31. package/public/assets/{insights-c08aW-7n.js → insights-64uExH1E.js} +2 -2
  32. package/public/assets/{insights-c08aW-7n.js.map → insights-64uExH1E.js.map} +1 -1
  33. package/public/assets/{integrations-BwpgcqnF.js → integrations-HyC1EaIp.js} +2 -2
  34. package/public/assets/{integrations-BwpgcqnF.js.map → integrations-HyC1EaIp.js.map} +1 -1
  35. package/public/assets/{maintenance-CTKP0kWB.js → maintenance-EVBpKH07.js} +2 -2
  36. package/public/assets/{maintenance-CTKP0kWB.js.map → maintenance-EVBpKH07.js.map} +1 -1
  37. package/public/assets/{managed-agents-BofCxo8d.js → managed-agents-BLZPxGuw.js} +2 -2
  38. package/public/assets/{managed-agents-BofCxo8d.js.map → managed-agents-BLZPxGuw.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CQ-6BIb8.js → markdown-preview-impl-Bg6rCWYN.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CQ-6BIb8.js.map → markdown-preview-impl-Bg6rCWYN.js.map} +1 -1
  41. package/public/assets/{memory-DvfXjMoi.js → memory-Bjr8bgjj.js} +2 -2
  42. package/public/assets/{memory-DvfXjMoi.js.map → memory-Bjr8bgjj.js.map} +1 -1
  43. package/public/assets/{messages-C9VsPzZ_.js → messages-CRYw1F4z.js} +2 -2
  44. package/public/assets/{messages-C9VsPzZ_.js.map → messages-CRYw1F4z.js.map} +1 -1
  45. package/public/assets/{orchestrators-MffVueeE.js → orchestrators-D-co1vdp.js} +2 -2
  46. package/public/assets/{orchestrators-MffVueeE.js.map → orchestrators-D-co1vdp.js.map} +1 -1
  47. package/public/assets/{overview-BSVajefM.js → overview-Ddy5czri.js} +2 -2
  48. package/public/assets/{overview-BSVajefM.js.map → overview-Ddy5czri.js.map} +1 -1
  49. package/public/assets/{pairs-B5711-Cs.js → pairs-CFVM8uHL.js} +2 -2
  50. package/public/assets/{pairs-B5711-Cs.js.map → pairs-CFVM8uHL.js.map} +1 -1
  51. package/public/assets/{security-DkUwK9Vj.js → security-Df4_76bh.js} +2 -2
  52. package/public/assets/{security-DkUwK9Vj.js.map → security-Df4_76bh.js.map} +1 -1
  53. package/public/assets/{select-DrM2RRiT.js → select-Cq3BBnL9.js} +2 -2
  54. package/public/assets/{select-DrM2RRiT.js.map → select-Cq3BBnL9.js.map} +1 -1
  55. package/public/assets/{settings-DdKCb-vy.js → settings-DOLEYXZL.js} +2 -2
  56. package/public/assets/{settings-DdKCb-vy.js.map → settings-DOLEYXZL.js.map} +1 -1
  57. package/public/assets/{store-iTiheuTD.js → store-C1E8jmNE.js} +4 -4
  58. package/public/assets/store-C1E8jmNE.js.map +1 -0
  59. package/public/assets/{tasks-e1y1PFfO.js → tasks-BcsmPi9_.js} +2 -2
  60. package/public/assets/{tasks-e1y1PFfO.js.map → tasks-BcsmPi9_.js.map} +1 -1
  61. package/public/assets/{teams-BUcxoGC_.js → teams-BQrV3eKI.js} +2 -2
  62. package/public/assets/{teams-BUcxoGC_.js.map → teams-BQrV3eKI.js.map} +1 -1
  63. package/public/assets/{terminal-viewer-impl-CmZjQOB2.js → terminal-viewer-impl-Dle04qpH.js} +2 -2
  64. package/public/assets/{terminal-viewer-impl-CmZjQOB2.js.map → terminal-viewer-impl-Dle04qpH.js.map} +1 -1
  65. package/public/assets/{work-queue-CXBQx43n.js → work-queue-BLhTLg4o.js} +2 -2
  66. package/public/assets/{work-queue-CXBQx43n.js.map → work-queue-BLhTLg4o.js.map} +1 -1
  67. package/public/assets/{workspaces-D2EfZh9a.js → workspaces-4NCNTmRQ.js} +3 -3
  68. package/public/assets/{workspaces-D2EfZh9a.js.map → workspaces-4NCNTmRQ.js.map} +1 -1
  69. package/public/index.html +3 -3
  70. package/runner/src/adapter.ts +2 -1
  71. package/runner/src/config.ts +143 -5
  72. package/src/agent-ref.ts +8 -6
  73. package/src/artifact-storage.ts +3 -5
  74. package/src/automations/constants.ts +12 -0
  75. package/src/automations/cron.ts +129 -0
  76. package/src/automations/crud.ts +84 -0
  77. package/src/automations/dispatch.ts +275 -0
  78. package/src/automations/index.ts +5 -0
  79. package/src/automations/reconcile.ts +245 -0
  80. package/src/automations/storage.ts +181 -0
  81. package/src/automations/types.ts +16 -0
  82. package/src/automations/validation.ts +140 -0
  83. package/src/automations.ts +1 -1045
  84. package/src/bus.ts +3 -4
  85. package/src/cli/_shared.ts +5 -4
  86. package/src/cli/agent-detect.ts +6 -4
  87. package/src/cli/introspect.ts +3 -2
  88. package/src/cli/token.ts +2 -1
  89. package/src/cli/workspace.ts +2 -1
  90. package/src/config.ts +176 -1
  91. package/src/connectors.ts +3 -3
  92. package/src/db/activity.ts +1 -1
  93. package/src/db/agent-predicates.ts +9 -0
  94. package/src/db/agent-search.ts +2 -2
  95. package/src/db/agents.ts +13 -41
  96. package/src/db/artifacts.ts +8 -8
  97. package/src/db/channel-identity.ts +38 -0
  98. package/src/db/channels.ts +37 -61
  99. package/src/db/connection.ts +2 -1
  100. package/src/db/continuation-archives.ts +7 -7
  101. package/src/db/delivery.ts +7 -12
  102. package/src/db/inbox.ts +1 -1
  103. package/src/db/integrations.ts +4 -4
  104. package/src/db/issue-lifecycle.ts +6 -6
  105. package/src/db/mappers.ts +1 -2
  106. package/src/db/merge-lease.ts +3 -3
  107. package/src/db/message-claims.ts +16 -0
  108. package/src/db/message-queries.ts +8 -0
  109. package/src/db/message-reads.ts +26 -9
  110. package/src/db/messages.ts +17 -41
  111. package/src/db/notification-subscriptions.ts +2 -2
  112. package/src/db/orchestrators.ts +4 -4
  113. package/src/db/pair-maintenance.ts +9 -0
  114. package/src/db/pairs.ts +10 -18
  115. package/src/db/plans.ts +1 -1
  116. package/src/db/policy-runtime.ts +14 -0
  117. package/src/db/project-entries.ts +1 -1
  118. package/src/db/projects.ts +2 -2
  119. package/src/db/shared.ts +7 -0
  120. package/src/db/stats.ts +2 -2
  121. package/src/db/task-core.ts +18 -0
  122. package/src/db/task-maintenance.ts +24 -0
  123. package/src/db/tasks.ts +5 -17
  124. package/src/db/teams.ts +3 -3
  125. package/src/db/workspaces.ts +6 -6
  126. package/src/index.ts +6 -5
  127. package/src/issue-tracker/github.ts +3 -3
  128. package/src/maintenance/constants.ts +52 -0
  129. package/src/maintenance/events.ts +32 -0
  130. package/src/maintenance/index.ts +7 -0
  131. package/src/maintenance/jobs.ts +340 -0
  132. package/src/maintenance/orphaned-sessions.ts +86 -0
  133. package/src/maintenance/scheduler.ts +231 -0
  134. package/src/maintenance/teams.ts +65 -0
  135. package/src/maintenance/types.ts +31 -0
  136. package/src/maintenance/workspaces.ts +251 -0
  137. package/src/maintenance.ts +1 -1099
  138. package/src/mcp/identity.ts +131 -0
  139. package/src/mcp/index.ts +208 -0
  140. package/src/mcp/tools-messaging.ts +459 -0
  141. package/src/mcp/tools-spawn.ts +145 -0
  142. package/src/mcp/tools-workspace.ts +227 -0
  143. package/src/mcp/types.ts +24 -0
  144. package/src/mcp/validation.ts +104 -0
  145. package/src/mcp.ts +1 -1319
  146. package/src/notification-router.ts +2 -2
  147. package/src/notification-rules.ts +2 -2
  148. package/src/project-ingest.ts +3 -3
  149. package/src/project-instructions.ts +1 -1
  150. package/src/provider-config-store.ts +5 -4
  151. package/src/provider-state.ts +2 -4
  152. package/src/reviewer-pipeline.ts +1 -1
  153. package/src/routes/agent-sessions.ts +5 -4
  154. package/src/routes/orchestrator-proxy.ts +5 -4
  155. package/src/routes/workspaces.ts +5 -4
  156. package/src/security.ts +13 -7
  157. package/src/self-resume.ts +3 -7
  158. package/src/services/agent-action.ts +2 -2
  159. package/src/services/issue-activity.ts +0 -4
  160. package/src/services/issue-associations.ts +2 -2
  161. package/src/services/issue-lifecycle.ts +8 -11
  162. package/src/services/managed-running.ts +1 -1
  163. package/src/services/parity-harness.ts +3 -3
  164. package/src/services/plan-graph.ts +4 -7
  165. package/src/services/send-message.ts +16 -12
  166. package/src/services/shutdown-agent.ts +2 -2
  167. package/src/services/team-templates.ts +2 -2
  168. package/src/services/team.ts +2 -2
  169. package/src/settings/modules.ts +1 -1
  170. package/src/settings/registry.ts +3 -3
  171. package/src/spawn-command.ts +1 -1
  172. package/src/sse.ts +0 -4
  173. package/src/upgrade.ts +7 -9
  174. package/src/workspace-actions.ts +1 -1
  175. package/src/workspace-auto-merge.ts +4 -5
  176. package/src/workspace-claim.ts +1 -2
  177. package/src/workspace-orphans.ts +2 -7
  178. package/src/workspace-probe.ts +5 -8
  179. package/public/assets/MessageBubble-DRrxfjyT.js.map +0 -1
  180. package/public/assets/display-DzNvOu1j.js.map +0 -1
  181. package/public/assets/store-iTiheuTD.js.map +0 -1
@@ -20,7 +20,11 @@ import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from
20
20
  import { ClaimError, ValidationError, getDb } from "./connection.ts";
21
21
  import { enforceQueueLimit, isChannelAgentId, matchingDeliveryAgents } from "./delivery.ts";
22
22
  import { MSG_SELECT, normalizeReactionEmoji, rowToMessage } from "./mappers.ts";
23
- import { getTask, insertTaskEvent, releaseExpiredClaims } from "./tasks.ts";
23
+ import { claimMessageRow } from "./message-claims.ts";
24
+ import { getMessage } from "./message-queries.ts";
25
+ import { runningAgentForPolicy } from "./policy-runtime.ts";
26
+ import { getTask, releaseExpiredClaims } from "./tasks.ts";
27
+ import { insertTaskEvent } from "./task-core.ts";
24
28
  import type {
25
29
  AgentCard,
26
30
  ActivityEvent,
@@ -84,6 +88,10 @@ import type {
84
88
  WorkspaceStatus,
85
89
  } from "../types";
86
90
 
91
+ export { claimMessageRow } from "./message-claims.ts";
92
+ export { getMessage } from "./message-queries.ts";
93
+ export { runningAgentForPolicy } from "./policy-runtime.ts";
94
+
87
95
  export function findMessageByIdempotencyKey(from: string, key: string): Message | null {
88
96
  const row = getDb()
89
97
  .query(`${MSG_SELECT} WHERE m.from_agent = ? AND m.idempotency_key = ? LIMIT 1`)
@@ -119,51 +127,38 @@ function upsertSessionStepInPlace(existing: Message, input: SendMessageInput, no
119
127
  return getMessage(existing.id);
120
128
  }
121
129
 
122
- export function policyNameFromTarget(target: string): string | null {
130
+ function policyNameFromTarget(target: string): string | null {
123
131
  if (!target.startsWith("policy:")) return null;
124
132
  const name = target.slice("policy:".length).trim();
125
133
  return name || null;
126
134
  }
127
135
 
128
- export function spawnPolicyExists(policyName: string): boolean {
136
+ function spawnPolicyExists(policyName: string): boolean {
129
137
  const row = getDb().query("SELECT 1 FROM config WHERE namespace = 'spawn-policy' AND key = ?").get(policyName);
130
138
  return Boolean(row);
131
139
  }
132
140
 
133
- export function runningAgentForPolicy(policyName: string): string | null {
134
- const row = getDb().query(`
135
- SELECT agent_id
136
- FROM managed_agent_state
137
- WHERE policy_name = ? AND status = 'running' AND agent_id IS NOT NULL
138
- `).get(policyName) as { agent_id?: string } | undefined;
139
- if (!row?.agent_id) return null;
140
- const agent = getAgent(row.agent_id);
141
- if (!agent || agent.status === "offline") return null;
142
- return row.agent_id;
143
- }
144
-
145
-
146
- export function claimableAllowedForTarget(target: string): boolean {
141
+ function claimableAllowedForTarget(target: string): boolean {
147
142
  return matchingDeliveryAgents(target).length > 1;
148
143
  }
149
144
 
150
- export function shouldStoreClaimable(input: SendMessageInput): boolean {
145
+ function shouldStoreClaimable(input: SendMessageInput): boolean {
151
146
  if (!input.claimable) return false;
152
147
  if (input.kind === "task" || input.kind === "system") return true;
153
148
  if (input.kind === "channel.event") return true;
154
149
  return claimableAllowedForTarget(input.to);
155
150
  }
156
151
 
157
- export function inferMessageKind(input: SendMessageInput): Message["kind"] {
152
+ function inferMessageKind(input: SendMessageInput): Message["kind"] {
158
153
  if (input.kind) return input.kind;
159
154
  if (input.claimable) return "task";
160
155
  if (isChannelAgentId(input.from) || isChannelAgentId(input.to)) return "channel.event";
161
156
  return "chat";
162
157
  }
163
158
 
164
- export const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
159
+ const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
165
160
 
166
- export function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
161
+ function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
167
162
  if (!input.replyTo || threadId === null || hasAttachments) return null;
168
163
  const row = getDb().query(`
169
164
  ${MSG_SELECT}
@@ -192,7 +187,7 @@ export function findRecentDuplicateReply(input: SendMessageInput, threadId: numb
192
187
  // receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
193
188
  // readers fall back to created_at) for absent/invalid values or anything more than a minute
194
189
  // in the future (clock-skew guard). Only a real backfilled time is stored.
195
- export function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
190
+ function sanitizeOccurredAt(occurredAt: number | undefined, receivedAt: number): number | null {
196
191
  if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return null;
197
192
  if (occurredAt <= 0 || occurredAt > receivedAt + 60_000) return null;
198
193
  return Math.floor(occurredAt);
@@ -369,20 +364,6 @@ export function findMessageByTelegramSource(input: {
369
364
  return row ? rowToMessage(row) : null;
370
365
  }
371
366
 
372
- export function claimMessageRow(messageId: number, agentId: string, now: number): { ok: false; error: string } | { ok: true } {
373
- const expiresAt = now + CLAIM_LEASE_MS;
374
- // Idempotent: a same-agent re-claim refreshes the lease (preserving the original claimed_at).
375
- // Without this, a delivery failure that locally drops the claim strands the message until the
376
- // lease expires (#261) — the holder's retry would otherwise 409 against its own claim.
377
- const result = getDb().query(
378
- "UPDATE messages SET claimed_by = ?, claimed_at = COALESCE(claimed_at, ?), claim_expires_at = ? WHERE id = ? AND (claimed_by IS NULL OR claimed_by = ?)"
379
- ).run(agentId, now, expiresAt, messageId, agentId);
380
-
381
- // Atomic: if changes === 0, someone else claimed it between our read and write
382
- if (result.changes === 0) return { ok: false, error: "claim race — already claimed" };
383
- return { ok: true };
384
- }
385
-
386
367
  export function claimMessage(messageId: number, agentId: string, guard?: AgentSessionGuard): { ok: boolean; error?: string; task?: Task } {
387
368
  releaseExpiredClaims();
388
369
  const session = validateAgentSession(agentId, guard);
@@ -475,8 +456,3 @@ export function renewMessageClaim(messageId: number, agentId: string, guard?: Ag
475
456
  })();
476
457
  return { ok: true, task };
477
458
  }
478
-
479
- export function getMessage(id: number): Message | null {
480
- const row = getDb().query(`${MSG_SELECT} WHERE m.id = ?`).get(id) as any;
481
- return row ? rowToMessage(row) : null;
482
- }
@@ -9,10 +9,10 @@ import { getDb, ValidationError } from "./connection";
9
9
  export const NOTIFICATION_SUBSCRIPTION_TYPES = ["*", ...Object.keys(DEFAULT_TTL_MS)] as Array<NotificationType | "*">;
10
10
  export const NOTIFICATION_SUBSCRIPTION_MODES = ["opt_in", "opt_out", "mute"] as const;
11
11
 
12
- export type NotificationSubscriptionType = NotificationType | "*";
12
+ type NotificationSubscriptionType = NotificationType | "*";
13
13
  export type NotificationSubscriptionMode = (typeof NOTIFICATION_SUBSCRIPTION_MODES)[number];
14
14
 
15
- export interface NotificationSubscription {
15
+ interface NotificationSubscription {
16
16
  id: string;
17
17
  agentId: string;
18
18
  type: NotificationSubscriptionType;
@@ -82,7 +82,7 @@ import type {
82
82
 
83
83
  // --- Orchestrators ---
84
84
 
85
- export function rowToOrchestrator(row: any): Orchestrator {
85
+ function rowToOrchestrator(row: any): Orchestrator {
86
86
  const meta = parseJson<Record<string, unknown>>(row.meta, {});
87
87
  const runtimePackage = parseRuntimePackage(meta.package);
88
88
  const version = stringValue(meta.version);
@@ -133,7 +133,7 @@ export function rowToOrchestrator(row: any): Orchestrator {
133
133
  };
134
134
  }
135
135
 
136
- export function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | undefined {
136
+ function parseOrchestratorUpgrade(value: unknown): OrchestratorUpgradeState | undefined {
137
137
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
138
138
  const v = value as Record<string, unknown>;
139
139
  const desiredVersion = stringValue(v.desiredVersion);
@@ -167,7 +167,7 @@ export function setOrchestratorUpgradeState(id: string, state: OrchestratorUpgra
167
167
  }
168
168
 
169
169
 
170
- export function orchestratorHealth(version: string | undefined, compatibility: ReturnType<typeof contractCompatibility>): OrchestratorHealth {
170
+ function orchestratorHealth(version: string | undefined, compatibility: ReturnType<typeof contractCompatibility>): OrchestratorHealth {
171
171
  const issues: OrchestratorHealth["issues"] = [];
172
172
  if (!version) {
173
173
  issues.push({ code: "missing-version", detail: "Orchestrator did not report a package version." });
@@ -204,7 +204,7 @@ export function orchestratorHealth(version: string | undefined, compatibility: R
204
204
  };
205
205
  }
206
206
 
207
- export function mergeOrchestratorRuntimeMeta(meta: Record<string, unknown>, input: OrchestratorRuntimeInput): Record<string, unknown> {
207
+ function mergeOrchestratorRuntimeMeta(meta: Record<string, unknown>, input: OrchestratorRuntimeInput): Record<string, unknown> {
208
208
  const contracts = parseRuntimeContracts(input.contracts, input.protocolVersion !== undefined ? { orchestratorProtocol: input.protocolVersion } : {});
209
209
  const capabilities = parseRuntimeCapabilities(input.capabilities);
210
210
  return {
@@ -0,0 +1,9 @@
1
+ import { getDb } from "./connection.ts";
2
+
3
+ export function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
4
+ getDb().query(`
5
+ UPDATE pairs
6
+ SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
7
+ WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
8
+ `).run(now, agentId, now, agentId, agentId);
9
+ }
package/src/db/pairs.ts CHANGED
@@ -18,6 +18,7 @@ import { getAgent, listAgents } from "./agents.ts";
18
18
  import { getDb } from "./connection.ts";
19
19
  import { rowToPair } from "./mappers.ts";
20
20
  import { sendMessage } from "./messages.ts";
21
+ export { closeOpenPairsForAgent } from "./pair-maintenance.ts";
21
22
  import type {
22
23
  AgentCard,
23
24
  ActivityEvent,
@@ -81,24 +82,16 @@ import type {
81
82
  WorkspaceStatus,
82
83
  } from "../types";
83
84
 
84
- export const OPEN_PAIR_STATUSES = ["pending", "active"] as const;
85
- export const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
86
- export const MAX_PAIR_TTL_MS = DAY_MS;
85
+ const OPEN_PAIR_STATUSES = ["pending", "active"] as const;
86
+ const DEFAULT_PAIR_TTL_MS = 5 * 60_000;
87
+ const MAX_PAIR_TTL_MS = DAY_MS;
87
88
 
88
- export function expirePendingPairs(now: number = Date.now()): void {
89
+ function expirePendingPairs(now: number = Date.now()): void {
89
90
  getDb().query("UPDATE pairs SET status = 'expired', updated_at = ?, ended_at = ? WHERE status = 'pending' AND expires_at <= ?")
90
91
  .run(now, now, now);
91
92
  }
92
93
 
93
- export function closeOpenPairsForAgent(agentId: string, now: number = Date.now()): void {
94
- getDb().query(`
95
- UPDATE pairs
96
- SET status = 'ended', ended_at = ?, ended_by = ?, updated_at = ?
97
- WHERE status IN ('pending', 'active') AND (requester_id = ? OR target_id = ?)
98
- `).run(now, agentId, now, agentId, agentId);
99
- }
100
-
101
- export function getOpenPairForAgent(agentId: string): PairSession | null {
94
+ function getOpenPairForAgent(agentId: string): PairSession | null {
102
95
  expirePendingPairs();
103
96
  const row = getDb().query(`
104
97
  SELECT * FROM pairs
@@ -109,15 +102,15 @@ export function getOpenPairForAgent(agentId: string): PairSession | null {
109
102
  return row ? rowToPair(row) : null;
110
103
  }
111
104
 
112
- export function pairParticipant(pair: PairSession, agentId: string): boolean {
105
+ function pairParticipant(pair: PairSession, agentId: string): boolean {
113
106
  return pair.requesterId === agentId || pair.targetId === agentId;
114
107
  }
115
108
 
116
- export function pairPeer(pair: PairSession, agentId: string): string {
109
+ function pairPeer(pair: PairSession, agentId: string): string {
117
110
  return pair.requesterId === agentId ? pair.targetId : pair.requesterId;
118
111
  }
119
112
 
120
- export function resolvePairTarget(target: string, requesterId: string): {
113
+ function resolvePairTarget(target: string, requesterId: string): {
121
114
  ok: true;
122
115
  agent: AgentCard;
123
116
  } | {
@@ -153,7 +146,7 @@ export function resolvePairTarget(target: string, requesterId: string): {
153
146
  return { ok: true, agent: available[0]! };
154
147
  }
155
148
 
156
- export function pairSystemMessage(pair: PairSession, to: string, event: string, subject: string, body: string): Message {
149
+ function pairSystemMessage(pair: PairSession, to: string, event: string, subject: string, body: string): Message {
157
150
  return sendMessage({
158
151
  from: "system",
159
152
  to,
@@ -321,4 +314,3 @@ export function sendPairMessage(id: string, input: PairMessageInput): { ok: true
321
314
  getDb().query("UPDATE pairs SET last_message_at = ?, updated_at = ? WHERE id = ?").run(now, now, id);
322
315
  return { ok: true, pair: getPair(id)!, message };
323
316
  }
324
-
package/src/db/plans.ts CHANGED
@@ -30,7 +30,7 @@ interface PlanVersionRow {
30
30
  changed_at: number;
31
31
  }
32
32
 
33
- export interface PlanVersionEntry {
33
+ interface PlanVersionEntry {
34
34
  id: number;
35
35
  planId: string;
36
36
  version: number;
@@ -0,0 +1,14 @@
1
+ import { getDb } from "./connection.ts";
2
+
3
+ export function runningAgentForPolicy(policyName: string): string | null {
4
+ const row = getDb().query(`
5
+ SELECT s.agent_id
6
+ FROM managed_agent_state s
7
+ JOIN agents a ON a.id = s.agent_id
8
+ WHERE s.policy_name = ?
9
+ AND s.status = 'running'
10
+ AND s.agent_id IS NOT NULL
11
+ AND a.status != 'offline'
12
+ `).get(policyName) as { agent_id?: string } | undefined;
13
+ return row?.agent_id ?? null;
14
+ }
@@ -203,7 +203,7 @@ const BLACKBOARD_TO_PROJECT_KIND: Record<BlackboardEntryKind, ProjectEntryKind>
203
203
  ruled_out: "rule",
204
204
  };
205
205
 
206
- export interface PromoteBlackboardEntryInput {
206
+ interface PromoteBlackboardEntryInput {
207
207
  blackboardEntryId: string;
208
208
  projectId: string;
209
209
  }
@@ -76,7 +76,7 @@ export function rootsForProject(id: string): ProjectRoot[] {
76
76
  return rows.map(rowToRoot);
77
77
  }
78
78
 
79
- export interface EnsureProjectInput {
79
+ interface EnsureProjectInput {
80
80
  slug: string;
81
81
  title?: string;
82
82
  description?: string;
@@ -265,7 +265,7 @@ export function dependencies(projectId: string): string[] {
265
265
  return rows.map((row) => row.to_project);
266
266
  }
267
267
 
268
- export interface ResolveProjectEntriesOptions {
268
+ interface ResolveProjectEntriesOptions {
269
269
  kind?: ProjectEntryKind;
270
270
  includeInactive?: boolean;
271
271
  /** When true (default), merge in entries from transitive `contains` ancestors,
@@ -0,0 +1,7 @@
1
+ import { parseJson } from "../utils";
2
+
3
+ export function parseStringArray(raw: string): string[] {
4
+ const parsed = parseJson<unknown>(raw, []);
5
+ if (!Array.isArray(parsed)) return [];
6
+ return parsed.filter((value): value is string => typeof value === "string");
7
+ }
package/src/db/stats.ts CHANGED
@@ -147,7 +147,7 @@ export type AnalyticsPeriod = keyof typeof ANALYTICS_PERIODS;
147
147
  // Conversation categories. Order is significant: a claimable/channel/pair message
148
148
  // is classified as such even when it is also a reply; only an otherwise-plain reply
149
149
  // counts as "Replies". Session/control/system rows are EXCLUDED (they're streams 2/mechanical).
150
- export const CONVERSATION_CATEGORIES = ["Chat", "Replies", "Channel", "Pair", "Work items"] as const;
150
+ const CONVERSATION_CATEGORIES = ["Chat", "Replies", "Channel", "Pair", "Work items"] as const;
151
151
  export type ConversationCategory = (typeof CONVERSATION_CATEGORIES)[number];
152
152
  const CONVERSATION_CATEGORY_SQL = `
153
153
  CASE
@@ -162,7 +162,7 @@ const CONVERSATION_FILTER = `kind NOT IN ('session','control','system') AND from
162
162
 
163
163
  // Session-mirror event sub-types we surface as the "agent work" composition. (prompt
164
164
  // is counted in the user-interaction stream; notice is mechanical chatter.)
165
- export const WORK_TYPES = ["tool", "reasoning", "narration", "response"] as const;
165
+ const WORK_TYPES = ["tool", "reasoning", "narration", "response"] as const;
166
166
  export type WorkType = (typeof WORK_TYPES)[number];
167
167
 
168
168
  export interface AnalyticsData {
@@ -0,0 +1,18 @@
1
+ import { getDb } from "./connection.ts";
2
+ import { rowToTaskEvent } from "./mappers.ts";
3
+ import type { TaskEvent } from "../types";
4
+
5
+ export const TASK_SELECT = "SELECT * FROM tasks";
6
+
7
+ export function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" | "taskId" | "createdAt">>, now: number): TaskEvent {
8
+ const result = getDb().query(`
9
+ INSERT INTO task_events (task_id, source, type, severity, title, body, metadata, created_at)
10
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
11
+ `).run(taskId, event.source, event.type, event.severity, event.title, event.body, JSON.stringify(event.metadata), now);
12
+ return getTaskEvent(Number(result.lastInsertRowid))!;
13
+ }
14
+
15
+ function getTaskEvent(id: number): TaskEvent | null {
16
+ const row = getDb().query("SELECT * FROM task_events WHERE id = ?").get(id) as any;
17
+ return row ? rowToTaskEvent(row) : null;
18
+ }
@@ -0,0 +1,24 @@
1
+ import { getDb } from "./connection.ts";
2
+ import { TASK_SELECT, insertTaskEvent } from "./task-core.ts";
3
+
4
+ // On-demand automation tasks (targetMode=on_demand_agent) are bound to a single
5
+ // ephemeral agent spawned just for that task. When that agent's claim is released,
6
+ // the task must not return to a claimable pool that can never match it again.
7
+ export function settleSingleTargetOnDemandTasks(condition: string, params: any[], now: number, reason: string): void {
8
+ const selectSql = `${TASK_SELECT} WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`;
9
+ const rows = getDb().query(selectSql).all(...params) as any[];
10
+ if (rows.length === 0) return;
11
+ getDb().query(
12
+ `UPDATE tasks SET status = 'done', claim_expires_at = NULL, updated_at = ?, last_seen_at = ? WHERE (${condition}) AND json_extract(metadata, '$.targetMode') = 'on_demand_agent' AND status IN ('claimed', 'in_progress', 'blocked', 'orphaned')`
13
+ ).run(now, now, ...params);
14
+ for (const row of rows) {
15
+ insertTaskEvent(row.id, {
16
+ source: "agent-relay",
17
+ type: "task.auto-completed",
18
+ severity: row.severity,
19
+ title: "On-demand task auto-resolved",
20
+ body: `On-demand agent ${row.claimed_by ?? "(unknown)"} exited (${reason}); task resolved so it does not orphan`,
21
+ metadata: { agentId: row.claimed_by, reason, completedBy: "relay" },
22
+ }, now);
23
+ }
24
+ }
package/src/db/tasks.ts CHANGED
@@ -14,11 +14,13 @@ import {
14
14
  } from "../contracts";
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
- import { getAgent, settleSingleTargetOnDemandTasks, validateAgentSession } from "./agents.ts";
17
+ import { getAgent, validateAgentSession } from "./agents.ts";
18
18
  import { messageTaskClosed } from "./claim-sql.ts";
19
19
  import { ClaimError, getDb } from "./connection.ts";
20
20
  import { rowToTask, rowToTaskEvent } from "./mappers.ts";
21
- import { claimMessageRow } from "./messages.ts";
21
+ import { claimMessageRow } from "./message-claims.ts";
22
+ import { TASK_SELECT, insertTaskEvent } from "./task-core.ts";
23
+ import { settleSingleTargetOnDemandTasks } from "./task-maintenance.ts";
22
24
  import type {
23
25
  AgentCard,
24
26
  ActivityEvent,
@@ -82,7 +84,7 @@ import type {
82
84
  WorkspaceStatus,
83
85
  } from "../types";
84
86
 
85
- export const TASK_SELECT = "SELECT * FROM tasks";
87
+ export { TASK_SELECT, insertTaskEvent } from "./task-core.ts";
86
88
 
87
89
  export function findOpenTaskByDedupe(source: string, dedupeKey: string): Task | null {
88
90
  const row = getDb()
@@ -91,19 +93,6 @@ export function findOpenTaskByDedupe(source: string, dedupeKey: string): Task |
91
93
  return row ? rowToTask(row) : null;
92
94
  }
93
95
 
94
- export function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" | "taskId" | "createdAt">>, now: number): TaskEvent {
95
- const result = getDb().query(`
96
- INSERT INTO task_events (task_id, source, type, severity, title, body, metadata, created_at)
97
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
98
- `).run(taskId, event.source, event.type, event.severity, event.title, event.body, JSON.stringify(event.metadata), now);
99
- return getTaskEvent(Number(result.lastInsertRowid))!;
100
- }
101
-
102
- export function getTaskEvent(id: number): TaskEvent | null {
103
- const row = getDb().query("SELECT * FROM task_events WHERE id = ?").get(id) as any;
104
- return row ? rowToTaskEvent(row) : null;
105
- }
106
-
107
96
  export function taskMessageBody(task: Pick<Task, "id" | "source" | "severity" | "body" | "externalUrl" | "dedupeKey">): string {
108
97
  const lines = [
109
98
  `[task:${task.id}] ${task.severity.toUpperCase()} from ${task.source}`,
@@ -405,4 +394,3 @@ export function listCallbackDeliveries(taskId: number): Array<{
405
394
  }
406
395
 
407
396
  // --- Pair sessions ---
408
-
package/src/db/teams.ts CHANGED
@@ -26,7 +26,7 @@ interface TeamRow {
26
26
  dissolved_at: number | null;
27
27
  }
28
28
 
29
- export interface TeamDissolveInput {
29
+ interface TeamDissolveInput {
30
30
  outcomeStatus?: Team["outcomeStatus"];
31
31
  outcomeSummary?: string;
32
32
  landsClean?: boolean;
@@ -53,7 +53,7 @@ export interface TeamOutcomeCompositionAggregate {
53
53
  avgWallClockMs: number | null;
54
54
  }
55
55
 
56
- export interface TeamOutcomeRecent {
56
+ interface TeamOutcomeRecent {
57
57
  id: string;
58
58
  title?: string;
59
59
  outcome: Team["outcomeStatus"] | null;
@@ -274,7 +274,7 @@ export function getTeamOutcomeAggregates(input: { recentLimit?: number } = {}):
274
274
  };
275
275
  }
276
276
 
277
- export interface TeamDissolveResult {
277
+ interface TeamDissolveResult {
278
278
  team: Team;
279
279
  /** Active blackboard findings not yet lifted into a project — surfaced (not auto-promoted)
280
280
  * so the operator/agent can decide what graduates to durable project knowledge (#404). */
@@ -78,7 +78,7 @@ import type {
78
78
  WorkspaceStatus,
79
79
  } from "../types";
80
80
 
81
- export function rowToWorkspace(row: any): WorkspaceRecord {
81
+ function rowToWorkspace(row: any): WorkspaceRecord {
82
82
  return {
83
83
  id: row.id,
84
84
  repoRoot: row.repo_root,
@@ -266,7 +266,7 @@ export function pruneOrphanedSharedWorkspaces(): string[] {
266
266
  // owner's branch-state badge" (#236). A hook, not a direct emit, because getDb().ts is
267
267
  // the lowest layer — sse.ts imports db, so db can't import sse without a cycle. sse
268
268
  // registers a listener at startup that re-emits the owner agent's status over SSE.
269
- export type WorkspaceChangeListener = (workspace: WorkspaceRecord) => void;
269
+ type WorkspaceChangeListener = (workspace: WorkspaceRecord) => void;
270
270
  export const workspaceChangeListeners = new Set<WorkspaceChangeListener>();
271
271
  export function onWorkspaceChange(listener: WorkspaceChangeListener): void {
272
272
  workspaceChangeListeners.add(listener);
@@ -322,9 +322,9 @@ export function setWorkspaceBranch(id: string, branch: string, baseSha?: string)
322
322
 
323
323
  // Workspace statuses that count as "live" for stewardship — an agent owning one
324
324
  // of these is a candidate steward; the repo is worth coordinating.
325
- export const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
325
+ const STEWARD_LIVE_STATUSES = "'active', 'ready', 'conflict', 'review_requested', 'merge_planned'";
326
326
 
327
- export interface RepoStewardRecord {
327
+ interface RepoStewardRecord {
328
328
  repoRoot: string;
329
329
  stewardAgentId?: string;
330
330
  lastStewardAgentId?: string;
@@ -332,7 +332,7 @@ export interface RepoStewardRecord {
332
332
  updatedAt: number;
333
333
  }
334
334
 
335
- export function rowToRepoSteward(row: any): RepoStewardRecord {
335
+ function rowToRepoSteward(row: any): RepoStewardRecord {
336
336
  return {
337
337
  repoRoot: row.repo_root,
338
338
  stewardAgentId: row.steward_agent_id ?? undefined,
@@ -354,7 +354,7 @@ export function listRepoStewards(): RepoStewardRecord[] {
354
354
  // Persist the elected steward for a repo. The row is never deleted, so a repo's
355
355
  // stewardship survives a full all-agents-offline gap (steward goes NULL/dormant,
356
356
  // last_steward_agent_id keeps continuity) and resumes on the next agent join.
357
- export function upsertRepoSteward(repoRoot: string, steward: string | null, now: number): void {
357
+ function upsertRepoSteward(repoRoot: string, steward: string | null, now: number): void {
358
358
  getDb().query(`
359
359
  INSERT INTO repo_stewards (repo_root, steward_agent_id, last_steward_agent_id, elected_at, updated_at)
360
360
  VALUES ($repoRoot, $steward, $steward, $electedAt, $now)
package/src/index.ts CHANGED
@@ -15,7 +15,11 @@ import { startPlanLiveBindings } from "./services/plan-live-bindings";
15
15
  import { resolve, sep } from "path";
16
16
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
17
17
  import {
18
+ AUTOMATION_INTERVAL_MS,
19
+ IDLE_TIMEOUT_SECONDS,
20
+ LOG_REQUESTS,
18
21
  VERSION,
22
+ relayToken,
19
23
  } from "./config";
20
24
  import { CONTRACT_VERSIONS } from "./contracts";
21
25
  import {
@@ -69,9 +73,6 @@ function startServer(): void {
69
73
  const PORT = Number(process.env.PORT) || 4850;
70
74
  const HOST = process.env.HOST || "127.0.0.1";
71
75
  const DB_PATH = process.env.DB_PATH || "agent-relay.db";
72
- const AUTOMATION_INTERVAL_MS = Number(process.env.AGENT_RELAY_AUTOMATION_INTERVAL_MS) || 30 * 1000;
73
- const IDLE_TIMEOUT_SECONDS = Number(process.env.AGENT_RELAY_IDLE_TIMEOUT_SECONDS) || 255;
74
- const LOG_REQUESTS = process.env.AGENT_RELAY_LOG_REQUESTS === "1";
75
76
 
76
77
  assertSafeNetworkConfig(HOST);
77
78
  initDb(DB_PATH);
@@ -246,9 +247,9 @@ export function createFetchHandler(
246
247
  upstream.protocol = upstream.protocol === "https:" ? "wss:" : "ws:";
247
248
  // Pass the relay token as a header (not a query param) so it never lands in
248
249
  // access/proxy logs (#149). The orchestrator accepts both, but headers are safer.
249
- const relayToken = process.env.AGENT_RELAY_TOKEN;
250
+ const token = relayToken();
250
251
  const upgraded = server.upgrade(req, {
251
- data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: relayToken, pending: [] },
252
+ data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, pending: [] },
252
253
  });
253
254
  if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
254
255
  return undefined;
@@ -2,7 +2,7 @@ import type { IssueRef, IssueRefStatus } from "../types";
2
2
  import type { IssueTrackerAdapter, IssueTrackerCloseOptions, IssueTrackerReopenOptions, IssueTrackerStatus } from "./adapter";
3
3
 
4
4
  export const GITHUB_FIXED_LABEL = "fixed";
5
- export const GITHUB_CREDENTIAL_REQUIREMENT = "GitHub issue tracker adapter requires gh authenticated with a token that has issues:write scope";
5
+ const GITHUB_CREDENTIAL_REQUIREMENT = "GitHub issue tracker adapter requires gh authenticated with a token that has issues:write scope";
6
6
 
7
7
  type GitHubApiMethod = "GET" | "PATCH" | "POST";
8
8
 
@@ -12,7 +12,7 @@ export interface GitHubApiRequest {
12
12
  body?: Record<string, unknown>;
13
13
  }
14
14
 
15
- export type GitHubApi = (request: GitHubApiRequest) => Promise<unknown>;
15
+ type GitHubApi = (request: GitHubApiRequest) => Promise<unknown>;
16
16
 
17
17
  interface GitHubIssuePayload {
18
18
  state?: unknown;
@@ -92,7 +92,7 @@ function uniqueLabels(labels: string[]): string[] {
92
92
  return [...new Set(labels.map((label) => label.trim()).filter(Boolean))];
93
93
  }
94
94
 
95
- export class GitHubIssueTrackerAdapter implements IssueTrackerAdapter {
95
+ class GitHubIssueTrackerAdapter implements IssueTrackerAdapter {
96
96
  constructor(private readonly api: GitHubApi = ghApi) {}
97
97
 
98
98
  resolveLink(ref: IssueRef): string {
@@ -0,0 +1,52 @@
1
+ import {
2
+ CONFLICT_SCAN_INTERVAL_MS,
3
+ DAY_MS,
4
+ DB_MAINTENANCE_INTERVAL_MS,
5
+ DB_VACUUM_EVERY,
6
+ ORPHAN_REAPER_INTERVAL_MS,
7
+ OUTBOX_RETENTION_MS,
8
+ REAP_INTERVAL_MS,
9
+ TOKEN_RECORD_RETENTION_SECONDS,
10
+ WORKSPACE_AUTO_MERGE_INTERVAL_MS,
11
+ WORKSPACE_GC_INTERVAL_MS,
12
+ WORKSPACE_ORPHAN_REAPER_INTERVAL_MS,
13
+ WORKSPACE_RETENTION_MS,
14
+ WORKSPACE_REVIEW_TTL_MS,
15
+ envMsOrDefault,
16
+ orphanReapCooldownMs,
17
+ orphanReapEnabled,
18
+ orphanGraceMs,
19
+ stewardEscalationMs,
20
+ stewardFallbackTarget,
21
+ } from "../config";
22
+ import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
23
+ import type { WorkspaceStatus } from "../types";
24
+
25
+ export { DAY_MS, REAP_INTERVAL_MS, TERMINAL_WORKSPACE_STATUSES };
26
+
27
+ export const DEFAULT_TIMEOUT_MS = 30_000;
28
+ export const SCHEDULER_TICK_MS = 10_000;
29
+ export const STRANDABLE_STATUSES = new Set<WorkspaceStatus>([...READY_TO_LAND_STATUSES, "conflict"]);
30
+ export const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
31
+ export const LANDED_RECONCILE_STATUSES = new Set<WorkspaceStatus>(["merge_planned", "review_requested", "conflict"]);
32
+
33
+ export const teamReapGraceMs = () => envMsOrDefault("TEAM_REAP_GRACE_MS", 30 * 60 * 1000);
34
+ export const teamReapIntervalMs = () => envMsOrDefault("TEAM_REAP_INTERVAL_MS", 5 * 60 * 1000);
35
+ export {
36
+ CONFLICT_SCAN_INTERVAL_MS,
37
+ DB_MAINTENANCE_INTERVAL_MS,
38
+ DB_VACUUM_EVERY,
39
+ ORPHAN_REAPER_INTERVAL_MS,
40
+ OUTBOX_RETENTION_MS,
41
+ TOKEN_RECORD_RETENTION_SECONDS,
42
+ WORKSPACE_AUTO_MERGE_INTERVAL_MS,
43
+ WORKSPACE_GC_INTERVAL_MS,
44
+ WORKSPACE_ORPHAN_REAPER_INTERVAL_MS,
45
+ WORKSPACE_RETENTION_MS,
46
+ WORKSPACE_REVIEW_TTL_MS,
47
+ orphanGraceMs,
48
+ orphanReapCooldownMs,
49
+ orphanReapEnabled,
50
+ stewardEscalationMs,
51
+ stewardFallbackTarget,
52
+ };
@@ -0,0 +1,32 @@
1
+ import { emitRelayEvent } from "../events";
2
+ import { routeNotification, type NotificationScope, type NotificationType } from "../notification-router";
3
+ import type { Command } from "../types";
4
+
5
+ export function emitCommand(command: Command): void {
6
+ const suffix = command.status === "timed_out" ? "timed_out" : command.status;
7
+ emitRelayEvent({
8
+ type: `command.${suffix}`,
9
+ source: command.source,
10
+ subject: command.id,
11
+ data: { command },
12
+ });
13
+ }
14
+
15
+ // Route a maintenance notification, swallowing failures (a stale/missing/misconfigured
16
+ // target must never break the sweep). Returns the target on success, null otherwise.
17
+ export function notifyTarget(
18
+ type: NotificationType,
19
+ scope: NotificationScope,
20
+ target: string,
21
+ subject: string,
22
+ body: string,
23
+ payload: Record<string, unknown>,
24
+ ): string | null {
25
+ if (!target) return null;
26
+ try {
27
+ routeNotification({ type, scope, recipients: [target], message: { subject, body, payload } });
28
+ return target;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }