@pipeline-moe/client-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/state.js ADDED
@@ -0,0 +1,300 @@
1
+ // Framework-agnostic room state + a pure SSE reducer.
2
+ //
3
+ // Every handler here was lifted verbatim from the web app's `useRoom` hook,
4
+ // which already wrote every update as a functional `setX(prev => next)` — i.e.
5
+ // each was already a pure function of (prevState, eventData). That made the
6
+ // transposition mechanical: this file holds exactly the same transitions with
7
+ // no React, no fetch, no timers.
8
+ //
9
+ // Notices are the one transition that isn't pure state→state (they auto-expire
10
+ // on a timer), so the reducer surfaces them as `effects` for the store to
11
+ // apply, rather than mutating a notice list here.
12
+ /** The initial state of a freshly-opened room, before any snapshot or SSE. */
13
+ export const initialRoomState = {
14
+ roster: [],
15
+ messages: [],
16
+ streaming: {},
17
+ liveActivity: {},
18
+ liveReasoning: {},
19
+ receipts: {},
20
+ workspace: [],
21
+ notices: [],
22
+ connected: false,
23
+ turnActive: false,
24
+ runningAgentId: null,
25
+ paused: false,
26
+ pausedQuestion: null,
27
+ pausedAskerId: null,
28
+ chaining: true,
29
+ routingMode: "auto",
30
+ defaultAgent: null,
31
+ fallbackAgent: null,
32
+ maxChainHops: 30,
33
+ circuitBreaker: true,
34
+ defaultThinkingLevel: "medium",
35
+ allowCloud: false,
36
+ compactionReserveTokens: 38000,
37
+ pendingRoute: null,
38
+ maxRooms: 8,
39
+ conversations: [],
40
+ currentConversationId: "",
41
+ providers: [],
42
+ explicitlyEnabled: [],
43
+ oauthProgress: null,
44
+ };
45
+ /**
46
+ * Reset the per-turn transient fields. These are driven only by
47
+ * `turn`/`token`/`activity` SSE events; on a room/conversation switch they must
48
+ * be cleared, otherwise a turn the new context never started would leave them
49
+ * pinned (e.g. a stuck "agents running…" or a stale ask_user prompt).
50
+ */
51
+ export function resetTransient(state) {
52
+ return {
53
+ ...state,
54
+ turnActive: false,
55
+ runningAgentId: null,
56
+ paused: false,
57
+ pausedQuestion: null,
58
+ pausedAskerId: null,
59
+ pendingRoute: null,
60
+ streaming: {},
61
+ liveActivity: {},
62
+ liveReasoning: {},
63
+ receipts: {},
64
+ };
65
+ }
66
+ /** The named SSE events a room client subscribes to. */
67
+ export const SSE_EVENT_NAMES = [
68
+ "roster",
69
+ "status",
70
+ "token",
71
+ "activity",
72
+ "reasoning",
73
+ "message",
74
+ "receipt",
75
+ "workspace",
76
+ "notice",
77
+ "turn",
78
+ "settings",
79
+ "routing",
80
+ "transcript",
81
+ "conversations",
82
+ "providers",
83
+ "oauth_progress",
84
+ ];
85
+ const noEffects = (state) => ({ state, effects: [] });
86
+ function omit(map, key) {
87
+ if (!(key in map))
88
+ return map;
89
+ const next = { ...map };
90
+ delete next[key];
91
+ return next;
92
+ }
93
+ /**
94
+ * The pure SSE reducer. Given the current state and one parsed event, returns
95
+ * the next state plus any notices to surface. Never mutates its inputs.
96
+ */
97
+ export function reduce(state, event) {
98
+ switch (event.name) {
99
+ case "roster":
100
+ return noEffects({ ...state, roster: event.data });
101
+ case "status": {
102
+ const data = event.data;
103
+ return noEffects({
104
+ ...state,
105
+ roster: state.roster.map((p) => {
106
+ if (p.id !== data.id)
107
+ return p;
108
+ const base = { ...p, status: data.status };
109
+ // Only update contextUsage when the payload explicitly carries it.
110
+ // Mid-turn status events (e.g. "working") don't include it — preserving
111
+ // the last known value prevents the progress bar from briefly clearing.
112
+ if (data.contextUsage !== undefined)
113
+ base.contextUsage = data.contextUsage;
114
+ if (data.sessionStats !== undefined)
115
+ base.sessionStats = data.sessionStats;
116
+ if (data.retry !== undefined)
117
+ base.retry = data.retry;
118
+ return base;
119
+ }),
120
+ });
121
+ }
122
+ case "token": {
123
+ const { id, delta } = event.data;
124
+ return noEffects({ ...state, streaming: { ...state.streaming, [id]: (state.streaming[id] ?? "") + delta } });
125
+ }
126
+ case "activity": {
127
+ const { id, item } = event.data;
128
+ const list = state.liveActivity[id] ?? [];
129
+ const idx = list.findIndex((x) => x.toolCallId === item.toolCallId);
130
+ const next = idx >= 0 ? list.map((x, i) => (i === idx ? item : x)) : [...list, item];
131
+ return noEffects({ ...state, liveActivity: { ...state.liveActivity, [id]: next } });
132
+ }
133
+ case "reasoning": {
134
+ const { id, delta } = event.data;
135
+ return noEffects({ ...state, liveReasoning: { ...state.liveReasoning, [id]: (state.liveReasoning[id] ?? "") + delta } });
136
+ }
137
+ case "message": {
138
+ const msg = event.data;
139
+ const messages = [...state.messages, msg];
140
+ if (msg.author === "user")
141
+ return noEffects({ ...state, messages });
142
+ // The message now carries its final activity; drop the live buffers.
143
+ return noEffects({
144
+ ...state,
145
+ messages,
146
+ streaming: omit(state.streaming, msg.author),
147
+ liveActivity: omit(state.liveActivity, msg.author),
148
+ liveReasoning: omit(state.liveReasoning, msg.author),
149
+ });
150
+ }
151
+ case "receipt": {
152
+ const r = event.data;
153
+ const last = [...state.messages].reverse().find((m) => m.author === r.participantId);
154
+ if (!last)
155
+ return noEffects(state);
156
+ return noEffects({ ...state, receipts: { ...state.receipts, [last.index]: r } });
157
+ }
158
+ case "workspace":
159
+ return noEffects({ ...state, workspace: event.data });
160
+ case "notice": {
161
+ const { msg, level } = event.data;
162
+ return { state, effects: [{ type: "notice", msg, level: level ?? "info" }] };
163
+ }
164
+ case "turn": {
165
+ const data = event.data;
166
+ if (data.phase === "start") {
167
+ return noEffects({
168
+ ...state,
169
+ turnActive: true,
170
+ runningAgentId: data.agentId ?? null,
171
+ streaming: {},
172
+ liveActivity: {},
173
+ liveReasoning: {},
174
+ });
175
+ }
176
+ if (data.phase === "end") {
177
+ return noEffects({
178
+ ...state,
179
+ turnActive: false,
180
+ runningAgentId: null,
181
+ paused: false,
182
+ pausedQuestion: null,
183
+ pausedAskerId: null,
184
+ pendingRoute: null,
185
+ });
186
+ }
187
+ if (data.phase === "pause") {
188
+ return {
189
+ state: {
190
+ ...state,
191
+ turnActive: false,
192
+ paused: true,
193
+ pausedQuestion: data.question ?? null,
194
+ pausedAskerId: data.askerId ?? null,
195
+ },
196
+ effects: [{ type: "notice", msg: `${data.askerId} is waiting for your answer.`, level: "info" }],
197
+ };
198
+ }
199
+ if (data.phase === "resume") {
200
+ return {
201
+ state: {
202
+ ...state,
203
+ paused: false,
204
+ pausedQuestion: null,
205
+ pausedAskerId: null,
206
+ turnActive: true,
207
+ },
208
+ effects: [{ type: "notice", msg: `Resuming — answering ${data.askerId}`, level: "info" }],
209
+ };
210
+ }
211
+ // chain
212
+ const to = (data.targets ?? []).map((t) => `@${t}`).join(" ");
213
+ return { state, effects: [{ type: "notice", msg: `@${data.from} → ${to}`, level: "info" }] };
214
+ }
215
+ case "settings": {
216
+ const d = event.data;
217
+ const next = { ...state, chaining: d.chaining };
218
+ if (d.routingMode !== undefined)
219
+ next.routingMode = d.routingMode;
220
+ if (d.defaultAgent !== undefined)
221
+ next.defaultAgent = d.defaultAgent;
222
+ if (d.fallbackAgent !== undefined)
223
+ next.fallbackAgent = d.fallbackAgent;
224
+ if (d.maxChainHops !== undefined)
225
+ next.maxChainHops = d.maxChainHops;
226
+ if (d.circuitBreaker !== undefined)
227
+ next.circuitBreaker = d.circuitBreaker;
228
+ if (d.defaultThinkingLevel !== undefined)
229
+ next.defaultThinkingLevel = d.defaultThinkingLevel;
230
+ if (d.allowCloud !== undefined)
231
+ next.allowCloud = d.allowCloud;
232
+ if (d.compactionReserveTokens !== undefined)
233
+ next.compactionReserveTokens = d.compactionReserveTokens;
234
+ return noEffects(next);
235
+ }
236
+ case "routing": {
237
+ const data = event.data;
238
+ if (data.type === "proposed") {
239
+ // paused for approval — not actively running
240
+ return noEffects({ ...state, pendingRoute: data.proposals ?? [], turnActive: false });
241
+ }
242
+ return noEffects({ ...state, pendingRoute: null });
243
+ }
244
+ case "transcript": {
245
+ const msgs = event.data;
246
+ return noEffects({
247
+ ...state,
248
+ messages: msgs,
249
+ streaming: {},
250
+ liveActivity: {},
251
+ liveReasoning: {},
252
+ receipts: {},
253
+ });
254
+ }
255
+ case "conversations": {
256
+ const { currentId, list } = event.data;
257
+ return noEffects({ ...state, conversations: list, currentConversationId: currentId });
258
+ }
259
+ case "providers": {
260
+ const data = event.data;
261
+ const next = { ...state };
262
+ if (data.providers)
263
+ next.providers = data.providers;
264
+ if (data.explicitlyEnabled)
265
+ next.explicitlyEnabled = data.explicitlyEnabled;
266
+ return noEffects(next);
267
+ }
268
+ case "oauth_progress": {
269
+ const data = event.data;
270
+ let effect;
271
+ if (data.type === "device_code") {
272
+ effect = { type: "notice", msg: `OAuth for ${data.provider}: visit ${data.verificationUri}, enter code ${data.userCode}`, level: "info" };
273
+ }
274
+ else if (data.type === "auth_url") {
275
+ effect = { type: "notice", msg: `OAuth for ${data.provider}: ${data.instructions || "visit " + data.url}`, level: "info" };
276
+ }
277
+ else if (data.type === "progress") {
278
+ effect = { type: "notice", msg: `OAuth ${data.provider}: ${data.message}`, level: "info" };
279
+ }
280
+ else if (data.type === "success") {
281
+ effect = { type: "notice", msg: data.message ?? "", level: "info" };
282
+ }
283
+ else {
284
+ effect = { type: "notice", msg: data.message ?? "", level: "error" };
285
+ }
286
+ const oauthProgress = {
287
+ provider: data.provider ?? state.oauthProgress?.provider ?? "",
288
+ status: data.type,
289
+ verificationUri: data.verificationUri,
290
+ userCode: data.userCode,
291
+ url: data.url,
292
+ instructions: data.instructions,
293
+ message: data.message,
294
+ };
295
+ return { state: { ...state, oauthProgress }, effects: [effect] };
296
+ }
297
+ default:
298
+ return noEffects(state);
299
+ }
300
+ }
@@ -0,0 +1,88 @@
1
+ import type { RoomApi } from "./api.js";
2
+ import { type RoomState, type SseEventName, type ThinkingLevel } from "./state.js";
3
+ import type { RouteDecision, RoutingMode } from "./types.js";
4
+ export interface SseConnection {
5
+ close(): void;
6
+ }
7
+ export interface SseHandlers {
8
+ onOpen(): void;
9
+ onError(): void;
10
+ onEvent(name: SseEventName, data: string): void;
11
+ }
12
+ export type EventSourceFactory = (url: string, handlers: SseHandlers) => SseConnection;
13
+ /** EventSource factory backed by the browser's global `EventSource`. */
14
+ export declare const browserEventSourceFactory: EventSourceFactory;
15
+ export interface RoomStoreOptions {
16
+ /** Server origin, e.g. "http://localhost:5300". */
17
+ apiBase: string;
18
+ /** Room to subscribe to. Defaults to "default". */
19
+ roomId?: string;
20
+ /** How events arrive. Defaults to the browser EventSource factory. */
21
+ eventSourceFactory?: EventSourceFactory;
22
+ /** Notice auto-dismiss delay in ms. Defaults to 5000. */
23
+ noticeTtlMs?: number;
24
+ }
25
+ export type RoomStore = ReturnType<typeof createRoomStore>;
26
+ /**
27
+ * Create a room store bound to a server and room. Call {@link RoomStore.start}
28
+ * to load the snapshot and open the SSE stream, and {@link RoomStore.stop} to
29
+ * tear it down. Read state via `getSnapshot()` and react to changes via
30
+ * `subscribe()`.
31
+ */
32
+ export declare function createRoomStore(opts: RoomStoreOptions): {
33
+ roomId: string;
34
+ getSnapshot: () => RoomState;
35
+ subscribe: (cb: () => void) => (() => void);
36
+ start: () => void;
37
+ stop: () => void;
38
+ actions: {
39
+ send: (text: string, images?: string[]) => void;
40
+ setActive: (id: string, active: boolean) => void;
41
+ setParallel: (id: string, parallel: boolean) => void;
42
+ kick: (id: string) => void;
43
+ createParticipant: (body: Parameters<RoomApi["create"]>[0]) => Promise<import("./types.js").RosterItem>;
44
+ addFromTemplate: (templateId: string) => Promise<import("./types.js").RosterItem>;
45
+ savePreset: (name: string) => Promise<import("./types.js").PresetFile>;
46
+ loadPreset: (name: string) => Promise<{
47
+ ok: boolean;
48
+ conversation: import("./types.js").ConversationMeta;
49
+ downgraded?: Array<{
50
+ agent: string;
51
+ model: string;
52
+ }>;
53
+ }>;
54
+ applyPreset: (name: string) => Promise<{
55
+ ok: boolean;
56
+ conversation: import("./types.js").ConversationMeta;
57
+ downgraded?: Array<{
58
+ agent: string;
59
+ model: string;
60
+ }>;
61
+ }>;
62
+ getParticipant: (id: string) => Promise<import("./types.js").PersonaDetail>;
63
+ updateParticipant: (id: string, patchBody: Parameters<RoomApi["updateAgent"]>[1]) => Promise<import("./types.js").RosterItem>;
64
+ reorderParticipants: (order: string[]) => void;
65
+ abort: () => void;
66
+ steer: (text: string, target: string) => void;
67
+ compactAgent: (id: string) => void;
68
+ setChaining: (value: boolean) => void;
69
+ setRoutingMode: (mode: RoutingMode) => void;
70
+ setCircuitBreaker: (value: boolean) => void;
71
+ setDefaultThinkingLevel: (level: ThinkingLevel) => void;
72
+ setAllowCloud: (value: boolean) => void;
73
+ setCompactionReserveTokens: (value: number) => void;
74
+ resolveRoute: (decision: RouteDecision) => void;
75
+ setDefaultAgent: (id: string | null) => void;
76
+ setFallbackAgent: (id: string | null) => void;
77
+ setMaxChainHops: (n: number) => void;
78
+ newConversation: (title?: string) => void;
79
+ loadConversation: (id: string) => void;
80
+ renameConversation: (id: string, title: string) => void;
81
+ deleteConversation: (id: string) => void;
82
+ addProvider: (name: string, key: string) => void;
83
+ removeProvider: (name: string) => void;
84
+ loginProvider: (name: string) => void;
85
+ dismissOAuth: () => void;
86
+ };
87
+ pushNotice: (msg: string, level?: "info" | "error") => void;
88
+ };
package/dist/store.js ADDED
@@ -0,0 +1,279 @@
1
+ // The room store: the single effectful owner of a room's live state.
2
+ //
3
+ // It wraps the pure reducer (state.ts) with everything that isn't pure — the
4
+ // initial REST snapshot, the SSE subscription, notice expiry timers, and the
5
+ // action methods that POST/PATCH/DELETE and map failures to notices. It is
6
+ // framework-agnostic: it exposes a `subscribe`/`getSnapshot` contract that a
7
+ // React `useSyncExternalStore` adapter or an Ink render loop can both consume.
8
+ import { createApi } from "./api.js";
9
+ import { initialRoomState, reduce, resetTransient, SSE_EVENT_NAMES, } from "./state.js";
10
+ /** EventSource factory backed by the browser's global `EventSource`. */
11
+ export const browserEventSourceFactory = (url, h) => {
12
+ const es = new EventSource(url);
13
+ es.onopen = () => h.onOpen();
14
+ es.onerror = () => h.onError();
15
+ for (const name of SSE_EVENT_NAMES) {
16
+ es.addEventListener(name, (e) => h.onEvent(name, e.data));
17
+ }
18
+ return { close: () => es.close() };
19
+ };
20
+ /**
21
+ * Create a room store bound to a server and room. Call {@link RoomStore.start}
22
+ * to load the snapshot and open the SSE stream, and {@link RoomStore.stop} to
23
+ * tear it down. Read state via `getSnapshot()` and react to changes via
24
+ * `subscribe()`.
25
+ */
26
+ export function createRoomStore(opts) {
27
+ const roomId = opts.roomId || "default";
28
+ const noticeTtlMs = opts.noticeTtlMs ?? 5000;
29
+ const makeEventSource = opts.eventSourceFactory ?? browserEventSourceFactory;
30
+ // Route prefix: /api for the default room (backward compat), /api/rooms/:id
31
+ // otherwise. SSE is always the room-scoped endpoint — even "default" — so the
32
+ // default room doesn't inherit the unfiltered global stream.
33
+ const prefix = roomId !== "default" ? `/api/rooms/${roomId}` : "/api";
34
+ const { makeRoomApi, api } = createApi(opts.apiBase);
35
+ const rApi = makeRoomApi(prefix);
36
+ const sseUrl = `${opts.apiBase}/api/rooms/${roomId}/events`;
37
+ let state = initialRoomState;
38
+ const listeners = new Set();
39
+ let noticeSeq = 1;
40
+ const noticeTimers = new Set();
41
+ let conn = null;
42
+ const getSnapshot = () => state;
43
+ const subscribe = (cb) => {
44
+ listeners.add(cb);
45
+ return () => listeners.delete(cb);
46
+ };
47
+ const emit = () => {
48
+ for (const cb of listeners)
49
+ cb();
50
+ };
51
+ const set = (next) => {
52
+ if (next === state)
53
+ return;
54
+ state = next;
55
+ emit();
56
+ };
57
+ /** Merge a partial into state (used by actions doing optimistic / response writes). */
58
+ const patch = (partial) => set({ ...state, ...partial });
59
+ const pushNotice = (msg, level = "info") => {
60
+ const id = noticeSeq++;
61
+ set({ ...state, notices: [...state.notices, { id, msg, level }] });
62
+ const timer = setTimeout(() => {
63
+ noticeTimers.delete(timer);
64
+ set({ ...state, notices: state.notices.filter((n) => n.id !== id) });
65
+ }, noticeTtlMs);
66
+ noticeTimers.add(timer);
67
+ };
68
+ const applyEffects = (effects) => {
69
+ for (const e of effects)
70
+ pushNotice(e.msg, e.level);
71
+ };
72
+ const onEvent = (name, raw) => {
73
+ let data;
74
+ try {
75
+ data = JSON.parse(raw);
76
+ }
77
+ catch {
78
+ return;
79
+ }
80
+ const result = reduce(state, { name, data });
81
+ set(result.state);
82
+ applyEffects(result.effects);
83
+ };
84
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
85
+ /** Load the REST snapshot and open the SSE stream. Idempotent-safe to call once. */
86
+ const start = () => {
87
+ // Clear transient fields before (re)loading — a reused store must not show a
88
+ // prior room's in-flight turn. Fresh stores are already clean; this is cheap.
89
+ set(resetTransient(state));
90
+ rApi.transcript().then((m) => patch({ messages: m })).catch(() => { });
91
+ rApi.workspace().then((w) => patch({ workspace: w })).catch(() => { });
92
+ rApi.roster().then((r) => patch({ roster: r })).catch(() => { });
93
+ rApi.settings().then((s) => {
94
+ const next = {
95
+ chaining: s.chaining,
96
+ routingMode: s.routingMode,
97
+ defaultAgent: s.defaultAgent,
98
+ };
99
+ if (s.fallbackAgent !== undefined)
100
+ next.fallbackAgent = s.fallbackAgent;
101
+ if (s.maxChainHops !== undefined)
102
+ next.maxChainHops = s.maxChainHops;
103
+ if (s.circuitBreaker !== undefined)
104
+ next.circuitBreaker = s.circuitBreaker;
105
+ if (s.defaultThinkingLevel !== undefined)
106
+ next.defaultThinkingLevel = s.defaultThinkingLevel;
107
+ if (s.allowCloud !== undefined)
108
+ next.allowCloud = s.allowCloud;
109
+ if (s.compactionReserveTokens !== undefined)
110
+ next.compactionReserveTokens = s.compactionReserveTokens;
111
+ if (s.maxRooms !== undefined)
112
+ next.maxRooms = s.maxRooms;
113
+ next.pendingRoute = s.pendingRoute ? s.pendingRoute.proposals : null;
114
+ patch(next);
115
+ }).catch(() => { });
116
+ rApi.conversations().then((c) => patch({ conversations: c.list, currentConversationId: c.currentId })).catch(() => { });
117
+ api.providers().then((d) => patch({ providers: d.providers, explicitlyEnabled: d.explicitlyEnabled })).catch(() => { });
118
+ conn = makeEventSource(sseUrl, {
119
+ onOpen: () => patch({ connected: true }),
120
+ onError: () => patch({ connected: false }),
121
+ onEvent,
122
+ });
123
+ };
124
+ /** Close the SSE stream and cancel pending notice timers. */
125
+ const stop = () => {
126
+ conn?.close();
127
+ conn = null;
128
+ for (const t of noticeTimers)
129
+ clearTimeout(t);
130
+ noticeTimers.clear();
131
+ };
132
+ const fail = (err) => pushNotice(String(err?.message ?? err), "error");
133
+ // ── Actions ───────────────────────────────────────────────────────────────
134
+ // Thin wrappers over the REST surface. Optimistic writes and response-driven
135
+ // state updates are preserved exactly as the web hook had them.
136
+ const actions = {
137
+ send: (text, images) => {
138
+ rApi.sendMessage(text, images).catch(fail);
139
+ },
140
+ setActive: (id, active) => {
141
+ rApi.setActive(id, active).catch(fail);
142
+ },
143
+ setParallel: (id, parallel) => {
144
+ rApi.setParallel(id, parallel).catch(fail);
145
+ },
146
+ kick: (id) => {
147
+ rApi.kick(id).catch(fail);
148
+ },
149
+ createParticipant: (body) => rApi.create(body).catch((err) => {
150
+ fail(err);
151
+ throw err;
152
+ }),
153
+ addFromTemplate: (templateId) => rApi.addFromTemplate(templateId).catch((err) => {
154
+ fail(err);
155
+ throw err;
156
+ }),
157
+ savePreset: (name) => rApi.savePreset(name).catch((err) => {
158
+ fail(err);
159
+ throw err;
160
+ }),
161
+ loadPreset: (name) => rApi.loadPreset(name).catch((err) => {
162
+ fail(err);
163
+ throw err;
164
+ }),
165
+ applyPreset: (name) => rApi.applyPreset(name).catch((err) => {
166
+ fail(err);
167
+ throw err;
168
+ }),
169
+ getParticipant: (id) => rApi.participant(id),
170
+ updateParticipant: (id, patchBody) => rApi.updateAgent(id, patchBody).catch((err) => {
171
+ fail(err);
172
+ throw err;
173
+ }),
174
+ reorderParticipants: (order) => {
175
+ // Optimistic: reorder the local roster immediately so the drag feels
176
+ // instant; the server's "roster" broadcast then confirms it.
177
+ const byId = new Map(state.roster.map((p) => [p.id, p]));
178
+ const reordered = order.map((id) => byId.get(id)).filter(Boolean);
179
+ for (const p of state.roster)
180
+ if (!order.includes(p.id))
181
+ reordered.push(p);
182
+ patch({ roster: reordered });
183
+ rApi.reorder(order).catch((err) => {
184
+ fail(err);
185
+ rApi.roster().then((r) => patch({ roster: r })).catch(() => { }); // revert to server truth
186
+ });
187
+ },
188
+ abort: () => {
189
+ rApi.abort().catch(() => { });
190
+ },
191
+ steer: (text, target) => {
192
+ rApi.steerMessage(text, target).catch((err) => {
193
+ const msg = String(err?.message ?? err);
194
+ if (msg.includes("not running") || msg.includes("cannot steer")) {
195
+ pushNotice(`@${target} is not running — cannot steer`, "error");
196
+ }
197
+ else {
198
+ pushNotice(msg, "error");
199
+ }
200
+ });
201
+ },
202
+ compactAgent: (id) => {
203
+ pushNotice(`Compacting @${id}…`);
204
+ rApi.compact(id)
205
+ .then((r) => pushNotice(`@${id} compacted: ${r.tokensBefore} tokens before → summary generated.`))
206
+ .catch(fail);
207
+ },
208
+ setChaining: (value) => {
209
+ rApi.setChaining(value).then((s) => patch({ chaining: s.chaining })).catch(fail);
210
+ },
211
+ setRoutingMode: (mode) => {
212
+ rApi.setRoutingMode(mode).then((s) => patch({ routingMode: s.routingMode })).catch(fail);
213
+ },
214
+ setCircuitBreaker: (value) => {
215
+ rApi.setCircuitBreaker(value).then((s) => patch({ circuitBreaker: s.circuitBreaker })).catch(fail);
216
+ },
217
+ setDefaultThinkingLevel: (level) => {
218
+ rApi.setDefaultThinkingLevel(level).then((s) => patch({ defaultThinkingLevel: s.defaultThinkingLevel })).catch(fail);
219
+ },
220
+ setAllowCloud: (value) => {
221
+ rApi.setAllowCloud(value).then((s) => patch({ allowCloud: s.allowCloud })).catch(fail);
222
+ },
223
+ setCompactionReserveTokens: (value) => {
224
+ rApi.setCompactionReserveTokens(value).then((s) => patch({ compactionReserveTokens: s.compactionReserveTokens })).catch(fail);
225
+ },
226
+ resolveRoute: (decision) => {
227
+ patch({ pendingRoute: null }); // optimistic — the card disappears immediately
228
+ rApi.resolveRoute(decision).catch(fail);
229
+ },
230
+ setDefaultAgent: (id) => {
231
+ rApi.setDefaultAgent(id).then((s) => patch({ defaultAgent: s.defaultAgent })).catch(fail);
232
+ },
233
+ setFallbackAgent: (id) => {
234
+ rApi.setFallbackAgent(id).then((s) => patch({ fallbackAgent: s.fallbackAgent ?? null })).catch(fail);
235
+ },
236
+ setMaxChainHops: (n) => {
237
+ rApi.setMaxChainHops(n).then((s) => patch({ maxChainHops: s.maxChainHops })).catch(fail);
238
+ },
239
+ newConversation: (title) => {
240
+ rApi.newConversation(title).catch(fail);
241
+ },
242
+ loadConversation: (id) => {
243
+ if (id === state.currentConversationId)
244
+ return;
245
+ rApi.loadConversation(id).catch(fail);
246
+ },
247
+ renameConversation: (id, title) => {
248
+ rApi.renameConversation(id, title).catch(fail);
249
+ },
250
+ deleteConversation: (id) => {
251
+ rApi.deleteConversation(id).catch(fail);
252
+ },
253
+ addProvider: (name, key) => {
254
+ api.addProvider(name, key).then(() => {
255
+ pushNotice(`Provider "${name}" configured.`);
256
+ // Refresh models so the dropdown picks up new models.
257
+ api.models().catch(() => { });
258
+ }).catch(fail);
259
+ },
260
+ removeProvider: (name) => {
261
+ api.removeProvider(name).then((r) => {
262
+ let msg = `Provider "${name}" removed.`;
263
+ if (r.agentsUsing && r.agentsUsing.length > 0) {
264
+ msg += ` Note: ${r.agentsUsing.join(", ")} may need model reassigned.`;
265
+ }
266
+ pushNotice(msg);
267
+ }).catch(fail);
268
+ },
269
+ loginProvider: (name) => {
270
+ api.loginProvider(name).then(() => {
271
+ pushNotice(`OAuth login started for ${name} — follow the instructions in notifications.`);
272
+ }).catch(fail);
273
+ },
274
+ dismissOAuth: () => {
275
+ patch({ oauthProgress: null });
276
+ },
277
+ };
278
+ return { roomId, getSnapshot, subscribe, start, stop, actions, pushNotice };
279
+ }