@opencode/client 0.0.0-reserved → 2.0.1
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/README.md +24 -2
- package/dist/contract.d.ts +1 -0
- package/dist/contract.js +1 -0
- package/dist/effect/api/api.d.ts +2698 -0
- package/dist/effect/api/api.js +0 -0
- package/dist/effect/api.d.ts +8 -0
- package/dist/effect/api.js +0 -0
- package/dist/effect/client.d.ts +2464 -0
- package/dist/effect/client.js +29 -0
- package/dist/effect/generated/client-error.d.ts +7 -0
- package/dist/effect/generated/client-error.js +5 -0
- package/dist/effect/generated/client.d.ts +2463 -0
- package/dist/effect/generated/client.js +603 -0
- package/dist/effect/generated/index.d.ts +2 -0
- package/dist/effect/generated/index.js +2 -0
- package/dist/effect/index.d.ts +35 -0
- package/dist/effect/index.js +30 -0
- package/dist/effect/rpc.d.ts +20 -0
- package/dist/effect/rpc.js +49 -0
- package/dist/effect/service.d.ts +78 -0
- package/dist/effect/service.js +259 -0
- package/dist/promise/api.d.ts +24 -0
- package/dist/promise/api.js +0 -0
- package/dist/promise/client.d.ts +257 -0
- package/dist/promise/client.js +13 -0
- package/dist/promise/generated/client-error.d.ts +6 -0
- package/dist/promise/generated/client-error.js +8 -0
- package/dist/promise/generated/client.d.ts +264 -0
- package/dist/promise/generated/client.js +1426 -0
- package/dist/promise/generated/index.d.ts +3 -0
- package/dist/promise/generated/index.js +3 -0
- package/dist/promise/generated/types.d.ts +8777 -0
- package/dist/promise/generated/types.js +30 -0
- package/dist/promise/index.d.ts +6 -0
- package/dist/promise/index.js +2 -0
- package/dist/promise/rpc.d.ts +32 -0
- package/dist/promise/rpc.js +86 -0
- package/dist/promise/service.d.ts +26 -0
- package/dist/promise/service.js +247 -0
- package/dist/pty-handoff.d.ts +9 -0
- package/dist/pty-handoff.js +121 -0
- package/dist/rpc-runtime.d.ts +21 -0
- package/dist/rpc-runtime.js +32 -0
- package/dist/service-contender.d.ts +11 -0
- package/dist/service-contender.js +56 -0
- package/dist/service-timing.d.ts +13 -0
- package/dist/service-timing.js +19 -0
- package/dist/service-version.d.ts +2 -0
- package/dist/service-version.js +9 -0
- package/dist/service.d.ts +52 -0
- package/dist/service.js +0 -0
- package/dist/shared-events.d.ts +11 -0
- package/dist/shared-events.js +137 -0
- package/dist/solid/connection.d.ts +37 -0
- package/dist/solid/connection.js +246 -0
- package/dist/solid/data.d.ts +201 -0
- package/dist/solid/data.js +1731 -0
- package/dist/solid/index.d.ts +3 -0
- package/dist/solid/index.js +3 -0
- package/dist/solid/pty.d.ts +22 -0
- package/dist/solid/pty.js +43 -0
- package/package.json +76 -6
|
@@ -0,0 +1,1731 @@
|
|
|
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.permissions.updated":
|
|
554
|
+
if (store.session.info[event.data.sessionID])
|
|
555
|
+
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions);
|
|
556
|
+
return;
|
|
557
|
+
case "session.moved": {
|
|
558
|
+
const current = store.session.info[event.data.sessionID];
|
|
559
|
+
if (current) {
|
|
560
|
+
const previous = {
|
|
561
|
+
location: { ...current.location },
|
|
562
|
+
projectID: current.projectID,
|
|
563
|
+
subpath: current.subpath,
|
|
564
|
+
};
|
|
565
|
+
setStore("session", "info", event.data.sessionID, "location", event.data.location);
|
|
566
|
+
if (event.data.projectID)
|
|
567
|
+
setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID);
|
|
568
|
+
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath);
|
|
569
|
+
message.insert(event.data.sessionID, {
|
|
570
|
+
id: messageIDFromEvent(event.id),
|
|
571
|
+
type: "location-switched",
|
|
572
|
+
location: event.data.location,
|
|
573
|
+
projectID: event.data.projectID,
|
|
574
|
+
subpath: event.data.subpath,
|
|
575
|
+
previous,
|
|
576
|
+
time: { created: event.created },
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
case "worktree.resolved": {
|
|
582
|
+
for (const [sessionID, info] of Object.entries(store.session.info)) {
|
|
583
|
+
const explicit = event.data.adopted?.includes(info.projectID);
|
|
584
|
+
const directory = explicit ? store.project.info[info.projectID]?.canonical : info.location.directory;
|
|
585
|
+
if (!directory) {
|
|
586
|
+
if (info.location.workspaceID)
|
|
587
|
+
continue;
|
|
588
|
+
result.session.invalidate(sessionID);
|
|
589
|
+
refresh(() => result.session.sync(sessionID));
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const adopted = Worktree.adopt({
|
|
593
|
+
projectID: info.projectID,
|
|
594
|
+
directory,
|
|
595
|
+
workspaceID: info.location.workspaceID,
|
|
596
|
+
}, event.data);
|
|
597
|
+
if (!adopted)
|
|
598
|
+
continue;
|
|
599
|
+
setStore("session", "info", sessionID, "projectID", adopted.projectID);
|
|
600
|
+
setStore("session", "info", sessionID, "subpath", adopted.subpath);
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
case "session.inbox.delivered": {
|
|
605
|
+
const admitted = result.session.input.has(event.data.sessionID, event.data.inboxID);
|
|
606
|
+
removePending(event.data.sessionID, event.data.inboxID);
|
|
607
|
+
message.update(event.data.sessionID, (draft, index) => {
|
|
608
|
+
const position = index.get(event.data.inboxID);
|
|
609
|
+
if (position === undefined)
|
|
610
|
+
return;
|
|
611
|
+
const existing = draft[position];
|
|
612
|
+
if (!existing || !admitted)
|
|
613
|
+
return;
|
|
614
|
+
existing.time.created = event.created;
|
|
615
|
+
draft.splice(position, 1);
|
|
616
|
+
draft.push(existing);
|
|
617
|
+
message.reindex(draft, index, position);
|
|
618
|
+
});
|
|
619
|
+
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
case "session.inbox.delivery.changed":
|
|
623
|
+
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery);
|
|
624
|
+
return;
|
|
625
|
+
case "session.inbox.cancelled": {
|
|
626
|
+
retractLocal(event.data.sessionID, event.data.inboxID);
|
|
627
|
+
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
case "session.inbox.enqueued": {
|
|
631
|
+
outbox.delete(event.data.inboxID);
|
|
632
|
+
admitLocal({
|
|
633
|
+
id: event.data.inboxID,
|
|
634
|
+
sessionID: event.data.sessionID,
|
|
635
|
+
timeCreated: event.created,
|
|
636
|
+
...event.data.item,
|
|
637
|
+
});
|
|
638
|
+
if (event.data.item.type === "compaction") {
|
|
639
|
+
const active = compacting.get(event.data.sessionID);
|
|
640
|
+
active?.observed.add(event.data.inboxID);
|
|
641
|
+
if (active && active.id !== event.data.inboxID && outbox.delete(active.id))
|
|
642
|
+
removePending(event.data.sessionID, active.id);
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
case "session.instructions.updated":
|
|
647
|
+
// Mirror the projector: the initial baseline and empty-rendering deltas carry no text
|
|
648
|
+
// and produce no transcript message.
|
|
649
|
+
const updateText = event.data.text;
|
|
650
|
+
if (updateText === undefined)
|
|
651
|
+
return;
|
|
652
|
+
message.insert(event.data.sessionID, {
|
|
653
|
+
id: messageIDFromEvent(event.id),
|
|
654
|
+
type: "system",
|
|
655
|
+
text: updateText,
|
|
656
|
+
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
|
657
|
+
metadata: event.metadata,
|
|
658
|
+
time: { created: event.created },
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
case "session.synthetic":
|
|
662
|
+
message.insert(event.data.sessionID, {
|
|
663
|
+
id: messageIDFromEvent(event.id),
|
|
664
|
+
type: "synthetic",
|
|
665
|
+
text: event.data.text,
|
|
666
|
+
description: event.data.description,
|
|
667
|
+
metadata: event.data.metadata,
|
|
668
|
+
time: { created: event.created },
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
case "session.shell.started":
|
|
672
|
+
message.insert(event.data.sessionID, {
|
|
673
|
+
id: messageIDFromEvent(event.id),
|
|
674
|
+
type: "shell",
|
|
675
|
+
shellID: event.data.shell.id,
|
|
676
|
+
command: event.data.shell.command,
|
|
677
|
+
status: event.data.shell.status,
|
|
678
|
+
exit: event.data.shell.exit,
|
|
679
|
+
metadata: event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
|
680
|
+
time: { created: event.created },
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
case "session.shell.ended":
|
|
684
|
+
message.update(event.data.sessionID, (draft) => {
|
|
685
|
+
const match = message.shell(draft, event.data.shell.id);
|
|
686
|
+
if (!match)
|
|
687
|
+
return;
|
|
688
|
+
match.status = event.data.shell.status;
|
|
689
|
+
match.exit = event.data.shell.exit;
|
|
690
|
+
match.output = event.data.output;
|
|
691
|
+
match.time.completed = event.created;
|
|
692
|
+
});
|
|
693
|
+
return;
|
|
694
|
+
case "session.step.started":
|
|
695
|
+
message.update(event.data.sessionID, (draft, index) => {
|
|
696
|
+
const position = index.get(event.data.assistantMessageID);
|
|
697
|
+
const existing = position === undefined ? undefined : draft[position];
|
|
698
|
+
if (existing?.type === "assistant") {
|
|
699
|
+
existing.agent = event.data.agent;
|
|
700
|
+
existing.model = event.data.model;
|
|
701
|
+
existing.retry = undefined;
|
|
702
|
+
existing.error = undefined;
|
|
703
|
+
existing.finish = undefined;
|
|
704
|
+
existing.rawFinish = undefined;
|
|
705
|
+
existing.providerState = undefined;
|
|
706
|
+
existing.time.streamed = undefined;
|
|
707
|
+
existing.time.completed = undefined;
|
|
708
|
+
if (event.data.snapshot)
|
|
709
|
+
existing.snapshot = { ...existing.snapshot, start: event.data.snapshot };
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const currentAssistant = message.activeAssistant(draft);
|
|
713
|
+
if (currentAssistant) {
|
|
714
|
+
currentAssistant.retry = undefined;
|
|
715
|
+
currentAssistant.time.completed = event.created;
|
|
716
|
+
}
|
|
717
|
+
message.append(draft, index, {
|
|
718
|
+
id: event.data.assistantMessageID,
|
|
719
|
+
type: "assistant",
|
|
720
|
+
agent: event.data.agent,
|
|
721
|
+
model: event.data.model,
|
|
722
|
+
metadata: event.metadata,
|
|
723
|
+
content: [],
|
|
724
|
+
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
|
725
|
+
time: { created: event.created },
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
return;
|
|
729
|
+
case "session.step.streamed":
|
|
730
|
+
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
|
731
|
+
assistant.time.streamed = event.created;
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
case "session.step.ended": {
|
|
735
|
+
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
|
736
|
+
assistant.time.completed = event.created;
|
|
737
|
+
assistant.finish = event.data.finish;
|
|
738
|
+
assistant.rawFinish = event.data.rawFinish;
|
|
739
|
+
assistant.providerState = event.data.providerState;
|
|
740
|
+
assistant.cost = event.data.cost;
|
|
741
|
+
assistant.tokens = event.data.tokens;
|
|
742
|
+
if (event.data.snapshot)
|
|
743
|
+
assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot };
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
case "session.step.failed":
|
|
748
|
+
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
|
749
|
+
assistant.time.completed = event.created;
|
|
750
|
+
assistant.finish = event.data.finish ?? "error";
|
|
751
|
+
assistant.rawFinish = event.data.rawFinish;
|
|
752
|
+
assistant.providerState = event.data.providerState;
|
|
753
|
+
assistant.error = event.data.error;
|
|
754
|
+
assistant.retry = undefined;
|
|
755
|
+
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
|
756
|
+
assistant.cost = event.data.cost;
|
|
757
|
+
assistant.tokens = event.data.tokens;
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
case "session.text.started":
|
|
762
|
+
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
|
763
|
+
assistant.content.push({ type: "text", text: "" });
|
|
764
|
+
});
|
|
765
|
+
return;
|
|
766
|
+
case "session.text.delta":
|
|
767
|
+
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
|
|
768
|
+
text.text += event.data.delta;
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
case "session.text.ended":
|
|
772
|
+
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
|
|
773
|
+
text.text = event.data.text;
|
|
774
|
+
});
|
|
775
|
+
return;
|
|
776
|
+
case "session.tool.input.started":
|
|
777
|
+
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
|
778
|
+
assistant.content.push({
|
|
779
|
+
type: "tool",
|
|
780
|
+
id: event.data.id,
|
|
781
|
+
name: event.data.name,
|
|
782
|
+
time: { created: event.created },
|
|
783
|
+
state: { status: "streaming", input: "" },
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
return;
|
|
787
|
+
case "session.tool.input.delta":
|
|
788
|
+
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
|
789
|
+
if (tool.state.status === "streaming")
|
|
790
|
+
tool.state.input += event.data.delta;
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
case "session.tool.input.ended":
|
|
794
|
+
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
|
795
|
+
if (tool.state.status === "streaming")
|
|
796
|
+
tool.state.input = event.data.text;
|
|
797
|
+
});
|
|
798
|
+
return;
|
|
799
|
+
case "session.tool.called":
|
|
800
|
+
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
|
801
|
+
tool.time.ran = event.created;
|
|
802
|
+
tool.executed = event.data.executed;
|
|
803
|
+
tool.providerState = event.data.state;
|
|
804
|
+
tool.state = { status: "running", input: event.data.input, metadata: {} };
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
case "session.tool.progress":
|
|
808
|
+
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
|
809
|
+
if (tool.state.status === "running")
|
|
810
|
+
tool.state.metadata = event.data.metadata;
|
|
811
|
+
});
|
|
812
|
+
return;
|
|
813
|
+
case "session.tool.success":
|
|
814
|
+
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
|
815
|
+
if (tool.state.status !== "running")
|
|
816
|
+
return;
|
|
817
|
+
tool.state = {
|
|
818
|
+
status: "completed",
|
|
819
|
+
input: tool.state.input,
|
|
820
|
+
metadata: event.data.metadata,
|
|
821
|
+
content: [...event.data.content],
|
|
822
|
+
};
|
|
823
|
+
tool.executed = event.data.executed || tool.executed === true;
|
|
824
|
+
tool.providerResultState = event.data.resultState;
|
|
825
|
+
tool.time.completed = event.created;
|
|
826
|
+
});
|
|
827
|
+
return;
|
|
828
|
+
case "session.tool.failed":
|
|
829
|
+
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
|
830
|
+
if (tool.state.status !== "streaming" && tool.state.status !== "running")
|
|
831
|
+
return;
|
|
832
|
+
tool.state = {
|
|
833
|
+
status: "error",
|
|
834
|
+
error: event.data.error,
|
|
835
|
+
input: typeof tool.state.input === "string" ? {} : tool.state.input,
|
|
836
|
+
metadata: event.data.metadata,
|
|
837
|
+
content: event.data.content,
|
|
838
|
+
};
|
|
839
|
+
tool.executed = event.data.executed || tool.executed === true;
|
|
840
|
+
tool.providerResultState = event.data.resultState;
|
|
841
|
+
tool.time.completed = event.created;
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
case "session.reasoning.started":
|
|
845
|
+
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
|
846
|
+
assistant.content.push({
|
|
847
|
+
type: "reasoning",
|
|
848
|
+
text: "",
|
|
849
|
+
state: event.data.state,
|
|
850
|
+
time: { created: event.created },
|
|
851
|
+
});
|
|
852
|
+
});
|
|
853
|
+
return;
|
|
854
|
+
case "session.reasoning.delta":
|
|
855
|
+
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
|
|
856
|
+
reasoning.text += event.data.delta;
|
|
857
|
+
});
|
|
858
|
+
return;
|
|
859
|
+
case "session.reasoning.ended":
|
|
860
|
+
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
|
|
861
|
+
reasoning.text = event.data.text;
|
|
862
|
+
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created };
|
|
863
|
+
if (event.data.state !== undefined)
|
|
864
|
+
reasoning.state = event.data.state;
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
case "session.retry.scheduled":
|
|
868
|
+
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
|
869
|
+
assistant.retry = { attempt: event.data.attempt, at: event.data.at, error: event.data.error };
|
|
870
|
+
});
|
|
871
|
+
return;
|
|
872
|
+
case "session.execution.started":
|
|
873
|
+
setSessionActive(event.data.sessionID, "running");
|
|
874
|
+
return;
|
|
875
|
+
case "session.compaction.started":
|
|
876
|
+
if (event.data.inputID)
|
|
877
|
+
removePending(event.data.sessionID, event.data.inputID);
|
|
878
|
+
message.insert(event.data.sessionID, {
|
|
879
|
+
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
|
880
|
+
type: "compaction",
|
|
881
|
+
status: "running",
|
|
882
|
+
reason: event.data.reason,
|
|
883
|
+
summary: "",
|
|
884
|
+
recent: event.data.recent ?? "",
|
|
885
|
+
time: { created: event.created },
|
|
886
|
+
});
|
|
887
|
+
if (event.data.inputID)
|
|
888
|
+
compacting.get(event.data.sessionID)?.observed.add(event.data.inputID);
|
|
889
|
+
return;
|
|
890
|
+
case "session.execution.succeeded":
|
|
891
|
+
case "session.execution.failed":
|
|
892
|
+
case "session.execution.interrupted":
|
|
893
|
+
setSessionActive(event.data.sessionID, "idle");
|
|
894
|
+
message.update(event.data.sessionID, (draft) => {
|
|
895
|
+
const currentAssistant = message.activeAssistant(draft);
|
|
896
|
+
if (currentAssistant)
|
|
897
|
+
currentAssistant.retry = undefined;
|
|
898
|
+
});
|
|
899
|
+
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown")
|
|
900
|
+
return;
|
|
901
|
+
// An event can overtake the first read; queue a revalidation when that read is still active.
|
|
902
|
+
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`))
|
|
903
|
+
return;
|
|
904
|
+
result.session.invalidate(event.data.sessionID);
|
|
905
|
+
refresh(() => result.session.sync(event.data.sessionID));
|
|
906
|
+
return;
|
|
907
|
+
case "session.viewed":
|
|
908
|
+
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`))
|
|
909
|
+
return;
|
|
910
|
+
result.session.invalidate(event.data.sessionID);
|
|
911
|
+
refresh(() => result.session.sync(event.data.sessionID));
|
|
912
|
+
return;
|
|
913
|
+
case "session.revert.staged":
|
|
914
|
+
if (store.session.info[event.data.sessionID])
|
|
915
|
+
setStore("session", "info", event.data.sessionID, "revert", event.data.revert);
|
|
916
|
+
return;
|
|
917
|
+
case "session.revert.cleared":
|
|
918
|
+
if (store.session.info[event.data.sessionID])
|
|
919
|
+
setStore("session", "info", event.data.sessionID, "revert", undefined);
|
|
920
|
+
return;
|
|
921
|
+
case "session.revert.committed":
|
|
922
|
+
if (store.session.info[event.data.sessionID]) {
|
|
923
|
+
setStore("session", "info", event.data.sessionID, "revert", undefined);
|
|
924
|
+
}
|
|
925
|
+
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
|
|
926
|
+
setStore("session", "pending", event.data.sessionID, (store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to));
|
|
927
|
+
message.update(event.data.sessionID, (draft, index) => {
|
|
928
|
+
const position = draft.findIndex((item) => item.id >= event.data.to);
|
|
929
|
+
if (position === -1)
|
|
930
|
+
return;
|
|
931
|
+
for (const item of draft.splice(position))
|
|
932
|
+
index.delete(item.id);
|
|
933
|
+
});
|
|
934
|
+
return;
|
|
935
|
+
case "session.compaction.delta":
|
|
936
|
+
message.update(event.data.sessionID, (draft) => {
|
|
937
|
+
const current = message.compaction(draft);
|
|
938
|
+
if (current?.status === "running")
|
|
939
|
+
current.summary += event.data.text;
|
|
940
|
+
});
|
|
941
|
+
return;
|
|
942
|
+
case "session.compaction.ended":
|
|
943
|
+
message.update(event.data.sessionID, (draft, index) => {
|
|
944
|
+
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running");
|
|
945
|
+
const current = draft[position];
|
|
946
|
+
if (current?.type === "compaction") {
|
|
947
|
+
Object.assign(current, {
|
|
948
|
+
status: "completed",
|
|
949
|
+
reason: event.data.reason,
|
|
950
|
+
model: event.data.model,
|
|
951
|
+
providerState: event.data.providerState,
|
|
952
|
+
providerContext: event.data.providerContext,
|
|
953
|
+
summary: event.data.text,
|
|
954
|
+
recent: event.data.recent,
|
|
955
|
+
cost: event.data.cost,
|
|
956
|
+
tokens: event.data.tokens,
|
|
957
|
+
});
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
message.append(draft, index, {
|
|
961
|
+
id: messageIDFromEvent(event.id),
|
|
962
|
+
type: "compaction",
|
|
963
|
+
status: "completed",
|
|
964
|
+
reason: event.data.reason,
|
|
965
|
+
model: event.data.model,
|
|
966
|
+
providerState: event.data.providerState,
|
|
967
|
+
providerContext: event.data.providerContext,
|
|
968
|
+
summary: event.data.text,
|
|
969
|
+
recent: event.data.recent,
|
|
970
|
+
cost: event.data.cost,
|
|
971
|
+
tokens: event.data.tokens,
|
|
972
|
+
time: { created: event.created },
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
976
|
+
case "session.compaction.failed":
|
|
977
|
+
if (event.data.inputID)
|
|
978
|
+
removePending(event.data.sessionID, event.data.inputID);
|
|
979
|
+
message.update(event.data.sessionID, (draft, index) => {
|
|
980
|
+
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running");
|
|
981
|
+
const current = draft[position];
|
|
982
|
+
const failed = {
|
|
983
|
+
id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id),
|
|
984
|
+
type: "compaction",
|
|
985
|
+
status: "failed",
|
|
986
|
+
reason: event.data.reason ?? "manual",
|
|
987
|
+
error: event.data.error ?? {
|
|
988
|
+
type: "compaction.failed",
|
|
989
|
+
message: "Compaction failed before recording an error",
|
|
990
|
+
},
|
|
991
|
+
metadata: current?.type === "compaction" ? current.metadata : event.metadata,
|
|
992
|
+
cost: event.data.cost,
|
|
993
|
+
tokens: event.data.tokens,
|
|
994
|
+
time: current?.type === "compaction" ? current.time : { created: event.created },
|
|
995
|
+
};
|
|
996
|
+
if (current?.type === "compaction") {
|
|
997
|
+
draft[position] = failed;
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
message.append(draft, index, failed);
|
|
1001
|
+
});
|
|
1002
|
+
if (event.data.inputID)
|
|
1003
|
+
compacting.get(event.data.sessionID)?.observed.add(event.data.inputID);
|
|
1004
|
+
return;
|
|
1005
|
+
case "permission.asked":
|
|
1006
|
+
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id))
|
|
1007
|
+
return;
|
|
1008
|
+
setStore("session", "permission", event.data.sessionID, [
|
|
1009
|
+
...(store.session.permission[event.data.sessionID] ?? []),
|
|
1010
|
+
event.data,
|
|
1011
|
+
]);
|
|
1012
|
+
return;
|
|
1013
|
+
case "permission.replied":
|
|
1014
|
+
removePermission(event.data.sessionID, event.data.requestID);
|
|
1015
|
+
return;
|
|
1016
|
+
case "form.replied":
|
|
1017
|
+
case "form.cancelled":
|
|
1018
|
+
removeForm(event.data.sessionID, event.data.id, event.location);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (event.type === "credential.updated" || event.type === "credential.switched") {
|
|
1022
|
+
Object.keys(store.location).forEach((key) => {
|
|
1023
|
+
const ref = JSON.parse(key);
|
|
1024
|
+
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined };
|
|
1025
|
+
if (event.type === "credential.updated") {
|
|
1026
|
+
result.location.integration.invalidate(location);
|
|
1027
|
+
refresh(() => result.location.integration.sync(location));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
setStore("location", key, (data) => ({
|
|
1031
|
+
integration: data?.integration?.map((integration) => {
|
|
1032
|
+
if (integration.id !== event.data.integrationID)
|
|
1033
|
+
return integration;
|
|
1034
|
+
const active = integration.connections.find((connection) => connection.type === "credential" && connection.id === event.data.credentialID);
|
|
1035
|
+
if (!active)
|
|
1036
|
+
return integration;
|
|
1037
|
+
return {
|
|
1038
|
+
...integration,
|
|
1039
|
+
connections: [active, ...integration.connections.filter((connection) => connection !== active)],
|
|
1040
|
+
};
|
|
1041
|
+
}),
|
|
1042
|
+
}));
|
|
1043
|
+
result.location.model.invalidate(location);
|
|
1044
|
+
result.location.provider.invalidate(location);
|
|
1045
|
+
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]));
|
|
1046
|
+
});
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (!event.location)
|
|
1050
|
+
return;
|
|
1051
|
+
const location = event.location;
|
|
1052
|
+
switch (event.type) {
|
|
1053
|
+
case "catalog.updated":
|
|
1054
|
+
result.location.model.invalidate(location);
|
|
1055
|
+
result.location.provider.invalidate(location);
|
|
1056
|
+
refresh(() => Promise.all([result.location.model.sync(location), result.location.provider.sync(location)]));
|
|
1057
|
+
break;
|
|
1058
|
+
case "agent.updated":
|
|
1059
|
+
result.location.agent.invalidate(location);
|
|
1060
|
+
refresh(() => result.location.agent.sync(location));
|
|
1061
|
+
break;
|
|
1062
|
+
case "command.updated":
|
|
1063
|
+
result.location.command.invalidate(location);
|
|
1064
|
+
refresh(() => result.location.command.sync(location));
|
|
1065
|
+
break;
|
|
1066
|
+
case "skill.updated":
|
|
1067
|
+
result.location.skill.invalidate(location);
|
|
1068
|
+
refresh(() => result.location.skill.sync(location));
|
|
1069
|
+
break;
|
|
1070
|
+
case "vcs.branch.updated":
|
|
1071
|
+
setStore("location", locationKey(location), (data) => ({
|
|
1072
|
+
vcs: {
|
|
1073
|
+
branch: {
|
|
1074
|
+
...data?.vcs?.branch,
|
|
1075
|
+
current: event.data.branch,
|
|
1076
|
+
},
|
|
1077
|
+
},
|
|
1078
|
+
}));
|
|
1079
|
+
break;
|
|
1080
|
+
case "form.created":
|
|
1081
|
+
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id))
|
|
1082
|
+
break;
|
|
1083
|
+
setStore("session", "form", event.data.form.sessionID, [
|
|
1084
|
+
...(store.session.form[event.data.form.sessionID] ?? []),
|
|
1085
|
+
event.data.form.sessionID === "global" ? { ...event.data.form, location } : event.data.form,
|
|
1086
|
+
]);
|
|
1087
|
+
break;
|
|
1088
|
+
case "shell.created":
|
|
1089
|
+
setStore("location", locationKey(location), (data) => ({
|
|
1090
|
+
shell: {
|
|
1091
|
+
...data?.shell,
|
|
1092
|
+
[event.data.info.id]: { ...event.data.info, location },
|
|
1093
|
+
},
|
|
1094
|
+
}));
|
|
1095
|
+
break;
|
|
1096
|
+
case "shell.exited":
|
|
1097
|
+
case "shell.deleted":
|
|
1098
|
+
setStore("location", locationKey(location), (data) => ({
|
|
1099
|
+
shell: Object.fromEntries(Object.entries(data?.shell ?? {}).filter(([id]) => id !== event.data.id)),
|
|
1100
|
+
}));
|
|
1101
|
+
break;
|
|
1102
|
+
case "reference.updated":
|
|
1103
|
+
result.location.reference.invalidate(location);
|
|
1104
|
+
refresh(() => result.location.reference.sync(location));
|
|
1105
|
+
break;
|
|
1106
|
+
case "integration.updated":
|
|
1107
|
+
result.location.integration.invalidate(location);
|
|
1108
|
+
result.location.model.invalidate(location);
|
|
1109
|
+
result.location.provider.invalidate(location);
|
|
1110
|
+
refresh(() => Promise.all([
|
|
1111
|
+
result.location.integration.sync(location),
|
|
1112
|
+
result.location.model.sync(location),
|
|
1113
|
+
result.location.provider.sync(location),
|
|
1114
|
+
]));
|
|
1115
|
+
break;
|
|
1116
|
+
case "config.updated":
|
|
1117
|
+
result.location.config.invalidate(location);
|
|
1118
|
+
if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`))
|
|
1119
|
+
refresh(() => result.location.config.sync(location));
|
|
1120
|
+
refresh(() => result.location.websearch.refresh(location));
|
|
1121
|
+
break;
|
|
1122
|
+
case "websearch.updated":
|
|
1123
|
+
refresh(() => result.location.websearch.refresh(location));
|
|
1124
|
+
break;
|
|
1125
|
+
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
|
1126
|
+
// so the mcp list syncs here rather than off integration.updated. The server emits one event
|
|
1127
|
+
// per MCP server as each settles, so a location booting nine servers emitted nine refetches.
|
|
1128
|
+
case "mcp.status.changed":
|
|
1129
|
+
result.location.mcp.server.invalidate(location);
|
|
1130
|
+
settle(`mcp.status:${locationKey(location)}`, () => result.location.mcp.server.sync(location));
|
|
1131
|
+
break;
|
|
1132
|
+
case "mcp.resources.changed":
|
|
1133
|
+
result.location.mcp.resource.invalidate(location);
|
|
1134
|
+
refresh(() => result.location.mcp.resource.sync(location));
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
|
|
1139
|
+
// effective location, and publishes under the server's canonical location; `alias`
|
|
1140
|
+
// also publishes under the requested key when the two differ.
|
|
1141
|
+
function locationResource(field, load, options) {
|
|
1142
|
+
const publish = (key, value) => setStore("location", key, { [field]: value });
|
|
1143
|
+
return {
|
|
1144
|
+
list: (ref) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
|
|
1145
|
+
sync: (ref) => {
|
|
1146
|
+
const location = ref ?? defaultLocation();
|
|
1147
|
+
const id = locationKey(location);
|
|
1148
|
+
return sync.run(`location.${field}:${id}`, async () => {
|
|
1149
|
+
const response = await load(locationQuery(location));
|
|
1150
|
+
const key = locationKey(response.location);
|
|
1151
|
+
publish(key, response.data);
|
|
1152
|
+
if (options?.alias && key !== id)
|
|
1153
|
+
publish(id, response.data);
|
|
1154
|
+
});
|
|
1155
|
+
},
|
|
1156
|
+
invalidate: (ref) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }));
|
|
1160
|
+
const shells = locationResource("shell", async (location) => {
|
|
1161
|
+
const response = await api().shell.list({ location });
|
|
1162
|
+
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID };
|
|
1163
|
+
return {
|
|
1164
|
+
location: response.location,
|
|
1165
|
+
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
|
|
1166
|
+
};
|
|
1167
|
+
});
|
|
1168
|
+
const result = {
|
|
1169
|
+
on: config.event.on,
|
|
1170
|
+
listen: config.event.listen,
|
|
1171
|
+
session: {
|
|
1172
|
+
list() {
|
|
1173
|
+
return sessions();
|
|
1174
|
+
},
|
|
1175
|
+
get(sessionID) {
|
|
1176
|
+
return store.session.info[sessionID];
|
|
1177
|
+
},
|
|
1178
|
+
creating(sessionID) {
|
|
1179
|
+
return creating.has(sessionID);
|
|
1180
|
+
},
|
|
1181
|
+
remember(info) {
|
|
1182
|
+
batch(() => {
|
|
1183
|
+
setStore("session", "info", info.id, reconcile(info));
|
|
1184
|
+
sync.complete(`session:${info.id}`);
|
|
1185
|
+
registerSession(info.id);
|
|
1186
|
+
});
|
|
1187
|
+
},
|
|
1188
|
+
setStatus(sessionID, status) {
|
|
1189
|
+
setSessionActive(sessionID, status);
|
|
1190
|
+
},
|
|
1191
|
+
root(sessionID) {
|
|
1192
|
+
return resolveRoot(sessionID);
|
|
1193
|
+
},
|
|
1194
|
+
family(sessionID) {
|
|
1195
|
+
return store.session.family[resolveRoot(sessionID)] ?? [];
|
|
1196
|
+
},
|
|
1197
|
+
/** Clear heavy cached data for the root and all known descendants. */
|
|
1198
|
+
evict(sessionID) {
|
|
1199
|
+
const root = resolveRoot(sessionID);
|
|
1200
|
+
batch(() => {
|
|
1201
|
+
for (const id of new Set([root, sessionID, ...(store.session.family[root] ?? [])]))
|
|
1202
|
+
evictSession(id);
|
|
1203
|
+
});
|
|
1204
|
+
},
|
|
1205
|
+
cost(sessionID) {
|
|
1206
|
+
const session = store.session.info[sessionID];
|
|
1207
|
+
if (!session)
|
|
1208
|
+
return 0;
|
|
1209
|
+
if (session.parentID)
|
|
1210
|
+
return session.cost;
|
|
1211
|
+
return (store.session.family[sessionID] ?? [sessionID]).reduce((total, id) => total + (store.session.info[id]?.cost ?? 0), 0);
|
|
1212
|
+
},
|
|
1213
|
+
status(sessionID) {
|
|
1214
|
+
return store.session.active[sessionID] ?? "idle";
|
|
1215
|
+
},
|
|
1216
|
+
// Inputs are the pending user and synthetic items; compactions are control items.
|
|
1217
|
+
input: {
|
|
1218
|
+
list(sessionID) {
|
|
1219
|
+
return (store.session.pending[sessionID] ?? []).flatMap((item) => item.type === "compaction" ? [] : [item.id]);
|
|
1220
|
+
},
|
|
1221
|
+
has(sessionID, inboxID) {
|
|
1222
|
+
return (store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false);
|
|
1223
|
+
},
|
|
1224
|
+
},
|
|
1225
|
+
pending: {
|
|
1226
|
+
list(sessionID) {
|
|
1227
|
+
return store.session.pending[sessionID] ?? [];
|
|
1228
|
+
},
|
|
1229
|
+
sync(sessionID) {
|
|
1230
|
+
return sync.run(`session.pending:${sessionID}`, async () => {
|
|
1231
|
+
const pending = await api().session.inbox.list({ sessionID });
|
|
1232
|
+
// A positive read acknowledges admission even when its SSE echo is delayed.
|
|
1233
|
+
pending.forEach((item) => outbox.delete(item.id));
|
|
1234
|
+
// Compactions also coalesce by Session, not just by the proposed ID.
|
|
1235
|
+
if (pending.some((item) => item.type === "compaction"))
|
|
1236
|
+
store.session.pending[sessionID]
|
|
1237
|
+
?.filter((item) => item.type === "compaction")
|
|
1238
|
+
.forEach((item) => outbox.delete(item.id));
|
|
1239
|
+
// Keep optimistic rows still awaiting their echo: this fetch may
|
|
1240
|
+
// have raced ahead of an in-flight admission the server does not
|
|
1241
|
+
// know about yet.
|
|
1242
|
+
const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id));
|
|
1243
|
+
const merged = inflight.length === 0 ? pending : [...pending, ...inflight];
|
|
1244
|
+
batch(() => {
|
|
1245
|
+
setStore("session", "pending", sessionID, reconcile(merged));
|
|
1246
|
+
merged.forEach(materializeInboxMessage);
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
},
|
|
1250
|
+
invalidate(sessionID) {
|
|
1251
|
+
sync.invalidate(`session.pending:${sessionID}`);
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
// Optimistic session creation: admit a local record under a
|
|
1255
|
+
// client-minted ID so a session view can mount immediately, then create
|
|
1256
|
+
// the session on the server. The session.created echo re-syncs the
|
|
1257
|
+
// record by ID, so the durable payload replaces the client's guess.
|
|
1258
|
+
// Returns the ID synchronously along with the in-flight request:
|
|
1259
|
+
// callers gate session-dependent sends on the request (prompt() gates
|
|
1260
|
+
// itself on any in-flight create of its session automatically).
|
|
1261
|
+
create(input) {
|
|
1262
|
+
const { projectID, ...payload } = input;
|
|
1263
|
+
const id = payload.id ?? SessionID.create();
|
|
1264
|
+
const location = payload.location ?? defaultLocation();
|
|
1265
|
+
const fresh = !store.session.info[id];
|
|
1266
|
+
if (fresh) {
|
|
1267
|
+
const now = Date.now();
|
|
1268
|
+
sessionOutbox.add(id);
|
|
1269
|
+
result.session.remember({
|
|
1270
|
+
id,
|
|
1271
|
+
projectID: projectID ?? store.location[locationKey(location)]?.info?.project.id ?? "",
|
|
1272
|
+
agent: payload.agent,
|
|
1273
|
+
model: payload.model,
|
|
1274
|
+
cost: 0,
|
|
1275
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
1276
|
+
time: { created: now, updated: now },
|
|
1277
|
+
title: payload.title,
|
|
1278
|
+
location,
|
|
1279
|
+
});
|
|
1280
|
+
// A mounted optimistic session must not fetch its empty collections
|
|
1281
|
+
// before creation settles. The session.created echo re-syncs info.
|
|
1282
|
+
sync.complete(`session.family:${id}`);
|
|
1283
|
+
sync.complete(`session.pending:${id}`);
|
|
1284
|
+
sync.complete(`session.message:${id}`);
|
|
1285
|
+
}
|
|
1286
|
+
// Wrapped so even a synchronous client failure reaches the rollback.
|
|
1287
|
+
const request = Promise.resolve()
|
|
1288
|
+
.then(() => api().session.create({ ...payload, id, location }))
|
|
1289
|
+
.then((info) => {
|
|
1290
|
+
sessionOutbox.delete(id);
|
|
1291
|
+
result.session.remember(info);
|
|
1292
|
+
return info;
|
|
1293
|
+
})
|
|
1294
|
+
.catch((error) => {
|
|
1295
|
+
// Roll back only a record this call admitted and neither the echo
|
|
1296
|
+
// nor the response has acknowledged: anything else is server state.
|
|
1297
|
+
if (fresh && sessionOutbox.delete(id))
|
|
1298
|
+
removeSession(id);
|
|
1299
|
+
throw error;
|
|
1300
|
+
});
|
|
1301
|
+
if (fresh)
|
|
1302
|
+
track(creating, id, request);
|
|
1303
|
+
return { id, request };
|
|
1304
|
+
},
|
|
1305
|
+
compact(input) {
|
|
1306
|
+
const active = compacting.get(input.sessionID);
|
|
1307
|
+
if (active)
|
|
1308
|
+
return active.request;
|
|
1309
|
+
// A known pending control ID may be consumed while setup waits. Propose
|
|
1310
|
+
// a fresh ID and let the server coalesce, without duplicating its row.
|
|
1311
|
+
const id = SessionMessage.ID.create();
|
|
1312
|
+
if (!store.session.pending[input.sessionID]?.some((item) => item.type === "compaction")) {
|
|
1313
|
+
outbox.add(id);
|
|
1314
|
+
admitLocal({
|
|
1315
|
+
id,
|
|
1316
|
+
sessionID: input.sessionID,
|
|
1317
|
+
timeCreated: Date.now(),
|
|
1318
|
+
type: "compaction",
|
|
1319
|
+
delivery: "steer",
|
|
1320
|
+
payload: {},
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
// Compaction admission can coalesce onto a different ID. Retire the
|
|
1324
|
+
// speculative row on an echo, and remember consumed IDs until the POST
|
|
1325
|
+
// settles so its older response cannot resurrect a queued row.
|
|
1326
|
+
const observed = new Set();
|
|
1327
|
+
const request = sendAdmission(input.sessionID, async () => {
|
|
1328
|
+
if (input.model)
|
|
1329
|
+
await api().session.switchModel({ sessionID: input.sessionID, model: input.model });
|
|
1330
|
+
return api().session.compact({ sessionID: input.sessionID, id });
|
|
1331
|
+
})
|
|
1332
|
+
.then((item) => {
|
|
1333
|
+
batch(() => {
|
|
1334
|
+
outbox.delete(id);
|
|
1335
|
+
if (item.id !== id)
|
|
1336
|
+
removePending(input.sessionID, id);
|
|
1337
|
+
if (!observed.has(item.id) && !messageIndex.get(input.sessionID)?.has(item.id))
|
|
1338
|
+
admitLocal(item);
|
|
1339
|
+
});
|
|
1340
|
+
return item;
|
|
1341
|
+
})
|
|
1342
|
+
.catch((error) => {
|
|
1343
|
+
if (outbox.delete(id))
|
|
1344
|
+
removePending(input.sessionID, id);
|
|
1345
|
+
throw error;
|
|
1346
|
+
})
|
|
1347
|
+
.finally(() => {
|
|
1348
|
+
if (compacting.get(input.sessionID)?.request === request)
|
|
1349
|
+
compacting.delete(input.sessionID);
|
|
1350
|
+
});
|
|
1351
|
+
compacting.set(input.sessionID, { id, observed, request });
|
|
1352
|
+
return request;
|
|
1353
|
+
},
|
|
1354
|
+
// Optimistic prompt admission: render the prompt immediately under a
|
|
1355
|
+
// client-minted ID, send it, and let the durable inbox.enqueued echo
|
|
1356
|
+
// upsert that same ID with the server's payload. Server admission is
|
|
1357
|
+
// idempotent per ID, so retrying with the identical payload cannot
|
|
1358
|
+
// double-admit.
|
|
1359
|
+
prompt(input) {
|
|
1360
|
+
const { gate, prepare, ...request } = input;
|
|
1361
|
+
const id = request.id ?? SessionMessage.ID.create();
|
|
1362
|
+
// A retry may reuse an ID that is already rendered — and possibly
|
|
1363
|
+
// already durable. Admit optimistically only for new IDs so a failed
|
|
1364
|
+
// retry cannot roll back acknowledged state.
|
|
1365
|
+
const fresh = !messageIndex.get(request.sessionID)?.has(id) &&
|
|
1366
|
+
!store.session.pending[request.sessionID]?.some((item) => item.id === id);
|
|
1367
|
+
if (fresh) {
|
|
1368
|
+
outbox.add(id);
|
|
1369
|
+
admitLocal({
|
|
1370
|
+
id,
|
|
1371
|
+
sessionID: request.sessionID,
|
|
1372
|
+
timeCreated: Date.now(),
|
|
1373
|
+
type: "user",
|
|
1374
|
+
delivery: request.delivery ?? "steer",
|
|
1375
|
+
// Files and skills stay off the optimistic row: their durable
|
|
1376
|
+
// forms are server-loaded (content, mime, resolution), so they
|
|
1377
|
+
// fill in when the echo upserts the row.
|
|
1378
|
+
payload: {
|
|
1379
|
+
text: request.text,
|
|
1380
|
+
agents: request.agents?.map((agent) => ({ ...agent })),
|
|
1381
|
+
metadata: request.metadata,
|
|
1382
|
+
},
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
return sendAdmission(request.sessionID, async () => {
|
|
1386
|
+
await prepare?.();
|
|
1387
|
+
return api().session.prompt({ ...request, id });
|
|
1388
|
+
}, gate).catch((error) => {
|
|
1389
|
+
// Roll back only rows this call admitted and the server has not
|
|
1390
|
+
// acknowledged: anything else is server state.
|
|
1391
|
+
if (fresh && outbox.delete(id))
|
|
1392
|
+
retractLocal(request.sessionID, id);
|
|
1393
|
+
throw error;
|
|
1394
|
+
});
|
|
1395
|
+
},
|
|
1396
|
+
sync(sessionID, options) {
|
|
1397
|
+
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
|
|
1398
|
+
const [info, children] = await Promise.all([
|
|
1399
|
+
api().session.get({ sessionID }),
|
|
1400
|
+
options?.children
|
|
1401
|
+
? api()
|
|
1402
|
+
.session.list({ parentID: sessionID, order: "desc" })
|
|
1403
|
+
.then((response) => response.data)
|
|
1404
|
+
: [],
|
|
1405
|
+
]);
|
|
1406
|
+
const sessions = [info, ...children];
|
|
1407
|
+
batch(() => {
|
|
1408
|
+
setStore("session", "info", produce((draft) => {
|
|
1409
|
+
for (const session of sessions)
|
|
1410
|
+
draft[session.id] = session;
|
|
1411
|
+
}));
|
|
1412
|
+
for (const session of sessions) {
|
|
1413
|
+
sync.complete(`session:${session.id}`);
|
|
1414
|
+
registerSession(session.id);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
},
|
|
1419
|
+
invalidate(sessionID) {
|
|
1420
|
+
sync.invalidate(`session:${sessionID}`);
|
|
1421
|
+
},
|
|
1422
|
+
message: {
|
|
1423
|
+
list(sessionID) {
|
|
1424
|
+
return store.session.message[sessionID] ?? [];
|
|
1425
|
+
},
|
|
1426
|
+
get(sessionID, messageID) {
|
|
1427
|
+
const messages = store.session.message[sessionID];
|
|
1428
|
+
const position = messageIndex.get(sessionID)?.get(messageID);
|
|
1429
|
+
return position === undefined ? undefined : messages?.[position];
|
|
1430
|
+
},
|
|
1431
|
+
sync(sessionID) {
|
|
1432
|
+
return sync.run(`session.message:${sessionID}`, async () => {
|
|
1433
|
+
const response = await api().message.list({
|
|
1434
|
+
sessionID,
|
|
1435
|
+
limit: config.initialMessageLimit?.() ?? messagePageLimit,
|
|
1436
|
+
order: "desc",
|
|
1437
|
+
});
|
|
1438
|
+
const fetched = response.data.toReversed();
|
|
1439
|
+
// Same protection as the pending sync: a re-fetch racing an
|
|
1440
|
+
// admission must not wipe its local transcript row.
|
|
1441
|
+
const ids = new Set(fetched.map((item) => item.id));
|
|
1442
|
+
const admitted = new Set((store.session.pending[sessionID] ?? []).flatMap((item) => item.type === "user" || item.type === "synthetic" ? [item.id] : []));
|
|
1443
|
+
const local = (store.session.message[sessionID] ?? []).filter((item) => !ids.has(item.id) && (outbox.has(item.id) || admitted.has(item.id)));
|
|
1444
|
+
const messages = local.length === 0 ? fetched : [...fetched, ...local];
|
|
1445
|
+
batch(() => {
|
|
1446
|
+
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])));
|
|
1447
|
+
setStore("session", "message", sessionID, reconcile(messages));
|
|
1448
|
+
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined);
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
},
|
|
1452
|
+
more(sessionID) {
|
|
1453
|
+
return store.session.messageCursor[sessionID] !== undefined;
|
|
1454
|
+
},
|
|
1455
|
+
loading(sessionID) {
|
|
1456
|
+
return store.session.messageLoading[sessionID] ?? false;
|
|
1457
|
+
},
|
|
1458
|
+
async loadMore(sessionID, options) {
|
|
1459
|
+
const signal = options?.signal;
|
|
1460
|
+
if (signal?.aborted)
|
|
1461
|
+
return;
|
|
1462
|
+
while (messageLoads.has(sessionID)) {
|
|
1463
|
+
const published = await (() => {
|
|
1464
|
+
const pending = messageLoads.get(sessionID);
|
|
1465
|
+
if (!signal)
|
|
1466
|
+
return pending;
|
|
1467
|
+
const aborted = Promise.withResolvers();
|
|
1468
|
+
const cancel = () => aborted.resolve();
|
|
1469
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
1470
|
+
return Promise.race([pending, aborted.promise])
|
|
1471
|
+
.catch((error) => {
|
|
1472
|
+
if (!signal.aborted)
|
|
1473
|
+
throw error;
|
|
1474
|
+
})
|
|
1475
|
+
.finally(() => signal.removeEventListener("abort", cancel));
|
|
1476
|
+
})();
|
|
1477
|
+
if ((!options?.all && published) || signal?.aborted)
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
const cursor = store.session.messageCursor[sessionID];
|
|
1481
|
+
if (!cursor || signal?.aborted)
|
|
1482
|
+
return;
|
|
1483
|
+
setStore("session", "messageLoading", sessionID, true);
|
|
1484
|
+
const request = (async () => {
|
|
1485
|
+
const fetched = [];
|
|
1486
|
+
let next = cursor;
|
|
1487
|
+
do {
|
|
1488
|
+
const response = await api().message.list({
|
|
1489
|
+
sessionID,
|
|
1490
|
+
limit: options?.all ? 200 : messagePageLimit,
|
|
1491
|
+
cursor: next,
|
|
1492
|
+
}, { signal });
|
|
1493
|
+
if (signal?.aborted)
|
|
1494
|
+
return;
|
|
1495
|
+
fetched.push(...response.data);
|
|
1496
|
+
next = response.cursor.next ?? undefined;
|
|
1497
|
+
if (!options?.all)
|
|
1498
|
+
break;
|
|
1499
|
+
} while (next);
|
|
1500
|
+
// A jump through history publishes once, not once per page of offscreen messages.
|
|
1501
|
+
const existing = store.session.message[sessionID] ?? [];
|
|
1502
|
+
const ids = new Set(existing.map((item) => item.id));
|
|
1503
|
+
const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing];
|
|
1504
|
+
batch(() => {
|
|
1505
|
+
options?.beforePublish?.();
|
|
1506
|
+
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])));
|
|
1507
|
+
setStore("session", "message", sessionID, reconcile(messages));
|
|
1508
|
+
setStore("session", "messageCursor", sessionID, next);
|
|
1509
|
+
});
|
|
1510
|
+
return true;
|
|
1511
|
+
})()
|
|
1512
|
+
.catch((error) => {
|
|
1513
|
+
if (!signal?.aborted)
|
|
1514
|
+
throw error;
|
|
1515
|
+
})
|
|
1516
|
+
.finally(() => setStore("session", "messageLoading", sessionID, false));
|
|
1517
|
+
track(messageLoads, sessionID, request);
|
|
1518
|
+
await request;
|
|
1519
|
+
},
|
|
1520
|
+
invalidate(sessionID) {
|
|
1521
|
+
sync.invalidate(`session.message:${sessionID}`);
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
permission: {
|
|
1525
|
+
list(sessionID) {
|
|
1526
|
+
return store.session.permission[sessionID];
|
|
1527
|
+
},
|
|
1528
|
+
sync(sessionID) {
|
|
1529
|
+
return sync.run(`session.permission:${sessionID}`, async () => {
|
|
1530
|
+
setStore("session", "permission", sessionID, await api().permission.list({ sessionID }));
|
|
1531
|
+
});
|
|
1532
|
+
},
|
|
1533
|
+
invalidate(sessionID) {
|
|
1534
|
+
sync.invalidate(`session.permission:${sessionID}`);
|
|
1535
|
+
},
|
|
1536
|
+
async reply(input) {
|
|
1537
|
+
await api()
|
|
1538
|
+
.permission.reply(input)
|
|
1539
|
+
.catch((error) => {
|
|
1540
|
+
if (!isPermissionNotFoundError(error))
|
|
1541
|
+
throw error;
|
|
1542
|
+
});
|
|
1543
|
+
removePermission(input.sessionID, input.requestID);
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
form: {
|
|
1547
|
+
list(sessionID, ref) {
|
|
1548
|
+
const forms = store.session.form[sessionID];
|
|
1549
|
+
if (sessionID !== "global")
|
|
1550
|
+
return forms;
|
|
1551
|
+
if (!ref)
|
|
1552
|
+
return;
|
|
1553
|
+
const key = locationKey(ref);
|
|
1554
|
+
return forms?.filter((form) => form.location && locationKey(form.location) === key);
|
|
1555
|
+
},
|
|
1556
|
+
sync(sessionID, ref) {
|
|
1557
|
+
const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`;
|
|
1558
|
+
return sync.run(key, async () => {
|
|
1559
|
+
if (sessionID === "global") {
|
|
1560
|
+
const response = await api().form.request.list({
|
|
1561
|
+
location: locationQuery(ref ?? defaultLocation()),
|
|
1562
|
+
});
|
|
1563
|
+
const location = {
|
|
1564
|
+
directory: response.location.directory,
|
|
1565
|
+
workspaceID: response.location.workspaceID,
|
|
1566
|
+
};
|
|
1567
|
+
const locationID = locationKey(location);
|
|
1568
|
+
setStore("session", "form", sessionID, [
|
|
1569
|
+
...(store.session.form[sessionID] ?? []).filter((form) => form.location && locationKey(form.location) !== locationID),
|
|
1570
|
+
...response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })),
|
|
1571
|
+
]);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
setStore("session", "form", sessionID, await api().form.list({ sessionID }));
|
|
1575
|
+
});
|
|
1576
|
+
},
|
|
1577
|
+
invalidate(sessionID, ref) {
|
|
1578
|
+
sync.invalidate(`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`);
|
|
1579
|
+
},
|
|
1580
|
+
reply(input, ref) {
|
|
1581
|
+
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)));
|
|
1582
|
+
},
|
|
1583
|
+
cancel(input, ref) {
|
|
1584
|
+
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)));
|
|
1585
|
+
},
|
|
1586
|
+
},
|
|
1587
|
+
},
|
|
1588
|
+
project: {
|
|
1589
|
+
list() {
|
|
1590
|
+
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated);
|
|
1591
|
+
},
|
|
1592
|
+
get(projectID) {
|
|
1593
|
+
return store.project.info[projectID];
|
|
1594
|
+
},
|
|
1595
|
+
sync() {
|
|
1596
|
+
return sync.run("project", async () => {
|
|
1597
|
+
const projects = await api().project.list();
|
|
1598
|
+
setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project]))));
|
|
1599
|
+
});
|
|
1600
|
+
},
|
|
1601
|
+
invalidate() {
|
|
1602
|
+
sync.invalidate("project");
|
|
1603
|
+
},
|
|
1604
|
+
permission: {
|
|
1605
|
+
list(projectID) {
|
|
1606
|
+
return store.project.permission[projectID];
|
|
1607
|
+
},
|
|
1608
|
+
sync(projectID) {
|
|
1609
|
+
return sync.run(`project.permission:${projectID}`, async () => {
|
|
1610
|
+
setStore("project", "permission", projectID, await api().permission.saved.list({ projectID }));
|
|
1611
|
+
});
|
|
1612
|
+
},
|
|
1613
|
+
invalidate(projectID) {
|
|
1614
|
+
sync.invalidate(`project.permission:${projectID}`);
|
|
1615
|
+
},
|
|
1616
|
+
},
|
|
1617
|
+
},
|
|
1618
|
+
shell: {
|
|
1619
|
+
list(location) {
|
|
1620
|
+
return Object.values(shells.list(location) ?? {});
|
|
1621
|
+
},
|
|
1622
|
+
listBySession(sessionID) {
|
|
1623
|
+
return Object.values(store.location)
|
|
1624
|
+
.flatMap((data) => Object.values(data.shell ?? {}))
|
|
1625
|
+
.filter((shell) => shell.metadata.sessionID === sessionID);
|
|
1626
|
+
},
|
|
1627
|
+
get(id) {
|
|
1628
|
+
return Object.values(store.location)
|
|
1629
|
+
.map((data) => data.shell?.[id])
|
|
1630
|
+
.find((shell) => shell !== undefined);
|
|
1631
|
+
},
|
|
1632
|
+
sync: shells.sync,
|
|
1633
|
+
invalidate: shells.invalidate,
|
|
1634
|
+
},
|
|
1635
|
+
location: {
|
|
1636
|
+
info(ref) {
|
|
1637
|
+
return store.location[locationKey(ref ?? defaultLocation())]?.info;
|
|
1638
|
+
},
|
|
1639
|
+
default() {
|
|
1640
|
+
return defaultLocation();
|
|
1641
|
+
},
|
|
1642
|
+
syncInfo(ref) {
|
|
1643
|
+
const current = ref ?? defaultLocation();
|
|
1644
|
+
return sync.run(`location:${locationKey(current)}`, async () => {
|
|
1645
|
+
const location = await api().location.get({ location: locationQuery(current) });
|
|
1646
|
+
const key = locationKey(location);
|
|
1647
|
+
if (!store.location[key])
|
|
1648
|
+
setStore("location", key, {});
|
|
1649
|
+
setStore("location", key, "info", location);
|
|
1650
|
+
if (!ref) {
|
|
1651
|
+
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID });
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
},
|
|
1655
|
+
async sync(ref) {
|
|
1656
|
+
await result.location.syncInfo(ref);
|
|
1657
|
+
const location = ref ?? defaultLocation();
|
|
1658
|
+
await Promise.all([
|
|
1659
|
+
result.location.vcs.sync(location),
|
|
1660
|
+
result.location.agent.sync(location),
|
|
1661
|
+
result.location.command.sync(location),
|
|
1662
|
+
result.location.integration.sync(location),
|
|
1663
|
+
result.location.mcp.server.sync(location),
|
|
1664
|
+
result.location.mcp.resource.sync(location),
|
|
1665
|
+
result.location.model.sync(location),
|
|
1666
|
+
result.location.provider.sync(location),
|
|
1667
|
+
result.location.reference.sync(location),
|
|
1668
|
+
result.location.skill.sync(location),
|
|
1669
|
+
result.shell.sync(location),
|
|
1670
|
+
result.session.form.sync("global", location),
|
|
1671
|
+
]);
|
|
1672
|
+
},
|
|
1673
|
+
invalidate(ref) {
|
|
1674
|
+
const location = ref ?? defaultLocation();
|
|
1675
|
+
sync.invalidate(`location:${locationKey(location)}`);
|
|
1676
|
+
result.location.vcs.invalidate(location);
|
|
1677
|
+
result.location.agent.invalidate(location);
|
|
1678
|
+
result.location.command.invalidate(location);
|
|
1679
|
+
result.location.config.invalidate(location);
|
|
1680
|
+
result.location.integration.invalidate(location);
|
|
1681
|
+
result.location.mcp.server.invalidate(location);
|
|
1682
|
+
result.location.mcp.resource.invalidate(location);
|
|
1683
|
+
result.location.model.invalidate(location);
|
|
1684
|
+
result.location.provider.invalidate(location);
|
|
1685
|
+
result.location.reference.invalidate(location);
|
|
1686
|
+
result.location.skill.invalidate(location);
|
|
1687
|
+
result.shell.invalidate(location);
|
|
1688
|
+
result.session.form.invalidate("global", location);
|
|
1689
|
+
},
|
|
1690
|
+
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
|
|
1691
|
+
agent: locationResource("agent", (location) => api().agent.list({ location })),
|
|
1692
|
+
command: locationResource("command", (location) => api().command.list({ location })),
|
|
1693
|
+
config: locationResource("config", async (location) => ({
|
|
1694
|
+
location: { directory: location.directory, workspaceID: location.workspace },
|
|
1695
|
+
data: await api().config.get({ location }),
|
|
1696
|
+
})),
|
|
1697
|
+
integration: locationResource("integration", (location) => api().integration.list({ location })),
|
|
1698
|
+
mcp: {
|
|
1699
|
+
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
|
|
1700
|
+
resource: locationResource("mcpResource", async (location) => {
|
|
1701
|
+
const response = await api().mcp.resource.catalog({ location });
|
|
1702
|
+
return { location: response.location, data: response.data.resources };
|
|
1703
|
+
}),
|
|
1704
|
+
},
|
|
1705
|
+
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
|
|
1706
|
+
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
|
|
1707
|
+
reference: locationResource("reference", (location) => api().reference.list({ location })),
|
|
1708
|
+
websearch: {
|
|
1709
|
+
list(location) {
|
|
1710
|
+
return store.location[locationKey(location ?? defaultLocation())]?.websearch;
|
|
1711
|
+
},
|
|
1712
|
+
async refresh(ref) {
|
|
1713
|
+
const input = { location: locationQuery(ref ?? defaultLocation()) };
|
|
1714
|
+
const providers = await api().websearch.providers(input);
|
|
1715
|
+
const key = locationKey(providers.location);
|
|
1716
|
+
setStore("location", key, { websearch: providers.data });
|
|
1717
|
+
},
|
|
1718
|
+
},
|
|
1719
|
+
skill: locationResource("skill", (location) => api().skill.list({ location })),
|
|
1720
|
+
},
|
|
1721
|
+
};
|
|
1722
|
+
createEffect(() => {
|
|
1723
|
+
if (config.connection?.status() === "connected")
|
|
1724
|
+
return;
|
|
1725
|
+
sync.invalidate();
|
|
1726
|
+
});
|
|
1727
|
+
onCleanup(config.event.listen(({ details }) => {
|
|
1728
|
+
handleEvent(details);
|
|
1729
|
+
}));
|
|
1730
|
+
return result;
|
|
1731
|
+
}
|