@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.
- package/dist/ui/assets/index-0MqV7dOT.js +102 -0
- package/dist/ui/assets/index-BCcOueOd.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +1 -1
- package/src/server/event-log.ts +11 -8
- package/src/server/index.ts +32 -16
- package/src/server/protocol.ts +41 -2
- package/src/server/session-registry.ts +39 -48
- package/src/shared/metrics.ts +8 -4
- package/dist/ui/assets/index-CBuNmrDT.css +0 -1
- package/dist/ui/assets/index-DmMmjg_M.js +0 -102
|
@@ -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}
|
package/dist/ui/index.html
CHANGED
|
@@ -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-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
package/src/server/event-log.ts
CHANGED
|
@@ -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(
|
|
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;
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
package/src/server/protocol.ts
CHANGED
|
@@ -51,10 +51,17 @@ export const NewSessionCommand = Type.Object({
|
|
|
51
51
|
requestId: Type.Optional(RequestId),
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
/**
|
|
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
|
-
|
|
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
|
|
67
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
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(
|
|
134
|
-
const conversation = this.get(conversationId);
|
|
142
|
+
async switchSession(sessionPath: string): Promise<Conversation> {
|
|
135
143
|
const target = this.sessionFileIn(sessionPath);
|
|
136
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
this.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
if (!
|
|
293
|
-
this.teardown(
|
|
294
|
-
this.conversations.delete(
|
|
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
|
-
|
|
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
|
|
package/src/shared/metrics.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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.
|
|
22
|
-
*
|
|
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}
|