@opencode/client 0.0.0-beta-19275

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +27 -0
  2. package/dist/contract.d.ts +1 -0
  3. package/dist/contract.js +1 -0
  4. package/dist/effect/api/api.d.ts +2659 -0
  5. package/dist/effect/api/api.js +0 -0
  6. package/dist/effect/api.d.ts +8 -0
  7. package/dist/effect/api.js +0 -0
  8. package/dist/effect/client.d.ts +2535 -0
  9. package/dist/effect/client.js +29 -0
  10. package/dist/effect/generated/client-error.d.ts +7 -0
  11. package/dist/effect/generated/client-error.js +5 -0
  12. package/dist/effect/generated/client.d.ts +2534 -0
  13. package/dist/effect/generated/client.js +602 -0
  14. package/dist/effect/generated/index.d.ts +2 -0
  15. package/dist/effect/generated/index.js +2 -0
  16. package/dist/effect/index.d.ts +35 -0
  17. package/dist/effect/index.js +30 -0
  18. package/dist/effect/rpc.d.ts +20 -0
  19. package/dist/effect/rpc.js +49 -0
  20. package/dist/effect/service.d.ts +78 -0
  21. package/dist/effect/service.js +259 -0
  22. package/dist/promise/api.d.ts +24 -0
  23. package/dist/promise/api.js +0 -0
  24. package/dist/promise/client.d.ts +257 -0
  25. package/dist/promise/client.js +13 -0
  26. package/dist/promise/generated/client-error.d.ts +6 -0
  27. package/dist/promise/generated/client-error.js +8 -0
  28. package/dist/promise/generated/client.d.ts +264 -0
  29. package/dist/promise/generated/client.js +1425 -0
  30. package/dist/promise/generated/index.d.ts +3 -0
  31. package/dist/promise/generated/index.js +3 -0
  32. package/dist/promise/generated/types.d.ts +8717 -0
  33. package/dist/promise/generated/types.js +30 -0
  34. package/dist/promise/index.d.ts +6 -0
  35. package/dist/promise/index.js +2 -0
  36. package/dist/promise/rpc.d.ts +32 -0
  37. package/dist/promise/rpc.js +86 -0
  38. package/dist/promise/service.d.ts +26 -0
  39. package/dist/promise/service.js +247 -0
  40. package/dist/pty-handoff.d.ts +9 -0
  41. package/dist/pty-handoff.js +121 -0
  42. package/dist/rpc-runtime.d.ts +21 -0
  43. package/dist/rpc-runtime.js +32 -0
  44. package/dist/service-contender.d.ts +11 -0
  45. package/dist/service-contender.js +56 -0
  46. package/dist/service-timing.d.ts +13 -0
  47. package/dist/service-timing.js +19 -0
  48. package/dist/service-version.d.ts +2 -0
  49. package/dist/service-version.js +9 -0
  50. package/dist/service.d.ts +52 -0
  51. package/dist/service.js +0 -0
  52. package/dist/shared-events.d.ts +11 -0
  53. package/dist/shared-events.js +137 -0
  54. package/dist/solid/connection.d.ts +37 -0
  55. package/dist/solid/connection.js +246 -0
  56. package/dist/solid/data.d.ts +199 -0
  57. package/dist/solid/data.js +1724 -0
  58. package/dist/solid/index.d.ts +3 -0
  59. package/dist/solid/index.js +3 -0
  60. package/dist/solid/pty.d.ts +22 -0
  61. package/dist/solid/pty.js +43 -0
  62. package/package.json +85 -0
@@ -0,0 +1,1724 @@
1
+ // Client data layer: apply server events and cache API reads into a Solid store.
2
+ // Prefer straightforward projection. Invalidated reads revalidate serially so an older
3
+ // response cannot commit after its replacement. Reconnect invalidates cached reads;
4
+ // active UI owners decide what to sync again.
5
+ import { Worktree } from "@opencode/schema/worktree";
6
+ import { SessionID } from "@opencode/schema/session-id";
7
+ import { SessionMessage } from "@opencode/schema/session-message";
8
+ import { isFormAlreadySettledError, isFormNotFoundError, isPermissionNotFoundError, } from "../promise";
9
+ import { createStore, produce, reconcile } from "solid-js/store";
10
+ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
11
+ const messageIDFromEvent = (eventID) => eventID.replace(/^evt_/, "msg_");
12
+ const messagePageLimit = 20;
13
+ // Trailing window for event bursts that each ask for the same refetch.
14
+ export const settleMs = 150;
15
+ export function locationKey(location) {
16
+ return JSON.stringify([location.directory, location.workspaceID]);
17
+ }
18
+ function locationQuery(ref) {
19
+ return { directory: ref.directory, workspace: ref.workspaceID };
20
+ }
21
+ function formRequestOptions(sessionID, ref) {
22
+ if (sessionID !== "global" || !ref)
23
+ return undefined;
24
+ return {
25
+ headers: {
26
+ "x-opencode-directory": encodeURIComponent(ref.directory),
27
+ ...(ref.workspaceID ? { "x-opencode-workspace": ref.workspaceID } : {}),
28
+ },
29
+ };
30
+ }
31
+ function createSync() {
32
+ const state = new Map();
33
+ const start = (key, load, wait) => {
34
+ const entry = { promise: Promise.resolve(), invalidated: false, started: !wait };
35
+ state.set(key, entry);
36
+ const run = () => {
37
+ entry.started = true;
38
+ return load();
39
+ };
40
+ entry.promise = (wait ? wait.catch(() => undefined).then(run) : run())
41
+ .then(() => {
42
+ if (state.get(key) === entry && !entry.invalidated)
43
+ state.set(key, true);
44
+ })
45
+ .finally(() => {
46
+ if (state.get(key) === entry)
47
+ state.delete(key);
48
+ });
49
+ return entry.promise;
50
+ };
51
+ return {
52
+ run(key, load) {
53
+ const active = state.get(key);
54
+ if (active === true)
55
+ return Promise.resolve();
56
+ if (!active)
57
+ return start(key, load);
58
+ if (!active.invalidated)
59
+ return active.promise;
60
+ return start(key, load, active.promise);
61
+ },
62
+ complete(key) {
63
+ if (state.has(key))
64
+ return;
65
+ state.set(key, true);
66
+ },
67
+ has(key) {
68
+ return state.has(key);
69
+ },
70
+ pending(key) {
71
+ const active = state.get(key);
72
+ return active !== undefined && active !== true;
73
+ },
74
+ invalidate(key) {
75
+ if (key) {
76
+ const active = state.get(key);
77
+ if (active === true)
78
+ state.delete(key);
79
+ if (active !== undefined && active !== true && active.started)
80
+ active.invalidated = true;
81
+ return;
82
+ }
83
+ state.forEach((active, current) => {
84
+ if (active === true)
85
+ state.delete(current);
86
+ if (active !== true && active.started)
87
+ active.invalidated = true;
88
+ });
89
+ },
90
+ };
91
+ }
92
+ export function createData(config) {
93
+ const api = config.api;
94
+ let disposed = false;
95
+ onCleanup(() => (disposed = true));
96
+ function refresh(load) {
97
+ if (disposed || (config.connection && config.connection.status() !== "connected"))
98
+ return;
99
+ void load().catch((error) => {
100
+ if (disposed || (config.connection && config.connection.status() !== "connected"))
101
+ return;
102
+ if (config.onError)
103
+ return config.onError(error);
104
+ console.error("Failed to refresh client data", error);
105
+ });
106
+ }
107
+ // Runs `load` once a burst of same-key events goes quiet, so N events cost one refetch.
108
+ const settling = new Map();
109
+ onCleanup(() => settling.forEach((timer) => clearTimeout(timer)));
110
+ function settle(key, load) {
111
+ clearTimeout(settling.get(key));
112
+ settling.set(key, setTimeout(() => {
113
+ settling.delete(key);
114
+ refresh(load);
115
+ }, settleMs));
116
+ }
117
+ const [store, setStore] = createStore({
118
+ session: {
119
+ info: {},
120
+ family: {},
121
+ active: {},
122
+ message: {},
123
+ messageCursor: {},
124
+ messageLoading: {},
125
+ pending: {},
126
+ permission: {},
127
+ form: {},
128
+ },
129
+ project: {
130
+ info: {},
131
+ permission: {},
132
+ },
133
+ location: {},
134
+ });
135
+ const [defaultLocation, setDefaultLocation] = createSignal({ directory: config.directory });
136
+ const sessions = createMemo(() => Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated));
137
+ const messageIndex = new Map();
138
+ const sync = createSync();
139
+ let activeUpdates;
140
+ function setSessionActive(sessionID, status) {
141
+ activeUpdates?.set(sessionID, status);
142
+ setStore("session", "active", sessionID, status);
143
+ }
144
+ function removePending(sessionID, inboxID) {
145
+ if (!inboxID)
146
+ return;
147
+ if (store.session.pending[sessionID]?.some((item) => item.id === inboxID))
148
+ setStore("session", "pending", sessionID, (store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID));
149
+ }
150
+ function removePermission(sessionID, requestID) {
151
+ const requests = store.session.permission[sessionID];
152
+ if (!requests?.some((request) => request.id === requestID))
153
+ return;
154
+ setStore("session", "permission", sessionID, requests.filter((request) => request.id !== requestID));
155
+ }
156
+ function removeForm(sessionID, formID, ref) {
157
+ const forms = store.session.form[sessionID];
158
+ if (!forms)
159
+ return false;
160
+ const location = ref && locationKey(ref);
161
+ const next = forms.filter((form) => {
162
+ if (form.id !== formID)
163
+ return true;
164
+ if (sessionID !== "global" || !location)
165
+ return false;
166
+ return !form.location || locationKey(form.location) !== location;
167
+ });
168
+ if (next.length === forms.length)
169
+ return false;
170
+ setStore("session", "form", sessionID, next);
171
+ return true;
172
+ }
173
+ function settleForm(input, ref, request) {
174
+ return request
175
+ .catch((error) => {
176
+ if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID)
177
+ throw error;
178
+ })
179
+ .then(() => {
180
+ if (!removeForm(input.sessionID, input.formID, ref))
181
+ return;
182
+ result.session.form.invalidate(input.sessionID, ref);
183
+ void result.session.form.sync(input.sessionID, ref).catch(() => undefined);
184
+ });
185
+ }
186
+ function updatePending(sessionID, inboxID, delivery) {
187
+ const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inboxID) ?? -1;
188
+ const item = store.session.pending[sessionID]?.[index];
189
+ if (index < 0 || !item || item.delivery === delivery)
190
+ return;
191
+ setStore("session", "pending", sessionID, index, { ...item, delivery });
192
+ }
193
+ // Inbox IDs of optimistic admissions awaiting acknowledgement, so rejection
194
+ // only rolls back unacknowledged rows and a pending re-fetch cannot wipe a
195
+ // row the server does not know about yet. Prompts clear on their durable
196
+ // echo, positive pending read, or rollback; compactions also reconcile the
197
+ // POST's canonical ID.
198
+ const outbox = new Set();
199
+ // Session IDs of optimistic create admissions still awaiting acknowledgement
200
+ // (the session.created echo or the create response itself). A failed create
201
+ // only rolls back a session the server never acknowledged. Unlike
202
+ // `creating`, this clears on the echo rather than request settlement.
203
+ const sessionOutbox = new Set();
204
+ // In-flight optimistic creates by session ID. prompt() gates its POST on
205
+ // this so a prompt sent to a still-creating session waits for the session
206
+ // to exist server-side instead of failing with "not found".
207
+ const creating = new Map();
208
+ // Per-session send chain: prompts and compactions must be admitted in
209
+ // submission order. Each waits for the previous POST to settle, so one
210
+ // failure does not block the next.
211
+ const sending = new Map();
212
+ const messageLoads = new Map();
213
+ const compacting = new Map();
214
+ onCleanup(() => compacting.clear());
215
+ // Register `promise` under `key` until it settles. A later registration
216
+ // replaces an earlier one; settlement only clears its own entry.
217
+ function track(map, key, promise) {
218
+ map.set(key, promise);
219
+ const settle = () => {
220
+ if (map.get(key) === promise)
221
+ map.delete(key);
222
+ };
223
+ void promise.then(settle, settle);
224
+ }
225
+ // Capture creation before settlement clears its entry, so dependent RPCs still see a failed create.
226
+ function sendAdmission(sessionID, send, gate) {
227
+ const created = creating.get(sessionID);
228
+ const previous = sending.get(sessionID);
229
+ const request = Promise.resolve()
230
+ .then(() => Promise.all([gate, created, previous]))
231
+ .then(send);
232
+ track(sending, sessionID, request.catch(() => undefined));
233
+ return request;
234
+ }
235
+ // Upsert an admitted inbox item into pending and (for user and synthetic
236
+ // items) the visible transcript. Used by the inbox.enqueued
237
+ // handler and by optimistic admission; the upsert is what reconciles
238
+ // the durable echo with an optimistic placeholder — the durable payload and
239
+ // times replace the client's guess.
240
+ function admitLocal(item) {
241
+ batch(() => {
242
+ const pending = store.session.pending[item.sessionID] ?? [];
243
+ const at = pending.findIndex((entry) => entry.id === item.id);
244
+ setStore("session", "pending", item.sessionID, at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)));
245
+ if (item.type === "compaction")
246
+ return;
247
+ materializeInboxMessage(item);
248
+ });
249
+ }
250
+ function materializeInboxMessage(item) {
251
+ if (item.type !== "user" && item.type !== "synthetic")
252
+ return;
253
+ message.update(item.sessionID, (draft, index) => {
254
+ const row = item.type === "user"
255
+ ? { id: item.id, type: "user", ...item.payload, time: { created: item.timeCreated } }
256
+ : { id: item.id, type: "synthetic", ...item.payload, time: { created: item.timeCreated } };
257
+ const position = index.get(item.id);
258
+ if (position === undefined)
259
+ return message.append(draft, index, row);
260
+ draft[position] = row;
261
+ });
262
+ }
263
+ // Remove an inbox item from pending, input, and the visible transcript.
264
+ // Used by the inbox.cancelled handler and by optimistic rollback.
265
+ function retractLocal(sessionID, inboxID) {
266
+ batch(() => {
267
+ removePending(sessionID, inboxID);
268
+ if (!messageIndex.get(sessionID)?.has(inboxID))
269
+ return;
270
+ message.update(sessionID, (draft, index) => {
271
+ const position = index.get(inboxID);
272
+ if (position === undefined)
273
+ return;
274
+ draft.splice(position, 1);
275
+ index.delete(inboxID);
276
+ message.reindex(draft, index, position);
277
+ });
278
+ });
279
+ }
280
+ const message = {
281
+ update(sessionID, fn) {
282
+ setStore("session", "message", produce((draft) => {
283
+ fn((draft[sessionID] ??= []), index(sessionID));
284
+ }));
285
+ },
286
+ append(messages, index, item) {
287
+ if (index.has(item.id))
288
+ return;
289
+ index.set(item.id, messages.length);
290
+ messages.push(item);
291
+ },
292
+ insert(sessionID, item) {
293
+ message.update(sessionID, (draft, index) => message.append(draft, index, item));
294
+ },
295
+ // Streaming events target one assistant message and, within it, the latest part of a kind.
296
+ // A missing target means the row was never loaded or was evicted; the event is dropped.
297
+ editAssistant(sessionID, messageID, fn) {
298
+ message.update(sessionID, (draft, index) => {
299
+ const position = index.get(messageID);
300
+ const item = position === undefined ? undefined : draft[position];
301
+ if (item?.type === "assistant")
302
+ fn(item);
303
+ });
304
+ },
305
+ editTool(sessionID, messageID, toolID, fn) {
306
+ message.editAssistant(sessionID, messageID, (assistant) => {
307
+ const tool = assistant.content.findLast((item) => item.type === "tool" && item.id === toolID);
308
+ if (tool)
309
+ fn(tool);
310
+ });
311
+ },
312
+ editText(sessionID, messageID, fn) {
313
+ message.editAssistant(sessionID, messageID, (assistant) => {
314
+ const text = assistant.content.findLast((item) => item.type === "text");
315
+ if (text)
316
+ fn(text);
317
+ });
318
+ },
319
+ editReasoning(sessionID, messageID, fn) {
320
+ message.editAssistant(sessionID, messageID, (assistant) => {
321
+ const reasoning = assistant.content.findLast((item) => item.type === "reasoning" && !item.time?.completed);
322
+ if (reasoning)
323
+ fn(reasoning);
324
+ });
325
+ },
326
+ activeAssistant(messages) {
327
+ const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed);
328
+ return item?.type === "assistant" ? item : undefined;
329
+ },
330
+ shell(messages, shellID) {
331
+ const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID);
332
+ return item?.type === "shell" ? item : undefined;
333
+ },
334
+ compaction(messages) {
335
+ const item = messages.findLast((item) => item.type === "compaction" && item.status === "running");
336
+ return item?.type === "compaction" ? item : undefined;
337
+ },
338
+ reindex(messages, index, start) {
339
+ for (let position = start; position < messages.length; position++) {
340
+ const item = messages[position];
341
+ if (item)
342
+ index.set(item.id, position);
343
+ }
344
+ },
345
+ };
346
+ function index(sessionID) {
347
+ const existing = messageIndex.get(sessionID);
348
+ if (existing)
349
+ return existing;
350
+ const created = new Map();
351
+ messageIndex.set(sessionID, created);
352
+ return created;
353
+ }
354
+ // Walk parentID upward through loaded session info to the family root. When a
355
+ // parent's info is missing, that missing ID is the furthest-known ancestor and
356
+ // is returned so orphan subtrees group under it until the parent arrives. A
357
+ // seen set guards against parent cycles, stopping at the last non-repeating
358
+ // ancestor.
359
+ function resolveRoot(sessionID) {
360
+ let current = sessionID;
361
+ let parentID = store.session.info[sessionID]?.parentID;
362
+ const seen = new Set([sessionID]);
363
+ while (parentID) {
364
+ if (seen.has(parentID))
365
+ break;
366
+ seen.add(parentID);
367
+ current = parentID;
368
+ parentID = store.session.info[parentID]?.parentID;
369
+ }
370
+ return current;
371
+ }
372
+ // Register one session into the family index. Idempotent: syncing an
373
+ // existing session never duplicates its ID. When a tentative family keyed by
374
+ // sessionID exists (descendants arrived while sessionID's own info was
375
+ // absent) but sessionID turns out to have a parent, fold the orphan subtree
376
+ // into the resolved root's family and drop the tentative entry.
377
+ function registerSession(sessionID) {
378
+ const info = store.session.info[sessionID];
379
+ if (!info)
380
+ return;
381
+ const rootID = resolveRoot(sessionID);
382
+ setStore("session", "family", produce((draft) => {
383
+ if (sessionID !== rootID && draft[sessionID]) {
384
+ const members = (draft[rootID] ??= []);
385
+ for (const id of draft[sessionID]) {
386
+ if (!members.includes(id))
387
+ members.push(id);
388
+ }
389
+ delete draft[sessionID];
390
+ }
391
+ const family = (draft[rootID] ??= []);
392
+ if (!family.includes(sessionID))
393
+ family.push(sessionID);
394
+ }));
395
+ }
396
+ function evictSession(sessionID) {
397
+ if (sessionOutbox.has(sessionID))
398
+ return;
399
+ sync.invalidate(`session.pending:${sessionID}`);
400
+ sync.invalidate(`session.message:${sessionID}`);
401
+ messageLoads.delete(sessionID);
402
+ // Keep unacknowledged submissions until their echo or rollback settles them.
403
+ const pending = store.session.pending[sessionID]?.filter((item) => outbox.has(item.id)) ?? [];
404
+ const messages = store.session.message[sessionID]?.filter((item) => outbox.has(item.id)) ?? [];
405
+ messageIndex.delete(sessionID);
406
+ if (messages.length)
407
+ messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])));
408
+ setStore("session", produce((draft) => {
409
+ delete draft.message[sessionID];
410
+ delete draft.messageCursor[sessionID];
411
+ delete draft.messageLoading[sessionID];
412
+ delete draft.pending[sessionID];
413
+ if (messages.length)
414
+ draft.message[sessionID] = messages;
415
+ if (pending.length)
416
+ draft.pending[sessionID] = pending;
417
+ }));
418
+ }
419
+ function removeSession(sessionID) {
420
+ activeUpdates?.set(sessionID, undefined);
421
+ store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id));
422
+ messageIndex.delete(sessionID);
423
+ sync.invalidate(`session:${sessionID}`);
424
+ sync.invalidate(`session.family:${sessionID}`);
425
+ sync.invalidate(`session.pending:${sessionID}`);
426
+ sync.invalidate(`session.message:${sessionID}`);
427
+ sync.invalidate(`session.permission:${sessionID}`);
428
+ sync.invalidate(`session.form:${sessionID}:`);
429
+ setStore("session", produce((draft) => {
430
+ delete draft.info[sessionID];
431
+ delete draft.active[sessionID];
432
+ delete draft.message[sessionID];
433
+ delete draft.messageCursor[sessionID];
434
+ delete draft.messageLoading[sessionID];
435
+ delete draft.pending[sessionID];
436
+ delete draft.permission[sessionID];
437
+ delete draft.form[sessionID];
438
+ for (const [rootID, family] of Object.entries(draft.family)) {
439
+ const next = family.filter((id) => id !== sessionID);
440
+ if (next.length === 0)
441
+ delete draft.family[rootID];
442
+ else
443
+ draft.family[rootID] = next;
444
+ }
445
+ }));
446
+ }
447
+ function handleEvent(event) {
448
+ switch (event.type) {
449
+ case "server.connected": {
450
+ const updates = new Map();
451
+ activeUpdates = updates;
452
+ refresh(() => api()
453
+ .session.active()
454
+ .then((active) => {
455
+ if (activeUpdates !== updates)
456
+ return;
457
+ // Lifecycle events received during hydration supersede the snapshot.
458
+ const snapshot = new Map(Object.keys(active).map((id) => [id, "running"]));
459
+ updates.forEach((status, id) => {
460
+ if (status === undefined)
461
+ return snapshot.delete(id);
462
+ snapshot.set(id, status);
463
+ });
464
+ activeUpdates = undefined;
465
+ setStore("session", "active", reconcile(Object.fromEntries(snapshot)));
466
+ })
467
+ .catch(() => {
468
+ if (activeUpdates === updates)
469
+ activeUpdates = undefined;
470
+ }));
471
+ refresh(() => api()
472
+ .location.get({ location: locationQuery(defaultLocation()) })
473
+ .then((location) => {
474
+ const key = locationKey(location);
475
+ setStore("location", key, { info: location });
476
+ }));
477
+ refresh(() => result.location.vcs.sync());
478
+ refresh(() => result.project.sync());
479
+ return;
480
+ }
481
+ case "project.updated":
482
+ setStore("project", "info", event.data.id, reconcile(event.data));
483
+ return;
484
+ case "session.created":
485
+ sessionOutbox.delete(event.data.sessionID);
486
+ result.session.invalidate(event.data.sessionID);
487
+ refresh(() => result.session.sync(event.data.sessionID));
488
+ // Band-aid: a newly created session starts empty, so live events can be its source of truth.
489
+ // Fetching pending inputs and projected messages separately lets promotion move an input between snapshots,
490
+ // causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until
491
+ // hydration can load pending and projected messages atomically.
492
+ sync.complete(`session.pending:${event.data.sessionID}`);
493
+ sync.complete(`session.message:${event.data.sessionID}`);
494
+ return;
495
+ case "session.deleted":
496
+ removeSession(event.data.sessionID);
497
+ return;
498
+ case "session.usage.updated":
499
+ if (store.session.info[event.data.sessionID])
500
+ setStore("session", "info", event.data.sessionID, {
501
+ cost: event.data.cost,
502
+ tokens: event.data.tokens,
503
+ });
504
+ return;
505
+ case "session.agent.selected": {
506
+ const previous = store.session.info[event.data.sessionID]?.agent;
507
+ if (store.session.info[event.data.sessionID])
508
+ setStore("session", "info", event.data.sessionID, "agent", event.data.agent);
509
+ message.insert(event.data.sessionID, {
510
+ id: messageIDFromEvent(event.id),
511
+ type: "agent-switched",
512
+ agent: event.data.agent,
513
+ previous,
514
+ time: { created: event.created },
515
+ });
516
+ return;
517
+ }
518
+ case "session.model.selected":
519
+ if (store.session.info[event.data.sessionID])
520
+ setStore("session", "info", event.data.sessionID, "model", event.data.model);
521
+ if (!store.session.message[event.data.sessionID])
522
+ return;
523
+ message.insert(event.data.sessionID, {
524
+ id: messageIDFromEvent(event.id),
525
+ type: "model-switched",
526
+ model: event.data.model,
527
+ time: { created: event.created },
528
+ });
529
+ refresh(() => api()
530
+ .session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
531
+ .then((item) => {
532
+ message.update(event.data.sessionID, (draft, index) => {
533
+ const position = index.get(item.id);
534
+ if (position === undefined)
535
+ return message.append(draft, index, item);
536
+ draft[position] = item;
537
+ });
538
+ }));
539
+ return;
540
+ case "session.renamed": {
541
+ // Preserve the live title when it races the session's initial read.
542
+ refresh(() => {
543
+ const family = sync.pending(`session.family:${event.data.sessionID}`)
544
+ ? result.session.sync(event.data.sessionID, { children: true })
545
+ : Promise.resolve();
546
+ return Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
547
+ if (store.session.info[event.data.sessionID])
548
+ setStore("session", "info", event.data.sessionID, "title", event.data.title);
549
+ });
550
+ });
551
+ return;
552
+ }
553
+ case "session.moved": {
554
+ const current = store.session.info[event.data.sessionID];
555
+ if (current) {
556
+ const previous = {
557
+ location: { ...current.location },
558
+ projectID: current.projectID,
559
+ subpath: current.subpath,
560
+ };
561
+ setStore("session", "info", event.data.sessionID, "location", event.data.location);
562
+ if (event.data.projectID)
563
+ setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID);
564
+ setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath);
565
+ message.insert(event.data.sessionID, {
566
+ id: messageIDFromEvent(event.id),
567
+ type: "location-switched",
568
+ location: event.data.location,
569
+ projectID: event.data.projectID,
570
+ subpath: event.data.subpath,
571
+ previous,
572
+ time: { created: event.created },
573
+ });
574
+ }
575
+ return;
576
+ }
577
+ case "worktree.resolved": {
578
+ for (const [sessionID, info] of Object.entries(store.session.info)) {
579
+ const explicit = event.data.adopted?.includes(info.projectID);
580
+ const directory = explicit ? store.project.info[info.projectID]?.canonical : info.location.directory;
581
+ if (!directory) {
582
+ if (info.location.workspaceID)
583
+ continue;
584
+ result.session.invalidate(sessionID);
585
+ refresh(() => result.session.sync(sessionID));
586
+ continue;
587
+ }
588
+ const adopted = Worktree.adopt({
589
+ projectID: info.projectID,
590
+ directory,
591
+ workspaceID: info.location.workspaceID,
592
+ }, event.data);
593
+ if (!adopted)
594
+ continue;
595
+ setStore("session", "info", sessionID, "projectID", adopted.projectID);
596
+ setStore("session", "info", sessionID, "subpath", adopted.subpath);
597
+ }
598
+ return;
599
+ }
600
+ case "session.inbox.delivered": {
601
+ const admitted = result.session.input.has(event.data.sessionID, event.data.inboxID);
602
+ removePending(event.data.sessionID, event.data.inboxID);
603
+ message.update(event.data.sessionID, (draft, index) => {
604
+ const position = index.get(event.data.inboxID);
605
+ if (position === undefined)
606
+ return;
607
+ const existing = draft[position];
608
+ if (!existing || !admitted)
609
+ return;
610
+ existing.time.created = event.created;
611
+ draft.splice(position, 1);
612
+ draft.push(existing);
613
+ message.reindex(draft, index, position);
614
+ });
615
+ compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID);
616
+ return;
617
+ }
618
+ case "session.inbox.delivery.changed":
619
+ updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery);
620
+ return;
621
+ case "session.inbox.cancelled": {
622
+ retractLocal(event.data.sessionID, event.data.inboxID);
623
+ compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID);
624
+ return;
625
+ }
626
+ case "session.inbox.enqueued": {
627
+ outbox.delete(event.data.inboxID);
628
+ admitLocal({
629
+ id: event.data.inboxID,
630
+ sessionID: event.data.sessionID,
631
+ timeCreated: event.created,
632
+ ...event.data.item,
633
+ });
634
+ if (event.data.item.type === "compaction") {
635
+ const active = compacting.get(event.data.sessionID);
636
+ active?.observed.add(event.data.inboxID);
637
+ if (active && active.id !== event.data.inboxID && outbox.delete(active.id))
638
+ removePending(event.data.sessionID, active.id);
639
+ }
640
+ return;
641
+ }
642
+ case "session.instructions.updated":
643
+ // Mirror the projector: the initial baseline and empty-rendering deltas carry no text
644
+ // and produce no transcript message.
645
+ const updateText = event.data.text;
646
+ if (updateText === undefined)
647
+ return;
648
+ message.insert(event.data.sessionID, {
649
+ id: messageIDFromEvent(event.id),
650
+ type: "system",
651
+ text: updateText,
652
+ description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
653
+ metadata: event.metadata,
654
+ time: { created: event.created },
655
+ });
656
+ return;
657
+ case "session.synthetic":
658
+ message.insert(event.data.sessionID, {
659
+ id: messageIDFromEvent(event.id),
660
+ type: "synthetic",
661
+ text: event.data.text,
662
+ description: event.data.description,
663
+ metadata: event.data.metadata,
664
+ time: { created: event.created },
665
+ });
666
+ return;
667
+ case "session.shell.started":
668
+ message.insert(event.data.sessionID, {
669
+ id: messageIDFromEvent(event.id),
670
+ type: "shell",
671
+ shellID: event.data.shell.id,
672
+ command: event.data.shell.command,
673
+ status: event.data.shell.status,
674
+ exit: event.data.shell.exit,
675
+ metadata: event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
676
+ time: { created: event.created },
677
+ });
678
+ return;
679
+ case "session.shell.ended":
680
+ message.update(event.data.sessionID, (draft) => {
681
+ const match = message.shell(draft, event.data.shell.id);
682
+ if (!match)
683
+ return;
684
+ match.status = event.data.shell.status;
685
+ match.exit = event.data.shell.exit;
686
+ match.output = event.data.output;
687
+ match.time.completed = event.created;
688
+ });
689
+ return;
690
+ case "session.message.content.updated": {
691
+ if (store.session.message[event.data.sessionID])
692
+ message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
693
+ assistant.content = [...event.data.content];
694
+ });
695
+ if (!sync.pending(`session.message:${event.data.sessionID}`))
696
+ return;
697
+ result.session.message.invalidate(event.data.sessionID);
698
+ refresh(() => result.session.message.sync(event.data.sessionID));
699
+ return;
700
+ }
701
+ case "session.step.started":
702
+ message.update(event.data.sessionID, (draft, index) => {
703
+ const position = index.get(event.data.assistantMessageID);
704
+ const existing = position === undefined ? undefined : draft[position];
705
+ if (existing?.type === "assistant") {
706
+ existing.agent = event.data.agent;
707
+ existing.model = event.data.model;
708
+ existing.retry = undefined;
709
+ existing.error = undefined;
710
+ existing.finish = undefined;
711
+ existing.rawFinish = undefined;
712
+ existing.providerState = undefined;
713
+ existing.time.streamed = undefined;
714
+ existing.time.completed = undefined;
715
+ if (event.data.snapshot)
716
+ existing.snapshot = { ...existing.snapshot, start: event.data.snapshot };
717
+ return;
718
+ }
719
+ const currentAssistant = message.activeAssistant(draft);
720
+ if (currentAssistant) {
721
+ currentAssistant.retry = undefined;
722
+ currentAssistant.time.completed = event.created;
723
+ }
724
+ message.append(draft, index, {
725
+ id: event.data.assistantMessageID,
726
+ type: "assistant",
727
+ agent: event.data.agent,
728
+ model: event.data.model,
729
+ metadata: event.metadata,
730
+ content: [],
731
+ snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
732
+ time: { created: event.created },
733
+ });
734
+ });
735
+ return;
736
+ case "session.step.streamed":
737
+ message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
738
+ assistant.time.streamed = event.created;
739
+ });
740
+ return;
741
+ case "session.step.ended": {
742
+ message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
743
+ assistant.time.completed = event.created;
744
+ assistant.finish = event.data.finish;
745
+ assistant.rawFinish = event.data.rawFinish;
746
+ assistant.providerState = event.data.providerState;
747
+ assistant.cost = event.data.cost;
748
+ assistant.tokens = event.data.tokens;
749
+ if (event.data.snapshot)
750
+ assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot };
751
+ });
752
+ return;
753
+ }
754
+ case "session.step.failed":
755
+ message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
756
+ assistant.time.completed = event.created;
757
+ assistant.finish = event.data.finish ?? "error";
758
+ assistant.rawFinish = event.data.rawFinish;
759
+ assistant.providerState = event.data.providerState;
760
+ assistant.error = event.data.error;
761
+ assistant.retry = undefined;
762
+ if (event.data.cost !== undefined && event.data.tokens !== undefined) {
763
+ assistant.cost = event.data.cost;
764
+ assistant.tokens = event.data.tokens;
765
+ }
766
+ });
767
+ return;
768
+ case "session.text.started":
769
+ message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
770
+ assistant.content.push({ type: "text", text: "" });
771
+ });
772
+ return;
773
+ case "session.text.delta":
774
+ message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
775
+ text.text += event.data.delta;
776
+ });
777
+ return;
778
+ case "session.text.ended":
779
+ message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
780
+ text.text = event.data.text;
781
+ });
782
+ return;
783
+ case "session.tool.input.started":
784
+ message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
785
+ assistant.content.push({
786
+ type: "tool",
787
+ id: event.data.id,
788
+ name: event.data.name,
789
+ time: { created: event.created },
790
+ state: { status: "streaming", input: "" },
791
+ });
792
+ });
793
+ return;
794
+ case "session.tool.input.delta":
795
+ message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
796
+ if (tool.state.status === "streaming")
797
+ tool.state.input += event.data.delta;
798
+ });
799
+ return;
800
+ case "session.tool.input.ended":
801
+ message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
802
+ if (tool.state.status === "streaming")
803
+ tool.state.input = event.data.text;
804
+ });
805
+ return;
806
+ case "session.tool.called":
807
+ message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
808
+ tool.time.ran = event.created;
809
+ tool.executed = event.data.executed;
810
+ tool.providerState = event.data.state;
811
+ tool.state = { status: "running", input: event.data.input, metadata: {} };
812
+ });
813
+ return;
814
+ case "session.tool.progress":
815
+ message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
816
+ if (tool.state.status === "running")
817
+ tool.state.metadata = event.data.metadata;
818
+ });
819
+ return;
820
+ case "session.tool.success":
821
+ message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
822
+ if (tool.state.status !== "running")
823
+ return;
824
+ tool.state = {
825
+ status: "completed",
826
+ input: tool.state.input,
827
+ metadata: event.data.metadata,
828
+ content: [...event.data.content],
829
+ };
830
+ tool.executed = event.data.executed || tool.executed === true;
831
+ tool.providerResultState = event.data.resultState;
832
+ tool.time.completed = event.created;
833
+ });
834
+ return;
835
+ case "session.tool.failed":
836
+ message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
837
+ if (tool.state.status !== "streaming" && tool.state.status !== "running")
838
+ return;
839
+ tool.state = {
840
+ status: "error",
841
+ error: event.data.error,
842
+ input: typeof tool.state.input === "string" ? {} : tool.state.input,
843
+ metadata: event.data.metadata,
844
+ content: event.data.content,
845
+ };
846
+ tool.executed = event.data.executed || tool.executed === true;
847
+ tool.providerResultState = event.data.resultState;
848
+ tool.time.completed = event.created;
849
+ });
850
+ return;
851
+ case "session.reasoning.started":
852
+ message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
853
+ assistant.content.push({
854
+ type: "reasoning",
855
+ text: "",
856
+ state: event.data.state,
857
+ time: { created: event.created },
858
+ });
859
+ });
860
+ return;
861
+ case "session.reasoning.delta":
862
+ message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
863
+ reasoning.text += event.data.delta;
864
+ });
865
+ return;
866
+ case "session.reasoning.ended":
867
+ message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
868
+ reasoning.text = event.data.text;
869
+ reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created };
870
+ if (event.data.state !== undefined)
871
+ reasoning.state = event.data.state;
872
+ });
873
+ return;
874
+ case "session.retry.scheduled":
875
+ message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
876
+ assistant.retry = { attempt: event.data.attempt, at: event.data.at, error: event.data.error };
877
+ });
878
+ return;
879
+ case "session.execution.started":
880
+ setSessionActive(event.data.sessionID, "running");
881
+ return;
882
+ case "session.compaction.started":
883
+ if (event.data.inputID)
884
+ removePending(event.data.sessionID, event.data.inputID);
885
+ message.insert(event.data.sessionID, {
886
+ id: event.data.inputID ?? messageIDFromEvent(event.id),
887
+ type: "compaction",
888
+ status: "running",
889
+ reason: event.data.reason,
890
+ summary: "",
891
+ recent: event.data.recent ?? "",
892
+ time: { created: event.created },
893
+ });
894
+ if (event.data.inputID)
895
+ compacting.get(event.data.sessionID)?.observed.add(event.data.inputID);
896
+ return;
897
+ case "session.execution.succeeded":
898
+ case "session.execution.failed":
899
+ case "session.execution.interrupted":
900
+ setSessionActive(event.data.sessionID, "idle");
901
+ message.update(event.data.sessionID, (draft) => {
902
+ const currentAssistant = message.activeAssistant(draft);
903
+ if (currentAssistant)
904
+ currentAssistant.retry = undefined;
905
+ });
906
+ if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown")
907
+ return;
908
+ // An event can overtake the first read; queue a revalidation when that read is still active.
909
+ if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`))
910
+ return;
911
+ result.session.invalidate(event.data.sessionID);
912
+ refresh(() => result.session.sync(event.data.sessionID));
913
+ return;
914
+ case "session.viewed":
915
+ if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`))
916
+ return;
917
+ result.session.invalidate(event.data.sessionID);
918
+ refresh(() => result.session.sync(event.data.sessionID));
919
+ return;
920
+ case "session.revert.staged":
921
+ if (store.session.info[event.data.sessionID])
922
+ setStore("session", "info", event.data.sessionID, "revert", event.data.revert);
923
+ return;
924
+ case "session.revert.cleared":
925
+ if (store.session.info[event.data.sessionID])
926
+ setStore("session", "info", event.data.sessionID, "revert", undefined);
927
+ return;
928
+ case "session.revert.committed":
929
+ if (store.session.info[event.data.sessionID]) {
930
+ setStore("session", "info", event.data.sessionID, "revert", undefined);
931
+ }
932
+ // The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
933
+ setStore("session", "pending", event.data.sessionID, (store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to));
934
+ message.update(event.data.sessionID, (draft, index) => {
935
+ const position = draft.findIndex((item) => item.id >= event.data.to);
936
+ if (position === -1)
937
+ return;
938
+ for (const item of draft.splice(position))
939
+ index.delete(item.id);
940
+ });
941
+ return;
942
+ case "session.compaction.delta":
943
+ message.update(event.data.sessionID, (draft) => {
944
+ const current = message.compaction(draft);
945
+ if (current?.status === "running")
946
+ current.summary += event.data.text;
947
+ });
948
+ return;
949
+ case "session.compaction.ended":
950
+ message.update(event.data.sessionID, (draft, index) => {
951
+ const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running");
952
+ const current = draft[position];
953
+ if (current?.type === "compaction") {
954
+ Object.assign(current, {
955
+ status: "completed",
956
+ reason: event.data.reason,
957
+ model: event.data.model,
958
+ providerState: event.data.providerState,
959
+ summary: event.data.text,
960
+ recent: event.data.recent,
961
+ });
962
+ return;
963
+ }
964
+ message.append(draft, index, {
965
+ id: messageIDFromEvent(event.id),
966
+ type: "compaction",
967
+ status: "completed",
968
+ reason: event.data.reason,
969
+ model: event.data.model,
970
+ providerState: event.data.providerState,
971
+ summary: event.data.text,
972
+ recent: event.data.recent,
973
+ time: { created: event.created },
974
+ });
975
+ });
976
+ return;
977
+ case "session.compaction.failed":
978
+ if (event.data.inputID)
979
+ removePending(event.data.sessionID, event.data.inputID);
980
+ message.update(event.data.sessionID, (draft, index) => {
981
+ const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running");
982
+ const current = draft[position];
983
+ const failed = {
984
+ id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id),
985
+ type: "compaction",
986
+ status: "failed",
987
+ reason: event.data.reason ?? "manual",
988
+ error: event.data.error ?? {
989
+ type: "compaction.failed",
990
+ message: "Compaction failed before recording an error",
991
+ },
992
+ metadata: current?.type === "compaction" ? current.metadata : event.metadata,
993
+ time: current?.type === "compaction" ? current.time : { created: event.created },
994
+ };
995
+ if (current?.type === "compaction") {
996
+ draft[position] = failed;
997
+ return;
998
+ }
999
+ message.append(draft, index, failed);
1000
+ });
1001
+ if (event.data.inputID)
1002
+ compacting.get(event.data.sessionID)?.observed.add(event.data.inputID);
1003
+ return;
1004
+ case "permission.asked":
1005
+ if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id))
1006
+ return;
1007
+ setStore("session", "permission", event.data.sessionID, [
1008
+ ...(store.session.permission[event.data.sessionID] ?? []),
1009
+ event.data,
1010
+ ]);
1011
+ return;
1012
+ case "permission.replied":
1013
+ removePermission(event.data.sessionID, event.data.requestID);
1014
+ return;
1015
+ case "form.replied":
1016
+ case "form.cancelled":
1017
+ removeForm(event.data.sessionID, event.data.id, event.location);
1018
+ return;
1019
+ }
1020
+ if (event.type === "credential.updated" || event.type === "credential.switched") {
1021
+ Object.keys(store.location).forEach((key) => {
1022
+ const ref = JSON.parse(key);
1023
+ const location = { directory: ref[0], workspaceID: ref[1] ?? undefined };
1024
+ if (event.type === "credential.updated") {
1025
+ result.location.integration.invalidate(location);
1026
+ refresh(() => result.location.integration.sync(location));
1027
+ return;
1028
+ }
1029
+ setStore("location", key, (data) => ({
1030
+ integration: data?.integration?.map((integration) => {
1031
+ if (integration.id !== event.data.integrationID)
1032
+ return integration;
1033
+ const active = integration.connections.find((connection) => connection.type === "credential" && connection.id === event.data.credentialID);
1034
+ if (!active)
1035
+ return integration;
1036
+ return {
1037
+ ...integration,
1038
+ connections: [active, ...integration.connections.filter((connection) => connection !== active)],
1039
+ };
1040
+ }),
1041
+ }));
1042
+ result.location.model.invalidate(location);
1043
+ result.location.provider.invalidate(location);
1044
+ refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]));
1045
+ });
1046
+ return;
1047
+ }
1048
+ if (!event.location)
1049
+ return;
1050
+ const location = event.location;
1051
+ switch (event.type) {
1052
+ case "catalog.updated":
1053
+ result.location.model.invalidate(location);
1054
+ result.location.provider.invalidate(location);
1055
+ refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]));
1056
+ break;
1057
+ case "agent.updated":
1058
+ result.location.agent.invalidate(location);
1059
+ refresh(() => result.location.agent.sync(location));
1060
+ break;
1061
+ case "command.updated":
1062
+ result.location.command.invalidate(location);
1063
+ refresh(() => result.location.command.sync(location));
1064
+ break;
1065
+ case "skill.updated":
1066
+ result.location.skill.invalidate(location);
1067
+ refresh(() => result.location.skill.sync(location));
1068
+ break;
1069
+ case "vcs.branch.updated":
1070
+ setStore("location", locationKey(location), (data) => ({
1071
+ vcs: {
1072
+ branch: {
1073
+ ...data?.vcs?.branch,
1074
+ current: event.data.branch,
1075
+ },
1076
+ },
1077
+ }));
1078
+ break;
1079
+ case "form.created":
1080
+ if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id))
1081
+ break;
1082
+ setStore("session", "form", event.data.form.sessionID, [
1083
+ ...(store.session.form[event.data.form.sessionID] ?? []),
1084
+ event.data.form.sessionID === "global" ? { ...event.data.form, location } : event.data.form,
1085
+ ]);
1086
+ break;
1087
+ case "shell.created":
1088
+ setStore("location", locationKey(location), (data) => ({
1089
+ shell: {
1090
+ ...data?.shell,
1091
+ [event.data.info.id]: { ...event.data.info, location },
1092
+ },
1093
+ }));
1094
+ break;
1095
+ case "shell.exited":
1096
+ case "shell.deleted":
1097
+ setStore("location", locationKey(location), (data) => ({
1098
+ shell: Object.fromEntries(Object.entries(data?.shell ?? {}).filter(([id]) => id !== event.data.id)),
1099
+ }));
1100
+ break;
1101
+ case "reference.updated":
1102
+ result.location.reference.invalidate(location);
1103
+ refresh(() => result.location.reference.sync(location));
1104
+ break;
1105
+ case "integration.updated":
1106
+ result.location.integration.invalidate(location);
1107
+ result.location.model.invalidate(location);
1108
+ result.location.provider.invalidate(location);
1109
+ refresh(() => Promise.all([
1110
+ result.location.integration.sync(location),
1111
+ result.location.model.sync(location),
1112
+ result.location.provider.sync(location),
1113
+ ]));
1114
+ break;
1115
+ case "config.updated":
1116
+ result.location.config.invalidate(location);
1117
+ if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`))
1118
+ refresh(() => result.location.config.sync(location));
1119
+ refresh(() => result.location.websearch.refresh(location));
1120
+ break;
1121
+ case "websearch.updated":
1122
+ refresh(() => result.location.websearch.refresh(location));
1123
+ break;
1124
+ // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
1125
+ // so the mcp list syncs here rather than off integration.updated. The server emits one event
1126
+ // per MCP server as each settles, so a location booting nine servers emitted nine refetches.
1127
+ case "mcp.status.changed":
1128
+ result.location.mcp.server.invalidate(location);
1129
+ settle(`mcp.status:${locationKey(location)}`, () => result.location.mcp.server.sync(location));
1130
+ break;
1131
+ case "mcp.resources.changed":
1132
+ result.location.mcp.resource.invalidate(location);
1133
+ refresh(() => result.location.mcp.resource.sync(location));
1134
+ break;
1135
+ }
1136
+ }
1137
+ // A cached per-location catalog. `sync` loads once per invalidation, keyed by the
1138
+ // effective location, and publishes under the server's canonical location; `alias`
1139
+ // also publishes under the requested key when the two differ.
1140
+ function locationResource(field, load, options) {
1141
+ const publish = (key, value) => setStore("location", key, { [field]: value });
1142
+ return {
1143
+ list: (ref) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
1144
+ sync: (ref) => {
1145
+ const location = ref ?? defaultLocation();
1146
+ const id = locationKey(location);
1147
+ return sync.run(`location.${field}:${id}`, async () => {
1148
+ const response = await load(locationQuery(location));
1149
+ const key = locationKey(response.location);
1150
+ publish(key, response.data);
1151
+ if (options?.alias && key !== id)
1152
+ publish(id, response.data);
1153
+ });
1154
+ },
1155
+ invalidate: (ref) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
1156
+ };
1157
+ }
1158
+ const vcs = locationResource("vcs", (location) => api().vcs.get({ location }));
1159
+ const shells = locationResource("shell", async (location) => {
1160
+ const response = await api().shell.list({ location });
1161
+ const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID };
1162
+ return {
1163
+ location: response.location,
1164
+ data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
1165
+ };
1166
+ });
1167
+ const result = {
1168
+ on: config.event.on,
1169
+ listen: config.event.listen,
1170
+ session: {
1171
+ list() {
1172
+ return sessions();
1173
+ },
1174
+ get(sessionID) {
1175
+ return store.session.info[sessionID];
1176
+ },
1177
+ creating(sessionID) {
1178
+ return creating.has(sessionID);
1179
+ },
1180
+ remember(info) {
1181
+ batch(() => {
1182
+ setStore("session", "info", info.id, reconcile(info));
1183
+ sync.complete(`session:${info.id}`);
1184
+ registerSession(info.id);
1185
+ });
1186
+ },
1187
+ setStatus(sessionID, status) {
1188
+ setSessionActive(sessionID, status);
1189
+ },
1190
+ root(sessionID) {
1191
+ return resolveRoot(sessionID);
1192
+ },
1193
+ family(sessionID) {
1194
+ return store.session.family[resolveRoot(sessionID)] ?? [];
1195
+ },
1196
+ /** Clear heavy cached data for the root and all known descendants. */
1197
+ evict(sessionID) {
1198
+ const root = resolveRoot(sessionID);
1199
+ batch(() => {
1200
+ for (const id of new Set([root, sessionID, ...(store.session.family[root] ?? [])]))
1201
+ evictSession(id);
1202
+ });
1203
+ },
1204
+ cost(sessionID) {
1205
+ const session = store.session.info[sessionID];
1206
+ if (!session)
1207
+ return 0;
1208
+ if (session.parentID)
1209
+ return session.cost;
1210
+ return (store.session.family[sessionID] ?? [sessionID]).reduce((total, id) => total + (store.session.info[id]?.cost ?? 0), 0);
1211
+ },
1212
+ status(sessionID) {
1213
+ return store.session.active[sessionID] ?? "idle";
1214
+ },
1215
+ // Inputs are the pending user and synthetic items; compactions are control items.
1216
+ input: {
1217
+ list(sessionID) {
1218
+ return (store.session.pending[sessionID] ?? []).flatMap((item) => item.type === "compaction" ? [] : [item.id]);
1219
+ },
1220
+ has(sessionID, inboxID) {
1221
+ return (store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false);
1222
+ },
1223
+ },
1224
+ pending: {
1225
+ list(sessionID) {
1226
+ return store.session.pending[sessionID] ?? [];
1227
+ },
1228
+ sync(sessionID) {
1229
+ return sync.run(`session.pending:${sessionID}`, async () => {
1230
+ const pending = await api().session.inbox.list({ sessionID });
1231
+ // A positive read acknowledges admission even when its SSE echo is delayed.
1232
+ pending.forEach((item) => outbox.delete(item.id));
1233
+ // Compactions also coalesce by Session, not just by the proposed ID.
1234
+ if (pending.some((item) => item.type === "compaction"))
1235
+ store.session.pending[sessionID]
1236
+ ?.filter((item) => item.type === "compaction")
1237
+ .forEach((item) => outbox.delete(item.id));
1238
+ // Keep optimistic rows still awaiting their echo: this fetch may
1239
+ // have raced ahead of an in-flight admission the server does not
1240
+ // know about yet.
1241
+ const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id));
1242
+ const merged = inflight.length === 0 ? pending : [...pending, ...inflight];
1243
+ batch(() => {
1244
+ setStore("session", "pending", sessionID, reconcile(merged));
1245
+ merged.forEach(materializeInboxMessage);
1246
+ });
1247
+ });
1248
+ },
1249
+ invalidate(sessionID) {
1250
+ sync.invalidate(`session.pending:${sessionID}`);
1251
+ },
1252
+ },
1253
+ // Optimistic session creation: admit a local record under a
1254
+ // client-minted ID so a session view can mount immediately, then create
1255
+ // the session on the server. The session.created echo re-syncs the
1256
+ // record by ID, so the durable payload replaces the client's guess.
1257
+ // Returns the ID synchronously along with the in-flight request:
1258
+ // callers gate session-dependent sends on the request (prompt() gates
1259
+ // itself on any in-flight create of its session automatically).
1260
+ create(input) {
1261
+ const { projectID, ...payload } = input;
1262
+ const id = payload.id ?? SessionID.create();
1263
+ const location = payload.location ?? defaultLocation();
1264
+ const fresh = !store.session.info[id];
1265
+ if (fresh) {
1266
+ const now = Date.now();
1267
+ sessionOutbox.add(id);
1268
+ result.session.remember({
1269
+ id,
1270
+ projectID: projectID ?? store.location[locationKey(location)]?.info?.project.id ?? "",
1271
+ agent: payload.agent,
1272
+ model: payload.model,
1273
+ cost: 0,
1274
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
1275
+ time: { created: now, updated: now },
1276
+ title: payload.title,
1277
+ location,
1278
+ });
1279
+ // A mounted optimistic session must not fetch its empty collections
1280
+ // before creation settles. The session.created echo re-syncs info.
1281
+ sync.complete(`session.family:${id}`);
1282
+ sync.complete(`session.pending:${id}`);
1283
+ sync.complete(`session.message:${id}`);
1284
+ }
1285
+ // Wrapped so even a synchronous client failure reaches the rollback.
1286
+ const request = Promise.resolve()
1287
+ .then(() => api().session.create({ ...payload, id, location }))
1288
+ .then((info) => {
1289
+ sessionOutbox.delete(id);
1290
+ result.session.remember(info);
1291
+ return info;
1292
+ })
1293
+ .catch((error) => {
1294
+ // Roll back only a record this call admitted and neither the echo
1295
+ // nor the response has acknowledged: anything else is server state.
1296
+ if (fresh && sessionOutbox.delete(id))
1297
+ removeSession(id);
1298
+ throw error;
1299
+ });
1300
+ if (fresh)
1301
+ track(creating, id, request);
1302
+ return { id, request };
1303
+ },
1304
+ compact(input) {
1305
+ const active = compacting.get(input.sessionID);
1306
+ if (active)
1307
+ return active.request;
1308
+ // A known pending control ID may be consumed while setup waits. Propose
1309
+ // a fresh ID and let the server coalesce, without duplicating its row.
1310
+ const id = SessionMessage.ID.create();
1311
+ if (!store.session.pending[input.sessionID]?.some((item) => item.type === "compaction")) {
1312
+ outbox.add(id);
1313
+ admitLocal({
1314
+ id,
1315
+ sessionID: input.sessionID,
1316
+ timeCreated: Date.now(),
1317
+ type: "compaction",
1318
+ delivery: "steer",
1319
+ payload: {},
1320
+ });
1321
+ }
1322
+ // Compaction admission can coalesce onto a different ID. Retire the
1323
+ // speculative row on an echo, and remember consumed IDs until the POST
1324
+ // settles so its older response cannot resurrect a queued row.
1325
+ const observed = new Set();
1326
+ const request = sendAdmission(input.sessionID, async () => {
1327
+ if (input.model)
1328
+ await api().session.switchModel({ sessionID: input.sessionID, model: input.model });
1329
+ return api().session.compact({ sessionID: input.sessionID, id });
1330
+ })
1331
+ .then((item) => {
1332
+ batch(() => {
1333
+ outbox.delete(id);
1334
+ if (item.id !== id)
1335
+ removePending(input.sessionID, id);
1336
+ if (!observed.has(item.id) && !messageIndex.get(input.sessionID)?.has(item.id))
1337
+ admitLocal(item);
1338
+ });
1339
+ return item;
1340
+ })
1341
+ .catch((error) => {
1342
+ if (outbox.delete(id))
1343
+ removePending(input.sessionID, id);
1344
+ throw error;
1345
+ })
1346
+ .finally(() => {
1347
+ if (compacting.get(input.sessionID)?.request === request)
1348
+ compacting.delete(input.sessionID);
1349
+ });
1350
+ compacting.set(input.sessionID, { id, observed, request });
1351
+ return request;
1352
+ },
1353
+ // Optimistic prompt admission: render the prompt immediately under a
1354
+ // client-minted ID, send it, and let the durable inbox.enqueued echo
1355
+ // upsert that same ID with the server's payload. Server admission is
1356
+ // idempotent per ID, so retrying with the identical payload cannot
1357
+ // double-admit.
1358
+ prompt(input) {
1359
+ const { gate, prepare, ...request } = input;
1360
+ const id = request.id ?? SessionMessage.ID.create();
1361
+ // A retry may reuse an ID that is already rendered — and possibly
1362
+ // already durable. Admit optimistically only for new IDs so a failed
1363
+ // retry cannot roll back acknowledged state.
1364
+ const fresh = !messageIndex.get(request.sessionID)?.has(id) &&
1365
+ !store.session.pending[request.sessionID]?.some((item) => item.id === id);
1366
+ if (fresh) {
1367
+ outbox.add(id);
1368
+ admitLocal({
1369
+ id,
1370
+ sessionID: request.sessionID,
1371
+ timeCreated: Date.now(),
1372
+ type: "user",
1373
+ delivery: request.delivery ?? "steer",
1374
+ // Files and skills stay off the optimistic row: their durable
1375
+ // forms are server-loaded (content, mime, resolution), so they
1376
+ // fill in when the echo upserts the row.
1377
+ payload: {
1378
+ text: request.text,
1379
+ agents: request.agents?.map((agent) => ({ ...agent })),
1380
+ metadata: request.metadata,
1381
+ },
1382
+ });
1383
+ }
1384
+ return sendAdmission(request.sessionID, async () => {
1385
+ await prepare?.();
1386
+ return api().session.prompt({ ...request, id });
1387
+ }, gate).catch((error) => {
1388
+ // Roll back only rows this call admitted and the server has not
1389
+ // acknowledged: anything else is server state.
1390
+ if (fresh && outbox.delete(id))
1391
+ retractLocal(request.sessionID, id);
1392
+ throw error;
1393
+ });
1394
+ },
1395
+ sync(sessionID, options) {
1396
+ return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
1397
+ const [info, children] = await Promise.all([
1398
+ api().session.get({ sessionID }),
1399
+ options?.children
1400
+ ? api()
1401
+ .session.list({ parentID: sessionID, order: "desc" })
1402
+ .then((response) => response.data)
1403
+ : [],
1404
+ ]);
1405
+ const sessions = [info, ...children];
1406
+ batch(() => {
1407
+ setStore("session", "info", produce((draft) => {
1408
+ for (const session of sessions)
1409
+ draft[session.id] = session;
1410
+ }));
1411
+ for (const session of sessions) {
1412
+ sync.complete(`session:${session.id}`);
1413
+ registerSession(session.id);
1414
+ }
1415
+ });
1416
+ });
1417
+ },
1418
+ invalidate(sessionID) {
1419
+ sync.invalidate(`session:${sessionID}`);
1420
+ },
1421
+ message: {
1422
+ list(sessionID) {
1423
+ return store.session.message[sessionID] ?? [];
1424
+ },
1425
+ get(sessionID, messageID) {
1426
+ const messages = store.session.message[sessionID];
1427
+ const position = messageIndex.get(sessionID)?.get(messageID);
1428
+ return position === undefined ? undefined : messages?.[position];
1429
+ },
1430
+ sync(sessionID) {
1431
+ return sync.run(`session.message:${sessionID}`, async () => {
1432
+ const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" });
1433
+ const fetched = response.data.toReversed();
1434
+ // Same protection as the pending sync: a re-fetch racing an
1435
+ // admission must not wipe its local transcript row.
1436
+ const ids = new Set(fetched.map((item) => item.id));
1437
+ const admitted = new Set((store.session.pending[sessionID] ?? []).flatMap((item) => item.type === "user" || item.type === "synthetic" ? [item.id] : []));
1438
+ const local = (store.session.message[sessionID] ?? []).filter((item) => !ids.has(item.id) && (outbox.has(item.id) || admitted.has(item.id)));
1439
+ const messages = local.length === 0 ? fetched : [...fetched, ...local];
1440
+ messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])));
1441
+ setStore("session", "message", sessionID, reconcile(messages));
1442
+ setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined);
1443
+ });
1444
+ },
1445
+ more(sessionID) {
1446
+ return store.session.messageCursor[sessionID] !== undefined;
1447
+ },
1448
+ loading(sessionID) {
1449
+ return store.session.messageLoading[sessionID] ?? false;
1450
+ },
1451
+ async loadMore(sessionID, options) {
1452
+ const signal = options?.signal;
1453
+ if (signal?.aborted)
1454
+ return;
1455
+ while (messageLoads.has(sessionID)) {
1456
+ const published = await (() => {
1457
+ const pending = messageLoads.get(sessionID);
1458
+ if (!signal)
1459
+ return pending;
1460
+ const aborted = Promise.withResolvers();
1461
+ const cancel = () => aborted.resolve();
1462
+ signal.addEventListener("abort", cancel, { once: true });
1463
+ return Promise.race([pending, aborted.promise])
1464
+ .catch((error) => {
1465
+ if (!signal.aborted)
1466
+ throw error;
1467
+ })
1468
+ .finally(() => signal.removeEventListener("abort", cancel));
1469
+ })();
1470
+ if ((!options?.all && published) || signal?.aborted)
1471
+ return;
1472
+ }
1473
+ const cursor = store.session.messageCursor[sessionID];
1474
+ if (!cursor || signal?.aborted)
1475
+ return;
1476
+ setStore("session", "messageLoading", sessionID, true);
1477
+ const request = (async () => {
1478
+ const fetched = [];
1479
+ let next = cursor;
1480
+ do {
1481
+ const response = await api().message.list({
1482
+ sessionID,
1483
+ limit: options?.all ? 200 : messagePageLimit,
1484
+ cursor: next,
1485
+ }, { signal });
1486
+ if (signal?.aborted)
1487
+ return;
1488
+ fetched.push(...response.data);
1489
+ next = response.cursor.next ?? undefined;
1490
+ if (!options?.all)
1491
+ break;
1492
+ } while (next);
1493
+ // A jump through history publishes once, not once per page of offscreen messages.
1494
+ const existing = store.session.message[sessionID] ?? [];
1495
+ const ids = new Set(existing.map((item) => item.id));
1496
+ const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing];
1497
+ batch(() => {
1498
+ options?.beforePublish?.();
1499
+ messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])));
1500
+ setStore("session", "message", sessionID, reconcile(messages));
1501
+ setStore("session", "messageCursor", sessionID, next);
1502
+ });
1503
+ return true;
1504
+ })()
1505
+ .catch((error) => {
1506
+ if (!signal?.aborted)
1507
+ throw error;
1508
+ })
1509
+ .finally(() => setStore("session", "messageLoading", sessionID, false));
1510
+ track(messageLoads, sessionID, request);
1511
+ await request;
1512
+ },
1513
+ invalidate(sessionID) {
1514
+ sync.invalidate(`session.message:${sessionID}`);
1515
+ },
1516
+ },
1517
+ permission: {
1518
+ list(sessionID) {
1519
+ return store.session.permission[sessionID];
1520
+ },
1521
+ sync(sessionID) {
1522
+ return sync.run(`session.permission:${sessionID}`, async () => {
1523
+ setStore("session", "permission", sessionID, await api().permission.list({ sessionID }));
1524
+ });
1525
+ },
1526
+ invalidate(sessionID) {
1527
+ sync.invalidate(`session.permission:${sessionID}`);
1528
+ },
1529
+ async reply(input) {
1530
+ await api()
1531
+ .permission.reply(input)
1532
+ .catch((error) => {
1533
+ if (!isPermissionNotFoundError(error))
1534
+ throw error;
1535
+ });
1536
+ removePermission(input.sessionID, input.requestID);
1537
+ },
1538
+ },
1539
+ form: {
1540
+ list(sessionID, ref) {
1541
+ const forms = store.session.form[sessionID];
1542
+ if (sessionID !== "global")
1543
+ return forms;
1544
+ if (!ref)
1545
+ return;
1546
+ const key = locationKey(ref);
1547
+ return forms?.filter((form) => form.location && locationKey(form.location) === key);
1548
+ },
1549
+ sync(sessionID, ref) {
1550
+ const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`;
1551
+ return sync.run(key, async () => {
1552
+ if (sessionID === "global") {
1553
+ const response = await api().form.request.list({
1554
+ location: locationQuery(ref ?? defaultLocation()),
1555
+ });
1556
+ const location = {
1557
+ directory: response.location.directory,
1558
+ workspaceID: response.location.workspaceID,
1559
+ };
1560
+ const locationID = locationKey(location);
1561
+ setStore("session", "form", sessionID, [
1562
+ ...(store.session.form[sessionID] ?? []).filter((form) => form.location && locationKey(form.location) !== locationID),
1563
+ ...response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })),
1564
+ ]);
1565
+ return;
1566
+ }
1567
+ setStore("session", "form", sessionID, await api().form.list({ sessionID }));
1568
+ });
1569
+ },
1570
+ invalidate(sessionID, ref) {
1571
+ sync.invalidate(`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`);
1572
+ },
1573
+ reply(input, ref) {
1574
+ return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)));
1575
+ },
1576
+ cancel(input, ref) {
1577
+ return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)));
1578
+ },
1579
+ },
1580
+ },
1581
+ project: {
1582
+ list() {
1583
+ return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated);
1584
+ },
1585
+ get(projectID) {
1586
+ return store.project.info[projectID];
1587
+ },
1588
+ sync() {
1589
+ return sync.run("project", async () => {
1590
+ const projects = await api().project.list();
1591
+ setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project]))));
1592
+ });
1593
+ },
1594
+ invalidate() {
1595
+ sync.invalidate("project");
1596
+ },
1597
+ permission: {
1598
+ list(projectID) {
1599
+ return store.project.permission[projectID];
1600
+ },
1601
+ sync(projectID) {
1602
+ return sync.run(`project.permission:${projectID}`, async () => {
1603
+ setStore("project", "permission", projectID, await api().permission.saved.list({ projectID }));
1604
+ });
1605
+ },
1606
+ invalidate(projectID) {
1607
+ sync.invalidate(`project.permission:${projectID}`);
1608
+ },
1609
+ },
1610
+ },
1611
+ shell: {
1612
+ list(location) {
1613
+ return Object.values(shells.list(location) ?? {});
1614
+ },
1615
+ listBySession(sessionID) {
1616
+ return Object.values(store.location)
1617
+ .flatMap((data) => Object.values(data.shell ?? {}))
1618
+ .filter((shell) => shell.metadata.sessionID === sessionID);
1619
+ },
1620
+ get(id) {
1621
+ return Object.values(store.location)
1622
+ .map((data) => data.shell?.[id])
1623
+ .find((shell) => shell !== undefined);
1624
+ },
1625
+ sync: shells.sync,
1626
+ invalidate: shells.invalidate,
1627
+ },
1628
+ location: {
1629
+ info(ref) {
1630
+ return store.location[locationKey(ref ?? defaultLocation())]?.info;
1631
+ },
1632
+ default() {
1633
+ return defaultLocation();
1634
+ },
1635
+ syncInfo(ref) {
1636
+ const current = ref ?? defaultLocation();
1637
+ return sync.run(`location:${locationKey(current)}`, async () => {
1638
+ const location = await api().location.get({ location: locationQuery(current) });
1639
+ const key = locationKey(location);
1640
+ if (!store.location[key])
1641
+ setStore("location", key, {});
1642
+ setStore("location", key, "info", location);
1643
+ if (!ref) {
1644
+ setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID });
1645
+ }
1646
+ });
1647
+ },
1648
+ async sync(ref) {
1649
+ await result.location.syncInfo(ref);
1650
+ const location = ref ?? defaultLocation();
1651
+ await Promise.all([
1652
+ result.location.vcs.sync(location),
1653
+ result.location.agent.sync(location),
1654
+ result.location.command.sync(location),
1655
+ result.location.integration.sync(location),
1656
+ result.location.mcp.server.sync(location),
1657
+ result.location.mcp.resource.sync(location),
1658
+ result.location.model.sync(location),
1659
+ result.location.provider.sync(location),
1660
+ result.location.reference.sync(location),
1661
+ result.location.skill.sync(location),
1662
+ result.shell.sync(location),
1663
+ result.session.form.sync("global", location),
1664
+ ]);
1665
+ },
1666
+ invalidate(ref) {
1667
+ const location = ref ?? defaultLocation();
1668
+ sync.invalidate(`location:${locationKey(location)}`);
1669
+ result.location.vcs.invalidate(location);
1670
+ result.location.agent.invalidate(location);
1671
+ result.location.command.invalidate(location);
1672
+ result.location.config.invalidate(location);
1673
+ result.location.integration.invalidate(location);
1674
+ result.location.mcp.server.invalidate(location);
1675
+ result.location.mcp.resource.invalidate(location);
1676
+ result.location.model.invalidate(location);
1677
+ result.location.provider.invalidate(location);
1678
+ result.location.reference.invalidate(location);
1679
+ result.location.skill.invalidate(location);
1680
+ result.shell.invalidate(location);
1681
+ result.session.form.invalidate("global", location);
1682
+ },
1683
+ vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
1684
+ agent: locationResource("agent", (location) => api().agent.list({ location })),
1685
+ command: locationResource("command", (location) => api().command.list({ location })),
1686
+ config: locationResource("config", async (location) => ({
1687
+ location: { directory: location.directory, workspaceID: location.workspace },
1688
+ data: await api().config.get({ location }),
1689
+ })),
1690
+ integration: locationResource("integration", (location) => api().integration.list({ location })),
1691
+ mcp: {
1692
+ server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
1693
+ resource: locationResource("mcpResource", async (location) => {
1694
+ const response = await api().mcp.resource.catalog({ location });
1695
+ return { location: response.location, data: response.data.resources };
1696
+ }),
1697
+ },
1698
+ model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
1699
+ provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
1700
+ reference: locationResource("reference", (location) => api().reference.list({ location })),
1701
+ websearch: {
1702
+ list(location) {
1703
+ return store.location[locationKey(location ?? defaultLocation())]?.websearch;
1704
+ },
1705
+ async refresh(ref) {
1706
+ const input = { location: locationQuery(ref ?? defaultLocation()) };
1707
+ const providers = await api().websearch.providers(input);
1708
+ const key = locationKey(providers.location);
1709
+ setStore("location", key, { websearch: providers.data });
1710
+ },
1711
+ },
1712
+ skill: locationResource("skill", (location) => api().skill.list({ location })),
1713
+ },
1714
+ };
1715
+ createEffect(() => {
1716
+ if (config.connection?.status() === "connected")
1717
+ return;
1718
+ sync.invalidate();
1719
+ });
1720
+ onCleanup(config.event.listen(({ details }) => {
1721
+ handleEvent(details);
1722
+ }));
1723
+ return result;
1724
+ }