agent-relay-server 0.75.0 → 0.77.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 (139) hide show
  1. package/docs/openapi.json +94 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-Ct4RjObq.js +3 -0
  4. package/public/assets/{MessageBubble-opNe8Lu2.js.map → MessageBubble-Ct4RjObq.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-_mpO-kEh.js → PlanGraphPanel-BQcLye6d.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-_mpO-kEh.js.map → PlanGraphPanel-BQcLye6d.js.map} +1 -1
  7. package/public/assets/{activity-B079s-aD.js → activity-DeQhQ17b.js} +2 -2
  8. package/public/assets/{activity-B079s-aD.js.map → activity-DeQhQ17b.js.map} +1 -1
  9. package/public/assets/{agent-profiles-D1X5BBW_.js → agent-profiles-DwR6zuEJ.js} +2 -2
  10. package/public/assets/{agent-profiles-D1X5BBW_.js.map → agent-profiles-DwR6zuEJ.js.map} +1 -1
  11. package/public/assets/{agents-kkvfOptR.js → agents-coulRN38.js} +2 -2
  12. package/public/assets/{agents-kkvfOptR.js.map → agents-coulRN38.js.map} +1 -1
  13. package/public/assets/{analytics-e_HOZBPx.js → analytics-BTq4XQiF.js} +3 -3
  14. package/public/assets/{analytics-e_HOZBPx.js.map → analytics-BTq4XQiF.js.map} +1 -1
  15. package/public/assets/{automation-B4CAzi64.js → automation-V97mnoZp.js} +2 -2
  16. package/public/assets/{automation-B4CAzi64.js.map → automation-V97mnoZp.js.map} +1 -1
  17. package/public/assets/{badge-YQhT30dy.js → badge-BxG6_yfp.js} +2 -2
  18. package/public/assets/{badge-YQhT30dy.js.map → badge-BxG6_yfp.js.map} +1 -1
  19. package/public/assets/branch-state-badge-DaCW_qGP.js +2 -0
  20. package/public/assets/{branch-state-badge-B7T81Oct.js.map → branch-state-badge-DaCW_qGP.js.map} +1 -1
  21. package/public/assets/{button-CaFI76Ja.js → button-D5k3GbF6.js} +2 -2
  22. package/public/assets/{button-CaFI76Ja.js.map → button-D5k3GbF6.js.map} +1 -1
  23. package/public/assets/{card-B_fQtKce.js → card-DKyFtrez.js} +2 -2
  24. package/public/assets/{card-B_fQtKce.js.map → card-DKyFtrez.js.map} +1 -1
  25. package/public/assets/{channels-BAD_kIo7.js → channels-EHZtUB_U.js} +2 -2
  26. package/public/assets/{channels-BAD_kIo7.js.map → channels-EHZtUB_U.js.map} +1 -1
  27. package/public/assets/chat-B8fs8r1x.js +2 -0
  28. package/public/assets/{chat-ZfR8BvTL.js.map → chat-B8fs8r1x.js.map} +1 -1
  29. package/public/assets/{connectors-Cdjtxly2.js → connectors-B3BbETXb.js} +2 -2
  30. package/public/assets/{connectors-Cdjtxly2.js.map → connectors-B3BbETXb.js.map} +1 -1
  31. package/public/assets/copy-button-D3XAkVC2.js +2 -0
  32. package/public/assets/{copy-button-m00JmflM.js.map → copy-button-D3XAkVC2.js.map} +1 -1
  33. package/public/assets/dist-B9_RgXN9.js +2 -0
  34. package/public/assets/{dist-DfQP9-VC.js.map → dist-B9_RgXN9.js.map} +1 -1
  35. package/public/assets/dist-BuYGwGZ-.js +2 -0
  36. package/public/assets/{dist-CydxJz3m.js.map → dist-BuYGwGZ-.js.map} +1 -1
  37. package/public/assets/{dist-ChP4KvKi.js → dist-CAu7-DrN.js} +2 -2
  38. package/public/assets/{dist-ChP4KvKi.js.map → dist-CAu7-DrN.js.map} +1 -1
  39. package/public/assets/{dist-DeAdFM5_.js → dist-CF2meFCY.js} +2 -2
  40. package/public/assets/{dist-DeAdFM5_.js.map → dist-CF2meFCY.js.map} +1 -1
  41. package/public/assets/{dist-B_yrHbsb.js → dist-CR7zKC1F.js} +2 -2
  42. package/public/assets/{dist-B_yrHbsb.js.map → dist-CR7zKC1F.js.map} +1 -1
  43. package/public/assets/{dist-C-viWls-.js → dist-CUxt3gHd.js} +2 -2
  44. package/public/assets/{dist-C-viWls-.js.map → dist-CUxt3gHd.js.map} +1 -1
  45. package/public/assets/{dist-D8Yr1AIJ.js → dist-D1Bwx4cc.js} +2 -2
  46. package/public/assets/{dist-D8Yr1AIJ.js.map → dist-D1Bwx4cc.js.map} +1 -1
  47. package/public/assets/{dist-Cnlh_NpI.js → dist-D32lF-j6.js} +2 -2
  48. package/public/assets/{dist-Cnlh_NpI.js.map → dist-D32lF-j6.js.map} +1 -1
  49. package/public/assets/{dist-DeM72J6a.js → dist-DDrB3y1I.js} +2 -2
  50. package/public/assets/{dist-DeM72J6a.js.map → dist-DDrB3y1I.js.map} +1 -1
  51. package/public/assets/dist-DPQKhU73.js +2 -0
  52. package/public/assets/{dist-CYS2puT9.js.map → dist-DPQKhU73.js.map} +1 -1
  53. package/public/assets/{dist-D7K3qgp7.js → dist-YjHj0NpU.js} +2 -2
  54. package/public/assets/{dist-D7K3qgp7.js.map → dist-YjHj0NpU.js.map} +1 -1
  55. package/public/assets/{es2015-C3DnXBqu.js → es2015-WF4MRcZQ.js} +2 -2
  56. package/public/assets/{es2015-C3DnXBqu.js.map → es2015-WF4MRcZQ.js.map} +1 -1
  57. package/public/assets/{formatted-body-impl-Bunmh796.js → formatted-body-impl-CQ-8HQ7u.js} +2 -2
  58. package/public/assets/{formatted-body-impl-Bunmh796.js.map → formatted-body-impl-CQ-8HQ7u.js.map} +1 -1
  59. package/public/assets/index-DJtda8l2.js +22 -0
  60. package/public/assets/index-DJtda8l2.js.map +1 -0
  61. package/public/assets/index-DvNy9IVg.css +2 -0
  62. package/public/assets/{input-Cqqq0OFl.js → input-DSJQbsdS.js} +2 -2
  63. package/public/assets/{input-Cqqq0OFl.js.map → input-DSJQbsdS.js.map} +1 -1
  64. package/public/assets/{insights-D9KEB80Q.js → insights-CtT5cEif.js} +2 -2
  65. package/public/assets/{insights-D9KEB80Q.js.map → insights-CtT5cEif.js.map} +1 -1
  66. package/public/assets/{integrations-DzRla8mj.js → integrations-BWBxWl3k.js} +2 -2
  67. package/public/assets/{integrations-DzRla8mj.js.map → integrations-BWBxWl3k.js.map} +1 -1
  68. package/public/assets/{lib-6L4m0E3M.js → lib-BxQSXiQ7.js} +3 -3
  69. package/public/assets/{lib-6L4m0E3M.js.map → lib-BxQSXiQ7.js.map} +1 -1
  70. package/public/assets/{lucide-react-DI8kTavp.js → lucide-react-BH2ROV8n.js} +2 -2
  71. package/public/assets/{lucide-react-DI8kTavp.js.map → lucide-react-BH2ROV8n.js.map} +1 -1
  72. package/public/assets/{maintenance-C0sGj_Na.js → maintenance-BWLMjX8h.js} +2 -2
  73. package/public/assets/{maintenance-C0sGj_Na.js.map → maintenance-BWLMjX8h.js.map} +1 -1
  74. package/public/assets/{managed-agents-BQq9EXOq.js → managed-agents-KxHV0VcQ.js} +2 -2
  75. package/public/assets/{managed-agents-BQq9EXOq.js.map → managed-agents-KxHV0VcQ.js.map} +1 -1
  76. package/public/assets/{markdown-preview-impl-B6xOjT1c.js → markdown-preview-impl-C5b5cBM0.js} +2 -2
  77. package/public/assets/{markdown-preview-impl-B6xOjT1c.js.map → markdown-preview-impl-C5b5cBM0.js.map} +1 -1
  78. package/public/assets/{memory-CB6zkEnm.js → memory-Bh3UgQCk.js} +2 -2
  79. package/public/assets/{memory-CB6zkEnm.js.map → memory-Bh3UgQCk.js.map} +1 -1
  80. package/public/assets/{messages-DhQjlDGk.js → messages-Dg7TSxpZ.js} +2 -2
  81. package/public/assets/{messages-DhQjlDGk.js.map → messages-Dg7TSxpZ.js.map} +1 -1
  82. package/public/assets/{orchestrators-Ze5VJVFK.js → orchestrators-BBKW7bO0.js} +2 -2
  83. package/public/assets/{orchestrators-Ze5VJVFK.js.map → orchestrators-BBKW7bO0.js.map} +1 -1
  84. package/public/assets/{overview-KxPTf_aQ.js → overview-a8lG62AZ.js} +2 -2
  85. package/public/assets/{overview-KxPTf_aQ.js.map → overview-a8lG62AZ.js.map} +1 -1
  86. package/public/assets/{pairs-CKAQN-Mp.js → pairs-ChSYXFwu.js} +2 -2
  87. package/public/assets/{pairs-CKAQN-Mp.js.map → pairs-ChSYXFwu.js.map} +1 -1
  88. package/public/assets/{projects-DluLrkwe.js → projects-DIN_O7AJ.js} +2 -2
  89. package/public/assets/{projects-DluLrkwe.js.map → projects-DIN_O7AJ.js.map} +1 -1
  90. package/public/assets/{react-dom-Ct8qXivQ.js → react-dom-B_tGtv3Y.js} +2 -2
  91. package/public/assets/{react-dom-Ct8qXivQ.js.map → react-dom-B_tGtv3Y.js.map} +1 -1
  92. package/public/assets/{security-BfN02cIU.js → security-D0obEjDT.js} +2 -2
  93. package/public/assets/{security-BfN02cIU.js.map → security-D0obEjDT.js.map} +1 -1
  94. package/public/assets/{select-2k89h2cU.js → select-DwCP2Jhh.js} +2 -2
  95. package/public/assets/{select-2k89h2cU.js.map → select-DwCP2Jhh.js.map} +1 -1
  96. package/public/assets/settings-f1l2R0e8.js +4 -0
  97. package/public/assets/{settings-CxHa6IHI.js.map → settings-f1l2R0e8.js.map} +1 -1
  98. package/public/assets/{store-D1K4_xId.js → store-m74m5eNF.js} +3 -3
  99. package/public/assets/{store-D1K4_xId.js.map → store-m74m5eNF.js.map} +1 -1
  100. package/public/assets/{tasks-B7S6eTJJ.js → tasks-DDVfmUst.js} +2 -2
  101. package/public/assets/{tasks-B7S6eTJJ.js.map → tasks-DDVfmUst.js.map} +1 -1
  102. package/public/assets/{teams-DPEBGRiy.js → teams-COZKriLe.js} +2 -2
  103. package/public/assets/{teams-DPEBGRiy.js.map → teams-COZKriLe.js.map} +1 -1
  104. package/public/assets/{terminal-viewer-impl-PeLYPKpp.js → terminal-viewer-impl-CjOZ__aw.js} +3 -3
  105. package/public/assets/{terminal-viewer-impl-PeLYPKpp.js.map → terminal-viewer-impl-CjOZ__aw.js.map} +1 -1
  106. package/public/assets/{use-keyboard-viewport-DxmCAbQi.js → use-keyboard-viewport-BVAU-Ud8.js} +2 -2
  107. package/public/assets/{use-keyboard-viewport-DxmCAbQi.js.map → use-keyboard-viewport-BVAU-Ud8.js.map} +1 -1
  108. package/public/assets/work-queue-BC7heUfD.js +2 -0
  109. package/public/assets/{work-queue-BI_d8G0U.js.map → work-queue-BC7heUfD.js.map} +1 -1
  110. package/public/assets/workspaces-CIGMb3ST.js +3 -0
  111. package/public/assets/{workspaces-B_nIm6Tw.js.map → workspaces-CIGMb3ST.js.map} +1 -1
  112. package/public/index.html +21 -21
  113. package/src/db/agents.ts +4 -46
  114. package/src/db/index.ts +1 -0
  115. package/src/db/migrations.ts +91 -1
  116. package/src/db/provider-quotas.ts +220 -0
  117. package/src/db/schema.ts +16 -1
  118. package/src/lifecycle-context-pressure.ts +98 -0
  119. package/src/lifecycle-manager.ts +21 -31
  120. package/src/quota.ts +14 -1
  121. package/src/routes/index.ts +3 -0
  122. package/src/routes/provider-quotas.ts +48 -0
  123. package/src/security.ts +1 -0
  124. package/src/token-db.ts +3 -3
  125. package/src/upgrade-local-id.ts +23 -0
  126. package/src/upgrade.ts +19 -27
  127. package/public/assets/MessageBubble-opNe8Lu2.js +0 -3
  128. package/public/assets/branch-state-badge-B7T81Oct.js +0 -2
  129. package/public/assets/chat-ZfR8BvTL.js +0 -2
  130. package/public/assets/copy-button-m00JmflM.js +0 -2
  131. package/public/assets/dist-CYS2puT9.js +0 -2
  132. package/public/assets/dist-CydxJz3m.js +0 -2
  133. package/public/assets/dist-DfQP9-VC.js +0 -2
  134. package/public/assets/index-CQQTCZxg.css +0 -2
  135. package/public/assets/index-DePqIpr0.js +0 -23
  136. package/public/assets/index-DePqIpr0.js.map +0 -1
  137. package/public/assets/settings-CxHa6IHI.js +0 -4
  138. package/public/assets/work-queue-BI_d8G0U.js +0 -2
  139. package/public/assets/workspaces-B_nIm6Tw.js +0 -3
@@ -602,13 +602,103 @@ export function applyMigrations(): void {
602
602
  getDb().run(`
603
603
  CREATE TABLE IF NOT EXISTS quota_snapshots (
604
604
  id INTEGER PRIMARY KEY AUTOINCREMENT,
605
- agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
605
+ provider TEXT NOT NULL,
606
+ account_key TEXT NOT NULL,
607
+ agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
606
608
  windows TEXT NOT NULL,
607
609
  source TEXT NOT NULL,
608
610
  quota_state TEXT NOT NULL,
609
611
  captured_at INTEGER NOT NULL
610
612
  )
611
613
  `);
614
+ const quotaSnapshotCols = (getDb().query("PRAGMA table_info(quota_snapshots)").all() as Array<{ name: string }>).map((col) => col.name);
615
+ if (!quotaSnapshotCols.includes("provider") || !quotaSnapshotCols.includes("account_key")) {
616
+ getDb().run("ALTER TABLE quota_snapshots RENAME TO quota_snapshots_legacy_agent");
617
+ getDb().run(`
618
+ CREATE TABLE quota_snapshots (
619
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
620
+ provider TEXT NOT NULL,
621
+ account_key TEXT NOT NULL,
622
+ agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
623
+ windows TEXT NOT NULL,
624
+ source TEXT NOT NULL,
625
+ quota_state TEXT NOT NULL,
626
+ captured_at INTEGER NOT NULL
627
+ )
628
+ `);
629
+ getDb().run(`
630
+ INSERT INTO quota_snapshots (id, provider, account_key, agent_id, windows, source, quota_state, captured_at)
631
+ SELECT
632
+ qs.id,
633
+ CASE
634
+ WHEN json_extract(a.meta, '$.provider') IS NOT NULL THEN lower(json_extract(a.meta, '$.provider'))
635
+ WHEN a.tags LIKE '%"codex"%' OR lower(a.id) LIKE '%codex%' OR lower(a.name) LIKE '%codex%' THEN 'codex'
636
+ ELSE 'claude'
637
+ END,
638
+ coalesce(
639
+ nullif(json_extract(a.meta, '$.accountKey'), ''),
640
+ nullif(json_extract(a.meta, '$.accountId'), ''),
641
+ nullif(json_extract(a.meta, '$.orgId'), ''),
642
+ 'host:' || coalesce(a.machine, a.rig, 'unknown')
643
+ ),
644
+ qs.agent_id,
645
+ qs.windows,
646
+ qs.source,
647
+ qs.quota_state,
648
+ qs.captured_at
649
+ FROM quota_snapshots_legacy_agent qs
650
+ LEFT JOIN agents a ON a.id = qs.agent_id
651
+ `);
652
+ getDb().run("DROP TABLE quota_snapshots_legacy_agent");
653
+ }
654
+ getDb().run(`
655
+ CREATE TABLE IF NOT EXISTS provider_quotas (
656
+ provider TEXT NOT NULL,
657
+ account_key TEXT NOT NULL,
658
+ quota_state TEXT,
659
+ source_agent_ids TEXT NOT NULL DEFAULT '[]',
660
+ last_updated_at INTEGER,
661
+ last_attempt_at INTEGER NOT NULL,
662
+ last_error TEXT,
663
+ updated_at INTEGER NOT NULL,
664
+ created_at INTEGER NOT NULL,
665
+ PRIMARY KEY (provider, account_key)
666
+ )
667
+ `);
668
+ getDb().run(`
669
+ INSERT INTO provider_quotas (
670
+ provider, account_key, quota_state, source_agent_ids, last_updated_at,
671
+ last_attempt_at, last_error, updated_at, created_at
672
+ )
673
+ SELECT
674
+ CASE
675
+ WHEN json_extract(meta, '$.provider') IS NOT NULL THEN lower(json_extract(meta, '$.provider'))
676
+ WHEN tags LIKE '%"codex"%' OR lower(id) LIKE '%codex%' OR lower(name) LIKE '%codex%' THEN 'codex'
677
+ ELSE 'claude'
678
+ END AS provider,
679
+ coalesce(
680
+ nullif(json_extract(meta, '$.accountKey'), ''),
681
+ nullif(json_extract(meta, '$.accountId'), ''),
682
+ nullif(json_extract(meta, '$.orgId'), ''),
683
+ 'host:' || coalesce(machine, rig, 'unknown')
684
+ ) AS account_key,
685
+ quota_state,
686
+ json_array(id),
687
+ coalesce(json_extract(quota_state, '$.updatedAt'), last_seen),
688
+ coalesce(json_extract(quota_state, '$.updatedAt'), last_seen),
689
+ NULL,
690
+ last_seen,
691
+ created_at
692
+ FROM agents
693
+ WHERE quota_state IS NOT NULL
694
+ ON CONFLICT(provider, account_key) DO UPDATE SET
695
+ quota_state = excluded.quota_state,
696
+ source_agent_ids = excluded.source_agent_ids,
697
+ last_updated_at = max(coalesce(provider_quotas.last_updated_at, 0), coalesce(excluded.last_updated_at, 0)),
698
+ last_attempt_at = max(provider_quotas.last_attempt_at, excluded.last_attempt_at),
699
+ updated_at = max(provider_quotas.updated_at, excluded.updated_at)
700
+ `);
701
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_quota_snapshots_account_time ON quota_snapshots(provider, account_key, captured_at DESC)");
612
702
  getDb().run("CREATE INDEX IF NOT EXISTS idx_quota_snapshots_agent_time ON quota_snapshots(agent_id, captured_at DESC)");
613
703
 
614
704
  getDb().run(`
@@ -0,0 +1,220 @@
1
+ import { DAY_MS } from "../config";
2
+ import { parseJson } from "../utils";
3
+ import { getDb } from "./connection.ts";
4
+ import { parseStringArray } from "./shared.ts";
5
+ import type { AgentCard, ProviderQuotaError, ProviderQuotaMatrix, ProviderQuotaRecord, ProviderQuotaSnapshot, ProviderQuotaUpdateInput, QuotaState } from "../types";
6
+
7
+ const PROVIDER_QUOTA_STALE_AFTER_MS = 10 * 60_000;
8
+
9
+ type ProviderQuotaRow = {
10
+ provider: string;
11
+ account_key: string;
12
+ quota_state?: string | null;
13
+ source_agent_ids?: string | null;
14
+ last_updated_at?: number | null;
15
+ last_attempt_at: number;
16
+ last_error?: string | null;
17
+ };
18
+
19
+ type ProviderQuotaSnapshotRow = {
20
+ provider: string;
21
+ account_key: string;
22
+ agent_id?: string | null;
23
+ quota_state: string;
24
+ captured_at: number;
25
+ };
26
+
27
+ function cleanKey(value: string): string {
28
+ return value.trim();
29
+ }
30
+
31
+ function normalizeProvider(value: string): string {
32
+ return cleanKey(value).toLowerCase();
33
+ }
34
+
35
+ function normalizeSourceAgentIds(value: unknown): string[] {
36
+ return [...new Set(parseStringArray(typeof value === "string" ? value : JSON.stringify(value ?? [])).filter(Boolean))];
37
+ }
38
+
39
+ function quotaFingerprint(quota: QuotaState): string {
40
+ return JSON.stringify(quota.windows.map((window) => ({
41
+ name: window.name,
42
+ unit: window.unit,
43
+ utilization: window.utilization,
44
+ })).sort((a, b) => a.name.localeCompare(b.name) || a.unit.localeCompare(b.unit)));
45
+ }
46
+
47
+ function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
48
+ return {
49
+ provider: row.provider,
50
+ accountKey: row.account_key,
51
+ capturedAt: row.captured_at,
52
+ ...(row.agent_id ? { sourceAgentId: row.agent_id } : {}),
53
+ quota: parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: row.captured_at }),
54
+ };
55
+ }
56
+
57
+ function rowToRecord(row: ProviderQuotaRow, history: ProviderQuotaSnapshot[], now: number): ProviderQuotaRecord {
58
+ const lastError = parseJson<ProviderQuotaError | undefined>(row.last_error, undefined);
59
+ const lastUpdatedAt = row.last_updated_at ?? undefined;
60
+ return {
61
+ provider: row.provider,
62
+ accountKey: row.account_key,
63
+ ...(row.quota_state ? { quota: parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at }) } : {}),
64
+ sourceAgentIds: normalizeSourceAgentIds(row.source_agent_ids),
65
+ ...(lastUpdatedAt !== undefined ? { lastUpdatedAt } : {}),
66
+ lastAttemptAt: row.last_attempt_at,
67
+ ...(lastError ? { lastError } : {}),
68
+ stale: lastUpdatedAt === undefined || now - lastUpdatedAt > PROVIDER_QUOTA_STALE_AFTER_MS,
69
+ history,
70
+ };
71
+ }
72
+
73
+ function latestSnapshotFingerprint(provider: string, accountKey: string): string | undefined {
74
+ const row = getDb().query(`
75
+ SELECT quota_state FROM quota_snapshots
76
+ WHERE provider = ? AND account_key = ?
77
+ ORDER BY captured_at DESC, id DESC
78
+ LIMIT 1
79
+ `).get(provider, accountKey) as { quota_state: string } | undefined;
80
+ return row ? quotaFingerprint(parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: 0 })) : undefined;
81
+ }
82
+
83
+ function providerQuotaIdentityForAgent(agent: Pick<AgentCard, "id" | "name" | "tags" | "machine" | "rig" | "meta">): { provider: string; accountKey: string } {
84
+ const meta = agent.meta ?? {};
85
+ const provider = typeof meta.provider === "string" && meta.provider.trim()
86
+ ? meta.provider
87
+ : agent.tags.includes("codex") || agent.id.toLowerCase().includes("codex") || agent.name.toLowerCase().includes("codex")
88
+ ? "codex"
89
+ : "claude";
90
+ const accountKey = typeof meta.accountKey === "string" && meta.accountKey.trim()
91
+ ? meta.accountKey
92
+ : typeof meta.accountId === "string" && meta.accountId.trim()
93
+ ? meta.accountId
94
+ : typeof meta.orgId === "string" && meta.orgId.trim()
95
+ ? meta.orgId
96
+ : `host:${agent.machine ?? agent.rig ?? "unknown"}`;
97
+ return { provider: normalizeProvider(provider), accountKey: cleanKey(accountKey) };
98
+ }
99
+
100
+ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.now()): ProviderQuotaRecord {
101
+ const provider = normalizeProvider(input.provider);
102
+ const accountKey = cleanKey(input.accountKey);
103
+ if (!provider) throw new Error("provider required");
104
+ if (!accountKey) throw new Error("accountKey required");
105
+ const existing = getDb().query("SELECT * FROM provider_quotas WHERE provider = ? AND account_key = ?").get(provider, accountKey) as ProviderQuotaRow | undefined;
106
+ const sourceAgentIds = normalizeSourceAgentIds(existing?.source_agent_ids);
107
+ if (input.sourceAgentId && !sourceAgentIds.includes(input.sourceAgentId)) sourceAgentIds.push(input.sourceAgentId);
108
+ const lastUpdatedAt = input.quota ? input.quota.updatedAt : existing?.last_updated_at ?? undefined;
109
+ const lastAttemptAt = input.lastAttemptAt ?? input.quota?.updatedAt ?? now;
110
+ const lastError = input.lastError === undefined
111
+ ? input.quota ? null : existing?.last_error ?? null
112
+ : input.lastError;
113
+ const quotaState = input.quota ? JSON.stringify(input.quota) : existing?.quota_state ?? null;
114
+
115
+ getDb().query(`
116
+ INSERT INTO provider_quotas (
117
+ provider, account_key, quota_state, source_agent_ids, last_updated_at,
118
+ last_attempt_at, last_error, updated_at, created_at
119
+ )
120
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
121
+ ON CONFLICT(provider, account_key) DO UPDATE SET
122
+ quota_state = excluded.quota_state,
123
+ source_agent_ids = excluded.source_agent_ids,
124
+ last_updated_at = excluded.last_updated_at,
125
+ last_attempt_at = excluded.last_attempt_at,
126
+ last_error = excluded.last_error,
127
+ updated_at = excluded.updated_at
128
+ `).run(
129
+ provider,
130
+ accountKey,
131
+ quotaState,
132
+ JSON.stringify(sourceAgentIds),
133
+ lastUpdatedAt ?? null,
134
+ lastAttemptAt,
135
+ lastError ? JSON.stringify(lastError) : null,
136
+ now,
137
+ now,
138
+ );
139
+
140
+ if (input.quota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(input.quota)) {
141
+ recordProviderQuotaSnapshot({ provider, accountKey, quota: input.quota, sourceAgentId: input.sourceAgentId }, now);
142
+ }
143
+
144
+ return listProviderQuotas({ provider, accountKey, historyLimit: 24, now }).quotas[0]!;
145
+ }
146
+
147
+ function recordProviderQuotaSnapshot(input: { provider: string; accountKey: string; quota: QuotaState; sourceAgentId?: string }, now: number): void {
148
+ const sourceAgentId = input.sourceAgentId && getDb().query("SELECT 1 FROM agents WHERE id = ?").get(input.sourceAgentId)
149
+ ? input.sourceAgentId
150
+ : undefined;
151
+ getDb().query(`
152
+ INSERT INTO quota_snapshots (provider, account_key, agent_id, windows, source, quota_state, captured_at)
153
+ VALUES (?, ?, ?, ?, ?, ?, ?)
154
+ `).run(
155
+ input.provider,
156
+ input.accountKey,
157
+ sourceAgentId ?? null,
158
+ JSON.stringify(input.quota.windows),
159
+ input.quota.source,
160
+ JSON.stringify(input.quota),
161
+ now,
162
+ );
163
+ pruneProviderQuotaSnapshots(input.provider, input.accountKey, now - DAY_MS);
164
+ }
165
+
166
+ function pruneProviderQuotaSnapshots(provider?: string, accountKey?: string, olderThan = Date.now() - DAY_MS): number {
167
+ const result = provider && accountKey
168
+ ? getDb().query("DELETE FROM quota_snapshots WHERE provider = ? AND account_key = ? AND captured_at < ?").run(provider, accountKey, olderThan)
169
+ : getDb().query("DELETE FROM quota_snapshots WHERE captured_at < ?").run(olderThan);
170
+ return result.changes;
171
+ }
172
+
173
+ function listProviderQuotaSnapshots(provider: string, accountKey: string, options: { since?: number; limit?: number } = {}): ProviderQuotaSnapshot[] {
174
+ const limit = Math.max(1, Math.min(options.limit ?? 288, 1000));
175
+ const params: any[] = [normalizeProvider(provider), cleanKey(accountKey)];
176
+ let sql = "SELECT * FROM quota_snapshots WHERE provider = ? AND account_key = ?";
177
+ if (options.since !== undefined) {
178
+ sql += " AND captured_at >= ?";
179
+ params.push(options.since);
180
+ }
181
+ sql += " ORDER BY captured_at ASC, id ASC LIMIT ?";
182
+ params.push(limit);
183
+ return (getDb().query(sql).all(...params) as ProviderQuotaSnapshotRow[]).map(rowToSnapshot);
184
+ }
185
+
186
+ export function listAgentQuotaSnapshotsFromProviderStore(agent: AgentCard, options: { since?: number; limit?: number } = {}) {
187
+ const identity = providerQuotaIdentityForAgent(agent);
188
+ return listProviderQuotaSnapshots(identity.provider, identity.accountKey, options).map((snapshot) => ({
189
+ agentId: agent.id,
190
+ capturedAt: snapshot.capturedAt,
191
+ windows: snapshot.quota.windows,
192
+ source: snapshot.quota.source,
193
+ updatedAt: snapshot.quota.updatedAt,
194
+ }));
195
+ }
196
+
197
+ export function listProviderQuotas(options: { provider?: string; accountKey?: string; since?: number; historyLimit?: number; now?: number } = {}): ProviderQuotaMatrix {
198
+ const historyLimit = Math.max(0, Math.min(options.historyLimit ?? 24, 288));
199
+ const now = options.now ?? Date.now();
200
+ const params: any[] = [];
201
+ let sql = "SELECT * FROM provider_quotas WHERE 1=1";
202
+ if (options.provider) {
203
+ sql += " AND provider = ?";
204
+ params.push(normalizeProvider(options.provider));
205
+ }
206
+ if (options.accountKey) {
207
+ sql += " AND account_key = ?";
208
+ params.push(cleanKey(options.accountKey));
209
+ }
210
+ sql += " ORDER BY provider ASC, account_key ASC";
211
+ const rows = getDb().query(sql).all(...params) as ProviderQuotaRow[];
212
+ return {
213
+ staleAfterMs: PROVIDER_QUOTA_STALE_AFTER_MS,
214
+ quotas: rows.map((row) => rowToRecord(
215
+ row,
216
+ historyLimit > 0 ? listProviderQuotaSnapshots(row.provider, row.account_key, { since: options.since, limit: historyLimit }) : [],
217
+ now,
218
+ )),
219
+ };
220
+ }
package/src/db/schema.ts CHANGED
@@ -292,7 +292,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
292
292
 
293
293
  CREATE TABLE IF NOT EXISTS quota_snapshots (
294
294
  id INTEGER PRIMARY KEY AUTOINCREMENT,
295
- agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
295
+ provider TEXT NOT NULL,
296
+ account_key TEXT NOT NULL,
297
+ agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
296
298
  windows TEXT NOT NULL,
297
299
  source TEXT NOT NULL,
298
300
  quota_state TEXT NOT NULL,
@@ -300,6 +302,19 @@ export function initDb(path: string = "agent-relay.db"): Database {
300
302
  );
301
303
  CREATE INDEX IF NOT EXISTS idx_quota_snapshots_agent_time ON quota_snapshots(agent_id, captured_at DESC);
302
304
 
305
+ CREATE TABLE IF NOT EXISTS provider_quotas (
306
+ provider TEXT NOT NULL,
307
+ account_key TEXT NOT NULL,
308
+ quota_state TEXT,
309
+ source_agent_ids TEXT NOT NULL DEFAULT '[]',
310
+ last_updated_at INTEGER,
311
+ last_attempt_at INTEGER NOT NULL,
312
+ last_error TEXT,
313
+ updated_at INTEGER NOT NULL,
314
+ created_at INTEGER NOT NULL,
315
+ PRIMARY KEY (provider, account_key)
316
+ );
317
+
303
318
  CREATE TABLE IF NOT EXISTS agent_continuations (
304
319
  agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
305
320
  objective TEXT NOT NULL,
@@ -0,0 +1,98 @@
1
+ import { createCommand } from "./commands-db";
2
+ import { getAgent, listAgents } from "./db";
3
+ import { getManagedAgentState } from "./config-store";
4
+ import { emitCommandEvent } from "./command-events";
5
+ import { handleContextPressure } from "./context-advisory";
6
+ import type { AgentCard, Command, SpawnPolicy } from "./types";
7
+
8
+ interface LifecycleContextPressureInput {
9
+ policies: SpawnPolicy[];
10
+ now: () => number;
11
+ hasActiveCommand: (target: string, type: string) => boolean;
12
+ }
13
+
14
+ interface CompactAgentInput {
15
+ agent: AgentCard;
16
+ reason: string;
17
+ policyName?: string;
18
+ alwaysReload?: string[];
19
+ now: () => number;
20
+ hasActiveCommand: (target: string, type: string) => boolean;
21
+ }
22
+
23
+ export function reconcileLifecycleContextPressure(input: LifecycleContextPressureInput): Set<string> {
24
+ const byPolicy = new Map(input.policies.map((policy) => [policy.name, policy]));
25
+ const candidates = new Map<string, { agent: AgentCard; policy?: SpawnPolicy }>();
26
+
27
+ for (const policy of input.policies) {
28
+ const state = getManagedAgentState(policy.name);
29
+ if (state?.status !== "running" || !state.agentId) continue;
30
+ const agent = getAgent(state.agentId);
31
+ if (!agent || agent.status === "offline") continue;
32
+ candidates.set(agent.id, { agent, policy });
33
+ }
34
+
35
+ for (const agent of listAgents()) {
36
+ if (!isLiveRunnerManagedAgent(agent) || candidates.has(agent.id)) continue;
37
+ const policyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
38
+ candidates.set(agent.id, { agent, policy: policyName ? byPolicy.get(policyName) : undefined });
39
+ }
40
+
41
+ const handled = new Set<string>();
42
+ for (const { agent, policy } of candidates.values()) {
43
+ if (handleContextPressure({
44
+ agent,
45
+ now: input.now,
46
+ hasActiveCommand: input.hasActiveCommand,
47
+ compact: (reason) => requestAgentCompact({
48
+ agent,
49
+ reason,
50
+ policyName: policy?.name,
51
+ alwaysReload: alwaysReloadTags(agent.tags),
52
+ now: input.now,
53
+ hasActiveCommand: input.hasActiveCommand,
54
+ }),
55
+ })) {
56
+ handled.add(agent.id);
57
+ }
58
+ }
59
+ return handled;
60
+ }
61
+
62
+ export function requestAgentCompact(input: CompactAgentInput): Command | null {
63
+ if (input.hasActiveCommand(input.agent.id, "agent.compact")) return null;
64
+ const spawnRequestId = typeof input.agent.meta?.spawnRequestId === "string" ? input.agent.meta.spawnRequestId : undefined;
65
+ const sessionName = typeof input.agent.meta?.sessionName === "string" ? input.agent.meta.sessionName : undefined;
66
+ const tmuxSession = typeof input.agent.meta?.tmuxSession === "string" ? input.agent.meta.tmuxSession : undefined;
67
+ const command = createCommand({
68
+ type: "agent.compact",
69
+ source: "system",
70
+ target: input.agent.id,
71
+ correlationId: spawnRequestId,
72
+ params: {
73
+ action: "compact",
74
+ agentId: input.agent.id,
75
+ ...(input.policyName ? { policyName: input.policyName } : {}),
76
+ ...(spawnRequestId ? { spawnRequestId } : {}),
77
+ ...(sessionName ? { sessionName } : {}),
78
+ ...(tmuxSession ? { tmuxSession } : {}),
79
+ reason: input.reason,
80
+ memory: input.alwaysReload?.length ? { alwaysReload: input.alwaysReload } : undefined,
81
+ requestedBy: "lifecycle-manager",
82
+ requestedAt: input.now(),
83
+ },
84
+ });
85
+ emitCommandEvent(command, "command.requested");
86
+ return command;
87
+ }
88
+
89
+ export function alwaysReloadTags(tags: string[]): string[] {
90
+ return tags
91
+ .filter((tag) => tag.startsWith("memory-reload:"))
92
+ .map((tag) => tag.slice("memory-reload:".length).trim())
93
+ .filter(Boolean);
94
+ }
95
+
96
+ function isLiveRunnerManagedAgent(agent: AgentCard): boolean {
97
+ return agent.meta?.runnerManaged === true && agent.status !== "offline" && agent.status !== "stale";
98
+ }
@@ -9,7 +9,7 @@ import {
9
9
  upsertManagedAgentState,
10
10
  } from "./config-store";
11
11
  import { emitRelayEvent } from "./events";
12
- import { handleContextPressure } from "./context-advisory";
12
+ import { alwaysReloadTags, reconcileLifecycleContextPressure, requestAgentCompact } from "./lifecycle-context-pressure";
13
13
  import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
14
14
  import { authContextFromSystem } from "./services/auth-context";
15
15
  import { emitCommandEvent } from "./command-events";
@@ -66,8 +66,10 @@ export class LifecycleManager {
66
66
  }
67
67
 
68
68
  async tick(): Promise<void> {
69
- for (const policy of this.loadPolicies()) {
70
- this.reconcilePolicy(policy);
69
+ const policies = this.loadPolicies();
70
+ const contextHandled = this.reconcileContextPressure(policies);
71
+ for (const policy of policies) {
72
+ this.reconcilePolicy(policy, contextHandled);
71
73
  }
72
74
  }
73
75
 
@@ -175,7 +177,7 @@ export class LifecycleManager {
175
177
  onConfigChanged(namespace: string, key: string): void {
176
178
  if (namespace !== "spawn-policy") return;
177
179
  const policy = this.loadPolicies().find((item) => item.name === key);
178
- if (policy) this.reconcilePolicy(policy);
180
+ if (policy) this.reconcilePolicy(policy, this.reconcileContextPressure([policy]));
179
181
  }
180
182
 
181
183
  onRelayUpdated(_newVersion: string): void {
@@ -309,7 +311,7 @@ export class LifecycleManager {
309
311
  return command;
310
312
  }
311
313
 
312
- private reconcilePolicy(policy: SpawnPolicy): void {
314
+ private reconcilePolicy(policy: SpawnPolicy, contextHandled = new Set<string>()): void {
313
315
  const state = getManagedAgentState(policy.name);
314
316
  const enabled = policy.enabled !== false;
315
317
  if (!state) {
@@ -354,9 +356,16 @@ export class LifecycleManager {
354
356
  this.stopAgent(policy, true, "idle-timeout");
355
357
  return;
356
358
  }
357
- if (handleContextPressure({ agent, now: this.now, hasActiveCommand: (target, type) => this.hasActiveCommand(target, type), compact: (reason) => this.compactAgent(policy, agent.id, reason, alwaysReloadTags(agent.tags)) })) return;
359
+ if (contextHandled.has(agent.id)) return;
358
360
  if (this.shouldCompact(agent)) {
359
- this.compactAgent(policy, agent.id, "context-threshold", alwaysReloadTags(agent.tags));
361
+ requestAgentCompact({
362
+ agent,
363
+ reason: "context-threshold",
364
+ policyName: policy.name,
365
+ alwaysReload: alwaysReloadTags(agent.tags),
366
+ now: this.now,
367
+ hasActiveCommand: (target, type) => this.hasActiveCommand(target, type),
368
+ });
360
369
  return;
361
370
  }
362
371
  if (policy.scheduledDailyRestart && this.dailyRestartDue(policy, state) && this.isIdle(policy, state, 3600)) {
@@ -384,24 +393,12 @@ export class LifecycleManager {
384
393
  }
385
394
  }
386
395
 
387
- private compactAgent(policy: SpawnPolicy, agentId: string, reason: string, alwaysReload: string[] = []): Command | null {
388
- if (this.hasActiveCommand(agentId, "agent.compact")) return null;
389
- const command = createCommand({
390
- type: "agent.compact",
391
- source: "system",
392
- target: agentId,
393
- params: {
394
- action: "compact",
395
- agentId,
396
- policyName: policy.name,
397
- reason,
398
- memory: alwaysReload.length ? { alwaysReload } : undefined,
399
- requestedBy: "lifecycle-manager",
400
- requestedAt: this.now(),
401
- },
396
+ private reconcileContextPressure(policies: SpawnPolicy[]): Set<string> {
397
+ return reconcileLifecycleContextPressure({
398
+ policies,
399
+ now: this.now,
400
+ hasActiveCommand: (target, type) => this.hasActiveCommand(target, type),
402
401
  });
403
- emitCommandEvent(command, "command.requested");
404
- return command;
405
402
  }
406
403
 
407
404
  private ensureStoppedState(policy: SpawnPolicy): void {
@@ -654,10 +651,3 @@ function agentTerminalFailureMessage(agent: ReturnType<typeof getAgent> | null):
654
651
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
655
652
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
656
653
  }
657
-
658
- function alwaysReloadTags(tags: string[]): string[] {
659
- return tags
660
- .filter((tag) => tag.startsWith("memory-reload:"))
661
- .map((tag) => tag.slice("memory-reload:".length).trim())
662
- .filter(Boolean);
663
- }
package/src/quota.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import type { QuotaState, QuotaWindowName, QuotaSource, QuotaUnit } from "./types";
2
+ import type { ProviderQuotaError, QuotaState, QuotaWindowName, QuotaSource, QuotaUnit } from "./types";
3
3
 
4
4
  const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest"]);
5
5
  const QUOTA_UNITS = new Set<QuotaUnit>(["tokens", "requests", "%"]);
@@ -38,3 +38,16 @@ export function quotaStateFromUnknown(value: unknown): QuotaState | undefined {
38
38
  if (windows.length === 0) return undefined;
39
39
  return { windows, source, updatedAt };
40
40
  }
41
+
42
+ export function providerQuotaErrorFromUnknown(value: unknown): ProviderQuotaError | undefined {
43
+ if (!isRecord(value)) return undefined;
44
+ const type = typeof value.type === "string" && value.type.trim() ? value.type.trim() : undefined;
45
+ const message = typeof value.message === "string" && value.message.trim() ? value.message.trim() : undefined;
46
+ if (!type || !message) return undefined;
47
+ const retryAfterMs = numberValue(value.retryAfterMs);
48
+ return {
49
+ type,
50
+ message,
51
+ ...(retryAfterMs !== undefined && retryAfterMs >= 0 ? { retryAfterMs } : {}),
52
+ };
53
+ }
@@ -5,6 +5,7 @@ import { deleteAgentTerminalSession, postAgentAction, postAgentPermissionDecisio
5
5
  import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
6
6
  import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
7
7
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
8
+ import { getProviderQuotasRoute, postProviderQuotaRoute } from "./provider-quotas";
8
9
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
9
10
  import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute, patchInboxThreadState, postChatHistoryImportRoute, putInboxDraft } from "./inbox";
10
11
  import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
@@ -87,6 +88,8 @@ const routes: Route[] = [
87
88
  route("GET", "/api/agents", getAgents),
88
89
  route("GET", "/api/agents/find", findAgents),
89
90
  route("POST", "/api/route", postRouteAdvice),
91
+ route("GET", "/api/provider-quotas", getProviderQuotasRoute),
92
+ route("POST", "/api/provider-quotas", postProviderQuotaRoute),
90
93
  route("GET", "/api/agents/spawn/directories", getHostDirectories),
91
94
  route("GET", "/api/agents/:id/chat", getAgentChatHistory),
92
95
  route("GET", "/api/agents/:id/context-snapshots", getAgentContextSnapshots),
@@ -0,0 +1,48 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { listProviderQuotas, upsertProviderQuota } from "../db";
3
+ import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
4
+ import { cleanString } from "../validation";
5
+ import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
6
+ import type { ProviderQuotaUpdateInput } from "../types";
7
+
8
+ function cleanProviderQuotaUpdate(body: unknown): ProviderQuotaUpdateInput | string {
9
+ if (!isRecord(body)) return "JSON object body required";
10
+ const provider = cleanString(body.provider, "provider", { required: true, max: 80 })!;
11
+ const accountKey = cleanString(body.accountKey, "accountKey", { required: true, max: 240 })!;
12
+ const quota = quotaStateFromUnknown(body.quota);
13
+ const lastAttemptAt = typeof body.lastAttemptAt === "number" && Number.isFinite(body.lastAttemptAt) ? body.lastAttemptAt : undefined;
14
+ const lastError = body.lastError === null ? null : providerQuotaErrorFromUnknown(body.lastError);
15
+ if (!quota && lastError === undefined) return "quota or lastError required";
16
+ const sourceAgentId = cleanString(body.sourceAgentId, "sourceAgentId", { max: 200 });
17
+ return {
18
+ provider,
19
+ accountKey,
20
+ ...(quota ? { quota } : {}),
21
+ ...(lastAttemptAt !== undefined ? { lastAttemptAt } : {}),
22
+ ...(lastError !== undefined ? { lastError } : {}),
23
+ ...(sourceAgentId ? { sourceAgentId } : {}),
24
+ };
25
+ }
26
+
27
+ export const getProviderQuotasRoute: Handler = (req) => {
28
+ const url = new URL(req.url);
29
+ const historyLimit = parseQueryInt(url.searchParams.get("historyLimit") ?? url.searchParams.get("limit"), { min: 0, max: 288, clamp: true });
30
+ if (Number.isNaN(historyLimit)) return error("historyLimit must be an integer between 0 and 288");
31
+ const sinceRaw = url.searchParams.get("since");
32
+ const since = sinceRaw ? Number(sinceRaw) : undefined;
33
+ if (sinceRaw && (!Number.isFinite(since) || since! < 0)) return error("since must be a positive timestamp");
34
+ return json(listProviderQuotas({
35
+ provider: url.searchParams.get("provider") ?? undefined,
36
+ accountKey: url.searchParams.get("accountKey") ?? undefined,
37
+ since,
38
+ historyLimit: historyLimit ?? undefined,
39
+ }));
40
+ };
41
+
42
+ export const postProviderQuotaRoute: Handler = async (req) => {
43
+ const parsed = await parseBody<unknown>(req);
44
+ if (!parsed.ok) return error(parsed.error, parsed.status);
45
+ const input = cleanProviderQuotaUpdate(parsed.body);
46
+ if (typeof input === "string") return error(input);
47
+ return json(upsertProviderQuota(input), 201);
48
+ };
package/src/security.ts CHANGED
@@ -274,6 +274,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
274
274
  // Spawn requires the spawn capability (see requiredScopeFor above) — matches the in-service gate.
275
275
  if (pathname === "/api/agents/spawn") return "command:spawn";
276
276
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
277
+ if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
277
278
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
278
279
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
279
280
  if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";