@openparachute/agent 0.2.2 → 0.2.3-rc.11

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 (74) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/programmatic.ts +35 -2
  6. package/src/backends/registry.ts +159 -40
  7. package/src/backends/types.ts +44 -0
  8. package/src/daemon.ts +317 -12
  9. package/src/def-vault-triggers.ts +317 -0
  10. package/src/preflight.ts +139 -0
  11. package/src/spawn-agent.ts +16 -0
  12. package/src/step-up.ts +316 -0
  13. package/src/terminal-ui.ts +73 -0
  14. package/src/transports/http-ui.ts +10 -8
  15. package/src/transports/vault.ts +48 -27
  16. package/src/ui-kit.ts +6 -3
  17. package/src/ui-ticket.ts +121 -0
  18. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  19. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  20. package/web/ui/dist/index.html +2 -2
  21. package/src/_parked/interactive-spawn.test.ts +0 -324
  22. package/src/_parked/interactive-spawn.ts +0 -701
  23. package/src/agent-defs.test.ts +0 -1504
  24. package/src/agent-mcp-config.test.ts +0 -115
  25. package/src/agents.test.ts +0 -360
  26. package/src/auth.test.ts +0 -46
  27. package/src/backends/attached-queue.test.ts +0 -376
  28. package/src/backends/programmatic.test.ts +0 -1715
  29. package/src/backends/registry.test.ts +0 -1494
  30. package/src/backends/stream-json.test.ts +0 -570
  31. package/src/channel-backend-wiring.test.ts +0 -237
  32. package/src/credentials.test.ts +0 -274
  33. package/src/cron.test.ts +0 -342
  34. package/src/daemon-agent-def-api.test.ts +0 -166
  35. package/src/daemon-agent-defs-api.test.ts +0 -953
  36. package/src/daemon-agent-env-api.test.ts +0 -338
  37. package/src/daemon-attached-queue-store.test.ts +0 -65
  38. package/src/daemon-config-api.test.ts +0 -962
  39. package/src/daemon-jobs-api.test.ts +0 -271
  40. package/src/daemon-vault-chat.test.ts +0 -250
  41. package/src/daemon.test.ts +0 -746
  42. package/src/def-vaults.test.ts +0 -136
  43. package/src/delivery-state.test.ts +0 -110
  44. package/src/effective-env.test.ts +0 -114
  45. package/src/grants.test.ts +0 -638
  46. package/src/hub-jwt.test.ts +0 -161
  47. package/src/jobs.test.ts +0 -245
  48. package/src/mcp-http.test.ts +0 -265
  49. package/src/mint-token.test.ts +0 -152
  50. package/src/module-manifest.test.ts +0 -158
  51. package/src/programmatic-wiring.test.ts +0 -838
  52. package/src/registry.test.ts +0 -227
  53. package/src/resolve-port.test.ts +0 -64
  54. package/src/routing.test.ts +0 -184
  55. package/src/runner.test.ts +0 -506
  56. package/src/sandbox/config.test.ts +0 -150
  57. package/src/sandbox/egress.test.ts +0 -113
  58. package/src/sandbox/live-seatbelt.test.ts +0 -277
  59. package/src/sandbox/mounts.test.ts +0 -154
  60. package/src/sandbox/sandbox.test.ts +0 -168
  61. package/src/services-manifest.test.ts +0 -106
  62. package/src/spa-serve.test.ts +0 -116
  63. package/src/spawn-agent-cli.test.ts +0 -172
  64. package/src/spawn-agent.test.ts +0 -1218
  65. package/src/spawn-deps.test.ts +0 -54
  66. package/src/terminal-assets.test.ts +0 -50
  67. package/src/terminal.test.ts +0 -530
  68. package/src/transports/http-ui.test.ts +0 -455
  69. package/src/transports/telegram.test.ts +0 -174
  70. package/src/transports/vault.test.ts +0 -2011
  71. package/src/ui-kit.test.ts +0 -178
  72. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  73. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  74. package/web/ui/tsconfig.json +0 -21
package/src/ui-kit.ts CHANGED
@@ -376,9 +376,12 @@ export const SHELL_JS = `
376
376
  el.className = "app-status" + (kind ? " " + kind : "");
377
377
  }
378
378
  // Hub-minted agent token (cookie-gated to the logged-in operator). Cached on
379
- // window.__token; pages attach it as a Bearer header and/or ?token= param.
380
- // (Endpoint renamed /admin/channel-token /admin/agent-token in the
381
- // channel→agent rename; the hub 301-redirects the old path for old bookmarks.)
379
+ // window.__token; pages attach it as a Bearer header. (Browser SSE auth moved
380
+ // off the query-param token to a one-time ticket in agent#25, so the JWT never
381
+ // lands in a URL; this template is the retired server-rendered shell, superseded
382
+ // by the SPA.) Endpoint renamed /admin/channel-token to /admin/agent-token in
383
+ // the channel-to-agent rename; the hub 301-redirects the old path for old
384
+ // bookmarks.
382
385
  function fetchToken() {
383
386
  return fetch(window.location.origin + "/admin/agent-token", { credentials: "include" })
384
387
  .then(function (r) { if (!r.ok) throw new Error("token " + r.status); return r.json(); })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * One-time SSE tickets for the browser EventSource auth path (Layer 2, human↔UI).
3
+ *
4
+ * THE LEAK THIS CLOSES. A browser `EventSource` can't set an `Authorization`
5
+ * header, so the agent SPA used to put the hub JWT directly in the SSE URL
6
+ * (`/ui/events?token=<JWT>`, `/api/channels/<ch>/turn-events?token=<JWT>`). A
7
+ * full bearer JWT in a URL lands in any access log, proxy log, browser history,
8
+ * or network trace — mitigated before only by the token's ~10min TTL. That's a
9
+ * credential-in-a-URL leak.
10
+ *
11
+ * THE FIX. Trade the JWT for an opaque, single-use, very-short-lived TICKET that
12
+ * goes in the URL instead. The SPA presents its bearer JWT to a normal
13
+ * authenticated endpoint (a Bearer header on a `fetch`, no leak), which mints a
14
+ * ticket: a crypto-random 256-bit nonce (base64url) stored ONLY server-side in
15
+ * this TTL'd map, carrying the validated scope(s)/audience of the presenting
16
+ * token. The SPA opens `/ui/events?ticket=<nonce>`; the SSE consume path looks
17
+ * the nonce up, CONSUMES it (deletes immediately — single-use), and establishes
18
+ * the stream with the ticket's scopes. The JWT never appears in a URL or log.
19
+ *
20
+ * SECURITY PROPERTIES (all load-bearing):
21
+ * - Unguessable: 32 random bytes (256 bits) from `crypto.getRandomValues`,
22
+ * base64url-encoded. Far above the issue's 128-bit floor.
23
+ * - Single-use: `consume` DELETES the entry before returning, so a replayed
24
+ * ticket (a second connect, or a stolen URL) finds nothing → 401.
25
+ * - Short TTL: default 60s — just long enough to open the connection. An
26
+ * expired entry is treated as absent (and lazily pruned).
27
+ * - No scope widening: the ticket stores EXACTLY the scopes the minting token
28
+ * presented (validated upstream by `requireScope` before `mint` is called).
29
+ * The consume path asserts the required scope against the stored set, so a
30
+ * ticket can never authorize more than the JWT that minted it.
31
+ *
32
+ * This is process-local in-memory state by design (mirrors the daemon's other
33
+ * in-process registries). The daemon is single-instance per machine; tickets
34
+ * live ≤60s and are cheap to lose on restart (the SPA just re-mints). A
35
+ * module-level singleton is used because the two consume paths live in different
36
+ * modules (`http-ui.ts`'s `ingestHttp` and the daemon's turn-events route) and
37
+ * both must hit the SAME store — `ingestHttp(req, url)` has no place to thread an
38
+ * instance through. `_resetTicketsForTest` isolates unit tests.
39
+ */
40
+
41
+ /** A minted ticket's server-side record. Never leaves the process. */
42
+ interface TicketRecord {
43
+ /** The validated scopes carried from the minting JWT (the ceiling — never widened). */
44
+ scopes: string[];
45
+ /** Epoch ms after which the ticket is expired (treated as absent). */
46
+ expiresAt: number;
47
+ }
48
+
49
+ /** Default ticket lifetime — long enough to open an EventSource, no longer. */
50
+ export const TICKET_TTL_MS = 60_000;
51
+
52
+ /** Nonce entropy: 32 bytes = 256 bits, well above the 128-bit floor. */
53
+ const TICKET_BYTES = 32;
54
+
55
+ /** The process-local ticket store. nonce → record. */
56
+ const tickets = new Map<string, TicketRecord>();
57
+
58
+ /** base64url-encode bytes (no padding) — URL-safe, no `+`/`/`/`=`. */
59
+ function base64url(bytes: Uint8Array): string {
60
+ let bin = "";
61
+ for (const b of bytes) bin += String.fromCharCode(b);
62
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
63
+ }
64
+
65
+ /**
66
+ * Mint a single-use ticket carrying `scopes` (a COPY of the validated scopes from
67
+ * the presenting JWT — the caller must have already authenticated + scope-checked
68
+ * the token, so this never widens authority). Returns the opaque nonce + its
69
+ * absolute expiry. TTL defaults to {@link TICKET_TTL_MS}.
70
+ */
71
+ export function mintTicket(scopes: readonly string[], ttlMs = TICKET_TTL_MS): {
72
+ ticket: string;
73
+ expiresAt: number;
74
+ } {
75
+ pruneExpiredTickets();
76
+ const bytes = new Uint8Array(TICKET_BYTES);
77
+ crypto.getRandomValues(bytes);
78
+ const ticket = base64url(bytes);
79
+ const expiresAt = Date.now() + ttlMs;
80
+ tickets.set(ticket, { scopes: [...scopes], expiresAt });
81
+ return { ticket, expiresAt };
82
+ }
83
+
84
+ /**
85
+ * Consume a ticket: look it up, and if present + unexpired, DELETE it (single-use)
86
+ * and return its scopes. Returns `null` for an absent / expired / already-consumed
87
+ * ticket — the caller maps that to a 401. Deletion happens before return, so two
88
+ * concurrent consumes of the same nonce can't both succeed.
89
+ */
90
+ export function consumeTicket(ticket: string | null | undefined): { scopes: string[] } | null {
91
+ if (!ticket) return null;
92
+ const rec = tickets.get(ticket);
93
+ if (!rec) return null;
94
+ // Single-use: remove FIRST, so even an expired hit can't be retried and a
95
+ // concurrent second consume finds nothing.
96
+ tickets.delete(ticket);
97
+ if (Date.now() >= rec.expiresAt) return null;
98
+ return { scopes: rec.scopes };
99
+ }
100
+
101
+ /**
102
+ * Drop every expired ticket. Called opportunistically on each mint so the map
103
+ * can't grow unbounded if some tickets are never consumed; not on a timer (no
104
+ * background work in a possibly-idle daemon). O(n) over a map that's tiny in
105
+ * practice (≤ a handful of live tickets at 60s TTL).
106
+ */
107
+ export function pruneExpiredTickets(now = Date.now()): void {
108
+ for (const [k, rec] of tickets) {
109
+ if (now >= rec.expiresAt) tickets.delete(k);
110
+ }
111
+ }
112
+
113
+ /** Test seam: clear all tickets so unit tests start from a clean store. */
114
+ export function _resetTicketsForTest(): void {
115
+ tickets.clear();
116
+ }
117
+
118
+ /** Test seam: the current live ticket count (asserts single-use deletion). */
119
+ export function _ticketCountForTest(): number {
120
+ return tickets.size;
121
+ }
@@ -0,0 +1 @@
1
+ :root{--bg: #faf8f4;--bg-soft: #f3f0ea;--fg: #2c2a26;--fg-muted: #6b6860;--fg-dim: #9a9690;--accent: #4a7c59;--accent-soft: rgba(74, 124, 89, .08);--accent-hover: #3d6849;--border: #e4e0d8;--border-light: #ece9e2;--card-bg: #ffffff;--error: #a3392b;--error-soft: rgba(163, 57, 43, .08);--warn: #b08023;--warn-soft: rgba(176, 128, 35, .08);--success: #3d6849;--success-soft: rgba(61, 104, 73, .08);--font-serif: Georgia, "Times New Roman", serif;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace;font-family:var(--font-sans)}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.45rem .9rem;cursor:pointer;transition:background .15s ease}button:hover{background:var(--accent-hover)}button:disabled{opacity:.5;cursor:not-allowed}button.secondary{background:#fff;color:var(--fg);border:1px solid var(--border)}button.secondary:hover{background:var(--bg-soft)}code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-soft);padding:.1em .3em;border-radius:3px}.page{max-width:960px;margin:0 auto;padding:1.5rem 1.5rem 6rem}.nav{display:flex;flex-wrap:wrap;gap:.6rem 1rem;align-items:center;padding-bottom:1rem;border-bottom:1px solid var(--border);margin-bottom:2rem}.nav .brand{font-weight:600;font-family:var(--font-serif);font-size:1.15rem;margin-right:auto;display:inline-flex;align-items:center;gap:.45rem;color:var(--accent);text-decoration:none}.nav .brand:hover{color:var(--accent-hover);text-decoration:none}.nav .brand-wordmark{color:var(--fg);letter-spacing:-.005em}.nav .brand .sub{color:var(--fg-dim);font-size:.78rem;font-weight:400;margin-left:.4rem;font-family:var(--font-sans)}.nav a{color:var(--fg-muted);font-size:.95rem}.nav a:hover{text-decoration:none;color:var(--fg)}.nav a.nav-link-active{color:var(--accent);font-weight:500;text-decoration:underline;text-underline-offset:.3em;text-decoration-thickness:2px}h1{margin:0 0 .5rem;font-family:var(--font-serif);font-size:1.85rem;font-weight:400;letter-spacing:-.01em;line-height:1.2;color:var(--fg)}h2{margin:0 0 1rem;font-size:1.4rem;font-weight:500}h3{margin:0 0 .5rem;font-size:1.05rem;font-weight:600}.muted{color:var(--fg-muted);font-size:.92rem}.dim{color:var(--fg-dim);font-size:.85rem}.lede{color:var(--fg-muted);font-size:.95rem;margin:0 0 1.5rem;max-width:60ch}.error-banner{background:var(--error-soft);border:1px solid var(--error);color:var(--error);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.info-banner{background:var(--accent-soft);border:1px solid var(--accent);color:var(--fg);padding:.65rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.88rem}.empty{border:1px dashed var(--border);border-radius:10px;padding:2rem 1.5rem;text-align:center;color:var(--fg-muted);background:var(--card-bg)}.loading{color:var(--fg-muted);padding:1rem 0;font-size:.92rem}.card{background:var(--card-bg);border:1px solid var(--border);border-radius:12px;padding:1.1rem 1.25rem;box-shadow:0 1px 2px #2c2a260a,0 8px 24px #2c2a260d;margin-bottom:1.5rem}.section-head{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:.75rem}.section-head .count{color:var(--fg-dim);font-size:.85rem}table{width:100%;border-collapse:collapse;font-size:.88rem}th,td{text-align:left;padding:.6rem .7rem;border-bottom:1px solid var(--border);vertical-align:middle}th{color:var(--fg-muted);font-weight:500;font-size:.78rem;text-transform:uppercase;letter-spacing:.03em}tr.agent-row{cursor:pointer}tr.agent-row:hover{background:var(--bg-soft)}tr.agent-row.selected{background:var(--accent-soft)}.cell-name{font-weight:600;color:var(--fg)}.cell-dim{color:var(--fg-dim)}.pill{display:inline-block;padding:.1rem .5rem;border-radius:999px;font-size:.72rem;font-weight:600;letter-spacing:.02em;border:1px solid var(--border);color:var(--fg-muted);background:var(--bg-soft)}.pill.backend-programmatic{color:var(--accent);background:var(--accent-soft);border-color:transparent}.pill.backend-channel{color:var(--warn);background:var(--warn-soft);border-color:transparent}.pill.status-enabled,.pill.status-idle{color:var(--success);background:var(--success-soft);border-color:transparent}.pill.status-working{color:var(--accent);background:var(--accent-soft);border-color:transparent}.pill.status-pending,.pill.status-queued{color:var(--warn);background:var(--warn-soft);border-color:transparent}.pill.status-error{color:var(--error);background:var(--error-soft);border-color:transparent}.detail{background:var(--card-bg);border:1px solid var(--border);border-radius:12px;padding:1.25rem 1.4rem;margin-bottom:1.5rem;box-shadow:0 1px 2px #2c2a260a,0 8px 24px #2c2a260d}.detail-head{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;margin-bottom:.9rem}.detail-head h2{margin:0;font-size:1.25rem}.detail-grid{display:grid;grid-template-columns:max-content 1fr;gap:.45rem 1.2rem;font-size:.9rem;margin-bottom:1rem}.detail-grid dt{color:var(--fg-muted);font-weight:500}.detail-grid dd{margin:0;color:var(--fg);word-break:break-word}.detail-prompt{background:var(--bg-soft);border:1px solid var(--border-light);border-radius:8px;padding:.75rem .9rem;font-family:var(--font-mono);font-size:.82rem;white-space:pre-wrap;color:var(--fg);margin:.3rem 0 1rem}.detail-note{font-size:.82rem;color:var(--fg-dim);margin:.5rem 0 0}.detail-close{margin-left:auto;background:#fff;color:var(--fg-muted);border:1px solid var(--border);padding:.3rem .7rem;font-size:.82rem}.detail-close:hover{background:var(--bg-soft);color:var(--fg)}.tag-list{display:flex;flex-wrap:wrap;gap:.35rem}.tag{font-family:var(--font-mono);font-size:.76rem;background:var(--bg-soft);border:1px solid var(--border-light);border-radius:4px;padding:.05rem .35rem;color:var(--fg-muted)}.section-head-actions{display:inline-flex;align-items:center;gap:.9rem}.button-link{display:inline-block;background:var(--accent);color:#fff;border-radius:6px;padding:.4rem .85rem;font-size:.85rem;font-weight:500;transition:background .15s ease}.button-link:hover{background:var(--accent-hover);color:#fff;text-decoration:none}.field{display:block;border:0;margin:0 0 1.25rem;padding:0}.field>label,.field>legend{display:block;font-weight:500;font-size:.9rem;color:var(--fg);margin-bottom:.35rem;padding:0}.field input[type=text],.field select,.field textarea{width:100%;font:inherit;font-size:.9rem;color:var(--fg);background:var(--card-bg);border:1px solid var(--border);border-radius:6px;padding:.5rem .6rem}.field textarea{font-family:var(--font-mono);font-size:.82rem;resize:vertical}.field input[type=text]:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.field-hint{color:var(--fg-dim);font-size:.8rem;margin:.3rem 0 0}.field-error{color:var(--error);font-size:.8rem;margin:.3rem 0 0}.radio-row{display:flex;align-items:flex-start;gap:.6rem;border:1px solid var(--border);border-radius:8px;padding:.65rem .8rem;margin-bottom:.5rem;cursor:pointer;transition:border-color .15s ease,background .15s ease}.radio-row:hover{background:var(--bg-soft)}.radio-row.selected{border-color:var(--accent);background:var(--accent-soft)}.radio-row input[type=radio]{margin-top:.2rem;accent-color:var(--accent)}.radio-body{display:flex;flex-direction:column;gap:.15rem}.radio-label{font-weight:500;font-size:.9rem;color:var(--fg)}.radio-help{font-size:.8rem;color:var(--fg-muted)}.advanced{margin:0 0 1.25rem}.advanced>summary{cursor:pointer;font-size:.88rem;font-weight:500;color:var(--fg-muted);margin-bottom:.75rem}.advanced>summary:hover{color:var(--fg)}.form-actions{display:flex;align-items:center;gap:1rem}.cancel-link{color:var(--fg-muted);font-size:.9rem}.success-banner{background:var(--success-soft);border:1px solid var(--success);color:var(--success);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.95rem}.snippet-row{display:flex;align-items:stretch;gap:.6rem;margin:.5rem 0}.snippet{flex:1;font-family:var(--font-mono);font-size:.8rem;background:var(--bg-soft);border:1px solid var(--border-light);border-radius:6px;padding:.6rem .75rem;color:var(--fg);white-space:pre-wrap;word-break:break-all}.detail-actions{display:flex;gap:.6rem;margin-top:1.25rem;padding-top:1rem;border-top:1px solid var(--border-light)}button.button-danger{background:#fff;color:var(--error);border:1px solid var(--error)}button.button-danger:hover{background:var(--error-soft)}button.button-danger:disabled{opacity:.5;cursor:not-allowed}.confirm-box{margin-top:1.25rem;padding:1rem;border:1px solid var(--error);border-radius:8px;background:var(--error-soft)}.confirm-prompt{margin:0 0 .75rem;font-size:.9rem;color:var(--fg)}.confirm-inline{display:inline-flex;align-items:center;gap:.6rem}.inline-form{margin:.75rem 0 1rem}.schedules,.detail-section{margin-top:1.25rem;padding-top:1rem;border-top:1px solid var(--border-light)}.schedules .section-head h3,.detail-section .section-head h3{margin:0;font-size:1.05rem}.schedule-presets{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.45rem}.schedule-presets button{font-size:.78rem;padding:.2rem .55rem}.schedule-row-actions{display:inline-flex;align-items:center;gap:.6rem}.schedule-status{font-size:.82rem;color:var(--fg-muted);margin:.5rem 0 0}.chat-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:.75rem 1rem;margin-bottom:1rem}.chat-head h1{margin:0}.chat-picker{display:inline-flex;align-items:center;gap:.5rem}.chat-picker-label{font-size:.82rem;color:var(--fg-muted)}.chat-picker select{font:inherit;font-size:.88rem;color:var(--fg);background:var(--card-bg);border:1px solid var(--border);border-radius:6px;padding:.35rem .5rem}.chat-status{font-size:.8rem;color:var(--fg-muted);margin-left:auto}.chat-status.status-live{color:var(--success)}.chat-status.status-err{color:var(--error)}.transcript{display:flex;flex-direction:column;gap:.5rem;height:60vh;min-height:18rem;overflow-y:auto;padding:1rem;background:var(--card-bg);border:1px solid var(--border);border-radius:12px 12px 0 0}.transcript .msg{max-width:78%;padding:.5rem .75rem;border-radius:12px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:anywhere}.transcript .msg.you{align-self:flex-end;background:var(--accent);color:#fff;border-bottom-right-radius:4px}.transcript .msg.them{align-self:flex-start;background:var(--bg-soft);color:var(--fg);border:1px solid var(--border);border-bottom-left-radius:4px}.transcript .msg.sys{align-self:center;background:transparent;color:var(--fg-muted);font-size:.8rem;font-style:italic;max-width:90%;text-align:center}.transcript .msg.live{border-style:dashed;animation:chatLivePulse 1.4s ease-in-out infinite}@keyframes chatLivePulse{0%,to{opacity:1}50%{opacity:.6}}.transcript .msg.live.errored{border-color:var(--error);color:var(--error);animation:none}.transcript .live-tools{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.4rem}.transcript .tool-chip{font-family:var(--font-mono);font-size:.72rem;padding:.05rem .45rem;border-radius:10px;background:var(--card-bg);color:var(--fg-muted);border:1px solid var(--border)}.transcript .live-working{margin-top:.4rem;font-size:.75rem;color:var(--fg-muted);font-style:italic}.composer{display:flex;gap:.6rem;padding:.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);border-top:0;border-radius:0 0 12px 12px;margin-bottom:1.5rem}.composer .chat-input{flex:1;font:inherit;font-size:.9rem;color:var(--fg);background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:.55rem .7rem;resize:none;max-height:7.5rem}.composer .chat-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.composer button{flex:0 0 auto;align-self:flex-end}.nav .nav-link-button{background:none;border:none;padding:0;margin:0;cursor:pointer;color:var(--fg-muted);font-size:.95rem;font-family:inherit}.nav .nav-link-button:hover{color:var(--fg)}.step-up-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#2c2a2673;display:flex;align-items:center;justify-content:center;padding:1rem;z-index:1000}.step-up-modal{background:var(--card-bg);border:1px solid var(--border);border-radius:10px;box-shadow:0 12px 40px #2c2a2633;padding:1.5rem;width:100%;max-width:26rem}.step-up-modal h2{margin:0 0 .5rem;font-family:var(--font-serif);font-size:1.25rem}.step-up-sub{color:var(--fg-muted);font-size:.9rem;margin:0 0 1rem}.step-up-field{display:block;margin-bottom:.85rem}.step-up-field>span{display:block;font-size:.85rem;color:var(--fg-muted);margin-bottom:.3rem}.step-up-field input{width:100%;padding:.5rem .6rem;border:1px solid var(--border);border-radius:6px;font-family:var(--font-mono);font-size:1.1rem;letter-spacing:.25em;background:var(--bg);color:var(--fg);box-sizing:border-box}.step-up-field input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.step-up-error{color:var(--error);background:var(--error-soft);border-radius:6px;padding:.5rem .6rem;font-size:.85rem;margin:0 0 .85rem}.step-up-actions{display:flex;justify-content:flex-end;gap:.6rem;margin-top:.5rem}