pilotswarm 0.5.41 → 0.5.43

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 (112) hide show
  1. package/mcp/README.md +25 -1
  2. package/mcp/dist/src/auth.d.ts.map +1 -1
  3. package/mcp/dist/src/auth.js +5 -3
  4. package/mcp/dist/src/auth.js.map +1 -1
  5. package/mcp/dist/src/resources/models.d.ts.map +1 -1
  6. package/mcp/dist/src/resources/models.js +21 -14
  7. package/mcp/dist/src/resources/models.js.map +1 -1
  8. package/mcp/dist/src/server.d.ts.map +1 -1
  9. package/mcp/dist/src/server.js +2 -0
  10. package/mcp/dist/src/server.js.map +1 -1
  11. package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/agent-packages.js +28 -16
  13. package/mcp/dist/src/tools/agent-packages.js.map +1 -1
  14. package/mcp/dist/src/tools/models.d.ts.map +1 -1
  15. package/mcp/dist/src/tools/models.js +55 -47
  16. package/mcp/dist/src/tools/models.js.map +1 -1
  17. package/mcp/dist/src/tools/providers.d.ts +18 -0
  18. package/mcp/dist/src/tools/providers.d.ts.map +1 -0
  19. package/mcp/dist/src/tools/providers.js +284 -0
  20. package/mcp/dist/src/tools/providers.js.map +1 -0
  21. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  22. package/mcp/dist/src/tools/sessions.js +10 -1
  23. package/mcp/dist/src/tools/sessions.js.map +1 -1
  24. package/mcp/dist/src/tools/system.js +2 -2
  25. package/mcp/dist/src/tools/system.js.map +1 -1
  26. package/mcp/dist/src/util/respond.d.ts.map +1 -1
  27. package/mcp/dist/src/util/respond.js +6 -0
  28. package/mcp/dist/src/util/respond.js.map +1 -1
  29. package/package.json +3 -3
  30. package/tui/README.md +8 -5
  31. package/tui/src/agents-cli.js +80 -78
  32. package/tui/src/app.js +119 -17
  33. package/tui/src/node-sdk-transport.js +150 -347
  34. package/ui/core/src/controller.js +1079 -23
  35. package/ui/core/src/history.js +25 -1
  36. package/ui/core/src/reducer.js +649 -1
  37. package/ui/core/src/selectors.js +906 -6
  38. package/ui/core/src/state.js +163 -3
  39. package/ui/react/src/components.js +146 -4
  40. package/ui/react/src/web-app.js +1942 -125
  41. package/web/README.md +4 -4
  42. package/web/api/router.js +33 -0
  43. package/web/dist/assets/{abnfDiagram-VRR7QNED-kpxu_PEa.js → abnfDiagram-VRR7QNED-BqKIaTz5.js} +1 -1
  44. package/web/dist/assets/{arc-BnVQmtB5.js → arc-DCSwKNAt.js} +1 -1
  45. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-CDynQ_6_.js → architectureDiagram-ZJ3FMSHR-B6j7Bv5B.js} +1 -1
  46. package/web/dist/assets/{blockDiagram-677ZJIJ3-BUpYaSDb.js → blockDiagram-677ZJIJ3-BT1iegLO.js} +1 -1
  47. package/web/dist/assets/{c4Diagram-LMCZKHZV-ZL8eskDB.js → c4Diagram-LMCZKHZV-DXEt-_ny.js} +1 -1
  48. package/web/dist/assets/channel-BLVoqfUa.js +1 -0
  49. package/web/dist/assets/{chunk-2Q5K7J3B-o0Nsyp1T.js → chunk-2Q5K7J3B-B144-8I2.js} +1 -1
  50. package/web/dist/assets/{chunk-32BRIVSS-CH5CSNRP.js → chunk-32BRIVSS-CMHrtYOC.js} +1 -1
  51. package/web/dist/assets/{chunk-5VM5RSS4-C9wwtPWL.js → chunk-5VM5RSS4-4r8OmbGE.js} +1 -1
  52. package/web/dist/assets/{chunk-EX3LRPZG--F9yFbhA.js → chunk-EX3LRPZG-R_j3wosN.js} +1 -1
  53. package/web/dist/assets/{chunk-JWPE2WC7-BPIstyWL.js → chunk-JWPE2WC7-BKlFEYXC.js} +1 -1
  54. package/web/dist/assets/{chunk-MOJQB5TN-5Dm2u-0k.js → chunk-MOJQB5TN-C2S4TPSa.js} +1 -1
  55. package/web/dist/assets/{chunk-RYQCIY6F-Dbtf9iQK.js → chunk-RYQCIY6F-Y2kpzPD7.js} +1 -1
  56. package/web/dist/assets/{chunk-V7JOEXUC-DrZEBJ5l.js → chunk-V7JOEXUC-kAOP_3Kc.js} +1 -1
  57. package/web/dist/assets/{chunk-VR4S4FIN-AqAUynqK.js → chunk-VR4S4FIN-B2Au1EfR.js} +1 -1
  58. package/web/dist/assets/{chunk-XXDRQBXY-DQlFaqCy.js → chunk-XXDRQBXY-CZfTG6DD.js} +1 -1
  59. package/web/dist/assets/classDiagram-OUVF2IWQ-BgaYclQI.js +1 -0
  60. package/web/dist/assets/classDiagram-v2-EOCWNBFH-BgaYclQI.js +1 -0
  61. package/web/dist/assets/{cose-bilkent-JH36ORCC-RAtPG1Ou.js → cose-bilkent-JH36ORCC--tNLpMEb.js} +1 -1
  62. package/web/dist/assets/{cynefin-VYW2F7L2-CbeHaUWZ.js → cynefin-VYW2F7L2-BeIRFyvX.js} +1 -1
  63. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-B6paqF5Y.js → cynefinDiagram-TSTJHNR4-Bm5lb_xz.js} +1 -1
  64. package/web/dist/assets/{dagre-VKFMJZFB-DziTBO2K.js → dagre-VKFMJZFB-Cz1i_B0y.js} +1 -1
  65. package/web/dist/assets/{diagram-FQU43EPY-CPRUiLcr.js → diagram-FQU43EPY-BF0j7c1T.js} +1 -1
  66. package/web/dist/assets/{diagram-G47NLZAW-CIMVdcL1.js → diagram-G47NLZAW-C2QWJ-Y4.js} +1 -1
  67. package/web/dist/assets/{diagram-NH7WQ7WH-CHPFllxR.js → diagram-NH7WQ7WH-BiNeThAA.js} +1 -1
  68. package/web/dist/assets/{diagram-OA4YK3LP-DwS6unBJ.js → diagram-OA4YK3LP-Cq7_37rx.js} +1 -1
  69. package/web/dist/assets/{diagram-WEI45ONY-DKWgkObI.js → diagram-WEI45ONY-Cl0GjIma.js} +1 -1
  70. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-zAVWV6-D.js → ebnfDiagram-CCIWWBDH-C-dTpVrf.js} +1 -1
  71. package/web/dist/assets/{erDiagram-Q63AITRT-D0tYX4h0.js → erDiagram-Q63AITRT-RPSg6Lws.js} +1 -1
  72. package/web/dist/assets/{flowDiagram-23GEKE2U-Cfq4I7R0.js → flowDiagram-23GEKE2U-CslnZm0D.js} +1 -1
  73. package/web/dist/assets/{ganttDiagram-NO4QXBWP-wgBAHr5I.js → ganttDiagram-NO4QXBWP-dzr6nimS.js} +1 -1
  74. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-BTIPX5PR.js → gitGraphDiagram-IHSO6WYX-UpjDHGP0.js} +1 -1
  75. package/web/dist/assets/index-C-ujPKpy.css +1 -0
  76. package/web/dist/assets/index-Cf_e477H.js +24 -0
  77. package/web/dist/assets/{infoDiagram-FWYZ7A6U-CRLiQ87D.js → infoDiagram-FWYZ7A6U-R4qLdwe7.js} +1 -1
  78. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CwzwUis1.js → ishikawaDiagram-FXEZZL3T-Cst7PjW4.js} +1 -1
  79. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B9pqMeFw.js → journeyDiagram-5HDEW3XC-CugDTBMp.js} +1 -1
  80. package/web/dist/assets/{kanban-definition-HUTT4EX6-CZ5Xky4p.js → kanban-definition-HUTT4EX6-D6Yf2Lgg.js} +1 -1
  81. package/web/dist/assets/{linear-BLZ2eH4a.js → linear-hUntimCu.js} +1 -1
  82. package/web/dist/assets/{mermaid.core-CPN1IVjz.js → mermaid.core-BhKlcGBI.js} +4 -4
  83. package/web/dist/assets/{mindmap-definition-LN4V7U3C-MdC8d2tc.js → mindmap-definition-LN4V7U3C-DKVBRUj4.js} +1 -1
  84. package/web/dist/assets/{papaparse.min-CtrVJupf.js → papaparse.min-DbTWBfDP.js} +1 -1
  85. package/web/dist/assets/{pegDiagram-2B236MQR-B2DqAc-A.js → pegDiagram-2B236MQR-DeilQyRr.js} +1 -1
  86. package/web/dist/assets/{pieDiagram-ENE6RG2P-C3QAoXZh.js → pieDiagram-ENE6RG2P-CSVE3nUJ.js} +1 -1
  87. package/web/dist/assets/pilotswarm-DaQpAPI0.js +97 -0
  88. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-BoUr5qFH.js → quadrantDiagram-ABIIQ3AL-Di-WUa_j.js} +1 -1
  89. package/web/dist/assets/{railroadDiagram-RFXS5EU6-bZzE4KFN.js → railroadDiagram-RFXS5EU6-jBlJSP-m.js} +1 -1
  90. package/web/dist/assets/{requirementDiagram-TGXJPOKE-B2ENAqLS.js → requirementDiagram-TGXJPOKE-BlLuScOr.js} +1 -1
  91. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-AJZ3h46Z.js → sankeyDiagram-HTMAVEWB-D76NurlB.js} +1 -1
  92. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-QUf0VVE7.js → sequenceDiagram-DBY2YBRQ-BGPIUJo0.js} +1 -1
  93. package/web/dist/assets/{sizeCapture-X5ZJPWSS-Cv9RcUy6.js → sizeCapture-X5ZJPWSS-DKmGUrSI.js} +1 -1
  94. package/web/dist/assets/{stateDiagram-2N3HPSRC-hDiye45t.js → stateDiagram-2N3HPSRC-DAYHO_nW.js} +1 -1
  95. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-jZoHf_Ku.js +1 -0
  96. package/web/dist/assets/{swimlanes-5IMT3BWC-hSk_OF85.js → swimlanes-5IMT3BWC-CiRowk4M.js} +2 -2
  97. package/web/dist/assets/swimlanesDiagram-G3AALYLV-C0ms7UKB.js +8 -0
  98. package/web/dist/assets/{timeline-definition-FHXFAJF6-ccOZEJ5e.js → timeline-definition-FHXFAJF6-iN7kQL24.js} +1 -1
  99. package/web/dist/assets/{vennDiagram-L72KCM5P-Bk5DLH_V.js → vennDiagram-L72KCM5P-C6TtJ36b.js} +1 -1
  100. package/web/dist/assets/{wardleyDiagram-EHGQE667-C20AX8ot.js → wardleyDiagram-EHGQE667-CBiv7EU-.js} +1 -1
  101. package/web/dist/assets/{xychartDiagram-FW5EYKEG-DaHqmi-R.js → xychartDiagram-FW5EYKEG-zXUejHih.js} +1 -1
  102. package/web/dist/index.html +3 -3
  103. package/web/runtime.js +220 -2
  104. package/web/server.js +35 -7
  105. package/web/dist/assets/channel-Bi5A6sHR.js +0 -1
  106. package/web/dist/assets/classDiagram-OUVF2IWQ-D8dCxI54.js +0 -1
  107. package/web/dist/assets/classDiagram-v2-EOCWNBFH-D8dCxI54.js +0 -1
  108. package/web/dist/assets/index-OHj-gGHp.js +0 -24
  109. package/web/dist/assets/index-eTH1FBtN.css +0 -1
  110. package/web/dist/assets/pilotswarm-CF1nmpMy.js +0 -97
  111. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-Bu4ufvaU.js +0 -1
  112. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTFePb1Y.js +0 -8
@@ -28,7 +28,13 @@ import {
28
28
  getContextHeaderBadge,
29
29
  } from "./context-usage.js";
30
30
  import { canonicalSystemTitle } from "./system-titles.js";
31
- import { normalizeArtifactEntries } from "./state.js";
31
+ import {
32
+ BUDGET_PERIODS,
33
+ BUDGET_SERIES_DAYS,
34
+ BUDGET_SERIES_RANGES,
35
+ normalizeArtifactEntries,
36
+ normalizeSessionPause,
37
+ } from "./state.js";
32
38
  import { isRecoverableTransportErrorText } from "./session-errors.js";
33
39
 
34
40
  export const ACTIVE_HIGHLIGHT_BACKGROUND = "activeHighlightBackground";
@@ -82,6 +88,13 @@ function getSessionVisualStatus(session) {
82
88
  if (!session) return "unknown";
83
89
  const status = session.status || "unknown";
84
90
  const dormant = status === "waiting" || status === "idle" || status === "unknown";
91
+ // A budget pause outranks every other dormant reading — see the same
92
+ // branch in the reducer's computeRawSessionVisualStatus, which the two
93
+ // must agree on. The session is not "waiting" in the sense the list means
94
+ // it: nothing happens until a person changes a number.
95
+ if (dormant && normalizeSessionPause(session)) {
96
+ return "budget_paused";
97
+ }
85
98
  if (session.cronActive === true && dormant) {
86
99
  return "cron_waiting";
87
100
  }
@@ -96,7 +109,115 @@ function getSessionVisualStatus(session) {
96
109
  return status;
97
110
  }
98
111
 
112
+ // ── Budget pauses: the words a paused session is described in ──────────
113
+ //
114
+ // The canonical sentences live in packages/sdk/src/provider-budgets.ts
115
+ // (`budgetWaitReason`) and are what the server writes into the wait itself.
116
+ // These are the SHORT forms — a list row has a few characters, not a
117
+ // sentence — plus a matching long form for the surface's "Paused now" band,
118
+ // which has to say which of the four reasons it is because each has a
119
+ // different remedy.
120
+
121
+ const BUDGET_PERIOD_WORD = { day: "daily", week: "weekly", month: "monthly" };
122
+
123
+ /** "paused · limit" — what fits beside a title. */
124
+ function budgetPauseLabel(pause) {
125
+ // The row status is debounced (see mergeSessionRowVisualStatus), so it can
126
+ // outlive by a few seconds the record that produced it. Name the state
127
+ // without inventing a reason for it.
128
+ if (!pause) return "paused";
129
+ switch (pause.kind) {
130
+ case "no_provider": return "no provider";
131
+ case "hold": return "paused · hold";
132
+ case "allowance": return "paused · allowance";
133
+ case "limit":
134
+ default: return "paused · limit";
135
+ }
136
+ }
137
+
138
+ /** The sentence the band and the selected row show. Names the remedy. */
139
+ function budgetPauseSentence(pause, nowMs = Date.now()) {
140
+ const provider = pause?.provider || "this session's provider";
141
+ // One reset spelling for the whole surface — see formatUntilLabel. The
142
+ // server writes the same sentence with a raw timestamp; this is the only
143
+ // clause that differs, because a bare UTC stamp is not something a reader
144
+ // can subtract from their own clock.
145
+ const until = pause?.resetsAtUtc ? formatUntilLabel(pause.resetsAtUtc, nowMs) : null;
146
+ const when = until ? ` Resets ${until}.` : "";
147
+ switch (pause?.kind) {
148
+ case "no_provider":
149
+ return pause?.provider
150
+ ? `No provider named ${pause.provider}. This session waits until that name exists again, or until it is switched to another provider and model.`
151
+ : "This session's model does not name a provider, so nothing can pay for it. Switch it to a provider and model.";
152
+ case "hold":
153
+ return pause?.resetsAtUtc
154
+ ? `${provider} is on hold.${when}`
155
+ : `${provider} is on hold until an administrator releases it.`;
156
+ case "allowance": {
157
+ // A record that names no period still reads as a sentence: the
158
+ // word is dropped, not left as a gap.
159
+ const period = BUDGET_PERIOD_WORD[pause?.period];
160
+ return `Your ${period ? `${period} ` : ""}allowance on ${provider} is used up. `
161
+ + `The provider has room; your share of it does not.${when}`;
162
+ }
163
+ case "limit":
164
+ default: {
165
+ const period = BUDGET_PERIOD_WORD[pause?.period];
166
+ const scope = pause?.modelQualified ? ` for ${pause.modelQualified}` : "";
167
+ return `${provider} has reached its ${period ? `${period} ` : ""}limit${scope}.${when}`;
168
+ }
169
+ }
170
+ }
171
+
172
+ /**
173
+ * The pause record for one session, from whichever place holds it.
174
+ *
175
+ * A session row carries `pauseState` on deployments that publish it. Where it
176
+ * does not, the row arrives with a bare status of "waiting" and the reason
177
+ * lives only in `listPausedSessions` — which is read into `state.budget.paused`
178
+ * — so three sessions stopped for three different reasons render identically.
179
+ * Joining the two here means the list, the detail box and the "Paused now"
180
+ * band all describe the same pause in the same words.
181
+ */
182
+ function budgetPauseForSession(session, state) {
183
+ const own = normalizeSessionPause(session);
184
+ if (own) return own;
185
+ const sessionId = session?.sessionId;
186
+ if (!sessionId) return null;
187
+ const rows = Array.isArray(state?.budget?.paused) ? state.budget.paused : [];
188
+ const row = rows.find((entry) => entry?.sessionId === sessionId);
189
+ // listPausedSessions calls the record `pause`; a session row calls it
190
+ // `pauseState`. One normalizer reads both.
191
+ return row ? normalizeSessionPause({ pauseState: row.pause }) : null;
192
+ }
193
+
194
+ /**
195
+ * The pause a session row draws, or null.
196
+ *
197
+ * Colour: RED, for every kind. A pause is a full stop that only a person can
198
+ * clear — the limits in this model are hard and there is no warn-only one —
199
+ * and yellow already means "waiting, nothing to do". The row still says
200
+ * "paused", not "failed", so the colour is the alarm and the label is the
201
+ * fact.
202
+ */
203
+ function sessionPauseMark(session, state = null) {
204
+ const pause = budgetPauseForSession(session, state);
205
+ if (!pause) return null;
206
+ return {
207
+ kind: pause.kind,
208
+ provider: pause.provider,
209
+ period: pause.period,
210
+ // A vanished provider never counts down — see budgetClearsLabel.
211
+ resetsAtUtc: pause.kind === "no_provider" ? null : pause.resetsAtUtc,
212
+ label: budgetPauseLabel(pause),
213
+ reason: budgetPauseSentence(pause),
214
+ clears: budgetClearsLabel(pause),
215
+ color: "red",
216
+ };
217
+ }
218
+
99
219
  function sessionStatusLabelForVisualStatus(session, status) {
220
+ if (status === "budget_paused") return budgetPauseLabel(normalizeSessionPause(session));
100
221
  if (status === "cron_waiting") return "waiting";
101
222
  if (status === "awaiting_children") {
102
223
  // The row status is debounced (see mergeSessionRowVisualStatus), so it
@@ -209,6 +330,8 @@ function getSessionRowVisualKind(session, mode = "local") {
209
330
  function sessionStatusColor(session, mode = "local") {
210
331
  switch (getSessionRowVisualKind(session, mode)) {
211
332
  case "running": return "green";
333
+ // See sessionPauseMark for why a pause is red rather than yellow.
334
+ case "budget_paused": return "red";
212
335
  case "cron_waiting": return "yellow";
213
336
  // Distinct from both running (this session is not doing the work) and
214
337
  // waiting (nothing is happening) — something IS running, one level down.
@@ -228,6 +351,9 @@ function sessionStatusColor(session, mode = "local") {
228
351
  function sessionStatusIcon(session, mode = "local") {
229
352
  switch (getSessionRowVisualKind(session, mode)) {
230
353
  case "running": return "*";
354
+ // Pause bars. One cell wide, like every other glyph in this column —
355
+ // the portal draws a coloured dot instead and ignores the character.
356
+ case "budget_paused": return "‖";
231
357
  case "cron_waiting": return "~";
232
358
  case "awaiting_children": return ">";
233
359
  case "waiting": return "~";
@@ -913,6 +1039,28 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
913
1039
  if (collapseBadge) {
914
1040
  titleRuns.push({ text: ` ${collapseBadge.text}`, color: collapseBadge.color, bold: collapseBadge.bold });
915
1041
  }
1042
+ // A budget pause rides on the TITLE line, not only in the selected-row
1043
+ // detail: a session that will not move again until someone raises a limit
1044
+ // has to say so from the list, or the only symptom is a session that has
1045
+ // been quiet for a suspiciously long time.
1046
+ //
1047
+ // Two ways in, and they are gated differently on purpose:
1048
+ //
1049
+ // the session's own `pauseState` — this is what the reducer debounces
1050
+ // into the row status, so the label rides the SAME debounced kind the
1051
+ // dot and the glyph read and the three cannot disagree. A pause that
1052
+ // has just been released leaves the dot red for a few seconds with no
1053
+ // label, exactly as every other status change here does.
1054
+ // the paused list (state.budget.paused) — not debounced, and the only
1055
+ // place the reason exists on a deployment whose session rows carry a
1056
+ // bare "waiting". Shown whenever the row is waiting, or three sessions
1057
+ // stopped for three different reasons all read the same.
1058
+ const pauseMark = (getSessionRowVisualKind(session, mode) === "budget_paused" || session?.status === "waiting")
1059
+ ? sessionPauseMark(session, state)
1060
+ : null;
1061
+ if (pauseMark) {
1062
+ titleRuns.push({ text: ` ${pauseMark.label}`, color: pauseMark.color });
1063
+ }
916
1064
  // Scheduled sessions keep a compact clock glyph on the title; the full
917
1065
  // cron cadence rides in the detail line.
918
1066
  const cronBadge = getCronBadge(session);
@@ -965,6 +1113,13 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
965
1113
  const childCount = totalDescendantCounts?.[session?.sessionId];
966
1114
  if (childCount) { pushSep(); detailRuns.push({ text: `${childCount} child${childCount === 1 ? "" : "ren"}`, color: "gray" }); }
967
1115
  if (cronBadge) { pushSep(); detailRuns.push({ text: cronBadge.text, color: cronBadge.color }); }
1116
+ // Why this session is parked, on the selected row. The reason names
1117
+ // the remedy, and the four reasons have four different ones, so the
1118
+ // detail line carries the sentence rather than the short label.
1119
+ if (pauseMark) {
1120
+ pushSep();
1121
+ detailRuns.push({ text: pauseMark.reason, color: pauseMark.color });
1122
+ }
968
1123
  // Shared-session marker (security model): private needs no marker;
969
1124
  // shared_read/shared_write surface here in the selected-row details.
970
1125
  if (session?.visibility === "shared_read" || session?.visibility === "shared_write") {
@@ -1025,6 +1180,10 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
1025
1180
  }
1026
1181
  : null,
1027
1182
  childBadge: collapseBadge ? { text: collapseBadge.text, color: collapseBadge.color } : null,
1183
+ // The pause as a CHIP: the portal draws one, the TUI already has
1184
+ // the same words in titleRuns above and ignores this.
1185
+ // { kind, label, reason, provider, period, resetsAtUtc, color } or null.
1186
+ pause: pauseMark,
1028
1187
  cron: Boolean(cronBadge),
1029
1188
  age: relTime || null,
1030
1189
  model: modelLabel || null,
@@ -1130,6 +1289,17 @@ export function selectSessionRows(state) {
1130
1289
  // back a row built before the draw and the marker never appears.
1131
1290
  state.canvas?.bySessionId?.[entry.sessionId],
1132
1291
  state.canvas?.prefs?.[entry.sessionId],
1292
+ // Why the session is parked, if it is. `session` above changes
1293
+ // identity whenever this does, so this entry is belt and braces —
1294
+ // but a row's inputs should be readable off this list, and a
1295
+ // paused row that never repaints is a session nobody can see is
1296
+ // stuck.
1297
+ session?.pauseState,
1298
+ // The other half of the same answer: on a deployment that does not
1299
+ // publish `pauseState` on a session row, the reason is only in the
1300
+ // paused list. Omit this and the memo hands back a row built before
1301
+ // that list arrived, and the row says "waiting" forever.
1302
+ state.budget?.paused,
1133
1303
  ];
1134
1304
  const cached = memo.get(entry.sessionId);
1135
1305
  if (cached && sameRowDeps(cached.deps, deps)) return cached.row;
@@ -1174,6 +1344,10 @@ export function selectSessionRows(state) {
1174
1344
  // "canvas" a canvas exists and nothing is new
1175
1345
  // "unseen" it changed since this user last looked at it
1176
1346
  canvasMark: sessionCanvasMark(state, entry.sessionId),
1347
+ // Why this session is parked on a budget, or null. Lifted out of
1348
+ // `chrome` as well so a host that draws its own row (the phone's
1349
+ // list) can act on it without reading the portal's descriptor.
1350
+ pause: rowView.chrome?.pause || null,
1177
1351
  };
1178
1352
  memo.set(entry.sessionId, { deps, row });
1179
1353
  return row;
@@ -3682,6 +3856,125 @@ export function selectAdminConsole(state) {
3682
3856
 
3683
3857
  const systemGhcpKey = admin.systemGhcpKey || { supported: false, loading: false, configured: false, changedBy: null, changedAt: null, error: null };
3684
3858
  const isAdmin = Boolean(profile?.isAdmin);
3859
+ const modelProviderState = admin.modelProviders || {};
3860
+ const providerRows = Array.isArray(modelProviderState.providers) ? modelProviderState.providers : [];
3861
+ const modelRows = Array.isArray(modelProviderState.models) ? modelProviderState.models : [];
3862
+ const ownProviderRows = providerRows.filter((provider) => provider?.class !== "shared"
3863
+ && (provider?.mine === true || provider?.usableByMe === true));
3864
+ const sharedProviderRows = providerRows.filter((provider) => provider?.class === "shared");
3865
+ const choiceRows = (providers) => providers.flatMap((provider) => modelRows
3866
+ .filter((model) => model?.providerId === provider.name || model?.providerId === provider.typeId)
3867
+ .map((model) => {
3868
+ const modelName = model?.modelName || model?.name || String(model?.qualifiedName || "").split(":").pop();
3869
+ const qualifiedName = model?.providerId === provider.name && model?.qualifiedName
3870
+ ? model.qualifiedName
3871
+ : `${provider.name}:${modelName}`;
3872
+ return {
3873
+ provider: provider.name,
3874
+ providerClass: provider.class,
3875
+ model: modelName,
3876
+ qualifiedName,
3877
+ supportedReasoningEfforts: Array.isArray(model?.supportedReasoningEfforts) ? model.supportedReasoningEfforts : [],
3878
+ defaultReasoningEffort: model?.defaultReasoningEffort || null,
3879
+ supportedContextTiers: Array.isArray(model?.supportedContextTiers) ? model.supportedContextTiers : [],
3880
+ defaultContextTier: model?.defaultContextTier || null,
3881
+ };
3882
+ }));
3883
+ const defaults = modelProviderState.defaults || {};
3884
+ const providerTypeMap = new Map();
3885
+ for (const model of modelRows) {
3886
+ if (!model?.providerId || providerTypeMap.has(model.providerId)) continue;
3887
+ providerTypeMap.set(model.providerId, {
3888
+ id: model.providerId,
3889
+ label: model.providerType || model.providerId,
3890
+ });
3891
+ }
3892
+ const providerSummary = (provider) => ({
3893
+ name: provider.name,
3894
+ typeId: provider.typeId || provider.type || null,
3895
+ class: provider.class === "shared" ? "shared" : "personal",
3896
+ hasCredential: Boolean(provider.hasCredential),
3897
+ usableByMe: provider.usableByMe !== false,
3898
+ systemUseEnabled: Boolean(provider.systemUseEnabled),
3899
+ systemEligible: Boolean(provider.systemEligible),
3900
+ isClusterDefault: Boolean(provider.isClusterDefault),
3901
+ isMyDefault: Boolean(provider.isMyDefault),
3902
+ isSystemDefault: Boolean(provider.isSystemDefault),
3903
+ });
3904
+ const overrides = isAdmin && Array.isArray(defaults.systemOverrides) ? defaults.systemOverrides : [];
3905
+ const overrideByAgent = new Map(overrides.map((override) => [override.agentId, override]));
3906
+ const systemAgentsById = new Map();
3907
+ for (const session of Object.values(state.sessions?.byId || {})) {
3908
+ if (!session?.isSystem || !session?.agentId) continue;
3909
+ if (!systemAgentsById.has(session.agentId)) {
3910
+ systemAgentsById.set(session.agentId, {
3911
+ agentId: session.agentId,
3912
+ title: canonicalSystemTitle(session, state.branding?.title || "PilotSwarm"),
3913
+ });
3914
+ }
3915
+ }
3916
+ for (const override of overrides) {
3917
+ if (!systemAgentsById.has(override.agentId)) {
3918
+ systemAgentsById.set(override.agentId, { agentId: override.agentId, title: override.agentId });
3919
+ }
3920
+ }
3921
+ const effectiveSystemDefault = defaults.system?.effective || null;
3922
+ const systemAgentRoutes = [...systemAgentsById.values()]
3923
+ .sort((left, right) => left.title.localeCompare(right.title))
3924
+ .map((agent) => {
3925
+ const override = overrideByAgent.get(agent.agentId) || null;
3926
+ const effective = override || effectiveSystemDefault;
3927
+ return {
3928
+ ...agent,
3929
+ effectiveModel: effective?.model || null,
3930
+ source: override ? "agent_override" : (effectiveSystemDefault?.source || "system_default"),
3931
+ override,
3932
+ };
3933
+ });
3934
+ const providerSelection = modelProviderState.selection || {};
3935
+ const providerPage = modelProviderState.page === "shared" && isAdmin ? "shared" : "mine";
3936
+ const modelProviders = {
3937
+ loading: Boolean(modelProviderState.loading),
3938
+ error: modelProviderState.error || null,
3939
+ page: providerPage,
3940
+ providerTypes: [...providerTypeMap.values()].sort((left, right) => left.id.localeCompare(right.id)),
3941
+ myProviders: ownProviderRows.map((provider) => ({
3942
+ ...providerSummary(provider),
3943
+ selected: provider.name === providerSelection.providerName,
3944
+ })),
3945
+ sharedProviders: sharedProviderRows.map((provider) => ({
3946
+ ...providerSummary(provider),
3947
+ selected: provider.name === providerSelection.providerName,
3948
+ })),
3949
+ userChoices: choiceRows([
3950
+ ...sharedProviderRows.filter((provider) => provider.usableByMe !== false),
3951
+ ...ownProviderRows.filter((provider) => provider.usableByMe !== false),
3952
+ ]),
3953
+ clusterChoices: isAdmin ? choiceRows(sharedProviderRows) : [],
3954
+ systemChoices: isAdmin ? choiceRows([
3955
+ ...sharedProviderRows,
3956
+ ...ownProviderRows.filter((provider) => provider.systemUseEnabled === true),
3957
+ ]) : [],
3958
+ mySessionDefault: defaults.userSession || { configured: null, effective: null },
3959
+ clusterSessionDefault: isAdmin ? (defaults.clusterSession || { configured: null, effective: null }) : null,
3960
+ systemSessionDefault: isAdmin ? (defaults.system || { configured: null, effective: null }) : null,
3961
+ systemAgentOverrides: overrides,
3962
+ systemAgentRoutes: isAdmin ? systemAgentRoutes.map((route) => ({
3963
+ ...route,
3964
+ selected: route.agentId === providerSelection.agentId,
3965
+ })) : [],
3966
+ selection: {
3967
+ focus: providerSelection.focus === "agents" ? "agents" : "providers",
3968
+ providerName: providerSelection.providerName
3969
+ || (providerPage === "shared" ? sharedProviderRows[0]?.name : ownProviderRows[0]?.name)
3970
+ || null,
3971
+ agentId: providerSelection.agentId || systemAgentRoutes[0]?.agentId || null,
3972
+ },
3973
+ mutation: {
3974
+ pending: modelProviderState.mutation?.pending || null,
3975
+ error: modelProviderState.mutation?.error || null,
3976
+ },
3977
+ };
3685
3978
  // Admin-only target switch: when on, Set/Replace/Clear act on the
3686
3979
  // SYSTEM user's key (ownerless system sessions run on it).
3687
3980
  const storeAsSystem = isAdmin && Boolean(ghcpKey.storeAsSystem);
@@ -3804,9 +4097,11 @@ export function selectAdminConsole(state) {
3804
4097
  // Settings tree — the session-list-slot navigation. Rendered by both
3805
4098
  // hosts; `kind` drives affordances (section rows switch panes, package
3806
4099
  // rows select a package).
3807
- const section = ["packages", "workers"].includes(admin.section) ? admin.section : "ghcp";
4100
+ const section = ["providers", "packages", "workers"].includes(admin.section) ? admin.section : "providers";
3808
4101
  const settingsTree = [
3809
- { id: "ghcp", kind: "section", depth: 0, label: "GitHub Keys", selected: section === "ghcp" },
4102
+ { id: "providers", kind: "section", depth: 0, label: "Model Providers", selected: false },
4103
+ { id: "myProviders", kind: "subsection", depth: 1, label: "My Providers", selected: section === "providers" && providerPage === "mine" },
4104
+ ...(isAdmin ? [{ id: "sharedProviders", kind: "subsection", depth: 1, label: "Shared Providers", selected: section === "providers" && providerPage === "shared" }] : []),
3810
4105
  { id: "agents", kind: "section", depth: 0, label: "Agents", selected: section === "packages" && !pkgState.selectedName },
3811
4106
  { id: "group:shared", kind: "group", depth: 1, label: "Shared", count: sharedRows.length },
3812
4107
  ...sharedRows.map((row) => ({ id: `pkg:shared:${row.name}`, kind: "package", depth: 2, label: row.name, ...row })),
@@ -4069,7 +4364,7 @@ export function selectAdminConsole(state) {
4069
4364
  if (section === "workers") {
4070
4365
  actions.push({ id: "workersRefresh", label: "Refresh workers", key: "r" });
4071
4366
  actions.push({ id: "showPackages", label: "Agents", key: "a" });
4072
- actions.push({ id: "showGhcp", label: "GitHub Keys", key: "g" });
4367
+ actions.push({ id: "showProviders", label: "Model Providers", key: "m" });
4073
4368
  actions.push({ id: "close", label: "Close console", key: "Esc" });
4074
4369
  return {
4075
4370
  visible: Boolean(admin.visible),
@@ -4081,6 +4376,7 @@ export function selectAdminConsole(state) {
4081
4376
  settingsTree,
4082
4377
  packages: packagesView,
4083
4378
  workers: workersView,
4379
+ modelProviders,
4084
4380
  ghcpKey: {
4085
4381
  configured: Boolean(profile?.githubCopilotKeySet),
4086
4382
  targetConfigured,
@@ -4107,7 +4403,52 @@ export function selectAdminConsole(state) {
4107
4403
  actions.push({ id: "packagesRefresh", label: "Refresh packages", key: "r" });
4108
4404
  actions.push({ id: "packagesNext", label: "Next package", key: "j" });
4109
4405
  actions.push({ id: "packagesPrev", label: "Previous package", key: "k" });
4110
- actions.push({ id: "showGhcp", label: "GitHub Keys", key: "g" });
4406
+ actions.push({ id: "showProviders", label: "Model Providers", key: "m" });
4407
+ actions.push({ id: "close", label: "Close console", key: "Esc" });
4408
+ return {
4409
+ visible: Boolean(admin.visible),
4410
+ loading: Boolean(admin.loading),
4411
+ loadError: admin.loadError || null,
4412
+ principal,
4413
+ isAdmin,
4414
+ section,
4415
+ settingsTree,
4416
+ packages: packagesView,
4417
+ workers: workersView,
4418
+ modelProviders,
4419
+ ghcpKey: {
4420
+ configured: Boolean(profile?.githubCopilotKeySet),
4421
+ targetConfigured,
4422
+ storeAsSystem,
4423
+ editing: false,
4424
+ draft: "",
4425
+ cursorIndex: 0,
4426
+ saving: false,
4427
+ error: null,
4428
+ statusText: ghcpStatusText,
4429
+ },
4430
+ systemGhcpKey: {
4431
+ supported: Boolean(systemGhcpKey.supported),
4432
+ loading: Boolean(systemGhcpKey.loading),
4433
+ configured: Boolean(systemGhcpKey.configured),
4434
+ changedBy: systemGhcpKey.changedBy || null,
4435
+ changedAt: systemGhcpKey.changedAt || null,
4436
+ error: systemGhcpKey.error || null,
4437
+ },
4438
+ actions,
4439
+ };
4440
+ }
4441
+ if (section === "providers") {
4442
+ actions.push({ id: "addProvider", label: "Add GitHub provider", key: "e" });
4443
+ actions.push({ id: "cycleUserDefault", label: "Cycle my default", key: "u" });
4444
+ if (isAdmin) {
4445
+ actions.push({ id: "cycleClusterDefault", label: "Cycle cluster default", key: "l" });
4446
+ actions.push({ id: "cycleSystemDefault", label: "Cycle system default", key: "s" });
4447
+ actions.push({ id: "toggleSystemUse", label: "Toggle system use", key: "t" });
4448
+ actions.push({ id: "cycleSystemOverride", label: "Cycle system-agent override", key: "o" });
4449
+ }
4450
+ actions.push({ id: "refreshProviders", label: "Refresh", key: "r" });
4451
+ actions.push({ id: "showPackages", label: "Agents", key: "a" });
4111
4452
  actions.push({ id: "close", label: "Close console", key: "Esc" });
4112
4453
  return {
4113
4454
  visible: Boolean(admin.visible),
@@ -4119,6 +4460,7 @@ export function selectAdminConsole(state) {
4119
4460
  settingsTree,
4120
4461
  packages: packagesView,
4121
4462
  workers: workersView,
4463
+ modelProviders,
4122
4464
  ghcpKey: {
4123
4465
  configured: Boolean(profile?.githubCopilotKeySet),
4124
4466
  targetConfigured,
@@ -4166,6 +4508,7 @@ export function selectAdminConsole(state) {
4166
4508
  settingsTree,
4167
4509
  packages: packagesView,
4168
4510
  workers: workersView,
4511
+ modelProviders,
4169
4512
  ghcpKey: {
4170
4513
  configured: Boolean(profile?.githubCopilotKeySet),
4171
4514
  targetConfigured,
@@ -4257,6 +4600,548 @@ export function selectAdminGhcpKeyEditorModal(state, maxWidth = 76) {
4257
4600
  };
4258
4601
  }
4259
4602
 
4603
+ export function selectAdminProviderCreateModal(state, maxWidth = 76) {
4604
+ const admin = state.admin || {};
4605
+ const create = admin.modelProviders?.create || {};
4606
+ if (!admin.visible || !create.editing) return null;
4607
+ const credentialStage = create.stage === "credential";
4608
+ const shared = create.shared === true;
4609
+ const ownTitle = /github/i.test(create.typeId || "")
4610
+ ? "Add GitHub Copilot provider"
4611
+ : `Add ${create.typeId || "model"} provider`;
4612
+ const value = String(create.draft || "");
4613
+ return {
4614
+ type: "adminProviderCreate",
4615
+ title: credentialStage
4616
+ ? (shared ? "Add shared model provider" : ownTitle)
4617
+ : (shared ? "Name the shared provider" : ownTitle),
4618
+ label: credentialStage ? "credential" : "provider name",
4619
+ displayValue: credentialStage && value ? "•".repeat(value.length) : value,
4620
+ cursorIndex: Math.max(0, Math.min(Number(create.cursorIndex) || 0, value.length)),
4621
+ placeholder: credentialStage ? "Paste token" : "my-ghcp",
4622
+ saving: Boolean(create.saving),
4623
+ error: create.error || null,
4624
+ idealWidth: Math.max(56, Math.min(maxWidth, 76)),
4625
+ detailsLines: credentialStage
4626
+ ? [
4627
+ { text: `Provider ${create.name}`, color: "white", bold: true },
4628
+ { text: `Type ${create.typeId}`, color: "cyan" },
4629
+ { text: shared ? "Shared" : "Only you", color: "gray" },
4630
+ ]
4631
+ : [
4632
+ { text: `Type ${create.typeId} [Tab cycles]`, color: "cyan", bold: true },
4633
+ { text: "Permanent. Used in provider:model.", color: "gray" },
4634
+ { text: "Letters, numbers, dot, dash, and underscore only.", color: "gray" },
4635
+ ],
4636
+ helpLines: credentialStage
4637
+ ? ["Type/paste credential", "Enter create provider", "Esc cancel and clear"]
4638
+ : ["Type provider name", "Tab next provider type", "Enter continue to credential", "Esc cancel"],
4639
+ };
4640
+ }
4641
+
4642
+ // ── Providers & Budgets ────────────────────────────────────────────────
4643
+ //
4644
+ // The view-model for the one table in
4645
+ // docs/proposals/providers-and-budgets-meters.md.
4646
+ //
4647
+ // `state.budget.grid` is what `getProviderUsageGrid` sent, in the order it
4648
+ // sent it: shared providers first, then the viewer's own, each immediately
4649
+ // followed by its model rows. Everything below turns that into rows already
4650
+ // carrying the numbers the CURRENT toggle prints, so the component does no
4651
+ // arithmetic and cannot print a number the selector did not mean.
4652
+ //
4653
+ // The vocabulary is binding: provider, limit, allowance, hold, paused,
4654
+ // period, your usage vs everyone's usage. Never pool, payer, grant, quota
4655
+ // rule, or bind mode.
4656
+
4657
+ const BUDGET_PERIOD_TITLE = { day: "Day", week: "Week", month: "Month" };
4658
+
4659
+ /** Is the person looking at this an administrator? */
4660
+ function viewerIsAdmin(state) {
4661
+ const role = state.auth?.authorization?.role;
4662
+ // "anonymous" is the single-user local deployment: no login, no one to be
4663
+ // less privileged than.
4664
+ return role === "admin" || role === "anonymous" || Boolean(state.admin?.profile?.isAdmin);
4665
+ }
4666
+
4667
+ /** "00:00 UTC" — the clock every period in this model turns over on. */
4668
+ function formatUtcClock(iso) {
4669
+ const match = /T(\d{2}:\d{2})/.exec(String(iso || ""));
4670
+ return match ? `${match[1]} UTC` : null;
4671
+ }
4672
+
4673
+ /**
4674
+ * "in 2h 10m (00:00 UTC)" — the ONE way this surface writes a reset.
4675
+ *
4676
+ * Both halves, every time: the relative half answers "how long do I wait",
4677
+ * the absolute half answers "when", and nobody should do UTC arithmetic at
4678
+ * 02:00 local to get from one to the other. Three spellings of one instant is
4679
+ * how the same reset came to read as three different facts.
4680
+ */
4681
+ function formatUntilLabel(iso, nowMs = Date.now()) {
4682
+ const at = iso ? Date.parse(iso) : NaN;
4683
+ if (!Number.isFinite(at)) return null;
4684
+ const clock = formatUtcClock(iso);
4685
+ const suffix = clock ? ` (${clock})` : "";
4686
+ const ms = at - nowMs;
4687
+ if (ms <= 0) return `now${suffix}`;
4688
+ const minutes = Math.floor(ms / 60_000);
4689
+ if (minutes < 60) return `in ${Math.max(1, minutes)}m${suffix}`;
4690
+ const hours = Math.floor(minutes / 60);
4691
+ if (hours < 24) return `in ${hours}h ${String(minutes % 60).padStart(2, "0")}m${suffix}`;
4692
+ return `in ${Math.floor(hours / 24)}d ${String(hours % 24).padStart(2, "0")}h${suffix}`;
4693
+ }
4694
+
4695
+ /**
4696
+ * When a pause ends, as one short phrase — or null.
4697
+ *
4698
+ * Null whenever the pause SENTENCE already answers it, which it does for
4699
+ * anything with a clock ("Resets in 2h 10m (00:00 UTC)."), for a hold ("until
4700
+ * an administrator releases it") and for a provider that is gone ("until that
4701
+ * name exists again"). Printing both put the same fact on the screen twice in
4702
+ * two different spellings, which is what made one reset read as two.
4703
+ *
4704
+ * A provider that no longer exists is also the ONE reason with no automatic
4705
+ * recovery, so it never gets a clock here under any circumstances.
4706
+ */
4707
+ function budgetClearsLabel(pause) {
4708
+ if (pause?.kind === "no_provider" || pause?.kind === "hold") return null;
4709
+ if (pause?.resetsAtUtc) return null;
4710
+ return "waits until someone changes it";
4711
+ }
4712
+
4713
+ /** The UTC day key ("2026-08-20") N days back from now. */
4714
+ function utcDayKey(nowMs, back) {
4715
+ return new Date(nowMs - back * 86_400_000).toISOString().slice(0, 10);
4716
+ }
4717
+
4718
+ function budgetPlural(n, one, many) {
4719
+ return n === 1 ? one : many;
4720
+ }
4721
+
4722
+ /**
4723
+ * One cell of the table: a used figure over a quota.
4724
+ *
4725
+ * The two pairs are never mixed. Unticked prints the viewer's own spend
4726
+ * against their share of the limit; ticked prints everyone's spend against
4727
+ * the limit itself. On a shared provider with an allowance the two are
4728
+ * usually very different, so which pair is on screen is a fact the table
4729
+ * carries rather than something the component guesses at.
4730
+ *
4731
+ * A null quota is no limit for that period — and the used figure beside it is
4732
+ * still real, because the meter runs whether or not anybody capped it.
4733
+ *
4734
+ * A null used figure means nobody is signed in. It stays unknown rather than
4735
+ * becoming a zero: "we do not know who you are" and "you spent nothing" are
4736
+ * different facts.
4737
+ */
4738
+ function budgetCell(raw, overall, nowMs) {
4739
+ const rawQuota = overall ? raw?.quotaTokens : raw?.yourQuotaTokens;
4740
+ const rawUsed = overall ? raw?.usedTokens : raw?.yourUsedTokens;
4741
+ const quotaTokens = rawQuota == null ? null : Number(rawQuota);
4742
+ const usedTokens = rawUsed == null ? null : Number(rawUsed);
4743
+ const known = Number.isFinite(usedTokens);
4744
+ const capped = Number.isFinite(quotaTokens) && quotaTokens > 0;
4745
+ const pct = known && capped ? Math.round((usedTokens / quotaTokens) * 100) : null;
4746
+ const usedLabel = known ? formatCompactNumber(usedTokens) : "—";
4747
+ const quotaLabel = Number.isFinite(quotaTokens) ? formatCompactNumber(quotaTokens) : "∞";
4748
+ // Whether the PROVIDER's own limit is spent, whichever pair is on screen.
4749
+ // With an allowance this is the case that stops you while your share
4750
+ // still shows room: a limit caps everyone TOGETHER, and a cell showing
4751
+ // 13% of your slice was drawn plain on a provider that had nothing left.
4752
+ const totalQuota = Number(raw?.quotaTokens);
4753
+ const totalUsed = Number(raw?.usedTokens);
4754
+ const providerExhausted = Number.isFinite(totalQuota) && totalQuota > 0
4755
+ && Number.isFinite(totalUsed) && totalUsed >= totalQuota;
4756
+ // Only worth marking when the cell's OWN pair does not already say it.
4757
+ // At 100/100 the two numbers are the story; the case this exists for is a
4758
+ // cell reading 13% of your share on a provider that has nothing left.
4759
+ const blockedButLooksFine = !overall && providerExhausted && (pct == null || pct < 100);
4760
+ return {
4761
+ providerExhausted: blockedButLooksFine,
4762
+ ruleId: raw?.ruleId || null,
4763
+ usedTokens: known ? usedTokens : null,
4764
+ quotaTokens: Number.isFinite(quotaTokens) ? quotaTokens : null,
4765
+ usedLabel,
4766
+ quotaLabel,
4767
+ // The whole cell, already written: "480.0K / 500.0K", "1.2M / ∞".
4768
+ text: `${usedLabel} / ${quotaLabel}`,
4769
+ // False = nobody is signed in, so there is no "your" number to print.
4770
+ known,
4771
+ // True = this period is uncapped. The used figure is still real.
4772
+ uncapped: !Number.isFinite(quotaTokens),
4773
+ // The real percentage, which CAN exceed 100: the turn that crosses a
4774
+ // limit completes and is charged, and only the next one pauses.
4775
+ pct,
4776
+ // What a meter may draw without overflowing its track.
4777
+ meterPct: pct == null ? null : Math.max(0, Math.min(100, pct)),
4778
+ // Plain under 90%, amber to 100%, red over. Never the only signal —
4779
+ // the two numbers beside it already say it.
4780
+ // Plain under 90%, amber to 100%, red over — and red as well when the
4781
+ // PROVIDER's limit is spent while your own share still reads low,
4782
+ // because nothing runs then either.
4783
+ tone: pct == null ? (blockedButLooksFine ? "red" : "idle")
4784
+ : pct > 100 ? "red"
4785
+ : blockedButLooksFine ? "red"
4786
+ : pct >= 90 ? "amber" : "plain",
4787
+ windowStartUtc: raw?.windowStartUtc || null,
4788
+ resetsAtUtc: raw?.resetsAtUtc || null,
4789
+ resetsLabel: formatUntilLabel(raw?.resetsAtUtc, nowMs),
4790
+ };
4791
+ }
4792
+
4793
+ /**
4794
+ * One row of the table — a provider, or one model-scoped limit under it.
4795
+ *
4796
+ * A model row is read exactly like the provider row above it, in the same
4797
+ * three columns, because a model limit can bite while the provider still has
4798
+ * room and one grid is what makes that visible.
4799
+ */
4800
+ function budgetTableRow(raw, { overall, selectedProvider, selectedScope = "*", nowMs, overModels = 0 }) {
4801
+ const providerName = String(raw?.providerName || "");
4802
+ const kind = raw?.rowKind === "model" ? "model" : "provider";
4803
+ const scope = String(raw?.scope || "*");
4804
+ const shared = raw?.class === "shared";
4805
+ const allowancePct = Number.isFinite(Number(raw?.allowancePct)) ? Number(raw.allowancePct) : 100;
4806
+ const holdUntilUtc = raw?.holdUntilUtc || null;
4807
+ const holdIndefinite = raw?.holdIndefinite === true;
4808
+ const held = holdIndefinite || (holdUntilUtc ? Date.parse(holdUntilUtc) > nowMs : false);
4809
+ const modelRowCount = kind === "provider" ? Number(raw?.modelRowCount) || 0 : 0;
4810
+ // Whose row this is, and whether you may change it. Both come from the
4811
+ // database, which is the thing that will actually decide — a client that
4812
+ // guessed armed Edit and Remove on providers the server then refused.
4813
+ const ownedByMe = raw?.ownedByMe !== false;
4814
+ const manageable = raw?.manageable === true;
4815
+ // Whose it is. On an administrator's screen a bare "USER" chip told two
4816
+ // people's providers apart only by whatever they happened to name them.
4817
+ const ownerLabel = typeof raw?.ownerLabel === "string" && raw.ownerLabel ? raw.ownerLabel : null;
4818
+ // A model row is selectable in its own right — its limit is a different
4819
+ // number from the provider's, so it gets its own chart.
4820
+ const selected = providerName === selectedProvider
4821
+ && (kind === "provider" ? selectedScope === "*" : scope === selectedScope);
4822
+ const periods = raw?.periods || {};
4823
+ return {
4824
+ // Unique across the table: one provider row plus one row per model
4825
+ // scope under it.
4826
+ key: `${providerName}${scope}`,
4827
+ providerName,
4828
+ kind,
4829
+ scope,
4830
+ // A provider row prints the provider's name; a model row prints just
4831
+ // the model, because the provider is the row directly above it and
4832
+ // "azure-research:gpt-5.4" under "azure-research" says it twice. The
4833
+ // full scope stays on `scope` for anything that needs to name it.
4834
+ label: kind === "provider"
4835
+ ? providerName
4836
+ : (scope.startsWith(`${providerName}:`) ? scope.slice(providerName.length + 1) : scope),
4837
+ // What this row is ABOUT, written out. A model row's own name is only
4838
+ // unambiguous while the provider is directly above it, so anything
4839
+ // that names the row away from the table — the chart heading, a
4840
+ // tooltip — uses this instead.
4841
+ subject: kind === "provider"
4842
+ ? (ownerLabel ? `${providerName} (${ownerLabel})` : providerName)
4843
+ : `${providerName} · ${(scope.startsWith(`${providerName}:`)
4844
+ ? scope.slice(providerName.length + 1) : scope)}`,
4845
+ class: shared ? "shared" : "personal",
4846
+ ownedByMe,
4847
+ manageable,
4848
+ ownerLabel: kind === "provider" ? ownerLabel : null,
4849
+ // The provider's CLASS, named the way the model names it. Both are
4850
+ // marked: an unmarked row is a row whose kind the reader has to
4851
+ // infer, and the two behave differently enough that guessing is
4852
+ // worse than a second chip.
4853
+ classLabel: kind !== "provider" ? null : (shared ? "Shared" : "User"),
4854
+ allowancePct,
4855
+ // A fact about how the TOTAL is divided, so it belongs beside the
4856
+ // total and nowhere else. 100 means there is no per-person ceiling.
4857
+ // A share of nothing is nothing: printed beside three ∞ cells the
4858
+ // label implied a cap that does not exist.
4859
+ allowanceLabel: kind === "provider" && overall && allowancePct < 100 && ownedByMe
4860
+ ? (["day", "week", "month"].some((p) => raw?.periods?.[p]?.quotaTokens != null)
4861
+ ? `Per-user allowance: ${allowancePct}% of each limit`
4862
+ : `Per-user allowance: ${allowancePct}%, but no limit is set to divide`)
4863
+ : null,
4864
+ hold: held
4865
+ ? {
4866
+ indefinite: holdIndefinite || !holdUntilUtc,
4867
+ untilUtc: holdUntilUtc,
4868
+ // Reads as the end of "It …", and carries the same reset
4869
+ // spelling as every other clock on this surface.
4870
+ label: holdIndefinite || !holdUntilUtc
4871
+ ? "lifts when an administrator releases it"
4872
+ : `lifts ${formatUntilLabel(holdUntilUtc, nowMs)}`,
4873
+ }
4874
+ : null,
4875
+ modelRowCount,
4876
+ selected,
4877
+ // Expansion follows the PROVIDER, not the exact row: standing on one
4878
+ // of its model rows must not make the teaser offer to expand rows
4879
+ // that are already on screen.
4880
+ expanded: providerName === selectedProvider && modelRowCount > 0,
4881
+ // The affordance, already worded. Its model rows are already in the
4882
+ // array — expanding shows rows the table has, it fetches nothing.
4883
+ expandLabel: modelRowCount === 0 ? null
4884
+ : providerName === selectedProvider
4885
+ ? `hide ${modelRowCount} ${budgetPlural(modelRowCount, "model", "models")}`
4886
+ : overModels > 0
4887
+ ? `show ${modelRowCount} ${budgetPlural(modelRowCount, "model", "models")} — ${overModels} over limit`
4888
+ : `show ${modelRowCount} ${budgetPlural(modelRowCount, "model", "models")}`,
4889
+ overModels: kind === "provider" ? overModels : 0,
4890
+ cells: {
4891
+ day: budgetCell(periods.day, overall, nowMs),
4892
+ week: budgetCell(periods.week, overall, nowMs),
4893
+ month: budgetCell(periods.month, overall, nowMs),
4894
+ },
4895
+ };
4896
+ }
4897
+
4898
+ /**
4899
+ * The provider table: every row it draws, already carrying the six numbers
4900
+ * (used and quota, for day, week and month) that the current toggle prints.
4901
+ *
4902
+ * Rows are NOT sorted here. The server returns them in render order and a
4903
+ * sort would move a model row out from under the provider it belongs to.
4904
+ */
4905
+ export function selectProviderTable(state) {
4906
+ const budget = state.budget || {};
4907
+ const nowMs = Date.now();
4908
+ const isAdmin = viewerIsAdmin(state);
4909
+ const overall = budget.overall === true;
4910
+ const rangeDays = BUDGET_SERIES_RANGES.includes(Number(budget.rangeDays))
4911
+ ? Number(budget.rangeDays)
4912
+ : BUDGET_SERIES_DAYS;
4913
+ const grid = Array.isArray(budget.grid) ? budget.grid : [];
4914
+ const error = budget.error || null;
4915
+ const loaded = budget.loaded === true;
4916
+ const selectedProvider = budget.selectedProvider || null;
4917
+ // Which row under it: '*' is the provider itself, a qualified model
4918
+ // reference is one of its per-model limits.
4919
+ const selectedScope = budget.selectedScope || "*";
4920
+
4921
+ const providerCount = grid.reduce((n, raw) => n + (raw?.rowKind === "model" ? 0 : 1), 0);
4922
+
4923
+ // Provider rows always; a provider's model rows only while it is the
4924
+ // selected one. Selecting is what expands it.
4925
+ const rows = [];
4926
+ // How many of a provider's model limits are over, whether or not its rows
4927
+ // are on screen. A collapsed teaser that said only "2 per-model limits"
4928
+ // hid the one fact worth expanding for.
4929
+ const overModelsByProvider = new Map();
4930
+ for (const raw of grid) {
4931
+ if (raw?.rowKind !== "model") continue;
4932
+ const over = ["day", "week", "month"].some((period) => {
4933
+ const cell = raw?.periods?.[period];
4934
+ const quota = Number(cell?.quotaTokens);
4935
+ const used = Number(overall ? cell?.usedTokens : cell?.yourUsedTokens);
4936
+ return Number.isFinite(quota) && quota > 0 && Number.isFinite(used) && used > quota;
4937
+ });
4938
+ if (over) {
4939
+ overModelsByProvider.set(raw.providerName, (overModelsByProvider.get(raw.providerName) || 0) + 1);
4940
+ }
4941
+ }
4942
+ for (const raw of grid) {
4943
+ if (raw?.rowKind === "model" && raw?.providerName !== selectedProvider) continue;
4944
+ rows.push(budgetTableRow(raw, {
4945
+ overall, selectedProvider, selectedScope, nowMs,
4946
+ overModels: overModelsByProvider.get(raw?.providerName) || 0,
4947
+ }));
4948
+ }
4949
+ // The row the actions and the chart are about. A model row can be it: its
4950
+ // limit is a different number from the provider's and deserves its own
4951
+ // days. The PROVIDER row stays findable for the things that are still
4952
+ // about the provider — Edit, Remove, and what the buttons name.
4953
+ const selected = rows.find((row) => row.selected)
4954
+ || rows.find((row) => row.kind === "provider" && row.providerName === selectedProvider)
4955
+ || null;
4956
+ const selectedProviderRow = rows.find(
4957
+ (row) => row.kind === "provider" && row.providerName === selectedProvider,
4958
+ ) || null;
4959
+
4960
+ // ── The one line above the table ───────────────────────────────────
4961
+ //
4962
+ // Not the old band. An administrator's fleet-wide "is anything stopped
4963
+ // right now" is the only thing the band was uniquely good at, and one
4964
+ // sentence with a link keeps it. WHY a particular session is stopped is
4965
+ // said on the session itself, which is where the remedy is.
4966
+ const pausedRaw = Array.isArray(budget.paused) ? budget.paused : [];
4967
+ const pausedByProvider = new Map();
4968
+ for (const row of pausedRaw) {
4969
+ const pause = normalizeSessionPause({ pauseState: row?.pause });
4970
+ const name = pause?.provider || null;
4971
+ if (!name) continue;
4972
+ pausedByProvider.set(name, (pausedByProvider.get(name) || 0) + 1);
4973
+ }
4974
+ const topPaused = [...pausedByProvider.entries()]
4975
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] || null;
4976
+ const pausedCount = pausedRaw.length;
4977
+ // Named only when that one provider accounts for every waiting session.
4978
+ // Naming one of two causes sends the reader to the wrong row.
4979
+ const pausedName = topPaused && topPaused[1] === pausedCount ? topPaused[0] : null;
4980
+ const pausedError = budget.pausedError || null;
4981
+ const paused = {
4982
+ count: pausedCount,
4983
+ // The waiting list is the half that failed. Said on the line itself,
4984
+ // because the numbers above it were read fine and marking THEM stale
4985
+ // is the opposite of the truth.
4986
+ error: pausedError,
4987
+ stale: Boolean(pausedError) && pausedCount > 0,
4988
+ // Where the link goes: the provider stopping the most sessions.
4989
+ provider: topPaused ? topPaused[0] : null,
4990
+ sentence: pausedCount === 0
4991
+ ? null
4992
+ : `${pausedCount} ${budgetPlural(pausedCount, "session is", "sessions are")} waiting`
4993
+ + `${pausedName ? ` on ${pausedName}` : ""}.`,
4994
+ };
4995
+
4996
+ // ── A name that is waited on and does not exist ────────────────────
4997
+ //
4998
+ // Only when sessions are actually stopped on it. A provider deleted with
4999
+ // nothing riding on it just leaves the table, which is correct and needs
5000
+ // no announcement; a name that sessions are stuck on has one remedy —
5001
+ // create it again — and this is the only screen that offers it.
5002
+ const missingName = budget.missingProvider || null;
5003
+ const missingWaiting = missingName ? (pausedByProvider.get(missingName) || 0) : 0;
5004
+ const missing = missingWaiting > 0
5005
+ ? {
5006
+ provider: missingName,
5007
+ count: missingWaiting,
5008
+ sentence: `No provider is named ${missingName}, and `
5009
+ + `${missingWaiting} ${budgetPlural(missingWaiting, "session waits", "sessions wait")} on it. `
5010
+ + "They run again as soon as a provider takes that name.",
5011
+ }
5012
+ : null;
5013
+
5014
+ // ── The chart under the selected provider ──────────────────────────
5015
+ //
5016
+ // The range is drawn in full, one bar per day, filled from the report. A
5017
+ // day the report omits is a day with no usage — not a missing axis.
5018
+ const seriesState = budget.series || {};
5019
+ const seriesDaily = new Map(
5020
+ (Array.isArray(seriesState.days) ? seriesState.days : [])
5021
+ .map((row) => [String(row?.dayUtc || "").slice(0, 10), row]),
5022
+ );
5023
+ const chartDays = [];
5024
+ for (let back = rangeDays - 1; back >= 0; back -= 1) {
5025
+ const dayUtc = utcDayKey(nowMs, back);
5026
+ const row = seriesDaily.get(dayUtc);
5027
+ chartDays.push({
5028
+ dayUtc,
5029
+ label: dayUtc.slice(5),
5030
+ tokens: Number(row?.tokensTotal) || 0,
5031
+ turns: Number(row?.turns) || 0,
5032
+ });
5033
+ }
5034
+ const peak = chartDays.reduce((max, day) => Math.max(max, day.tokens), 0);
5035
+ // The dashed line is the very number the Day cell just printed, so the
5036
+ // chart and the row it belongs to cannot disagree.
5037
+ const dayCell = selected?.cells?.day || null;
5038
+ const quotaTokens = dayCell?.quotaTokens ?? null;
5039
+ // The axis has to hold both the tallest bar and the line, or a provider
5040
+ // well under its limit draws a line off the top of the plot.
5041
+ const scaleMax = Math.max(peak, quotaTokens || 0);
5042
+ for (const day of chartDays) {
5043
+ day.pct = scaleMax > 0 ? Math.round((day.tokens / scaleMax) * 100) : 0;
5044
+ }
5045
+ const seriesError = seriesState.error || null;
5046
+ const seriesLoaded = seriesState.loaded === true;
5047
+ const series = {
5048
+ provider: seriesState.provider || null,
5049
+ days: chartDays,
5050
+ peak,
5051
+ peakLabel: formatCompactNumber(peak),
5052
+ scaleMax,
5053
+ quotaTokens,
5054
+ quotaLabel: dayCell?.quotaLabel ?? "∞",
5055
+ // Where to draw the line, as a percentage of the plot height. Null
5056
+ // when the day is uncapped: there is no line to draw.
5057
+ quotaPct: quotaTokens != null && scaleMax > 0
5058
+ ? Math.round((quotaTokens / scaleMax) * 100)
5059
+ : null,
5060
+ rangeDays,
5061
+ rangeLabel: `Last ${rangeDays} days`,
5062
+ loading: seriesState.loading === true,
5063
+ loaded: seriesLoaded,
5064
+ error: seriesError,
5065
+ // The read failed and there is nothing behind it. Distinct from a
5066
+ // range that really had no usage in it.
5067
+ failed: Boolean(seriesError) && !seriesLoaded,
5068
+ stale: Boolean(seriesError) && seriesLoaded,
5069
+ // Nothing to draw. The view says so instead of drawing an empty axis.
5070
+ empty: seriesLoaded && !seriesError && peak === 0,
5071
+ // The bars and the dashed line are now the SAME scope in both
5072
+ // states — the request carries `mine` when the tick is off — so there
5073
+ // is nothing left to warn about. Kept as a field because the view
5074
+ // reads it; it is false because the mismatch it named is fixed.
5075
+ mixedScope: false,
5076
+ };
5077
+
5078
+ const systemState = budget.systemUsage || {};
5079
+ const systemMatches = isAdmin && selected
5080
+ && systemState.provider === selectedProvider
5081
+ && (systemState.scope || "*") === selectedScope
5082
+ && (systemState.rangeDays || BUDGET_SERIES_DAYS) === rangeDays;
5083
+ const systemSpend = !isAdmin || !selected ? null : {
5084
+ loading: systemMatches && systemState.loading === true,
5085
+ loaded: systemMatches && systemState.loaded === true,
5086
+ error: systemMatches ? (systemState.error || null) : null,
5087
+ stale: systemMatches && Boolean(systemState.error) && systemState.loaded === true,
5088
+ rangeDays,
5089
+ rangeLabel: `Last ${rangeDays} days`,
5090
+ tokens: Number(systemMatches ? systemState.totals?.tokensTotal : 0) || 0,
5091
+ tokensLabel: formatCompactNumber(Number(systemMatches ? systemState.totals?.tokensTotal : 0) || 0),
5092
+ turns: Number(systemMatches ? systemState.totals?.turns : 0) || 0,
5093
+ models: (systemMatches && Array.isArray(systemState.breakdown) ? systemState.breakdown : [])
5094
+ .map((row) => ({
5095
+ key: String(row?.key || row?.label || "unknown"),
5096
+ label: String(row?.label || row?.key || "Unknown model"),
5097
+ tokens: Number(row?.tokensTotal) || 0,
5098
+ tokensLabel: formatCompactNumber(Number(row?.tokensTotal) || 0),
5099
+ turns: Number(row?.turns) || 0,
5100
+ }))
5101
+ .sort((left, right) => right.tokens - left.tokens || left.label.localeCompare(right.label)),
5102
+ };
5103
+
5104
+ return {
5105
+ open: Boolean(state.ui?.budgetOpen),
5106
+ isAdmin,
5107
+ loading: Boolean(budget.loading),
5108
+ refreshing: Boolean(budget.refreshing),
5109
+ loaded,
5110
+ error,
5111
+ fetchedAt: Number(budget.fetchedAt) || 0,
5112
+ // The read failed and there is nothing trustworthy behind it. The two
5113
+ // must never look alike: "no limits" and "could not load the limits"
5114
+ // are different facts and only one of them is safe to act on.
5115
+ failed: Boolean(error) && !loaded,
5116
+ // The read failed but earlier numbers are still on screen. Say they
5117
+ // are old rather than passing them off as current.
5118
+ stale: Boolean(error) && loaded,
5119
+ // A namespace that really is empty, which is a state with its own
5120
+ // remedy (create a provider) — and only ever reported after a
5121
+ // SUCCESSFUL read.
5122
+ empty: loaded && !error && providerCount === 0,
5123
+ // Which pair of numbers the cells are printing.
5124
+ overall,
5125
+ rangeDays,
5126
+ rangeOptions: BUDGET_SERIES_RANGES.map((days) => ({ value: days, label: `${days}d` })),
5127
+ overallLabel: "Show all user spend",
5128
+ usageHeading: overall ? "All user spend" : "My spend",
5129
+ columns: BUDGET_PERIODS.map((period) => ({ id: period, label: BUDGET_PERIOD_TITLE[period] })),
5130
+ rows,
5131
+ providerCount,
5132
+ selectedProvider,
5133
+ selectedScope,
5134
+ selected,
5135
+ // Edit and Remove always act on the provider, even while a model row
5136
+ // under it is the one being read.
5137
+ selectedProviderRow,
5138
+ paused,
5139
+ missing,
5140
+ series,
5141
+ systemSpend,
5142
+ };
5143
+ }
5144
+
4260
5145
  export function selectStatusBar(state) {
4261
5146
  const focus = state.ui.focusRegion;
4262
5147
  const paneFullscreen = state.ui.fullscreenPane || null;
@@ -4590,6 +5475,7 @@ const SEQUENCE_ORCHESTRATOR_TYPES = new Set([
4590
5475
  "spawn",
4591
5476
  "cmd_recv",
4592
5477
  "cmd_done",
5478
+ "model",
4593
5479
  ]);
4594
5480
 
4595
5481
  function isSequenceOrchestratorType(type) {
@@ -4723,6 +5609,20 @@ function mapEventToSequenceEntry(event) {
4723
5609
  return { ...base, type: "compaction", color: "gray", detail: "compaction…" };
4724
5610
  case "session.compaction_complete":
4725
5611
  return { ...base, type: "compaction", color: "gray", detail: "compacted" };
5612
+ case "session.model_changed": {
5613
+ // The switch itself already appears as /set_model command rows;
5614
+ // this row is the one that says what actually changed.
5615
+ const from = shortModelName(event?.data?.oldModel) || "default";
5616
+ const to = shortModelName(event?.data?.newModel) || "default";
5617
+ const effort = event?.data?.newReasoningEffort;
5618
+ const effortChanged = effort && effort !== event?.data?.oldReasoningEffort;
5619
+ return {
5620
+ ...base,
5621
+ type: "model",
5622
+ color: "cyan",
5623
+ detail: `model ${from} → ${to}${effortChanged ? `:${effort}` : ""}`,
5624
+ };
5625
+ }
4726
5626
  case "session.error":
4727
5627
  return { ...base, type: "error", color: "red", detail: preview || "error" };
4728
5628
  default:
@@ -5499,7 +6399,7 @@ export function selectModelPickerModal(state, maxWidth = 72) {
5499
6399
  : []),
5500
6400
  ...(defaultReasoning ? [[{ text: `Default reasoning: ${defaultReasoning}`, color: "gray" }]] : []),
5501
6401
  ...(selectedItem.disabled
5502
- ? [[{ text: "Unavailable: requires a GitHub Copilot key. Add yours in the Admin console, then reopen this picker.", color: "yellow" }]]
6402
+ ? [[{ text: "Unavailable: add a personal provider in Admin Console, then reopen this picker.", color: "yellow" }]]
5503
6403
  : []),
5504
6404
  [{ text: "", color: "gray" }],
5505
6405
  [{