@amenophis1er/foreman 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
package/src/types.ts ADDED
@@ -0,0 +1,442 @@
1
+ /**
2
+ * Shared types for Foreman's orchestrator, persistence, and HTTP layer.
3
+ *
4
+ * Everything the UI sees flows through {@link ForemanEvent}: each event is
5
+ * broadcast to connected SSE clients *and* appended to the run's event log,
6
+ * so replaying a log reproduces exactly what a live client observed.
7
+ */
8
+
9
+ export type RunStatus = 'running' | 'done' | 'error' | 'interrupted';
10
+
11
+ /** A linked project — a folder Foreman runs missions in. */
12
+ export interface Project {
13
+ id: string;
14
+ /** Display name; defaults to the folder's basename. */
15
+ name: string;
16
+ /** Absolute path of the working directory. */
17
+ folder: string;
18
+ createdAt: number;
19
+ /** Default budget suggested in the composer. */
20
+ defaultBudgetUsd: number;
21
+ /**
22
+ * Who serves and who pays. Absent means the legacy `claudeInstance` below
23
+ * (or the server default) — see providerOf() in provider.ts.
24
+ */
25
+ provider?: ProviderRef;
26
+ /** @deprecated Pre-provider pin. Read through providerOf(); never written. */
27
+ claudeInstance?: ClaudeInstanceRef;
28
+ }
29
+
30
+ /**
31
+ * @deprecated The pre-provider shape, still read from records written before
32
+ * {@link ProviderRef} existed. Never written. See providerFromLegacy().
33
+ */
34
+ export interface ClaudeInstanceRef {
35
+ /** CLAUDE_CONFIG_DIR for the agent. */
36
+ configDir?: string;
37
+ /** Claude Code executable; the SDK's bundled one when absent. */
38
+ executable?: string;
39
+ /**
40
+ * Who pays for this project's missions.
41
+ * - `inherit` (default): whatever the server process authenticates as. An
42
+ * ANTHROPIC_API_KEY in the server environment outranks any stored login,
43
+ * so every project bills that key.
44
+ * - `own-login`: strip inherited API-key credentials from the agent's
45
+ * environment so the pinned config dir's own stored login pays. This is
46
+ * what makes per-project accounts work on a single shared server.
47
+ */
48
+ billing?: 'inherit' | 'own-login';
49
+ }
50
+
51
+ /**
52
+ * Who serves the model, who pays for it, and where requests go — one choice,
53
+ * not three settings. See src/provider.ts and docs/provider-model.md; the
54
+ * union shape is what makes "subscription login + custom base URL", the
55
+ * combination that leaks a token, unrepresentable.
56
+ */
57
+ export type ProviderRef =
58
+ /** An installed Claude Code, using whatever it is logged into. Ambient. */
59
+ | {
60
+ kind: 'claude-code';
61
+ /** CLAUDE_CONFIG_DIR; the server default when absent. */
62
+ configDir?: string;
63
+ /** Claude Code executable; the SDK's bundled one when absent. */
64
+ executable?: string;
65
+ /** Strip inherited key credentials so this install's own login pays. */
66
+ ownLogin?: boolean;
67
+ }
68
+ /** An Anthropic API key Foreman is told about by name, never by value. */
69
+ | {
70
+ kind: 'anthropic-api';
71
+ /** Stable id; names this provider's Foreman-owned config dir. */
72
+ id: string;
73
+ /** Server environment variable holding the key. Foreman stores no secrets. */
74
+ apiKeyEnv: string;
75
+ model?: string;
76
+ }
77
+ /** An installed Codex CLI, using the login `codex login` already stored. */
78
+ | {
79
+ kind: 'codex';
80
+ id: string;
81
+ /** CODEX_HOME; `~/.codex` when absent. */
82
+ codexHome?: string;
83
+ /** Codex inference backend; the public one when absent. */
84
+ upstreamUrl?: string;
85
+ model?: string;
86
+ }
87
+ /** Anything speaking OpenAI Chat Completions: Ollama, OpenRouter, vLLM, … */
88
+ | {
89
+ kind: 'openai-compatible';
90
+ id: string;
91
+ baseUrl: string;
92
+ /**
93
+ * Name of a server environment variable holding the key. The fallback
94
+ * when nothing is stored for this provider; omit it entirely to use a
95
+ * stored key alone.
96
+ */
97
+ apiKeyEnv?: string;
98
+ /**
99
+ * This endpoint requires a credential. Distinguishes "a key is stored
100
+ * for it" from "it needs none at all" — a local Ollama is the latter,
101
+ * and inferring from the absence of `apiKeyEnv` would make every stored
102
+ * key look like no key.
103
+ */
104
+ needsKey?: boolean;
105
+ /** Short name for badges — "Ollama", "OpenRouter". */
106
+ label?: string;
107
+ model?: string;
108
+ };
109
+
110
+ export type WorkerStatus = 'running' | 'done' | 'error';
111
+
112
+ /** Persisted, replayable event envelope. */
113
+ export interface ForemanEvent {
114
+ /** Milliseconds since epoch, assigned at emit time. */
115
+ ts: number;
116
+ /** SSE event name; the UI switches on this. */
117
+ event: string;
118
+ /** JSON-serializable payload; shape depends on `event`. */
119
+ data: unknown;
120
+ }
121
+
122
+ /**
123
+ * What a run's spend *is* — which is a different question from how much.
124
+ *
125
+ * A boolean `metered` used to carry this, and it collapsed two states that
126
+ * are not the same thing at all: a local model that costs nothing and an
127
+ * OpenAI key that costs real money Foreman holds no price table for both
128
+ * answered `false`, and rendered identically. "You are spending nothing" and
129
+ * "you are spending an amount I cannot tell you" are different sentences to
130
+ * put in front of someone, and only one of them is a reason to go and look at
131
+ * a vendor's dashboard.
132
+ *
133
+ * - `priced` — the dollar figure is the real one. Dollars are displayed,
134
+ * and the budget cap binds.
135
+ * - `free` — nothing is charged per token; the model runs on hardware
136
+ * the operator already owns. Tokens and turns are the only
137
+ * true units.
138
+ * - `unpriced` — real spend, of an amount Foreman cannot state: a paid
139
+ * endpoint with no price source, or a subscription allowance
140
+ * being consumed. Shown in tokens and turns like `free`, but
141
+ * said out loud to be untracked rather than passed off as
142
+ * costing nothing.
143
+ *
144
+ * Only `priced` may display or enforce dollars. `free` and `unpriced` differ
145
+ * in what they say and never in what they enforce — which is why this is
146
+ * three states and not four: the fourth distinction people reach for
147
+ * (subscription vs. pay-as-you-go) changes no behaviour here, and the
148
+ * provider label already carries it in words.
149
+ */
150
+ export type CostBasis = 'priced' | 'free' | 'unpriced';
151
+
152
+ /**
153
+ * A run's cost basis, including for runs that predate the field.
154
+ *
155
+ * The old `metered: false` meant "not priceable", which is the union of
156
+ * `free` and `unpriced` — so it cannot be split after the fact. This reads it
157
+ * as `unpriced`, because of the two possible errors, describing real spend as
158
+ * free is the one that costs somebody money.
159
+ */
160
+ export function costBasisOf(meta: { costBasis?: CostBasis; metered?: boolean }): CostBasis {
161
+ if (meta.costBasis) return meta.costBasis;
162
+ return meta.metered === false ? 'unpriced' : 'priced';
163
+ }
164
+
165
+ /**
166
+ * One cost basis for a run whose two roles may not share one.
167
+ *
168
+ * `priced` wins outright: if any part of the run bills real dollars, the
169
+ * dollar cap must still arm, because the alternative is an uncapped run
170
+ * spending genuine money. It is not a *precise* figure on a mixed run — the
171
+ * SDK prices the gateway role's tokens with Anthropic's table too, so the
172
+ * total overstates — but overstating a real bill is a safe error in a way
173
+ * that ignoring one is not. A per-role price table is what fixes it properly.
174
+ *
175
+ * Otherwise `unpriced` beats `free`, on the same principle that decides a
176
+ * single provider: never call a run free when part of it is not.
177
+ */
178
+ export function combineBasis(a: CostBasis, b: CostBasis): CostBasis {
179
+ if (a === 'priced' || b === 'priced') return 'priced';
180
+ if (a === 'unpriced' || b === 'unpriced') return 'unpriced';
181
+ return 'free';
182
+ }
183
+
184
+ /** Whether dollars may be displayed or enforced at all. The one real branch. */
185
+ export function isPriced(meta: { costBasis?: CostBasis; metered?: boolean }): boolean {
186
+ return costBasisOf(meta) === 'priced';
187
+ }
188
+
189
+ /**
190
+ * Real token usage. `costUsd` is notional on a subscription plan and
191
+ * fictional through a gateway (the SDK prices foreign tokens with
192
+ * Anthropic's rate table) — token counts are what actually moved on any
193
+ * provider, so this is what an unmetered run's meter should show instead of
194
+ * a dollar figure nobody billed.
195
+ */
196
+ export interface TokenUsage {
197
+ inputTokens: number;
198
+ outputTokens: number;
199
+ cacheReadTokens: number;
200
+ cacheWriteTokens: number;
201
+ }
202
+
203
+ /** Snapshot of one worker, persisted in run metadata. */
204
+ export interface WorkerMeta {
205
+ id: string;
206
+ status: WorkerStatus;
207
+ costUsd: number;
208
+ sessionId?: string;
209
+ /** First 500 chars of the task brief, for run-history display. */
210
+ task: string;
211
+ /**
212
+ * The worker's final report, kept on the record rather than handed back
213
+ * once and forgotten: spawning is asynchronous, so the director reads
214
+ * results through check_workers / wait_for_worker — possibly more than
215
+ * once, possibly after a restart — and a report that lived only in a
216
+ * returned promise would be gone by then.
217
+ */
218
+ report?: string;
219
+ isError?: boolean;
220
+ startedAt?: number;
221
+ endedAt?: number;
222
+ /** Last SDK message seen; what "seconds since last activity" is measured from. */
223
+ lastActivityAt?: number;
224
+ toolCalls?: number;
225
+ /**
226
+ * Rolling window of the last few activity lines (tool name + a short arg
227
+ * hint, or the head of an assistant message). The director cannot see a
228
+ * worker's transcript; this is the glance that tells "reading tests" apart
229
+ * from "re-running the same failing command".
230
+ */
231
+ recent?: string[];
232
+ /**
233
+ * The worker's own account of where it is, from its report_progress tool.
234
+ * Kept apart from `recent` because the two answer different questions:
235
+ * `recent` is what the harness saw the worker call, this is what the
236
+ * worker says it is doing and how far along it is — the only signal that
237
+ * tells "two of three files written, stuck on the third" from flailing.
238
+ * Latest report wins; `at` dates it so a stale one reads as stale.
239
+ */
240
+ progress?: WorkerProgress;
241
+ }
242
+
243
+ /** One report_progress call, as stored on the record and sent to the UI. */
244
+ export interface WorkerProgress {
245
+ /** One-line summary, capped at 200 chars by the tool. */
246
+ status: string;
247
+ done?: string[];
248
+ next?: string;
249
+ blocked?: string;
250
+ at: number;
251
+ }
252
+
253
+ /**
254
+ * Model for an agent: a harness alias (opus/sonnet/haiku), a full claude-*
255
+ * model id, or `undefined` to inherit the harness default.
256
+ */
257
+ export type ModelChoice = string | undefined;
258
+
259
+ /** Per-tool decision applied before any prompt is considered. */
260
+ export type ToolPolicy = Record<string, 'allow' | 'ask' | 'deny'>;
261
+
262
+ /** Persisted run metadata (meta.json). Small, rewritten atomically on change. */
263
+ export interface RunMeta {
264
+ /** Effective tool policy, snapshotted from Settings at run start. */
265
+ toolPolicy?: ToolPolicy;
266
+ /** Whether read-only tools run silently (Settings; default true). */
267
+ autoAllowReadOnly?: boolean;
268
+ id: string;
269
+ /**
270
+ * Short generated name for the mission, from one cheap model call at start.
271
+ * Absent when the call failed or the run predates titling — the UI falls
272
+ * back to the brief, so nothing depends on this being here.
273
+ */
274
+ title?: string;
275
+ /** Owning project; absent on runs recorded before projects existed. */
276
+ projectId?: string;
277
+ /** Model override for the director session. */
278
+ directorModel?: ModelChoice;
279
+ /** Model override for worker sessions (cost lever). */
280
+ workerModel?: ModelChoice;
281
+ /**
282
+ * Provider serving each role, when it differs from the run's own.
283
+ *
284
+ * A model carries the provider that serves it, so a run can put a capable
285
+ * director on one and cheap workers on another — the configuration the
286
+ * whole provider model exists to make possible. Absent means "the run's
287
+ * provider", which is every run recorded before this existed.
288
+ */
289
+ directorProviderId?: string;
290
+ workerProviderId?: string;
291
+ /**
292
+ * Provider frozen at dispatch, so history and resume agree — and so a later
293
+ * settings change cannot move an in-flight or resumed run to another
294
+ * provider, or another bill.
295
+ */
296
+ provider?: ProviderRef;
297
+ /** @deprecated Pre-provider pin, still read for runs recorded before providers. */
298
+ claudeInstance?: ClaudeInstanceRef;
299
+ /** Number of times this run was resumed after an interruption. */
300
+ resumes?: number;
301
+ /** Tools the human granted "always allow" for this run (survives resume). */
302
+ allowedTools?: string[];
303
+ /**
304
+ * Absolute directories the human opened for this run by approving "always"
305
+ * on a folder-boundary card (see `PendingPermission.escapedPath`). Paths
306
+ * under them count as inside the mission folder. Persisted beside
307
+ * `allowedTools` for the same reason: a resume must not re-ask what the
308
+ * human already answered.
309
+ */
310
+ allowedRoots?: string[];
311
+ /** Dev servers the crew exposed through Foreman's proxy (see services.ts). */
312
+ services?: Array<{ port: number; label: string; path: string; since: number }>;
313
+ /**
314
+ * How long an approval card or director question may wait for the human
315
+ * before it is resolved with its unattended default (deny / "decide
316
+ * yourself"). Absent means the orchestrator's default; 0 disables the timer
317
+ * for a run someone intends to babysit.
318
+ */
319
+ askTimeoutMs?: number;
320
+ /** Give agents a headless Playwright browser (navigate, click, screenshot). */
321
+ browserTools?: boolean;
322
+ folder: string;
323
+ mission: string;
324
+ budgetUsd: number;
325
+ /**
326
+ * What this run's spend *is* — see {@link CostBasis}. Absent on runs
327
+ * recorded before the split; read it through {@link costBasisOf}, never
328
+ * directly, so those runs keep answering.
329
+ */
330
+ costBasis?: CostBasis;
331
+ /**
332
+ * @deprecated Superseded by {@link costBasis}, which distinguishes the two
333
+ * states this boolean collapsed. Still read for runs recorded before the
334
+ * split, and still written beside `costBasis` so a downgrade is survivable.
335
+ * Nothing new should branch on it.
336
+ */
337
+ metered?: boolean;
338
+ /** Director turns before the run winds down. Universal; provider-independent. */
339
+ maxTurns?: number;
340
+ /** Wall-clock cap. Matters most exactly where dollars matter least. */
341
+ maxSeconds?: number;
342
+ /**
343
+ * How long a worker may produce nothing before it is treated as stalled and
344
+ * stopped. Absent means the orchestrator's default. Raise it for slow local
345
+ * models whose first token legitimately takes minutes.
346
+ */
347
+ workerSilenceMs?: number;
348
+ status: RunStatus;
349
+ costUsd: number;
350
+ /**
351
+ * Where `costUsd` came from, kept apart so a resume adds to the right
352
+ * pile: `native` is the SDK's own figure for Anthropic-native roles,
353
+ * `rated` is gateway tokens priced from published or listed rates, and
354
+ * `ledger` is what a gateway upstream itself reported per response. When
355
+ * the upstream reports, its number outranks the rated one for the same
356
+ * tokens — the party that sends the bill wins.
357
+ */
358
+ costParts?: { native: number; rated: number; ledger: number };
359
+ /**
360
+ * Token counts accumulated from every director and worker `result`
361
+ * message's `usage` object. Absent on runs recorded before this landed.
362
+ */
363
+ usage?: TokenUsage;
364
+ /**
365
+ * Director turns taken so far, persisted so a resumed run keeps counting
366
+ * against {@link maxTurns} instead of restarting the cap from zero — the
367
+ * orchestrator's private counter is only ever accurate within one process.
368
+ */
369
+ turns?: number;
370
+ createdAt: number;
371
+ endedAt?: number;
372
+ directorSessionId?: string;
373
+ workers: WorkerMeta[];
374
+ }
375
+
376
+ /** Listing entry returned by GET /runs (meta without heavy fields). */
377
+ export type RunSummary = RunMeta;
378
+
379
+ /**
380
+ * A project's planning conversation — the talk that happens before a mission.
381
+ *
382
+ * Only the session id and the running tally are kept here; the conversation
383
+ * itself is the append-only event log beside this file, exactly like a run's.
384
+ * There is no process behind it: each turn resumes the stored session and
385
+ * exits, so an idle conversation costs nothing but disk.
386
+ */
387
+ export interface ChatMeta {
388
+ projectId: string;
389
+ /** Claude Code session to resume; absent until the first turn completes. */
390
+ sessionId?: string;
391
+ /** Everything this conversation has cost, for the whole of its life. */
392
+ costUsd: number;
393
+ createdAt: number;
394
+ updatedAt: number;
395
+ /** The most recent mission the planner proposed, if it has not been used. */
396
+ proposal?: MissionProposal;
397
+ }
398
+
399
+ /** A mission the planner drafted, waiting for the human to start or discard. */
400
+ export interface MissionProposal {
401
+ id: string;
402
+ /** The brief, ready to go into the composer. */
403
+ mission: string;
404
+ /** Verifiable completion criteria, shown as a checklist. */
405
+ doneWhen: string[];
406
+ /** What the planner thinks it should cost. The human always sees it. */
407
+ budgetUsd: number;
408
+ /** Why this budget and this shape — one short paragraph. */
409
+ rationale?: string;
410
+ /**
411
+ * The planner judged the DONE WHEN criteria need a browser (a page must
412
+ * load, render, be console-clean or be screenshotted). The card starts with
413
+ * the switch on. Absent means "the planner did not say", not "no".
414
+ */
415
+ browser?: boolean;
416
+ /**
417
+ * The planner's model recommendations, already validated against the
418
+ * machine's list — an id it could not have picked from the list is dropped
419
+ * and the role inherits. The card pre-selects these; the human can change
420
+ * them. `modelRationale` is the planner's one line on why, shown beside
421
+ * the pickers so the choice reads as a suggestion with a reason, not a
422
+ * setting that appeared.
423
+ */
424
+ directorModel?: string;
425
+ workerModel?: string;
426
+ directorProviderId?: string;
427
+ workerProviderId?: string;
428
+ modelRationale?: string;
429
+ createdAt: number;
430
+ }
431
+
432
+ /** Result of a permission decision made by the human. */
433
+ export type PermissionDecision = 'allow' | 'allow_always' | 'deny';
434
+
435
+ /** Free-form UI settings blob (shape owned by the design system's modal). */
436
+ export type SettingsValues = Record<string, unknown>;
437
+
438
+ /** Persisted settings: global values + sparse per-project overlays. */
439
+ export interface SettingsFile {
440
+ global: SettingsValues;
441
+ projects: Record<string, SettingsValues>;
442
+ }
@@ -0,0 +1 @@
1
+ @import"https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap";:root{color-scheme:dark;--bg-app: #121417;--bg-panel: #1a1d22;--bg-card: #22262d;--bg-inset: #14161a;--bg-hover: #1e2228;--line: #2d323b;--line-strong: #3a4048;--ink-0: #e8e6df;--ink-1: #a7afb9;--ink-2: #6d7683;--brand: #e8b04b;--brand-ink: #14161a;--agent-director: var(--brand);--agent-worker: #9c86e8;--agent-worker-1: #9c86e8;--agent-worker-2: #7f9ff0;--agent-worker-3: #c48be0;--agent-worker-4: #b3a6f2;--agent-worker-5: #7a72d8;--agent-worker-6: #d19ac8;--status-good: #3fb950;--status-warning: #fab219;--status-serious: #ec835a;--status-critical: #e5484d;--brand-wash: rgba(232, 176, 75, .06);--brand-wash-strong: rgba(232, 176, 75, .12);--scrim: rgba(0, 0, 0, .55);--diff-add-bg: rgba(63, 185, 80, .12);--diff-del-bg: rgba(229, 72, 77, .12)}[data-theme=light]{color-scheme:light;--bg-app: #ecebe6;--bg-panel: #f6f5f1;--bg-card: #ffffff;--bg-inset: #e4e2db;--bg-hover: #f0efea;--line: #d9d6cd;--line-strong: #c2beb2;--ink-0: #1c1e22;--ink-1: #555c67;--ink-2: #7b828d;--brand: #c98f1f;--brand-ink: #ffffff;--agent-worker: #6f57d6;--agent-worker-1: #6f57d6;--agent-worker-2: #4a6fdc;--agent-worker-3: #a35bc9;--agent-worker-4: #8a78e0;--agent-worker-5: #5449b8;--agent-worker-6: #b3609e;--status-good: #1f8f3a;--status-warning: #b8790a;--status-serious: #c4592c;--status-critical: #c8323a;--brand-wash: rgba(201, 143, 31, .08);--brand-wash-strong: rgba(201, 143, 31, .16);--scrim: rgba(20, 22, 26, .45);--diff-add-bg: rgba(31, 143, 58, .12);--diff-del-bg: rgba(200, 50, 58, .12)}:root{--font-ui: "Source Sans 3", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;--ui-scale: 1;--fs-xs: .75rem;--fs-sm: .8125rem;--fs-md: .9375rem;--fs-lg: 1.0625rem;--fs-xl: 1.25rem;--lh: 1.45;--lh-prose: 1.55;--fw-regular: 400;--fw-medium: 500;--fw-semibold: 600;--ls-caps: .08em;--icon-sm: 14px;--icon-md: 16px;--icon-lg: 20px;--icon-stroke: 1.75}:root{--sp-1: 4px;--sp-2: 8px;--sp-3: 12px;--sp-4: 16px;--sp-5: 24px;--r-sm: 6px;--r-md: 10px;--r-pill: 999px;--rail-left: 15.625rem;--rail-right: 21.25rem;--bp-narrow: 900px;--bp-medium: 1180px;--fleet-max: 75rem;--composer-max: 45rem;--spine-max: 62rem;--card-min: 18.75rem;--card-min-h: 8.125rem;--modal-w: 30rem;--modal-w-wide: 32.5rem;--accent-w: 3px;--meter-h: 4px;--dot: 8px;--dot-sm: 6px;--focus: 0 0 0 2px var(--bg-app), 0 0 0 4px var(--brand)}:root{--dur-fast: .12s;--dur-meter: .3s;--dur-pulse: 1.6s;--ease: ease-in-out}@keyframes pulse{0%,to{opacity:1}50%{opacity:.55}}.pulse{animation:pulse var(--dur-pulse) var(--ease) infinite}@keyframes ticker-in{0%{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}.ticker{animation:ticker-in var(--dur-fast, .15s) var(--ease) both}:root{--surface-app: var(--bg-app);--surface-panel: var(--bg-panel);--surface-card: var(--bg-card);--surface-inset: var(--bg-inset);--surface-overlay: var(--bg-panel);--border-subtle: var(--line);--border-strong: var(--line-strong);--text-body: var(--ink-0);--text-secondary: var(--ink-1);--text-muted: var(--ink-2);--text-on-brand: var(--brand-ink);--text-accent: var(--brand);--text-danger: var(--status-critical);--intent-primary: var(--brand);--intent-affirm: var(--status-good);--intent-deny: var(--status-critical);--intent-caution: var(--status-warning);--intent-degraded: var(--status-serious);--state-idle: var(--ink-2);--state-running: var(--status-good);--state-done: var(--status-good);--state-interrupted: var(--status-serious);--state-error: var(--status-critical)}*{box-sizing:border-box}html,body,#root{height:100%}html{font-size:calc(100% * var(--ui-scale))}body{margin:0;background:var(--bg-app);color:var(--ink-0);font:var(--fs-md)/var(--lh) var(--font-ui)}button,input,textarea,select{font:inherit;color:inherit}:is(button,input,textarea,select):focus-visible{outline:none;box-shadow:var(--focus)}a{color:var(--brand);text-decoration:none}a:hover{text-decoration:underline}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--line-strong);border-radius:5px}::-webkit-scrollbar-track{background:transparent}