crewly 1.20.187 → 1.20.188

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 (141) hide show
  1. package/config/skills/agent/core/project-tickets/SKILL.md +3 -2
  2. package/config/skills/agent/core/project-tickets/execute.sh +4 -4
  3. package/config/skills/agent/core/project-tickets/execute.test.sh +2 -2
  4. package/dist/backend/backend/src/constants.d.ts +70 -12
  5. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  6. package/dist/backend/backend/src/constants.js +70 -12
  7. package/dist/backend/backend/src/constants.js.map +1 -1
  8. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts +1 -1
  9. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js +5 -1
  11. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.controller.js.map +1 -1
  12. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.d.ts +1 -1
  13. package/dist/backend/backend/src/controllers/project-tickets/project-tickets.routes.js +1 -1
  14. package/dist/backend/backend/src/controllers/system/usage.controller.d.ts +46 -0
  15. package/dist/backend/backend/src/controllers/system/usage.controller.d.ts.map +1 -0
  16. package/dist/backend/backend/src/controllers/system/usage.controller.js +181 -0
  17. package/dist/backend/backend/src/controllers/system/usage.controller.js.map +1 -0
  18. package/dist/backend/backend/src/index.d.ts +7 -0
  19. package/dist/backend/backend/src/index.d.ts.map +1 -1
  20. package/dist/backend/backend/src/index.js +59 -4
  21. package/dist/backend/backend/src/index.js.map +1 -1
  22. package/dist/backend/backend/src/routes/modules/system.routes.js +3 -3
  23. package/dist/backend/backend/src/routes/modules/system.routes.js.map +1 -1
  24. package/dist/backend/backend/src/services/agent/agent-registration.service.js +7 -7
  25. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  26. package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts +4 -2
  27. package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
  28. package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js +6 -1
  29. package/dist/backend/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
  30. package/dist/backend/backend/src/services/messaging/queue-processor.service.js +2 -2
  31. package/dist/backend/backend/src/services/monitoring/activity-monitor.service.d.ts.map +1 -1
  32. package/dist/backend/backend/src/services/monitoring/activity-monitor.service.js +4 -2
  33. package/dist/backend/backend/src/services/monitoring/activity-monitor.service.js.map +1 -1
  34. package/dist/backend/backend/src/services/monitoring/antigravity-usage-sync.service.d.ts +130 -0
  35. package/dist/backend/backend/src/services/monitoring/antigravity-usage-sync.service.d.ts.map +1 -0
  36. package/dist/backend/backend/src/services/monitoring/antigravity-usage-sync.service.js +274 -0
  37. package/dist/backend/backend/src/services/monitoring/antigravity-usage-sync.service.js.map +1 -0
  38. package/dist/backend/backend/src/services/monitoring/codex-rollout-sync.service.d.ts +152 -0
  39. package/dist/backend/backend/src/services/monitoring/codex-rollout-sync.service.d.ts.map +1 -0
  40. package/dist/backend/backend/src/services/monitoring/codex-rollout-sync.service.js +311 -0
  41. package/dist/backend/backend/src/services/monitoring/codex-rollout-sync.service.js.map +1 -0
  42. package/dist/backend/backend/src/services/monitoring/token-usage.service.d.ts +53 -1
  43. package/dist/backend/backend/src/services/monitoring/token-usage.service.d.ts.map +1 -1
  44. package/dist/backend/backend/src/services/monitoring/token-usage.service.js +51 -3
  45. package/dist/backend/backend/src/services/monitoring/token-usage.service.js.map +1 -1
  46. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts +4 -4
  47. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.d.ts.map +1 -1
  48. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js +1 -1
  49. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-decision.js.map +1 -1
  50. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts +4 -3
  51. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js +7 -4
  53. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot-messages.js.map +1 -1
  54. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts +40 -9
  55. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js +68 -20
  57. package/dist/backend/backend/src/services/project-tickets/ticket-autopilot.service.js.map +1 -1
  58. package/dist/backend/backend/src/services/spend/spend-cap-command.d.ts +48 -18
  59. package/dist/backend/backend/src/services/spend/spend-cap-command.d.ts.map +1 -1
  60. package/dist/backend/backend/src/services/spend/spend-cap-command.js +195 -62
  61. package/dist/backend/backend/src/services/spend/spend-cap-command.js.map +1 -1
  62. package/dist/backend/backend/src/services/spend/spend-cap.gate.d.ts +11 -15
  63. package/dist/backend/backend/src/services/spend/spend-cap.gate.d.ts.map +1 -1
  64. package/dist/backend/backend/src/services/spend/spend-cap.gate.js +8 -13
  65. package/dist/backend/backend/src/services/spend/spend-cap.gate.js.map +1 -1
  66. package/dist/backend/backend/src/services/spend/spend-cap.service.d.ts +179 -64
  67. package/dist/backend/backend/src/services/spend/spend-cap.service.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/spend/spend-cap.service.js +406 -149
  69. package/dist/backend/backend/src/services/spend/spend-cap.service.js.map +1 -1
  70. package/dist/backend/backend/src/services/spend/spend-cap.store.d.ts +52 -11
  71. package/dist/backend/backend/src/services/spend/spend-cap.store.d.ts.map +1 -1
  72. package/dist/backend/backend/src/services/spend/spend-cap.store.js +67 -21
  73. package/dist/backend/backend/src/services/spend/spend-cap.store.js.map +1 -1
  74. package/dist/backend/backend/src/services/spend/spend-cap.wiring.d.ts +3 -1
  75. package/dist/backend/backend/src/services/spend/spend-cap.wiring.d.ts.map +1 -1
  76. package/dist/backend/backend/src/services/spend/spend-cap.wiring.js +21 -7
  77. package/dist/backend/backend/src/services/spend/spend-cap.wiring.js.map +1 -1
  78. package/dist/backend/backend/src/services/spend/spend-ledger.service.d.ts +57 -36
  79. package/dist/backend/backend/src/services/spend/spend-ledger.service.d.ts.map +1 -1
  80. package/dist/backend/backend/src/services/spend/spend-ledger.service.js +80 -55
  81. package/dist/backend/backend/src/services/spend/spend-ledger.service.js.map +1 -1
  82. package/dist/backend/backend/src/services/task-pool/work-item-usage.d.ts +10 -1
  83. package/dist/backend/backend/src/services/task-pool/work-item-usage.d.ts.map +1 -1
  84. package/dist/backend/backend/src/services/task-pool/work-item-usage.js +2 -0
  85. package/dist/backend/backend/src/services/task-pool/work-item-usage.js.map +1 -1
  86. package/dist/backend/backend/src/services/usage/token-format.d.ts +49 -0
  87. package/dist/backend/backend/src/services/usage/token-format.d.ts.map +1 -0
  88. package/dist/backend/backend/src/services/usage/token-format.js +81 -0
  89. package/dist/backend/backend/src/services/usage/token-format.js.map +1 -0
  90. package/dist/backend/backend/src/services/usage/usage-stats.service.d.ts +132 -0
  91. package/dist/backend/backend/src/services/usage/usage-stats.service.d.ts.map +1 -0
  92. package/dist/backend/backend/src/services/usage/usage-stats.service.js +226 -0
  93. package/dist/backend/backend/src/services/usage/usage-stats.service.js.map +1 -0
  94. package/dist/backend/backend/src/types/decision.types.d.ts +2 -2
  95. package/dist/backend/backend/src/types/ticket-autopilot.types.d.ts +24 -7
  96. package/dist/backend/backend/src/types/ticket-autopilot.types.d.ts.map +1 -1
  97. package/dist/backend/backend/src/types/ticket-autopilot.types.js +29 -13
  98. package/dist/backend/backend/src/types/ticket-autopilot.types.js.map +1 -1
  99. package/dist/backend/build-info.json +2 -2
  100. package/dist/cli/backend/src/constants.d.ts +70 -12
  101. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  102. package/dist/cli/backend/src/constants.js +70 -12
  103. package/dist/cli/backend/src/constants.js.map +1 -1
  104. package/dist/cli/backend/src/services/agent/agent-registration.service.js +7 -7
  105. package/dist/cli/backend/src/services/agent/agent-registration.service.js.map +1 -1
  106. package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts +4 -2
  107. package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.d.ts.map +1 -1
  108. package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js +6 -1
  109. package/dist/cli/backend/src/services/messaging/owner-message-watchdog.service.js.map +1 -1
  110. package/dist/cli/backend/src/services/messaging/queue-processor.service.js +2 -2
  111. package/dist/cli/backend/src/services/monitoring/activity-monitor.service.d.ts.map +1 -1
  112. package/dist/cli/backend/src/services/monitoring/activity-monitor.service.js +4 -2
  113. package/dist/cli/backend/src/services/monitoring/activity-monitor.service.js.map +1 -1
  114. package/dist/cli/backend/src/services/monitoring/token-usage.service.d.ts +53 -1
  115. package/dist/cli/backend/src/services/monitoring/token-usage.service.d.ts.map +1 -1
  116. package/dist/cli/backend/src/services/monitoring/token-usage.service.js +51 -3
  117. package/dist/cli/backend/src/services/monitoring/token-usage.service.js.map +1 -1
  118. package/dist/cli/backend/src/services/spend/spend-cap.gate.d.ts +11 -15
  119. package/dist/cli/backend/src/services/spend/spend-cap.gate.d.ts.map +1 -1
  120. package/dist/cli/backend/src/services/spend/spend-cap.gate.js +8 -13
  121. package/dist/cli/backend/src/services/spend/spend-cap.gate.js.map +1 -1
  122. package/dist/cli/backend/src/services/task-pool/work-item-usage.d.ts +10 -1
  123. package/dist/cli/backend/src/services/task-pool/work-item-usage.d.ts.map +1 -1
  124. package/dist/cli/backend/src/services/task-pool/work-item-usage.js +2 -0
  125. package/dist/cli/backend/src/services/task-pool/work-item-usage.js.map +1 -1
  126. package/dist/cli/backend/src/services/usage/token-format.d.ts +49 -0
  127. package/dist/cli/backend/src/services/usage/token-format.d.ts.map +1 -0
  128. package/dist/cli/backend/src/services/usage/token-format.js +81 -0
  129. package/dist/cli/backend/src/services/usage/token-format.js.map +1 -0
  130. package/dist/cli/backend/src/types/decision.types.d.ts +2 -2
  131. package/dist/cli/backend/src/types/ticket-autopilot.types.d.ts +24 -7
  132. package/dist/cli/backend/src/types/ticket-autopilot.types.d.ts.map +1 -1
  133. package/dist/cli/backend/src/types/ticket-autopilot.types.js +29 -13
  134. package/dist/cli/backend/src/types/ticket-autopilot.types.js.map +1 -1
  135. package/frontend/dist/assets/{index-15c2faf2.js → index-49f50b45.js} +276 -276
  136. package/frontend/dist/index.html +1 -1
  137. package/package.json +1 -1
  138. package/dist/backend/backend/src/controllers/system/spend.controller.d.ts +0 -32
  139. package/dist/backend/backend/src/controllers/system/spend.controller.d.ts.map +0 -1
  140. package/dist/backend/backend/src/controllers/system/spend.controller.js +0 -100
  141. package/dist/backend/backend/src/controllers/system/spend.controller.js.map +0 -1
@@ -1,33 +1,36 @@
1
1
  /**
2
- * Per-agent daily spend cap with a hard stop.
2
+ * Daily token caps with a hard stop: per agent, per team and all agents
3
+ * together, plus temporary boosts.
3
4
  *
5
+ * - The unit is tokens (input incl. cached + output, see `eventTokens`).
4
6
  * - Caps are the owner's own limits (unlike the runtime fallback, which
5
7
  * reacts to a provider's usage limit). All are OFF until the owner sets
6
- * one: a default per-agent cap, per-agent overrides, and an optional
7
- * all-agents daily total.
8
+ * one: a default per-agent cap, per-agent overrides, per-team caps, and an
9
+ * optional all-agents total.
8
10
  * - At {@link SPEND_CAP_CONSTANTS.WARN_FRACTION} of a cap the owner gets ONE
9
11
  * heads-up.
10
12
  * - At 100% the agent is hard-stopped: {@link stopOf} answers the delivery
11
13
  * and wake gates, so no new turn starts (the current one may finish) and
12
- * messages stay queued. The owner gets ONE decision card: "Raise to $Y
13
- * today" / "Keep stopped".
14
- * - Local midnight resets everything; raises last for the day they were
15
- * made. When a stop lifts (midnight, raise, cap change) the queued
16
- * messages are released.
17
- *
18
- * Spend comes from {@link SpendLedger} (the shared token ledger and its one
19
- * cost computation).
14
+ * messages stay queued. The owner gets ONE decision card: "Boost +X
15
+ * today" / "Unlimited today" / "Keep stopped".
16
+ * - A boost covers an agent, a team (its members) or everyone. For every
17
+ * agent it covers, it adds `extraTokens` to EVERY cap checked for that
18
+ * agent (its own, its team's, the total), or lifts them all
19
+ * (`unlimited`). It ends at `until` — by default the next local midnight.
20
+ * - Local midnight resets the day; when a stop lifts (midnight, boost, cap
21
+ * change) the queued messages are released.
20
22
  *
21
23
  * specs/2026-10-02-spend-cap.md
22
24
  *
23
25
  * @module services/spend/spend-cap.service
24
26
  */
27
+ import { randomUUID } from 'crypto';
25
28
  import { ORCHESTRATOR_SESSION_NAME, SPEND_CAP_CONSTANTS as C } from '../../constants.js';
26
29
  import { localDateKey, localMidnight } from '../project-tickets/ticket-autopilot-decision.js';
27
- import { formatUsd, spendCapReason } from './spend-cap.gate.js';
28
- import { cents } from './spend-ledger.service.js';
30
+ import { compactTokens, formatTokens, parseTokenAmount } from '../usage/token-format.js';
31
+ import { spendCapReason } from './spend-cap.gate.js';
29
32
  import { emptyDay, emptySpendCapFile } from './spend-cap.store.js';
30
- /** A rejected cap change. */
33
+ /** A rejected change. */
31
34
  export class SpendCapError extends Error {
32
35
  status;
33
36
  /**
@@ -52,42 +55,57 @@ function isCap(v) {
52
55
  /**
53
56
  * Validate a cap from the API.
54
57
  *
55
- * @param v - Value
58
+ * @param v - Value (number or "20M")
56
59
  * @param what - Field name for the error
57
- * @returns The cap (whole cents) or null
60
+ * @returns The cap (whole tokens) or null
58
61
  * @throws SpendCapError
59
62
  */
60
63
  function parseCap(v, what) {
61
- if (v === null)
64
+ if (v === null || v === '')
62
65
  return null;
63
- const n = typeof v === 'string' ? Number(v.replace(/^\$/, '')) : v;
64
- if (!isCap(n))
65
- throw new SpendCapError(400, `${what} must be a positive amount in USD (e.g. 5) or null to turn it off`);
66
- return cents(n);
66
+ const n = parseTokenAmount(v);
67
+ if (n === null)
68
+ throw new SpendCapError(400, `${what} must be a positive number of tokens (e.g. 5000000 or "5M"), or null to turn it off`);
69
+ return n;
67
70
  }
68
71
  /**
69
- * The "Raise to $Y today" amount: the cap times RAISE_FACTOR, rounded up to
70
- * whole dollars, and always above what was already spent.
72
+ * Round up to a whole million (at least 1M).
71
73
  *
72
- * @param capUsd - Cap in force
73
- * @param spentUsd - Spent today
74
- * @returns USD
74
+ * @param tokens - Tokens
75
+ * @returns Rounded tokens
75
76
  */
76
- export function suggestedRaise(capUsd, spentUsd) {
77
- return Math.max(Math.ceil(capUsd * C.RAISE_FACTOR), Math.ceil(spentUsd) + 1);
77
+ export function roundUpMillions(tokens) {
78
+ return Math.max(1_000_000, Math.ceil(tokens / 1_000_000) * 1_000_000);
78
79
  }
79
80
  /**
80
- * Read the amount out of a "Raise to $Y today" option label.
81
+ * The "Boost +X today" amount on a cap card: the cap times BOOST_FACTOR,
82
+ * rounded up to a whole million.
83
+ *
84
+ * @param capTokens - Cap in force
85
+ * @returns Tokens
86
+ */
87
+ export function suggestedBoost(capTokens) {
88
+ return roundUpMillions(capTokens * C.BOOST_FACTOR);
89
+ }
90
+ /**
91
+ * Read the amount out of a "Boost +X tokens today" option label.
81
92
  *
82
93
  * @param label - Option label
83
- * @returns USD, or null when the label is not a raise
94
+ * @returns Tokens, `unlimited`, or null when the label is not a boost
84
95
  */
85
- export function raiseAmountOf(label) {
86
- const m = /^Raise to \$(\d+(?:\.\d+)?) today/i.exec(label ?? '');
87
- return m ? Number(m[1]) : null;
96
+ export function boostOfLabel(label) {
97
+ const text = (label ?? '').trim();
98
+ if (text.toLowerCase().startsWith(C.OPTIONS.UNLIMITED.toLowerCase()))
99
+ return 'unlimited';
100
+ const m = /^Boost \+(\S+?)(?: tokens)? today/i.exec(text);
101
+ return m ? parseTokenAmount(m[1]) : null;
102
+ }
103
+ /** Team target key. */
104
+ export function teamTarget(teamId) {
105
+ return `${C.TEAM_TARGET_PREFIX}${teamId}`;
88
106
  }
89
107
  /**
90
- * Per-agent daily spend cap service.
108
+ * Daily token cap service.
91
109
  */
92
110
  export class SpendCapService {
93
111
  deps;
@@ -97,9 +115,11 @@ export class SpendCapService {
97
115
  timer = null;
98
116
  /** Sessions stopped at the last evaluation (to release them when the stop lifts) */
99
117
  lastStopped = new Set();
100
- /** Evaluation passes run one after another */
101
118
  chain = Promise.resolve();
102
119
  queuedPass = null;
120
+ /** Team index, refreshed on every evaluation (the gate is synchronous) */
121
+ teamList = [];
122
+ teamsOfSession = new Map();
103
123
  /**
104
124
  * @param deps - Collaborators
105
125
  */
@@ -123,6 +143,17 @@ export class SpendCapService {
123
143
  clearInterval(this.timer);
124
144
  this.timer = null;
125
145
  }
146
+ /** Re-read the team list now (tests; the tick does it too). */
147
+ async refreshTeams() {
148
+ const teams = (await this.deps.teams?.().catch(() => [])) ?? [];
149
+ this.teamList = teams;
150
+ const index = new Map();
151
+ for (const t of teams) {
152
+ for (const s of t.members)
153
+ index.set(s, [...(index.get(s) ?? []), t.id]);
154
+ }
155
+ this.teamsOfSession = index;
156
+ }
126
157
  // ---------------------------------------------------------------- caps
127
158
  /** @returns The owner's caps (a copy) */
128
159
  getConfig() {
@@ -139,56 +170,165 @@ export class SpendCapService {
139
170
  if (!patch || typeof patch !== 'object')
140
171
  throw new SpendCapError(400, 'Send the caps to change as JSON');
141
172
  const next = this.getConfig();
142
- if ('defaultAgentCapUsd' in patch)
143
- next.defaultAgentCapUsd = parseCap(patch.defaultAgentCapUsd, 'defaultAgentCapUsd');
144
- if ('totalCapUsd' in patch)
145
- next.totalCapUsd = parseCap(patch.totalCapUsd, 'totalCapUsd');
173
+ if ('defaultAgentCapTokens' in patch)
174
+ next.defaultAgentCapTokens = parseCap(patch.defaultAgentCapTokens, 'defaultAgentCapTokens');
175
+ if ('totalCapTokens' in patch)
176
+ next.totalCapTokens = parseCap(patch.totalCapTokens, 'totalCapTokens');
146
177
  if (patch.agents !== undefined) {
147
178
  if (!patch.agents || typeof patch.agents !== 'object')
148
- throw new SpendCapError(400, 'agents must be an object: { "<session>": 5 | null | "default" }');
179
+ throw new SpendCapError(400, 'agents must be an object: { "<session>": 5000000 | null | "default" }');
149
180
  for (const [session, value] of Object.entries(patch.agents)) {
150
181
  if (!session.trim())
151
182
  throw new SpendCapError(400, 'agents keys must be agent session names');
152
183
  if (value === 'default')
153
- delete next.agentCapsUsd[session];
184
+ delete next.agentCapsTokens[session];
154
185
  else
155
- next.agentCapsUsd[session] = parseCap(value, `agents.${session}`);
186
+ next.agentCapsTokens[session] = parseCap(value, `agents.${session}`);
187
+ }
188
+ }
189
+ if (patch.teams !== undefined) {
190
+ if (!patch.teams || typeof patch.teams !== 'object')
191
+ throw new SpendCapError(400, 'teams must be an object: { "<teamId>": 50000000 | null }');
192
+ for (const [teamId, value] of Object.entries(patch.teams)) {
193
+ if (!teamId.trim())
194
+ throw new SpendCapError(400, 'teams keys must be team ids');
195
+ const cap = parseCap(value, `teams.${teamId}`);
196
+ if (cap === null)
197
+ delete next.teamCapsTokens[teamId];
198
+ else
199
+ next.teamCapsTokens[teamId] = cap;
156
200
  }
157
201
  }
158
202
  next.updatedAt = this.now().toISOString();
159
203
  this.file.config = next;
160
204
  this.persist();
161
- this.logger?.info('Spend caps changed', { caps: next });
205
+ this.logger?.info('Token caps changed', { caps: next });
162
206
  await this.evaluate();
163
207
  return this.getConfig();
164
208
  }
209
+ // ---------------------------------------------------------------- boosts
210
+ /** @returns Boosts in force (expired ones are dropped) */
211
+ activeBoosts() {
212
+ this.pruneBoosts();
213
+ return this.file.boosts.map((b) => ({ ...b }));
214
+ }
165
215
  /**
166
- * Lift a cap for today only.
216
+ * Add a temporary boost.
167
217
  *
168
- * @param target - Agent session, or `*` for the all-agents total
169
- * @param usd - New cap for today (must be above today's spend)
170
- * @returns The new cap for today
171
- * @throws SpendCapError when the amount is not above today's spend
218
+ * @param input - Scope, target, extra tokens or unlimited, optional end
219
+ * @returns The boost
220
+ * @throws SpendCapError on a bad request
172
221
  */
173
- async raiseToday(target, usd) {
174
- const cap = parseCap(usd, 'capUsd');
175
- if (cap === null)
176
- throw new SpendCapError(400, 'capUsd must be a positive amount in USD');
177
- this.rollDay();
178
- const spent = target === C.TOTAL_TARGET ? this.deps.ledger.totalToday() : this.deps.ledger.spentToday(target);
179
- if (cap <= spent) {
180
- throw new SpendCapError(400, `${formatUsd(cap)} is not above what ${this.nameOf(target)} already spent today (${formatUsd(spent)}); pick a higher amount`);
222
+ async boost(input) {
223
+ if (!input || typeof input !== 'object')
224
+ throw new SpendCapError(400, 'Send the boost as JSON');
225
+ const target = await this.resolveBoostTarget(input.scope, input.id);
226
+ const unlimited = input.unlimited === true;
227
+ const extra = unlimited ? null : parseTokenAmount(input.extraTokens);
228
+ if (!unlimited && extra === null)
229
+ throw new SpendCapError(400, 'Send extraTokens (a positive number of tokens, e.g. 20000000 or "20M") or unlimited: true');
230
+ const now = this.now();
231
+ let until = this.nextMidnight();
232
+ if (input.until !== undefined && input.until !== null && input.until !== '') {
233
+ const t = new Date(String(input.until));
234
+ if (Number.isNaN(t.getTime()) || t.getTime() <= now.getTime())
235
+ throw new SpendCapError(400, 'until must be a future time (ISO 8601)');
236
+ until = t;
181
237
  }
182
- this.file.day.raised[target] = cap;
238
+ const boost = {
239
+ id: randomUUID(),
240
+ target,
241
+ ...(unlimited ? { unlimited: true } : { extraTokens: extra }),
242
+ until: until.toISOString(),
243
+ createdAt: now.toISOString(),
244
+ ...(input.by ? { by: input.by } : {}),
245
+ };
246
+ this.pruneBoosts();
247
+ this.file.boosts.push(boost);
183
248
  this.persist();
184
- this.logger?.info('Spend cap raised for today', { target, capUsd: cap, spentUsd: cents(spent) });
249
+ this.logger?.info('Usage boost added', { target, extraTokens: boost.extraTokens, unlimited: boost.unlimited, until: boost.until });
185
250
  await this.evaluate();
186
- return cap;
251
+ return { ...boost };
252
+ }
253
+ /**
254
+ * End a boost early.
255
+ *
256
+ * @param id - Boost id
257
+ * @returns True when it existed
258
+ */
259
+ async removeBoost(id) {
260
+ const before = this.file.boosts.length;
261
+ this.file.boosts = this.file.boosts.filter((b) => b.id !== id);
262
+ if (this.file.boosts.length === before)
263
+ return false;
264
+ this.persist();
265
+ await this.evaluate();
266
+ return true;
267
+ }
268
+ /**
269
+ * Owner-facing description of a boost target.
270
+ *
271
+ * @param target - Boost target
272
+ * @returns e.g. `everyone`, `team CE`, `Ella`
273
+ */
274
+ describeTarget(target) {
275
+ if (target === C.TOTAL_TARGET)
276
+ return 'everyone';
277
+ if (target.startsWith(C.TEAM_TARGET_PREFIX)) {
278
+ const id = target.slice(C.TEAM_TARGET_PREFIX.length);
279
+ return `team ${this.teamList.find((t) => t.id === id)?.name ?? id}`;
280
+ }
281
+ return this.nameOf(target);
282
+ }
283
+ /**
284
+ * Extra tokens / unlimited a boost set gives for one target set.
285
+ *
286
+ * @param covers - Which boost targets apply
287
+ * @returns Sum of extras and whether any is unlimited
288
+ */
289
+ boostFor(covers) {
290
+ let extra = 0;
291
+ let unlimited = false;
292
+ for (const b of this.activeBoostsRaw()) {
293
+ if (!covers(b.target))
294
+ continue;
295
+ if (b.unlimited)
296
+ unlimited = true;
297
+ else
298
+ extra += b.extraTokens ?? 0;
299
+ }
300
+ return { extra, unlimited };
301
+ }
302
+ /** Boosts covering an agent: its own, its teams', everyone's. */
303
+ boostForSession(session) {
304
+ const teams = new Set((this.teamsOfSession.get(session) ?? []).map(teamTarget));
305
+ return this.boostFor((t) => t === C.TOTAL_TARGET || t === session || teams.has(t));
306
+ }
307
+ /**
308
+ * Boosts covering a team (team and everyone boosts) — what the ticket
309
+ * autopilot budget of the team's projects honours.
310
+ *
311
+ * @param teamId - Team id
312
+ * @returns Extra tokens and unlimited
313
+ */
314
+ boostForTeam(teamId) {
315
+ return this.boostForTeams([teamId]);
316
+ }
317
+ /**
318
+ * Boosts covering any of several teams, each boost counted once (a
319
+ * project worked on by two teams).
320
+ *
321
+ * @param teamIds - Team ids
322
+ * @returns Extra tokens and unlimited
323
+ */
324
+ boostForTeams(teamIds) {
325
+ const keys = new Set(teamIds.map(teamTarget));
326
+ return this.boostFor((t) => t === C.TOTAL_TARGET || keys.has(t));
187
327
  }
188
328
  // ---------------------------------------------------------------- gate
189
329
  /**
190
330
  * The stop in force for a session (the delivery / wake gate). Synchronous
191
- * and live: it reads today's spend now, not at the last tick.
331
+ * and live: it reads today's usage now, not at the last tick.
192
332
  *
193
333
  * @param session - Agent session
194
334
  * @returns Stop, or null when the agent may run
@@ -210,72 +350,98 @@ export class SpendCapService {
210
350
  return this.nameOf(session);
211
351
  }
212
352
  /**
213
- * Cap in force today for a target, and where it comes from.
353
+ * An agent's own cap before boosts, and where it comes from.
214
354
  *
215
- * @param target - Agent session, or `*`
355
+ * @param session - Agent session
216
356
  * @returns Cap (null = none) and source
217
357
  */
218
- capOf(target) {
219
- const raised = this.file.day.raised[target];
220
- if (isCap(raised))
221
- return { capUsd: raised, source: 'raised' };
358
+ ownCapOf(session) {
222
359
  const cfg = this.file.config;
223
- if (target === C.TOTAL_TARGET)
224
- return isCap(cfg.totalCapUsd) ? { capUsd: cfg.totalCapUsd, source: 'default' } : { capUsd: null, source: 'none' };
225
- if (Object.prototype.hasOwnProperty.call(cfg.agentCapsUsd, target)) {
226
- const v = cfg.agentCapsUsd[target];
227
- return isCap(v) ? { capUsd: v, source: 'override' } : { capUsd: null, source: 'exempt' };
360
+ if (Object.prototype.hasOwnProperty.call(cfg.agentCapsTokens, session)) {
361
+ const v = cfg.agentCapsTokens[session];
362
+ return isCap(v) ? { capTokens: v, source: 'override' } : { capTokens: null, source: 'exempt' };
228
363
  }
229
- return isCap(cfg.defaultAgentCapUsd) ? { capUsd: cfg.defaultAgentCapUsd, source: 'default' } : { capUsd: null, source: 'none' };
364
+ return isCap(cfg.defaultAgentCapTokens) ? { capTokens: cfg.defaultAgentCapTokens, source: 'default' } : { capTokens: null, source: 'none' };
230
365
  }
231
366
  // ---------------------------------------------------------------- view
232
367
  /**
233
- * Spend plus caps for the API / Settings panel.
368
+ * Usage plus caps for the API / Settings panel.
234
369
  *
235
370
  * @param days - Window
236
371
  * @returns View
237
372
  */
238
373
  async view(days = C.DEFAULT_DAYS) {
239
374
  this.rollDay();
375
+ await this.refreshTeams();
240
376
  const summary = this.deps.ledger.summarize(days);
241
377
  const known = (await this.deps.knownSessions?.().catch(() => [])) ?? [];
242
378
  const rows = new Map(summary.agents.map((a) => [a.session, a]));
243
- for (const s of known) {
379
+ for (const s of [...known, ...this.teamList.flatMap((t) => t.members)]) {
244
380
  if (!rows.has(s))
245
- rows.set(s, { session: s, runtimes: [], todayUsd: 0, windowUsd: 0, daily: summary.days.map(() => 0) });
381
+ rows.set(s, { session: s, runtimes: [], todayTokens: 0, windowTokens: 0, windowCachedTokens: 0, daily: summary.days.map(() => 0) });
246
382
  }
247
383
  const agents = [...rows.values()].map((a) => {
248
- const cap = this.capOf(a.session);
384
+ const own = this.ownCapOf(a.session);
385
+ const b = this.boostForSession(a.session);
249
386
  const stop = this.computeStop(a.session);
250
387
  return {
251
- ...a,
388
+ session: a.session,
252
389
  name: this.nameOf(a.session),
253
- capUsd: cap.capUsd,
254
- capSource: cap.source,
390
+ ...(this.teamsOfSession.get(a.session)?.[0] ? { teamId: this.teamsOfSession.get(a.session)?.[0] } : {}),
391
+ runtimes: a.runtimes,
392
+ todayTokens: a.todayTokens,
393
+ windowTokens: a.windowTokens,
394
+ daily: a.daily,
395
+ baseCapTokens: own.capTokens,
396
+ capTokens: own.capTokens === null || b.unlimited ? null : own.capTokens + b.extra,
397
+ capSource: own.source,
398
+ boosted: b.unlimited || b.extra > 0,
399
+ unlimited: b.unlimited,
255
400
  stopped: !!stop,
256
401
  ...(stop ? { stopReason: spendCapReason(stop, this.nameOf(a.session)) } : {}),
257
402
  };
258
403
  });
259
- const total = this.capOf(C.TOTAL_TARGET).capUsd;
404
+ const teams = this.teamList.map((t) => {
405
+ const base = this.file.config.teamCapsTokens[t.id];
406
+ const b = this.boostForTeam(t.id);
407
+ const cap = isCap(base) && !b.unlimited ? base + b.extra : null;
408
+ const used = this.deps.ledger.groupToday(t.members);
409
+ return {
410
+ teamId: t.id,
411
+ name: t.name,
412
+ members: t.members,
413
+ todayTokens: used,
414
+ baseCapTokens: isCap(base) ? base : null,
415
+ capTokens: cap,
416
+ extraTokens: b.extra,
417
+ unlimited: b.unlimited,
418
+ boosts: this.activeBoostsRaw().filter((x) => x.target === teamTarget(t.id)),
419
+ stopped: cap !== null && used >= cap,
420
+ };
421
+ });
422
+ const totalBoost = this.boostFor((t) => t === C.TOTAL_TARGET);
423
+ const total = isCap(this.file.config.totalCapTokens) && !totalBoost.unlimited ? this.file.config.totalCapTokens + totalBoost.extra : null;
424
+ const { agents: _omit, ...rest } = summary;
425
+ void _omit;
260
426
  return {
261
- ...summary,
427
+ ...rest,
262
428
  agents,
429
+ teams,
263
430
  caps: this.getConfig(),
264
- raisedToday: { ...this.file.day.raised },
265
- totalCapTodayUsd: total,
266
- suggestedAgentCapUsd: summary.p90AgentDayUsd > 0 ? Math.max(1, Math.ceil(summary.p90AgentDayUsd)) : null,
431
+ boosts: this.activeBoosts(),
432
+ totalCapTodayTokens: total,
433
+ suggestedAgentCapTokens: summary.p90AgentDayTokens > 0 ? roundUpMillions(summary.p90AgentDayTokens) : null,
267
434
  totalStopped: total !== null && this.deps.ledger.totalToday() >= total,
268
435
  };
269
436
  }
270
437
  // ---------------------------------------------------------------- enforcement
271
438
  /**
272
- * One enforcement pass: day roll, 80% notices, stop announcements (one
273
- * card per stop), and releasing agents whose stop lifted. Passes run
274
- * one at a time; a call during a pass gets another pass after it.
439
+ * One enforcement pass: day roll, boost expiry, 80% notices, stop
440
+ * announcements (one card per stop), and releasing agents whose stop
441
+ * lifted. Passes run one at a time; a call during a pass gets another
442
+ * pass after it.
275
443
  */
276
444
  evaluate() {
277
- // Coalesce: a pass not yet started is shared; a call during a running
278
- // pass gets a fresh pass after it (so it sees the latest change).
279
445
  if (this.queuedPass)
280
446
  return this.queuedPass;
281
447
  const pass = this.chain.then(async () => {
@@ -284,7 +450,7 @@ export class SpendCapService {
284
450
  await this.evaluateOnce();
285
451
  }
286
452
  catch (err) {
287
- this.logger?.warn('Spend cap evaluation failed', { error: err instanceof Error ? err.message : String(err) });
453
+ this.logger?.warn('Token cap evaluation failed', { error: err instanceof Error ? err.message : String(err) });
288
454
  }
289
455
  });
290
456
  this.queuedPass = pass;
@@ -307,100 +473,135 @@ export class SpendCapService {
307
473
  await this.reply(d.id, 'This card is from an earlier day; the cap has already reset at midnight.');
308
474
  return null;
309
475
  }
310
- const name = this.nameOf(target);
476
+ const who = this.describeTarget(target);
311
477
  if (d.status !== 'resolved')
312
478
  return null;
313
479
  const label = d.options.find((o) => o.key === d.chosenKey)?.label;
314
- const amount = raiseAmountOf(label);
315
- if (amount === null) {
316
- await this.reply(d.id, `OK, ${name} stays stopped until midnight. Its messages stay queued.`);
480
+ const boost = boostOfLabel(label);
481
+ if (boost === null) {
482
+ await this.reply(d.id, `OK, ${who} stays stopped until midnight. Messages stay queued.`);
317
483
  return null;
318
484
  }
319
485
  try {
320
- const cap = await this.raiseToday(target, amount);
321
- await this.reply(d.id, `Raised ${name}'s cap to ${formatUsd(cap)} for today. Queued messages are being delivered.`);
486
+ const scope = target === C.TOTAL_TARGET ? 'all' : target.startsWith(C.TEAM_TARGET_PREFIX) ? 'team' : 'agent';
487
+ const id = scope === 'team' ? target.slice(C.TEAM_TARGET_PREFIX.length) : scope === 'agent' ? target : undefined;
488
+ await this.boost(boost === 'unlimited' ? { scope, id, unlimited: true, by: 'card' } : { scope, id, extraTokens: boost, by: 'card' });
489
+ const what = boost === 'unlimited' ? 'no cap' : `+${formatTokens(boost)}`;
490
+ await this.reply(d.id, `Done: ${what} for ${who} until midnight. Queued messages are being delivered.`);
322
491
  }
323
492
  catch (err) {
324
- await this.reply(d.id, `Could not raise the cap: ${err instanceof Error ? err.message : String(err)}`);
493
+ await this.reply(d.id, `Could not boost: ${err instanceof Error ? err.message : String(err)}`);
325
494
  }
326
495
  return null;
327
496
  }
328
497
  // ---------------------------------------------------------------- internals
329
498
  async evaluateOnce() {
330
499
  this.rollDay();
500
+ const hadBoosts = this.file.boosts.length;
501
+ this.pruneBoosts();
502
+ if (hadBoosts !== this.file.boosts.length)
503
+ this.persist();
331
504
  if (!this.anyCap() && this.lastStopped.size === 0)
332
505
  return;
506
+ await this.refreshTeams();
333
507
  this.deps.ledger.invalidate();
334
508
  const summary = this.deps.ledger.summarize(1);
335
509
  const known = (await this.deps.knownSessions?.().catch(() => [])) ?? [];
336
- const sessions = new Set([...known, ...summary.agents.map((a) => a.session), ...Object.keys(this.file.day.raised).filter((t) => t !== C.TOTAL_TARGET)]);
337
- if (this.lastStopped.size > 0)
338
- for (const s of this.lastStopped)
339
- sessions.add(s);
340
- const stoppedNow = new Set();
510
+ const sessions = new Set([...known, ...summary.agents.map((a) => a.session), ...this.teamList.flatMap((t) => t.members)]);
511
+ for (const s of this.lastStopped)
512
+ sessions.add(s);
341
513
  // The all-agents total first: its notice names the whole crew once.
342
- const total = this.capOf(C.TOTAL_TARGET).capUsd;
343
- if (total !== null) {
344
- const spent = this.deps.ledger.totalToday();
345
- await this.checkThresholds(C.TOTAL_TARGET, total, spent);
514
+ const totalCap = this.capForTarget(C.TOTAL_TARGET);
515
+ if (totalCap !== null)
516
+ await this.checkThresholds(C.TOTAL_TARGET, totalCap, this.deps.ledger.totalToday());
517
+ for (const t of this.teamList) {
518
+ const cap = this.capForTarget(teamTarget(t.id));
519
+ if (cap !== null)
520
+ await this.checkThresholds(teamTarget(t.id), cap, this.deps.ledger.groupToday(t.members));
346
521
  }
522
+ const stoppedNow = new Set();
347
523
  for (const session of sessions) {
348
- const cap = this.capOf(session).capUsd;
524
+ const cap = this.capForTarget(session);
349
525
  if (cap !== null)
350
- await this.checkThresholds(session, cap, this.deps.ledger.spentToday(session));
526
+ await this.checkThresholds(session, cap, this.deps.ledger.usedToday(session));
351
527
  if (this.computeStop(session))
352
528
  stoppedNow.add(session);
353
529
  }
354
530
  const released = [...this.lastStopped].filter((s) => !stoppedNow.has(s));
355
531
  this.lastStopped = stoppedNow;
356
532
  if (released.length > 0) {
357
- this.logger?.info('Spend cap stop lifted — releasing queued messages', { sessions: released });
533
+ this.logger?.info('Token cap stop lifted — releasing queued messages', { sessions: released });
358
534
  await this.deps.onReleased?.(released).catch((err) => this.logger?.warn('Releasing queued messages failed', { sessions: released, error: err instanceof Error ? err.message : String(err) }));
359
535
  }
360
536
  }
361
- async checkThresholds(target, cap, spent) {
537
+ /**
538
+ * The cap in force for a cap target, with the boosts that cover the
539
+ * target itself (for notices and cards).
540
+ */
541
+ capForTarget(target) {
542
+ const cfg = this.file.config;
543
+ if (target === C.TOTAL_TARGET) {
544
+ const b = this.boostFor((t) => t === C.TOTAL_TARGET);
545
+ return isCap(cfg.totalCapTokens) && !b.unlimited ? cfg.totalCapTokens + b.extra : null;
546
+ }
547
+ if (target.startsWith(C.TEAM_TARGET_PREFIX)) {
548
+ const id = target.slice(C.TEAM_TARGET_PREFIX.length);
549
+ const base = cfg.teamCapsTokens[id];
550
+ const b = this.boostForTeam(id);
551
+ return isCap(base) && !b.unlimited ? base + b.extra : null;
552
+ }
553
+ const own = this.ownCapOf(target).capTokens;
554
+ const b = this.boostForSession(target);
555
+ return own !== null && !b.unlimited ? own + b.extra : null;
556
+ }
557
+ async checkThresholds(target, cap, used) {
362
558
  const key = `${target}@${cap}`;
363
559
  const day = this.file.day;
364
- const name = this.nameOf(target);
365
- if (spent >= cap) {
560
+ const who = this.describeTarget(target);
561
+ if (used >= cap) {
366
562
  if (day.stopped.includes(key))
367
563
  return;
368
564
  day.stopped.push(key);
369
565
  if (!day.warned.includes(key))
370
566
  day.warned.push(key);
371
567
  this.persist();
372
- this.logger?.warn('Daily spend cap reached — hard stop (no new turns until midnight)', { target, capUsd: cap, spentUsd: cents(spent) });
373
- await this.announceStop(target, name, cap, spent);
568
+ this.logger?.warn('Daily token cap reached — hard stop (no new turns until midnight)', { target, capTokens: cap, usedTokens: used });
569
+ await this.announceStop(target, who, cap, used);
374
570
  return;
375
571
  }
376
- if (spent >= cap * C.WARN_FRACTION && !day.warned.includes(key)) {
572
+ if (used >= cap * C.WARN_FRACTION && !day.warned.includes(key)) {
377
573
  day.warned.push(key);
378
574
  this.persist();
379
- const pct = Math.floor((spent / cap) * 100);
380
- const who = target === C.TOTAL_TARGET ? 'All agents together have' : `${name} has`;
381
- const its = target === C.TOTAL_TARGET ? 'the' : 'its';
382
- const stops = target === C.TOTAL_TARGET ? 'every agent stops' : 'it stops';
383
- await this.notify(`Heads-up: ${who} spent ${formatUsd(spent)} of ${its} ${formatUsd(cap)} daily spend cap today (${pct}%). At ${formatUsd(cap)} ${stops} taking new turns until midnight.`);
575
+ const pct = Math.floor((used / cap) * 100);
576
+ const subject = target === C.TOTAL_TARGET ? 'All agents together have' : target.startsWith(C.TEAM_TARGET_PREFIX) ? `Team ${who.replace(/^team /, '')} has` : `${who} has`;
577
+ const stops = target === C.TOTAL_TARGET ? 'every agent stops' : target.startsWith(C.TEAM_TARGET_PREFIX) ? 'its members stop' : 'it stops';
578
+ await this.notify(`Heads-up: ${subject} used ${formatTokens(used)} of the ${formatTokens(cap)} daily token cap today (${pct}%). At ${compactTokens(cap)} ${stops} taking new turns until midnight.`);
384
579
  }
385
580
  }
386
- async announceStop(target, name, cap, spent) {
387
- const raise = suggestedRaise(cap, spent);
388
- const who = target === C.TOTAL_TARGET ? 'total' : target === ORCHESTRATOR_SESSION_NAME ? 'orc' : name;
581
+ async announceStop(target, who, cap, used) {
582
+ const extra = suggestedBoost(cap);
583
+ const dmWho = target === C.TOTAL_TARGET ? 'everyone' : target === ORCHESTRATOR_SESSION_NAME ? 'orc' : who.replace(/^team /, '');
389
584
  const headline = target === C.TOTAL_TARGET
390
- ? `All agents together hit the daily total spend cap (${formatUsd(cap)}); every agent is stopped until midnight.`
391
- : `${name} hit its daily spend cap (${formatUsd(cap)}) and is stopped until midnight.`;
585
+ ? `All agents together hit the daily token cap (${formatTokens(cap)}); every agent is stopped until midnight.`
586
+ : target.startsWith(C.TEAM_TARGET_PREFIX)
587
+ ? `Team ${who.replace(/^team /, '')} hit its daily token cap (${formatTokens(cap)}); its members are stopped until midnight.`
588
+ : `${who} hit its daily token cap (${formatTokens(cap)}) and is stopped until midnight.`;
392
589
  const decisions = this.deps.decisions?.() ?? null;
393
590
  if (decisions) {
394
591
  try {
395
592
  const d = await decisions.askSystem({
396
593
  kind: C.DECISION_KIND,
397
594
  system: { key: target, defaultIsDecline: true },
398
- title: 'Daily spend cap reached',
399
- question: `${headline} Raise it for today?`,
595
+ title: 'Daily token cap reached',
596
+ question: `${headline} Boost it for today?`,
400
597
  body: [
401
- `Spent today: ${formatUsd(spent)}. The current turn was allowed to finish; no new turns start. Messages are kept and delivered when it runs again.`,
598
+ `Used today: ${formatTokens(used)}. The current turn was allowed to finish; no new turns start. Messages are kept and delivered when it runs again.`,
599
+ ],
600
+ options: [
601
+ `Boost +${compactTokens(extra)} tokens today — runs again until midnight`,
602
+ `${C.OPTIONS.UNLIMITED} — no cap until midnight`,
603
+ `${C.OPTIONS.KEEP} — resets at midnight`,
402
604
  ],
403
- options: [`Raise to $${raise} today — runs again until midnight`, `${C.OPTIONS.KEEP} — resets at midnight`],
404
605
  default: C.OPTIONS.KEEP,
405
606
  deadline: this.nextMidnight(),
406
607
  sensitive: 'spend',
@@ -410,50 +611,106 @@ export class SpendCapService {
410
611
  return;
411
612
  }
412
613
  catch (err) {
413
- this.logger?.warn('Could not post the spend cap card — sending a notice instead', { target, error: err instanceof Error ? err.message : String(err) });
614
+ this.logger?.warn('Could not post the token cap card — sending a notice instead', { target, error: err instanceof Error ? err.message : String(err) });
414
615
  }
415
616
  }
416
- await this.notify(`${headline} Its messages are queued. Reply \`raise cap for ${who} to $${raise} today\` to raise it.`);
617
+ await this.notify(`${headline} Messages are queued. Reply \`boost ${dmWho} by ${compactTokens(extra)} today\` or \`unlimited today for ${dmWho}\`.`);
417
618
  }
418
619
  anyCap() {
419
620
  const cfg = this.file.config;
420
- return (isCap(cfg.defaultAgentCapUsd) ||
421
- isCap(cfg.totalCapUsd) ||
422
- Object.values(cfg.agentCapsUsd).some(isCap) ||
423
- Object.values(this.file.day.raised).some(isCap));
621
+ return (isCap(cfg.defaultAgentCapTokens) ||
622
+ isCap(cfg.totalCapTokens) ||
623
+ Object.values(cfg.agentCapsTokens).some(isCap) ||
624
+ Object.values(cfg.teamCapsTokens).some(isCap));
424
625
  }
425
626
  computeStop(session) {
426
- const own = this.capOf(session).capUsd;
627
+ const b = this.boostForSession(session);
628
+ if (b.unlimited)
629
+ return null;
630
+ const cfg = this.file.config;
631
+ const own = this.ownCapOf(session).capTokens;
427
632
  if (own !== null) {
428
- const spent = this.deps.ledger.spentToday(session);
429
- if (spent >= own)
430
- return { session, scope: 'agent', capUsd: own, spentUsd: spent };
633
+ const used = this.deps.ledger.usedToday(session);
634
+ if (used >= own + b.extra)
635
+ return { session, scope: 'agent', capTokens: own + b.extra, usedTokens: used };
636
+ }
637
+ for (const teamId of this.teamsOfSession.get(session) ?? []) {
638
+ const base = cfg.teamCapsTokens[teamId];
639
+ if (!isCap(base))
640
+ continue;
641
+ const team = this.teamList.find((t) => t.id === teamId);
642
+ const used = this.deps.ledger.groupToday(team?.members ?? [session]);
643
+ if (used >= base + b.extra)
644
+ return { session, scope: 'team', capTokens: base + b.extra, usedTokens: used, teamId, teamName: team?.name ?? teamId };
431
645
  }
432
- const total = this.capOf(C.TOTAL_TARGET).capUsd;
433
- if (total !== null) {
434
- const spent = this.deps.ledger.totalToday();
435
- if (spent >= total)
436
- return { session, scope: 'total', capUsd: total, spentUsd: spent };
646
+ if (isCap(cfg.totalCapTokens)) {
647
+ const used = this.deps.ledger.totalToday();
648
+ if (used >= cfg.totalCapTokens + b.extra)
649
+ return { session, scope: 'total', capTokens: cfg.totalCapTokens + b.extra, usedTokens: used };
437
650
  }
438
651
  return null;
439
652
  }
440
653
  stopKey(stop) {
441
- return `${stop.scope === 'total' ? C.TOTAL_TARGET : stop.session}@${stop.capUsd}`;
654
+ const target = stop.scope === 'total' ? C.TOTAL_TARGET : stop.scope === 'team' ? teamTarget(stop.teamId ?? '') : stop.session;
655
+ return `${target}@${stop.capTokens}`;
442
656
  }
443
657
  scheduleEvaluate() {
444
658
  void this.evaluate();
445
659
  }
660
+ activeBoostsRaw() {
661
+ const now = this.now().getTime();
662
+ return this.file.boosts.filter((b) => new Date(b.until).getTime() > now);
663
+ }
664
+ pruneBoosts() {
665
+ const live = this.activeBoostsRaw();
666
+ if (live.length !== this.file.boosts.length) {
667
+ const ended = this.file.boosts.filter((b) => !live.includes(b));
668
+ this.file.boosts = live;
669
+ this.logger?.info('Usage boosts ended', { boosts: ended.map((b) => ({ target: b.target, until: b.until })) });
670
+ }
671
+ }
672
+ async resolveBoostTarget(scope, id) {
673
+ if (scope === 'all')
674
+ return C.TOTAL_TARGET;
675
+ const raw = typeof id === 'string' ? id.trim() : '';
676
+ if (scope === 'team') {
677
+ if (!raw)
678
+ throw new SpendCapError(400, 'id is required for a team boost (team id or name)');
679
+ await this.refreshTeams();
680
+ const w = raw.toLowerCase();
681
+ const hit = this.teamList.find((t) => t.id === raw) ?? this.teamList.filter((t) => t.name.toLowerCase() === w).at(0);
682
+ if (!hit)
683
+ throw new SpendCapError(404, `No team called "${raw}"`);
684
+ return teamTarget(hit.id);
685
+ }
686
+ if (scope === 'agent') {
687
+ if (!raw)
688
+ throw new SpendCapError(400, 'id is required for an agent boost (session or name)');
689
+ await this.refreshTeams();
690
+ const known = new Set([...((await this.deps.knownSessions?.().catch(() => [])) ?? []), ...this.teamList.flatMap((t) => t.members)]);
691
+ if (known.has(raw))
692
+ return raw;
693
+ const byName = [...known].filter((s) => this.nameOf(s).toLowerCase() === raw.toLowerCase());
694
+ if (byName.length === 1)
695
+ return byName[0];
696
+ if (byName.length > 1)
697
+ throw new SpendCapError(400, `More than one agent is called "${raw}"; use its session name`);
698
+ return raw;
699
+ }
700
+ throw new SpendCapError(400, 'scope must be team, agent or all');
701
+ }
446
702
  /** Reset today's bookkeeping when the local day changed. */
447
703
  rollDay() {
448
704
  const today = localDateKey(this.now());
449
705
  if (this.file.day.date === today)
450
706
  return;
451
- const had = Object.keys(this.file.day.raised).length + this.file.day.stopped.length;
707
+ const had = this.file.day.stopped.length;
452
708
  this.file.day = emptyDay(today);
709
+ this.pruneBoosts();
453
710
  this.deps.ledger.invalidate();
454
711
  this.persist();
455
712
  if (had > 0)
456
- this.logger?.info('Spend caps reset for the new day', { date: today });
713
+ this.logger?.info('Token caps reset for the new day', { date: today });
457
714
  }
458
715
  nextMidnight() {
459
716
  const next = localMidnight(this.now());
@@ -476,7 +733,7 @@ export class SpendCapService {
476
733
  await this.deps.notifyOwner(text);
477
734
  }
478
735
  catch (err) {
479
- this.logger?.warn('Could not send the spend cap notice', { error: err instanceof Error ? err.message : String(err) });
736
+ this.logger?.warn('Could not send the token cap notice', { error: err instanceof Error ? err.message : String(err) });
480
737
  }
481
738
  }
482
739
  async reply(decisionId, text) {
@@ -487,7 +744,7 @@ export class SpendCapService {
487
744
  this.deps.store.write(this.file);
488
745
  }
489
746
  catch (err) {
490
- this.logger?.warn('Could not save spend caps', { error: err instanceof Error ? err.message : String(err) });
747
+ this.logger?.warn('Could not save token caps', { error: err instanceof Error ? err.message : String(err) });
491
748
  }
492
749
  }
493
750
  }