@makerstufferupper/flypi 0.1.5 → 0.1.6

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.
@@ -0,0 +1 @@
1
+ .revealed.svelte-19l53a6{color:var(--ink)}.memory-body.svelte-3w3u32{color:var(--ink);flex-basis:100%}.memory-row.svelte-3w3u32{align-items:flex-start}.memory-why.svelte-3w3u32,.memory-history.svelte-3w3u32,.memory-uses.svelte-3w3u32{gap:.25rem;width:100%;margin:.3rem 0 0;padding:0;list-style:none;display:grid}.memory-why.svelte-3w3u32 li:where(.svelte-3w3u32),.memory-history.svelte-3w3u32 li:where(.svelte-3w3u32){flex-wrap:wrap;align-items:center;gap:.4rem;display:flex}.search.svelte-3w3u32{font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:.2rem .3rem}.preimage.svelte-3w3u32 summary:where(.svelte-3w3u32){color:var(--muted);cursor:pointer;font-size:11px}.preimage.svelte-3w3u32 pre:where(.svelte-3w3u32){max-height:12rem}.wiki-page.svelte-3w3u32 h4:where(.svelte-3w3u32){margin:.8rem 0 .2rem;font-size:13px}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#fbfbfd;--panel:#fff;--line:#e3e4e9;--ink:#17181d;--muted:#6b6f7a;--accent:#2f6f4f;--error:#a3262a;--surface:#f4f5f7;--code:#f6f7f9;--queue:#f2f7f4}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--bg:#13151a;--panel:#1b1e25;--line:#2c303a;--ink:#e6e8ee;--muted:#98a0ae;--accent:#6cb892;--error:#e08084;--surface:#232730;--code:#1f232b;--queue:#1d2b24}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0}.layout{grid-template-columns:var(--sidebar-w,15rem) .5rem minmax(0, 1fr) .5rem var(--activity-w,22rem);gap:.25rem;height:100vh;padding:.75rem;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:grid}.updating{text-align:center;height:100vh;color:var(--muted);place-content:center;gap:.3rem;padding:1rem;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:grid}.updating h2{color:var(--ink);margin:0 0 .3rem;font-size:1.1rem}.updating p{margin:0}.sidebar,.chat,.activity-panel,.settings-view{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:.75rem;overflow:auto}.gutter{cursor:col-resize;touch-action:none;background:var(--line);border-radius:3px}.gutter:hover,.gutter:focus-visible{background:var(--accent)}.activity-panel{flex-direction:column;display:flex}.sidebar h1{margin:0 0 .5rem;font-size:1rem}.sidebar .port{color:var(--muted);font-weight:400}.sidebar .update{color:var(--muted);flex-wrap:wrap;align-items:center;gap:.3rem;margin:0 0 .5rem;font-size:.8rem;display:flex}.panel-head{flex-wrap:wrap;align-items:center;gap:.35rem;margin-bottom:.5rem;display:flex}.panel-head h1{flex:1;margin:0}.panel-actions{gap:.35rem;margin-bottom:.5rem;display:flex}.icon{flex:none;padding:.2rem .35rem}.panel-head .logo{border-radius:6px;flex:none;width:2.5rem;height:2.5rem}.icon svg{width:1.05rem;height:1.05rem;display:block}.field{color:var(--muted);margin-bottom:.5rem;font-size:12px;display:block}.field select{width:100%;margin-top:.15rem}.sessions{margin:.5rem 0;padding:0;list-style:none}.sessions li{align-items:center;gap:.15rem;display:flex}.sessions .session,.sessions input.name{flex:1;min-width:0}.sessions .run-dot{background:var(--accent,#3b82f6);border-radius:50%;flex:none;width:.45rem;height:.45rem;animation:1.4s ease-in-out infinite run-pulse}@media (prefers-reduced-motion:reduce){.sessions .run-dot{animation:none}}@keyframes run-pulse{0%,to{opacity:1}50%{opacity:.3}}.sessions .unread-dot{background:var(--accent,#3b82f6);border-radius:50%;flex:none;width:.45rem;height:.45rem}.sessions li.unread .session{font-weight:700}.sessions .remove{opacity:.6}.sessions .remove:hover{opacity:1}.sessions .session{text-align:left;white-space:nowrap;text-overflow:ellipsis;background:0 0;border:0;border-radius:6px;width:100%;overflow:hidden}.sessions .session:hover{background:var(--surface)}.sessions .session.current{background:var(--queue)}.sessions input.name{background:var(--queue);width:100%;color:inherit;border:0;border-radius:6px;font-weight:600}.sessions input.name:hover{background:var(--queue)}button,select,input,textarea{font:inherit;color:inherit}button{border:1px solid var(--line);background:var(--surface);cursor:pointer;border-radius:6px;padding:.25rem .5rem}button:hover{border-color:var(--accent)}input:not([type=checkbox]),select,textarea{border:1px solid var(--line);background:var(--surface);border-radius:6px;padding:.3rem .4rem}textarea{width:100%}.chat{flex-direction:column;display:flex}.messages{flex:1;margin:0;padding:0;list-style:none;overflow:auto}.msg{border-bottom:1px solid var(--line);padding:.4rem 0}.msg .role{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-size:11px;display:block}.msg[data-role=user] .role{color:var(--accent)}.usage{color:var(--muted);float:right;font-size:11px}.msg .fork{opacity:.45;margin-top:.2rem}.msg:hover .fork{opacity:.8}.msg .fork:hover{opacity:1}.markdown pre,.tool-output,.thinking pre,.system-prompt,.orphan-result,.memory-block,.cache-usage,.cfg-log{background:var(--code);border:1px solid var(--line);white-space:pre-wrap;border-radius:6px;padding:.4rem;font-family:ui-monospace,monospace;font-size:12.5px;overflow:auto}.markdown pre{margin:.35rem 0}.markdown code{font-family:ui-monospace,monospace}details.thinking summary{cursor:pointer;color:var(--muted);font-size:12px}.tool-card{border:1px solid var(--line);border-radius:6px;margin:.35rem 0;padding:.35rem .5rem}.tool-card[data-state=running]{border-color:var(--accent)}.tool-card[data-state=error]{border-color:var(--error)}.tool-card header{align-items:baseline;gap:.5rem;display:flex}.tool-name{font-weight:600}.tool-args{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:12px;overflow:hidden}.tool-state{color:var(--muted);font-size:11px}.queue{margin:.35rem 0 0;padding:0;list-style:none}.queued{border-left:3px solid var(--accent);background:var(--queue);padding:.25rem .4rem;font-size:12.5px}.extension-output{margin:.35rem 0 0;padding:0;list-style:none}.extension-note{border-left:3px solid var(--accent);background:var(--queue);white-space:pre-wrap;padding:.25rem .4rem;font-size:12.5px}.extension-note[data-level=warning]{border-left-color:var(--muted)}.extension-note[data-level=error]{border-left-color:var(--error)}.extension-dialog{border:1px solid var(--line);background:var(--surface);border-radius:8px;margin:.35rem 0 0;padding:.4rem .5rem}.extension-dialog .ask{white-space:pre-wrap;margin:0 0 .35rem;font-size:13px}.answers{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.answer-input{align-items:center;gap:.35rem;display:flex}.commands{flex-wrap:wrap;gap:.3rem;margin:.35rem 0 0;padding:0;list-style:none;display:flex}.command{border:1px solid var(--line);background:var(--surface);cursor:pointer;border-radius:6px;padding:.15rem .4rem;font-size:12px}.command .desc{color:var(--muted);margin-left:.3rem}.attachments{flex-wrap:wrap;gap:.35rem;margin:0 0 .35rem;padding:0;list-style:none;display:flex}.attachment{border:1px solid var(--line);background:var(--surface);border-radius:6px;align-items:center;gap:.35rem;padding:.2rem .35rem;font-size:12px;display:flex}.attachment .thumb{object-fit:cover;border-radius:4px;flex:none;width:1.5rem;height:1.5rem}.attachment .name{text-overflow:ellipsis;white-space:nowrap;max-width:14rem;overflow:hidden}.attachment .remove{color:var(--muted);cursor:pointer;background:0 0;border:0;padding:0 .15rem}.attachment .remove:hover{color:var(--error)}.attachment-image{border:1px solid var(--line);border-radius:6px;max-width:min(100%,32rem);max-height:24rem;margin:.35rem 0;display:block}.error{color:var(--error)}.muted{color:var(--muted)}.notice{color:var(--muted);border-left:2px solid var(--line);padding:.15rem 0 .15rem .5rem}.picker{gap:.5rem;margin-top:.5rem;display:flex}.picker .field{flex:1;margin-bottom:0}.composer{border-top:1px solid var(--line);padding-top:.5rem}.controls{flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.35rem;display:flex}.controls .stop{border-color:var(--error);color:var(--error)}.offline{color:var(--muted);font-size:12px}.metrics{color:var(--muted);flex-wrap:wrap;align-items:center;gap:.6rem;margin-left:auto;font-size:11.5px;display:flex}.metrics b{color:var(--ink);font-variant-numeric:tabular-nums;font-weight:600}.gauge{flex:none;width:1.15rem;height:1.15rem;position:relative}.gauge svg{width:100%;height:100%;position:absolute;inset:0;transform:rotate(-90deg)}.gauge circle{fill:none;stroke-width:3px}.gauge .track{stroke:var(--line)}.gauge .fill{stroke:var(--accent);stroke-linecap:round;transition:stroke-dasharray .3s}.activity-panel h2{text-transform:uppercase;color:var(--muted);margin:0 0 .5rem;font-size:12px}.activity-panel h2 .count{font-weight:400}.activity-panel footer{border-top:1px solid var(--line);padding-top:.5rem}.activity{flex:1;margin:0;padding:0;list-style:none;overflow:auto}.activity li{border-bottom:1px solid var(--line);padding:.3rem 0}.activity li .tool-card{margin:0}.activity-empty{color:var(--muted);font-size:12px}.settings-view{grid-column:3/-1}.settings-view .panel-head h2{flex:1;margin:0;font-size:1rem}.settings-view .cfg-note{color:var(--muted);margin:0 0 .5rem;font-size:12px}.settings-view .cfg-note code{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-stack{gap:1rem;max-width:46rem;display:grid}.cfg-section{align-items:center;gap:.5rem;margin:.9rem 0 .25rem;display:flex}.cfg-section h3{flex:1;margin:0;font-size:13px}.cfg-list.cfg-plain{border:0}.cfg-list.cfg-plain li{border-bottom:0;padding-left:0}.cfg-list{border:1px solid var(--line);border-radius:6px;margin:0;padding:0;list-style:none}.cfg-list li{border-bottom:1px solid var(--line);flex-wrap:wrap;align-items:center;gap:.4rem;padding:.3rem .4rem;display:flex}.cfg-list li:last-child{border-bottom:0}.cfg-list code{min-width:8rem;color:var(--muted);overflow-wrap:anywhere;flex:1;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-list .cfg-name{color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;padding:0;font-weight:600}.cfg-list .field{flex:none;margin:0}.cfg-list .field select{width:auto;margin-top:0}.cfg-list .icon{opacity:.6;flex:none}.cfg-list .icon:hover{opacity:1}.cfg-list .icon:disabled{opacity:.2;cursor:default}.cfg-list .remove{color:var(--error)}.cfg-default{color:var(--accent);white-space:nowrap;align-items:center;gap:.25rem;font-size:11px;font-weight:600;display:flex}.cfg-default select{font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:.1rem .2rem;font-weight:400}.cfg-list .cfg-off,.cfg-list .cfg-none{color:var(--muted);font-size:11px}.cfg-list .cfg-state{color:var(--muted);white-space:nowrap;font-size:11px}.cfg-list .cfg-state.connected{color:var(--accent)}.cfg-list .cfg-state.failed,.cfg-list .cfg-state.needs-auth,.cfg-list .cfg-state-error{color:var(--error)}.cfg-log{max-height:12rem;margin:.4rem 0 0}.cfg-signin{border-top:1px solid var(--line);flex:1 0 100%;margin-top:.3rem;padding-top:.35rem}.cfg-signin p{overflow-wrap:anywhere;margin:.2rem 0;font-size:12px}.cfg-signin-form{flex-wrap:wrap;align-items:end;gap:.4rem;display:flex}.cfg-signin-form .field{flex:18rem;margin:0}.cfg-signin-form input{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:11px/1.4 ui-monospace,monospace}.cfg-modal{background:var(--panel);width:min(30rem,92vw);color:var(--ink);border:1px solid var(--line);border-radius:8px;max-height:78vh;margin:12vh auto auto;padding:.9rem;overflow:auto}.cfg-modal::backdrop{background:#0000008c}.cfg-modal .cfg-section{margin-top:0}.cfg-modal .field{margin-bottom:.35rem}.cfg-modal input,.cfg-modal select{width:100%;font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem}.cfg-modal input:disabled{color:var(--muted)}.cfg-inline{align-items:end;gap:.5rem;display:flex}.cfg-inline .field:first-child{flex:1}.cfg-install{align-items:end;gap:.5rem;margin-bottom:.4rem;display:flex}.cfg-install .field{flex:18rem;margin:0}.cfg-install input{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:11px/1.4 ui-monospace,monospace}.cfg-modal textarea,.cfg-raw{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);resize:vertical;border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-modal details{margin:.45rem 0}.cfg-modal summary{color:var(--accent);cursor:pointer;font-size:12px}.cfg-modal details .cfg-note{margin:.35rem 0}.cfg-modal .error{font-size:12px}.cfg-modal .cfg-note{color:var(--muted);margin:0 0 .6rem;font-size:12px}.cfg-enabled{white-space:nowrap;align-items:center;gap:.25rem;display:flex}.cfg-enabled input{margin:0;width:auto!important}.cfg-actions{gap:.4rem;display:flex}.cfg-actions .remove{color:var(--error)}.linklike{color:var(--accent);font:inherit;background:0 0;border:0;padding:0;text-decoration:underline}.cfg-errors{max-width:46rem;margin:0 0 .5rem;padding:0;font-size:12px;list-style:none}
@@ -8,8 +8,8 @@
8
8
  one of the two files and there is no script in this document to do anything else. -->
9
9
  <link rel="icon" href="/flypi-logo-light.jpeg" media="(prefers-color-scheme: light)" />
10
10
  <link rel="icon" href="/flypi-logo-dark.jpeg" media="(prefers-color-scheme: dark)" />
11
- <script type="module" crossorigin src="/assets/index-DmMmjg_M.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CBuNmrDT.css">
11
+ <script type="module" crossorigin src="/assets/index-0MqV7dOT.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BCcOueOd.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerstufferupper/flypi",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Local multi-model agent tool with a browser UI and a personal knowledge wiki, built on the pi SDK.",
6
6
  "license": "MIT",
@@ -6,9 +6,15 @@
6
6
  * numbering and the client can dedupe on it. When the ring overwrites old events the gap is
7
7
  * reported rather than hidden: a client that asks for something evicted is told, not fed a
8
8
  * transcript that quietly starts later.
9
+ *
10
+ * `at` is stamped here too, for the same reason: this process is the only one that sees an event
11
+ * **when it happens**. A replay hands the same stamp back, so a client that attaches later still
12
+ * measures the response's real window instead of the burst it arrived in (src/shared/metrics.ts).
9
13
  */
10
14
  export interface BufferedEvent<T> {
11
15
  seq: number;
16
+ /** When this process saw the event, in epoch milliseconds. */
17
+ at: number;
12
18
  event: T;
13
19
  }
14
20
 
@@ -16,10 +22,13 @@ export class EventLog<T> {
16
22
  private readonly events: BufferedEvent<T>[] = [];
17
23
  private nextSeq = 1;
18
24
 
19
- constructor(private readonly capacity: number = 5000) {}
25
+ constructor(
26
+ private readonly capacity: number = 5000,
27
+ private readonly now: () => number = Date.now,
28
+ ) {}
20
29
 
21
30
  push(event: T): BufferedEvent<T> {
22
- const buffered: BufferedEvent<T> = { seq: this.nextSeq++, event };
31
+ const buffered: BufferedEvent<T> = { seq: this.nextSeq++, at: this.now(), event };
23
32
  this.events.push(buffered);
24
33
  if (this.events.length > this.capacity) this.events.shift();
25
34
  return buffered;
@@ -29,12 +38,6 @@ export class EventLog<T> {
29
38
  return this.nextSeq - 1;
30
39
  }
31
40
 
32
- /** Start over. Opening another session replaces the transcript a client is looking at. */
33
- reset(): void {
34
- this.events.length = 0;
35
- this.nextSeq = 1;
36
- }
37
-
38
41
  /** Highest seq already dropped from the ring (0 when nothing has been dropped). */
39
42
  get evictedThroughSeq(): number {
40
43
  return this.events[0] ? this.events[0].seq - 1 : this.lastSeq;
@@ -181,25 +181,24 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
181
181
  if (requestId) send({ type: "result", requestId, ...extra });
182
182
  };
183
183
 
184
- /** Start (or keep) this socket's subscription to a conversation; `afterSeq` replays what it missed. */
184
+ /**
185
+ * Start (or keep) this socket's subscription to a conversation; `afterSeq` replays what it missed.
186
+ *
187
+ * Subscriptions are additive: one socket may follow several conversations, because a page holds every
188
+ * session it has opened — the one on screen and any background turn still running. Nothing is detached
189
+ * here; a client that is finished with an idle conversation says so with `unsubscribe`, which is what
190
+ * lets `evictIdle` release it.
191
+ */
185
192
  const subscribe = (conversationId: string, afterSeq?: number) => {
186
- // A page holds one conversation, so a socket follows one conversation. Everything it watched
187
- // before is detached here: a turn started in the chat you left would otherwise keep streaming,
188
- // and its frames would be folded into the transcript of the chat that replaced it. It also keeps
189
- // the abandoned conversation attached forever, so `evictIdle` never sees it as idle.
190
- for (const [watched, previous] of [...subscriptions]) {
191
- if (watched === conversationId) continue;
192
- previous.detach();
193
- previous.detachUi();
194
- subscriptions.delete(watched);
195
- }
196
193
  let subscription = subscriptions.get(conversationId);
197
194
  if (!subscription) {
198
195
  const state: Subscription = { lastSent: 0, detach: () => {}, detachUi: () => {} };
199
- state.detach = registry.attach(conversationId, (seq, event) => {
196
+ state.detach = registry.attach(conversationId, (seq, event, at) => {
200
197
  if (seq <= state.lastSent) return;
201
198
  state.lastSent = seq;
202
- send({ type: "event", conversationId, seq, event });
199
+ // `at` is the backend's own clock: it is what lets a client attaching late measure the
200
+ // response's real window rather than the burst its replay arrived in.
201
+ send({ type: "event", conversationId, seq, at, event });
203
202
  });
204
203
  // Extension output is not a pi event, so it has no seq to dedupe: the page sees each frame once,
205
204
  // live, and a dialog it was not connected for is simply missed (src/server/extension-ui.ts).
@@ -220,11 +219,22 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
220
219
  for (const entry of replay.events) {
221
220
  if (entry.seq <= subscription.lastSent) continue;
222
221
  subscription.lastSent = entry.seq;
223
- send({ type: "event", conversationId, seq: entry.seq, event: entry.event });
222
+ send({ type: "event", conversationId, seq: entry.seq, at: entry.at, event: entry.event });
224
223
  }
225
224
  };
226
225
 
227
- send({ type: "hello", protocolVersion: PROTOCOL_VERSION, serverVersion: options.serverVersion ?? "dev", ...(options.pi ? { pi: options.pi() } : {}) });
226
+ /** Stop following one conversation. The pi session behind it is untouched: only this socket lets go. */
227
+ const unsubscribe = (conversationId: string) => {
228
+ const subscription = subscriptions.get(conversationId);
229
+ if (!subscription) return;
230
+ subscription.detach();
231
+ subscription.detachUi();
232
+ subscriptions.delete(conversationId);
233
+ };
234
+
235
+ // `now` is this process's clock, so a client on another machine can put its own ticking windows on
236
+ // the same timeline the events are stamped with instead of a skew's worth of milliseconds off.
237
+ send({ type: "hello", protocolVersion: PROTOCOL_VERSION, serverVersion: options.serverVersion ?? "dev", now: Date.now(), ...(options.pi ? { pi: options.pi() } : {}) });
228
238
 
229
239
  const handle = async (raw: string): Promise<void> => {
230
240
  const parsed = parseClientCommand(raw);
@@ -249,6 +259,9 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
249
259
  case "resume":
250
260
  subscribe(command.conversationId, command.afterSeq);
251
261
  return;
262
+ case "unsubscribe":
263
+ unsubscribe(command.conversationId);
264
+ return reply(requestId, { conversationId });
252
265
  case "extensionReply":
253
266
  // The answer to an extension's question. Nothing waiting on that id means it timed out or
254
267
  // another tab answered it, and the page has to stop showing the dialog either way.
@@ -267,10 +280,13 @@ export async function startFlypiServer(options: FlypiServerOptions): Promise<Fly
267
280
  await registry.abort(command.conversationId);
268
281
  return reply(requestId, { conversationId });
269
282
  case "switchSession": {
270
- const switched = await registry.switchSession(command.conversationId, command.sessionPath);
283
+ const switched = await registry.switchSession(command.sessionPath);
271
284
  // A turn the last process left half-done is reported with the session it belongs to — the
272
285
  // client is about to render that chat, and nothing else tells it why the last answer stops short.
273
286
  const interrupted = options.interruptedRun?.(switched.session.sessionFile ?? "");
287
+ // Opened but not followed: the client is handed the conversation id and decides what to
288
+ // watch. Following it here would make every session ever clicked a live subscription, and
289
+ // an unattached idle conversation is what `evictIdle` exists to release.
274
290
  return reply(requestId, {
275
291
  conversationId: switched.id,
276
292
  sessionPath: switched.session.sessionFile,
@@ -51,10 +51,17 @@ export const NewSessionCommand = Type.Object({
51
51
  requestId: Type.Optional(RequestId),
52
52
  });
53
53
 
54
- /** Rebind this conversation to an existing session file (the sidebar's "open"). */
54
+ /**
55
+ * Open an existing session file (the sidebar's "open") as its **own** conversation.
56
+ *
57
+ * Path-scoped, not conversation-scoped: every session file gets one conversation of its own, so opening a
58
+ * session never rebinds or disposes whatever the client was looking at. The `result` names the conversation
59
+ * that answers the file — the one already on it when there is one, otherwise a new one — and the client
60
+ * resumes it. No `conversationId` is asked for, because a page recovering from a restart holds ids the
61
+ * server has forgotten and still has to be able to open a session.
62
+ */
55
63
  export const SwitchSessionCommand = Type.Object({
56
64
  type: Type.Literal("switchSession"),
57
- conversationId: Id,
58
65
  sessionPath: Id,
59
66
  requestId: Type.Optional(RequestId),
60
67
  });
@@ -118,6 +125,18 @@ export const ResumeCommand = Type.Object({
118
125
  requestId: Type.Optional(RequestId),
119
126
  });
120
127
 
128
+ /**
129
+ * Stop streaming one conversation to this socket. A page holds every session it has opened, but only the
130
+ * ones with a turn in flight (and the one on screen) need to keep their pi session attached: an idle
131
+ * background conversation can be detached and then evicted by its own idle TTL, and a later `resume`
132
+ * re-attaches it. Detaching is per socket, so another tab's subscription is untouched.
133
+ */
134
+ export const UnsubscribeCommand = Type.Object({
135
+ type: Type.Literal("unsubscribe"),
136
+ conversationId: Id,
137
+ requestId: Type.Optional(RequestId),
138
+ });
139
+
121
140
  /** The sidebar's "open": the session files flypi owns. No conversation needed. */
122
141
  export const ListSessionsCommand = Type.Object({
123
142
  type: Type.Literal("listSessions"),
@@ -618,6 +637,7 @@ export const ClientCommandSchema = Type.Union([
618
637
  SetModelCommand,
619
638
  SetThinkingLevelCommand,
620
639
  ResumeCommand,
640
+ UnsubscribeCommand,
621
641
  ExtensionReplyCommand,
622
642
  ListSessionsCommand,
623
643
  RenameSessionCommand,
@@ -659,6 +679,12 @@ export const HelloFrame = Type.Object({
659
679
  type: Type.Literal("hello"),
660
680
  protocolVersion: Type.Integer(),
661
681
  serverVersion: Type.String(),
682
+ /**
683
+ * The server's clock at handshake, in epoch ms. Event frames are stamped with that same clock, so a
684
+ * page on another machine can measure a streaming response against it instead of against a skewed
685
+ * local clock. Optional: a server that does not send it is assumed to share this one's clock.
686
+ */
687
+ now: Type.Optional(Type.Integer()),
662
688
  /** Optional so an older server still parses; every flypi server from here on sends it. */
663
689
  pi: Type.Optional(PiInfoFrame),
664
690
  });
@@ -667,6 +693,13 @@ export const EventFrame = Type.Object({
667
693
  type: Type.Literal("event"),
668
694
  conversationId: Id,
669
695
  seq: Type.Integer({ minimum: 1 }),
696
+ /**
697
+ * When the **backend** first saw this event, in epoch milliseconds. It rides on replays too, so the
698
+ * same event carries the same clock whenever it arrives — which is what lets a client that attaches
699
+ * late (a reconnect, or opening a session it was not holding) measure the response's real window
700
+ * instead of the burst the replay arrived in. Optional: a client without it falls back to arrival.
701
+ */
702
+ at: Type.Optional(Type.Integer({ minimum: 0 })),
670
703
  event: SessionEventFrameBody,
671
704
  });
672
705
 
@@ -677,6 +710,12 @@ export const SessionSummary = Type.Object({
677
710
  modified: Type.String(),
678
711
  messageCount: Type.Integer(),
679
712
  firstMessage: Type.String(),
713
+ /**
714
+ * Is a turn running in this session right now, in this process? The sidebar needs it as its own fact:
715
+ * sessions keep running with nobody looking, so the only place a background turn is visible before it
716
+ * lands is this flag — and it says nothing about a turn a previous process was cut short on.
717
+ */
718
+ running: Type.Optional(Type.Boolean()),
680
719
  });
681
720
 
682
721
  /**
@@ -25,7 +25,9 @@ import { createExtensionUiBridge } from "./extension-ui.ts";
25
25
  import type { CommandRow, DebugInfo, ExtensionUiFrame, SessionSummary, ConfigSnapshot, McpStatusSnapshot, PackagesSnapshot } from "./protocol.ts";
26
26
  import type { FlypiMessage } from "../shared/transcript.ts";
27
27
 
28
- export type EventListener = (seq: number, event: AgentSessionEvent) => void;
28
+ /** `at` is when this process saw the event (src/server/event-log.ts); a listener that does not need the
29
+ * clock for its own accounting can ignore it. */
30
+ export type EventListener = (seq: number, event: AgentSessionEvent, at: number) => void;
29
31
  /** What an extension said to the user (`src/server/extension-ui.ts`) — a second channel, not a pi event. */
30
32
  export type ExtensionUiListener = (frame: ExtensionUiFrame) => void;
31
33
  /** Supplies the session for a conversation: production uses `createFlypiSession`, tests use the stub model. */
@@ -63,8 +65,8 @@ export interface Conversation {
63
65
  memory: { block: string | null };
64
66
  /**
65
67
  * The conversation's extension UI bridge: `ctx.ui.notify/select/confirm/input` reach the page through it,
66
- * and it holds the dialogs still waiting for an answer. It outlives the session — `switchSession` binds a
67
- * new session to the same bridge, so a dialog survives the chat being reopened.
68
+ * and it holds the dialogs still waiting for an answer. It belongs to the conversation and to nothing
69
+ * else — one per conversation, for the whole life of the session it answers.
68
70
  */
69
71
  extensionUi: ReturnType<typeof createExtensionUiBridge>;
70
72
  /** Sockets watching this conversation's extension output. Detached with the socket, like event listeners. */
@@ -127,45 +129,29 @@ export class SessionRegistry {
127
129
  }
128
130
 
129
131
  /**
130
- * Open an existing session for a client: the conversation already answering it when there is one, and
131
- * otherwise this conversation moved onto it — or a new one, if this conversation is mid-turn.
132
+ * The conversation answering a session file, opened if there is none. **One session file, one
133
+ * conversation** — this is the whole mapping, and it is what makes a client's chr(c) safe to leave: a
134
+ * conversation is never moved onto another file and never taken away because someone looked at something
135
+ * else.
136
+ *
137
+ * Two readers of the same file would be two writers on it, so a file another conversation is already
138
+ * answering hands *that* conversation back — including a mid-turn one, whose answer is on its way to a
139
+ * page that may or may not be looking at it. Nothing is disposed here: a background turn keeps running
140
+ * and its events keep landing in its own log, which is what a client that comes back resumes.
132
141
  */
133
- async switchSession(conversationId: string, sessionPath: string): Promise<Conversation> {
134
- const conversation = this.get(conversationId);
142
+ async switchSession(sessionPath: string): Promise<Conversation> {
135
143
  const target = this.sessionFileIn(sessionPath);
136
- // Already answering it: opening the file a second time would put a second writer on it, so the
137
- // conversation the page is already on stays where it is. An idle one is reopened as asked — that is
138
- // how a client reloads a session's history from disk.
139
- if (conversation.session.sessionFile === target && !conversation.session.isIdle) return conversation;
140
- // A session another conversation is already answering **is** that conversation, not a copy of it. Opening
141
- // the file a second time would put a second writer on it and freeze the transcript at the moment of the
142
- // switch, so the answer on its way would never reach the page that just asked for that session.
143
- const live = [...this.conversations.values()].find((other) => other.id !== conversationId && other.session.sessionFile === target);
144
+ const live = this.findByFile(target);
144
145
  if (live) return live;
145
- // Rebinding disposes the session this conversation is answering in, and a turn still running would be cut
146
- // short: its answer would reach neither the page nor the session file. So a session with no conversation
147
- // of its own is opened as one, and this conversation is left alone to finish (it stays in the map, and
148
- // switching back to it returns it from the lookup above, log and all).
149
- if (!conversation.session.isIdle) return this.bind(SessionManager.open(target, this.paths.sessionsDir));
150
- this.teardown(conversation);
151
- // The conversation lives on with a new session, so it is not disposed any more. Without this the
152
- // exactly-once guard below stays set and the *new* session is never unsubscribed or disposed — it
153
- // keeps streaming into this conversation's log (and its listeners) after the page moved on, keeps
154
- // the conversation unevictable, and writes back any session file it is deleted out from under.
155
- conversation.disposed = false;
156
- conversation.session = await this.createSession({
157
- sessionManager: SessionManager.open(target, this.paths.sessionsDir),
158
- conversationId,
159
- onMemoryBlock: (block) => (conversation.memory.block = block),
160
- extensionUi: conversation.extensionUi.uiContext,
161
- });
162
- conversation.memory.block = null;
163
- this.watch(conversation);
164
- // The transcript a client is looking at becomes the opened session's, from the start — so the
165
- // log restarts and the seed above is the whole of it. The client re-reads it with `resume`.
166
- conversation.log.reset();
167
- this.seedHistory(conversation);
168
- return conversation;
146
+ return this.bind(SessionManager.open(target, this.paths.sessionsDir));
147
+ }
148
+
149
+ /** The conversation answering a session file, or undefined. There is at most one, by construction. */
150
+ findByFile(sessionFile: string): Conversation | undefined {
151
+ for (const conversation of this.conversations.values()) {
152
+ if (conversation.session.sessionFile === sessionFile) return conversation;
153
+ }
154
+ return undefined;
169
155
  }
170
156
 
171
157
  /**
@@ -261,6 +247,11 @@ export class SessionRegistry {
261
247
  * The session files flypi owns, for the sidebar's list. pi's own list, so flypi keeps no second index
262
248
  * of conversations (docs/decisions.md: one source of truth) — which is also why a chat you have only
263
249
  * started, with nothing written, has no row: pi creates the file on its first entry.
250
+ *
251
+ * `running` is the one thing pi's list cannot know: whether a conversation in *this* process is
252
+ * mid-turn on that file. A turn keeps running with nobody looking at it, and that flag is the only
253
+ * sign of it on screen. It says nothing about a turn a previous process was cut short on — that is
254
+ * the daemon's `runs` record, reported when the session is opened.
264
255
  */
265
256
  async listSessions(): Promise<SessionSummary[]> {
266
257
  const sessions = await SessionManager.list(this.paths.root, this.paths.sessionsDir);
@@ -270,6 +261,7 @@ export class SessionRegistry {
270
261
  modified: session.modified.toISOString(),
271
262
  messageCount: session.messageCount,
272
263
  firstMessage: session.firstMessage,
264
+ running: this.findByFile(session.path)?.session.isIdle === false,
273
265
  }));
274
266
  }
275
267
 
@@ -282,16 +274,14 @@ export class SessionRegistry {
282
274
  * Delete a session file for good. Anything that was showing it goes first: pi appends lazily, so a
283
275
  * live session bound to a deleted file would write it back and the row would come straight back.
284
276
  * A turn still running is refused rather than cut short — that work would be lost silently.
285
- * ponytail: one user, one browser. Another tab holding the same session loses its conversation id
286
- * when this runs; if tabs ever share the server, tell them over the socket instead.
287
277
  */
288
278
  async deleteSession(sessionPath: string): Promise<void> {
289
279
  const target = this.sessionFileIn(sessionPath);
290
- for (const conversation of this.conversations.values()) {
291
- if (conversation.session.sessionFile !== target) continue;
292
- if (!conversation.session.isIdle) throw new Error(`session ${target} is mid-turn; abort it first`);
293
- this.teardown(conversation);
294
- this.conversations.delete(conversation.id);
280
+ const holder = this.findByFile(target);
281
+ if (holder) {
282
+ if (!holder.session.isIdle) throw new Error(`session ${target} is mid-turn; abort it first`);
283
+ this.teardown(holder);
284
+ this.conversations.delete(holder.id);
295
285
  }
296
286
  rmSync(target); // no force: a file that is not there is an error frame, not a silent success
297
287
  }
@@ -471,7 +461,8 @@ export class SessionRegistry {
471
461
  });
472
462
  const conversation: Conversation = {
473
463
  id,
474
- log: new EventLog<AgentSessionEvent>(this.options.logCapacity),
464
+ // The same clock eviction uses, so a scripted clock in a test stamps the events it drives.
465
+ log: new EventLog<AgentSessionEvent>(this.options.logCapacity, this.now),
475
466
  listeners: new Set(),
476
467
  messages: new Map(),
477
468
  session: await this.createSession({
@@ -554,7 +545,7 @@ export class SessionRegistry {
554
545
  }
555
546
  const buffered = conversation.log.push(event);
556
547
  if (event.type === "message_end") conversation.messages.set(buffered.seq, event.message);
557
- for (const listener of conversation.listeners) listener(buffered.seq, event);
548
+ for (const listener of conversation.listeners) listener(buffered.seq, event, buffered.at);
558
549
  });
559
550
  }
560
551
 
@@ -2,8 +2,11 @@
2
2
  * Live model speed metrics, read out of the same pi event stream the transcript mapper reads
3
3
  * (`src/shared/transcript.ts`).
4
4
  *
5
- * pi reports tokens, never tokens/second, so the only clock available is when the events arrived here.
6
- * That decides what each number honestly means:
5
+ * pi reports tokens, never tokens/second, so the only clock available is when the events were **seen**. That
6
+ * clock is the backend's: every event frame carries an `at` stamp taken when the server logged it
7
+ * (src/server/event-log.ts), and a replay carries the same stamp. Timing the arrival here instead made a
8
+ * replayed response — a reconnect, or a session opened late — arrive as one burst, which reported a
9
+ * thousands-of-tokens/second rate for a model that streamed at twenty. What each number honestly means:
7
10
  *
8
11
  * - **prefill** — the provider request up to the first content token, over the prompt tokens that
9
12
  * request carried. pi emits `turn_start` once per provider request, which is the closest the stream
@@ -18,8 +21,9 @@
18
21
  * cannot pull either rate down.
19
22
  *
20
23
  * `now` is injectable for the same reason the transcript reducer takes one: the numbers are testable
21
- * without a clock. A response with no measurable window (both events inside one ms, which a local model
22
- * does) reports `null`, not a huge number — an unknown rate is shown as unknown.
24
+ * without a clock. The caller passes the event's own stamp, so `now()` is one fixed instant per event. A
25
+ * response with no measurable window (both events inside one ms, which a local model does) reports `null`,
26
+ * not a huge number — an unknown rate is shown as unknown.
23
27
  *
24
28
  * A response that is still streaming is counted only from what the provider reported about it. Providers
25
29
  * differ in when they report: some carry usage in every chunk, so those numbers move as the answer streams;
@@ -1 +0,0 @@
1
- .revealed.svelte-19l53a6{color:var(--ink)}.memory-body.svelte-3w3u32{color:var(--ink);flex-basis:100%}.memory-row.svelte-3w3u32{align-items:flex-start}.memory-why.svelte-3w3u32,.memory-history.svelte-3w3u32,.memory-uses.svelte-3w3u32{gap:.25rem;width:100%;margin:.3rem 0 0;padding:0;list-style:none;display:grid}.memory-why.svelte-3w3u32 li:where(.svelte-3w3u32),.memory-history.svelte-3w3u32 li:where(.svelte-3w3u32){flex-wrap:wrap;align-items:center;gap:.4rem;display:flex}.search.svelte-3w3u32{font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:.2rem .3rem}.preimage.svelte-3w3u32 summary:where(.svelte-3w3u32){color:var(--muted);cursor:pointer;font-size:11px}.preimage.svelte-3w3u32 pre:where(.svelte-3w3u32){max-height:12rem}.wiki-page.svelte-3w3u32 h4:where(.svelte-3w3u32){margin:.8rem 0 .2rem;font-size:13px}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#fbfbfd;--panel:#fff;--line:#e3e4e9;--ink:#17181d;--muted:#6b6f7a;--accent:#2f6f4f;--error:#a3262a;--surface:#f4f5f7;--code:#f6f7f9;--queue:#f2f7f4}:root[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--bg:#13151a;--panel:#1b1e25;--line:#2c303a;--ink:#e6e8ee;--muted:#98a0ae;--accent:#6cb892;--error:#e08084;--surface:#232730;--code:#1f232b;--queue:#1d2b24}*{box-sizing:border-box}body{background:var(--bg);color:var(--ink);margin:0}.layout{grid-template-columns:var(--sidebar-w,15rem) .5rem minmax(0, 1fr) .5rem var(--activity-w,22rem);gap:.25rem;height:100vh;padding:.75rem;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:grid}.updating{text-align:center;height:100vh;color:var(--muted);place-content:center;gap:.3rem;padding:1rem;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:grid}.updating h2{color:var(--ink);margin:0 0 .3rem;font-size:1.1rem}.updating p{margin:0}.sidebar,.chat,.activity-panel,.settings-view{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:.75rem;overflow:auto}.gutter{cursor:col-resize;touch-action:none;background:var(--line);border-radius:3px}.gutter:hover,.gutter:focus-visible{background:var(--accent)}.activity-panel{flex-direction:column;display:flex}.sidebar h1{margin:0 0 .5rem;font-size:1rem}.sidebar .port{color:var(--muted);font-weight:400}.sidebar .update{color:var(--muted);flex-wrap:wrap;align-items:center;gap:.3rem;margin:0 0 .5rem;font-size:.8rem;display:flex}.panel-head{flex-wrap:wrap;align-items:center;gap:.35rem;margin-bottom:.5rem;display:flex}.panel-head h1{flex:1;margin:0}.panel-actions{gap:.35rem;margin-bottom:.5rem;display:flex}.icon{flex:none;padding:.2rem .35rem}.panel-head .logo{border-radius:6px;flex:none;width:2.5rem;height:2.5rem}.icon svg{width:1.05rem;height:1.05rem;display:block}.field{color:var(--muted);margin-bottom:.5rem;font-size:12px;display:block}.field select{width:100%;margin-top:.15rem}.sessions{margin:.5rem 0;padding:0;list-style:none}.sessions li{align-items:center;gap:.15rem;display:flex}.sessions li>:first-child{flex:1;min-width:0}.sessions .remove{opacity:.6}.sessions .remove:hover{opacity:1}.sessions .session{text-align:left;white-space:nowrap;text-overflow:ellipsis;background:0 0;border:0;border-radius:6px;width:100%;overflow:hidden}.sessions .session:hover{background:var(--surface)}.sessions .session.current{background:var(--queue)}.sessions input.name{background:var(--queue);width:100%;color:inherit;border:0;border-radius:6px;font-weight:600}.sessions input.name:hover{background:var(--queue)}button,select,input,textarea{font:inherit;color:inherit}button{border:1px solid var(--line);background:var(--surface);cursor:pointer;border-radius:6px;padding:.25rem .5rem}button:hover{border-color:var(--accent)}input:not([type=checkbox]),select,textarea{border:1px solid var(--line);background:var(--surface);border-radius:6px;padding:.3rem .4rem}textarea{width:100%}.chat{flex-direction:column;display:flex}.messages{flex:1;margin:0;padding:0;list-style:none;overflow:auto}.msg{border-bottom:1px solid var(--line);padding:.4rem 0}.msg .role{text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-size:11px;display:block}.msg[data-role=user] .role{color:var(--accent)}.usage{color:var(--muted);float:right;font-size:11px}.msg .fork{opacity:.45;margin-top:.2rem}.msg:hover .fork{opacity:.8}.msg .fork:hover{opacity:1}.markdown pre,.tool-output,.thinking pre,.system-prompt,.orphan-result,.memory-block,.cache-usage,.cfg-log{background:var(--code);border:1px solid var(--line);white-space:pre-wrap;border-radius:6px;padding:.4rem;font-family:ui-monospace,monospace;font-size:12.5px;overflow:auto}.markdown pre{margin:.35rem 0}.markdown code{font-family:ui-monospace,monospace}details.thinking summary{cursor:pointer;color:var(--muted);font-size:12px}.tool-card{border:1px solid var(--line);border-radius:6px;margin:.35rem 0;padding:.35rem .5rem}.tool-card[data-state=running]{border-color:var(--accent)}.tool-card[data-state=error]{border-color:var(--error)}.tool-card header{align-items:baseline;gap:.5rem;display:flex}.tool-name{font-weight:600}.tool-args{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:12px;overflow:hidden}.tool-state{color:var(--muted);font-size:11px}.queue{margin:.35rem 0 0;padding:0;list-style:none}.queued{border-left:3px solid var(--accent);background:var(--queue);padding:.25rem .4rem;font-size:12.5px}.extension-output{margin:.35rem 0 0;padding:0;list-style:none}.extension-note{border-left:3px solid var(--accent);background:var(--queue);white-space:pre-wrap;padding:.25rem .4rem;font-size:12.5px}.extension-note[data-level=warning]{border-left-color:var(--muted)}.extension-note[data-level=error]{border-left-color:var(--error)}.extension-dialog{border:1px solid var(--line);background:var(--surface);border-radius:8px;margin:.35rem 0 0;padding:.4rem .5rem}.extension-dialog .ask{white-space:pre-wrap;margin:0 0 .35rem;font-size:13px}.answers{flex-wrap:wrap;align-items:center;gap:.35rem;display:flex}.answer-input{align-items:center;gap:.35rem;display:flex}.commands{flex-wrap:wrap;gap:.3rem;margin:.35rem 0 0;padding:0;list-style:none;display:flex}.command{border:1px solid var(--line);background:var(--surface);cursor:pointer;border-radius:6px;padding:.15rem .4rem;font-size:12px}.command .desc{color:var(--muted);margin-left:.3rem}.attachments{flex-wrap:wrap;gap:.35rem;margin:0 0 .35rem;padding:0;list-style:none;display:flex}.attachment{border:1px solid var(--line);background:var(--surface);border-radius:6px;align-items:center;gap:.35rem;padding:.2rem .35rem;font-size:12px;display:flex}.attachment .thumb{object-fit:cover;border-radius:4px;flex:none;width:1.5rem;height:1.5rem}.attachment .name{text-overflow:ellipsis;white-space:nowrap;max-width:14rem;overflow:hidden}.attachment .remove{color:var(--muted);cursor:pointer;background:0 0;border:0;padding:0 .15rem}.attachment .remove:hover{color:var(--error)}.attachment-image{border:1px solid var(--line);border-radius:6px;max-width:min(100%,32rem);max-height:24rem;margin:.35rem 0;display:block}.error{color:var(--error)}.muted{color:var(--muted)}.notice{color:var(--muted);border-left:2px solid var(--line);padding:.15rem 0 .15rem .5rem}.picker{gap:.5rem;margin-top:.5rem;display:flex}.picker .field{flex:1;margin-bottom:0}.composer{border-top:1px solid var(--line);padding-top:.5rem}.controls{flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.35rem;display:flex}.controls .stop{border-color:var(--error);color:var(--error)}.offline{color:var(--muted);font-size:12px}.metrics{color:var(--muted);flex-wrap:wrap;align-items:center;gap:.6rem;margin-left:auto;font-size:11.5px;display:flex}.metrics b{color:var(--ink);font-variant-numeric:tabular-nums;font-weight:600}.gauge{flex:none;width:1.15rem;height:1.15rem;position:relative}.gauge svg{width:100%;height:100%;position:absolute;inset:0;transform:rotate(-90deg)}.gauge circle{fill:none;stroke-width:3px}.gauge .track{stroke:var(--line)}.gauge .fill{stroke:var(--accent);stroke-linecap:round;transition:stroke-dasharray .3s}.activity-panel h2{text-transform:uppercase;color:var(--muted);margin:0 0 .5rem;font-size:12px}.activity-panel h2 .count{font-weight:400}.activity-panel footer{border-top:1px solid var(--line);padding-top:.5rem}.activity{flex:1;margin:0;padding:0;list-style:none;overflow:auto}.activity li{border-bottom:1px solid var(--line);padding:.3rem 0}.activity li .tool-card{margin:0}.activity-empty{color:var(--muted);font-size:12px}.settings-view{grid-column:3/-1}.settings-view .panel-head h2{flex:1;margin:0;font-size:1rem}.settings-view .cfg-note{color:var(--muted);margin:0 0 .5rem;font-size:12px}.settings-view .cfg-note code{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-stack{gap:1rem;max-width:46rem;display:grid}.cfg-section{align-items:center;gap:.5rem;margin:.9rem 0 .25rem;display:flex}.cfg-section h3{flex:1;margin:0;font-size:13px}.cfg-list.cfg-plain{border:0}.cfg-list.cfg-plain li{border-bottom:0;padding-left:0}.cfg-list{border:1px solid var(--line);border-radius:6px;margin:0;padding:0;list-style:none}.cfg-list li{border-bottom:1px solid var(--line);flex-wrap:wrap;align-items:center;gap:.4rem;padding:.3rem .4rem;display:flex}.cfg-list li:last-child{border-bottom:0}.cfg-list code{min-width:8rem;color:var(--muted);overflow-wrap:anywhere;flex:1;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-list .cfg-name{color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;padding:0;font-weight:600}.cfg-list .field{flex:none;margin:0}.cfg-list .field select{width:auto;margin-top:0}.cfg-list .icon{opacity:.6;flex:none}.cfg-list .icon:hover{opacity:1}.cfg-list .icon:disabled{opacity:.2;cursor:default}.cfg-list .remove{color:var(--error)}.cfg-default{color:var(--accent);white-space:nowrap;align-items:center;gap:.25rem;font-size:11px;font-weight:600;display:flex}.cfg-default select{font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:.1rem .2rem;font-weight:400}.cfg-list .cfg-off,.cfg-list .cfg-none{color:var(--muted);font-size:11px}.cfg-list .cfg-state{color:var(--muted);white-space:nowrap;font-size:11px}.cfg-list .cfg-state.connected{color:var(--accent)}.cfg-list .cfg-state.failed,.cfg-list .cfg-state.needs-auth,.cfg-list .cfg-state-error{color:var(--error)}.cfg-log{max-height:12rem;margin:.4rem 0 0}.cfg-signin{border-top:1px solid var(--line);flex:1 0 100%;margin-top:.3rem;padding-top:.35rem}.cfg-signin p{overflow-wrap:anywhere;margin:.2rem 0;font-size:12px}.cfg-signin-form{flex-wrap:wrap;align-items:end;gap:.4rem;display:flex}.cfg-signin-form .field{flex:18rem;margin:0}.cfg-signin-form input{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:11px/1.4 ui-monospace,monospace}.cfg-modal{background:var(--panel);width:min(30rem,92vw);color:var(--ink);border:1px solid var(--line);border-radius:8px;max-height:78vh;margin:12vh auto auto;padding:.9rem;overflow:auto}.cfg-modal::backdrop{background:#0000008c}.cfg-modal .cfg-section{margin-top:0}.cfg-modal .field{margin-bottom:.35rem}.cfg-modal input,.cfg-modal select{width:100%;font:inherit;background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem}.cfg-modal input:disabled{color:var(--muted)}.cfg-inline{align-items:end;gap:.5rem;display:flex}.cfg-inline .field:first-child{flex:1}.cfg-install{align-items:end;gap:.5rem;margin-bottom:.4rem;display:flex}.cfg-install .field{flex:18rem;margin:0}.cfg-install input{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:11px/1.4 ui-monospace,monospace}.cfg-modal textarea,.cfg-raw{background:var(--panel);width:100%;color:var(--ink);border:1px solid var(--line);resize:vertical;border-radius:4px;margin-top:.15rem;padding:.2rem .3rem;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}.cfg-modal details{margin:.45rem 0}.cfg-modal summary{color:var(--accent);cursor:pointer;font-size:12px}.cfg-modal details .cfg-note{margin:.35rem 0}.cfg-modal .error{font-size:12px}.cfg-modal .cfg-note{color:var(--muted);margin:0 0 .6rem;font-size:12px}.cfg-enabled{white-space:nowrap;align-items:center;gap:.25rem;display:flex}.cfg-enabled input{margin:0;width:auto!important}.cfg-actions{gap:.4rem;display:flex}.cfg-actions .remove{color:var(--error)}.linklike{color:var(--accent);font:inherit;background:0 0;border:0;padding:0;text-decoration:underline}.cfg-errors{max-width:46rem;margin:0 0 .5rem;padding:0;font-size:12px;list-style:none}