@shellraining/dsh-side-chat 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +12 -2
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1849 -0
- package/lib/index.js +1038 -0
- package/package.json +58 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
// Generated by `pnpm run build`; edit src/, not this file.
|
|
2
|
+
|
|
3
|
+
// src/shared/protocol.ts
|
|
4
|
+
var PLUGIN_NAME = "dsh-side-chat";
|
|
5
|
+
var API_PREFIX = "/dsh-side-chat/api/";
|
|
6
|
+
var JSON_ROUTES = ["create", "prompt", "cancel", "close", "snapshot"];
|
|
7
|
+
var EVENTS_ROUTE = "events";
|
|
8
|
+
var SESSION_ID_PREFIX = "side-chat-";
|
|
9
|
+
var BOUNDARY_SOURCE_KIND = "plugin:side-chat";
|
|
10
|
+
var BOUNDARY_FIRST_LINE = "Side conversation boundary.";
|
|
11
|
+
var CLIENT_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
12
|
+
"user/message",
|
|
13
|
+
"assistant/message",
|
|
14
|
+
"assistant/attempt",
|
|
15
|
+
"tool/result",
|
|
16
|
+
"request/context",
|
|
17
|
+
"llm/retry-started"
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
// src/host/util.ts
|
|
21
|
+
function isRecord(value) {
|
|
22
|
+
return value !== null && typeof value === "object";
|
|
23
|
+
}
|
|
24
|
+
function asArray(value) {
|
|
25
|
+
return Array.isArray(value) ? value : void 0;
|
|
26
|
+
}
|
|
27
|
+
function errorMessage(error) {
|
|
28
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return text.length > 500 ? `${text.slice(0, 499)}\u2026` : text;
|
|
30
|
+
}
|
|
31
|
+
function clip(text, max) {
|
|
32
|
+
if (typeof text !== "string") return "";
|
|
33
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
34
|
+
}
|
|
35
|
+
function textBlocks(content) {
|
|
36
|
+
const list = asArray(content);
|
|
37
|
+
if (list === void 0) return [];
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const block of list) {
|
|
40
|
+
if (isRecord(block) && block.type === "text" && typeof block.text === "string") out.push(block.text);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function failure(code, message) {
|
|
45
|
+
return message === void 0 ? { ok: false, code } : { ok: false, code, message };
|
|
46
|
+
}
|
|
47
|
+
function success(fields) {
|
|
48
|
+
return { ok: true, ...fields };
|
|
49
|
+
}
|
|
50
|
+
function toDisposer(value) {
|
|
51
|
+
if (typeof value !== "function") return void 0;
|
|
52
|
+
return () => {
|
|
53
|
+
Reflect.apply(value, void 0, []);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/host/http.ts
|
|
58
|
+
var MAX_REQUEST_BODY_BYTES = 1024 * 1024;
|
|
59
|
+
var SSE_HEADERS = Object.freeze({
|
|
60
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
61
|
+
"Cache-Control": "no-cache, no-transform",
|
|
62
|
+
Connection: "keep-alive",
|
|
63
|
+
"X-Accel-Buffering": "no"
|
|
64
|
+
});
|
|
65
|
+
function isAsyncIterable(value) {
|
|
66
|
+
return isRecord(value) && typeof Reflect.get(value, Symbol.asyncIterator) === "function";
|
|
67
|
+
}
|
|
68
|
+
async function readRequestBody(req) {
|
|
69
|
+
if (!isAsyncIterable(req)) return "";
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let size = 0;
|
|
72
|
+
for await (const chunk of req) {
|
|
73
|
+
let bytes;
|
|
74
|
+
if (typeof chunk === "string") bytes = Buffer.from(chunk, "utf8");
|
|
75
|
+
else if (chunk instanceof Uint8Array) bytes = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
76
|
+
else throw new Error("request body chunk is not binary or text");
|
|
77
|
+
size += bytes.byteLength;
|
|
78
|
+
if (size > MAX_REQUEST_BODY_BYTES) throw new Error("request body exceeds the size limit");
|
|
79
|
+
chunks.push(bytes);
|
|
80
|
+
}
|
|
81
|
+
return Buffer.concat(chunks, size).toString("utf8");
|
|
82
|
+
}
|
|
83
|
+
async function readJsonBody(req) {
|
|
84
|
+
const body = await readRequestBody(req);
|
|
85
|
+
return body.trim() === "" ? {} : JSON.parse(body);
|
|
86
|
+
}
|
|
87
|
+
function isJsonObject(value) {
|
|
88
|
+
return isRecord(value) && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
function sendJson(res, value) {
|
|
91
|
+
let body;
|
|
92
|
+
try {
|
|
93
|
+
body = JSON.stringify(value);
|
|
94
|
+
} catch {
|
|
95
|
+
body = JSON.stringify(failure("internal", "response is not JSON-serializable"));
|
|
96
|
+
}
|
|
97
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
98
|
+
res.end(body);
|
|
99
|
+
}
|
|
100
|
+
function trySendJson(res, value, logger) {
|
|
101
|
+
try {
|
|
102
|
+
sendJson(res, value);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger?.warn?.(`[side-chat] response write failed: ${errorMessage(error)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function routeHandler(handler, logger) {
|
|
108
|
+
return async function sideChatRoute(req, res) {
|
|
109
|
+
let args = {};
|
|
110
|
+
try {
|
|
111
|
+
const parsed = await readJsonBody(req);
|
|
112
|
+
if (isJsonObject(parsed)) args = parsed;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
logger?.warn?.(`[side-chat] rejected request: ${errorMessage(error)}`);
|
|
115
|
+
trySendJson(res, failure("internal", `invalid request body: ${errorMessage(error)}`), logger);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = await handler(args);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
logger?.warn?.(`[side-chat] route handler failed: ${errorMessage(error)}`);
|
|
123
|
+
result = failure("internal", errorMessage(error));
|
|
124
|
+
}
|
|
125
|
+
trySendJson(res, result, logger);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function sseFrame(eventName, value) {
|
|
129
|
+
return `event: ${eventName}
|
|
130
|
+
data: ${JSON.stringify(value)}
|
|
131
|
+
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
var SSE_HEARTBEAT = ": ping\n\n";
|
|
135
|
+
|
|
136
|
+
// src/host/service.ts
|
|
137
|
+
import { randomUUID } from "node:crypto";
|
|
138
|
+
import { rm } from "node:fs/promises";
|
|
139
|
+
import { basename, dirname, isAbsolute } from "node:path";
|
|
140
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
141
|
+
|
|
142
|
+
// src/host/entries.ts
|
|
143
|
+
var SNAPSHOT_PAGE_LIMIT = 2e3;
|
|
144
|
+
function isNumber(value) {
|
|
145
|
+
return typeof value === "number";
|
|
146
|
+
}
|
|
147
|
+
function projectEntry(event) {
|
|
148
|
+
if (!isRecord(event)) return void 0;
|
|
149
|
+
const { seq, type, time, surfaceOp } = event;
|
|
150
|
+
if (typeof seq !== "number" || typeof type !== "string" || !CLIENT_EVENT_TYPES.has(type)) return void 0;
|
|
151
|
+
const sourceEventSeqs = asArray(event.sourceEventSeqs);
|
|
152
|
+
return {
|
|
153
|
+
seq,
|
|
154
|
+
type,
|
|
155
|
+
...typeof time === "number" || typeof time === "string" ? { time } : {},
|
|
156
|
+
data: event.data,
|
|
157
|
+
...typeof surfaceOp === "string" ? { surfaceOp } : {},
|
|
158
|
+
...sourceEventSeqs !== void 0 && sourceEventSeqs.every(isNumber) ? { sourceEventSeqs } : {}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function snapshotEntries(events, floor = 0, limit = SNAPSHOT_PAGE_LIMIT) {
|
|
162
|
+
const list = [];
|
|
163
|
+
for (const event of asArray(events) ?? []) {
|
|
164
|
+
const entry = projectEntry(event);
|
|
165
|
+
if (entry !== void 0 && entry.seq >= floor) list.push(entry);
|
|
166
|
+
}
|
|
167
|
+
if (limit > 0 && list.length > limit) return { entries: list.slice(list.length - limit), truncated: true };
|
|
168
|
+
return { entries: list, truncated: false };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/host/live.ts
|
|
172
|
+
var LiveAttemptState = class {
|
|
173
|
+
/** Last accepted frame revision; 0 before the first frame. */
|
|
174
|
+
revision = 0;
|
|
175
|
+
/** The in-flight attempt, or undefined when no attempt is streaming. */
|
|
176
|
+
active = void 0;
|
|
177
|
+
/**
|
|
178
|
+
* Fold one assistant-stream frame into the live state.
|
|
179
|
+
* @param frame - an `agent/assistant-stream` frame (`start`, `chunk`, `end`).
|
|
180
|
+
* @returns true when the frame was accepted; malformed frames are ignored.
|
|
181
|
+
*/
|
|
182
|
+
accept(frame) {
|
|
183
|
+
if (!isRecord(frame)) return false;
|
|
184
|
+
const { type, revision } = frame;
|
|
185
|
+
if (type !== "start" && type !== "chunk" && type !== "end") return false;
|
|
186
|
+
if (typeof revision !== "number" || !Number.isSafeInteger(revision)) return false;
|
|
187
|
+
let startAttemptId = "";
|
|
188
|
+
if (type === "start") {
|
|
189
|
+
const attemptId = frame.attemptId;
|
|
190
|
+
if (typeof attemptId !== "string" || attemptId === "") return false;
|
|
191
|
+
startAttemptId = attemptId;
|
|
192
|
+
if (revision === 1 && this.revision !== 0) {
|
|
193
|
+
this.revision = 0;
|
|
194
|
+
this.active = void 0;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (revision !== this.revision + 1) {
|
|
198
|
+
this.active = void 0;
|
|
199
|
+
this.revision = revision;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
this.revision = revision;
|
|
203
|
+
if (type === "start") {
|
|
204
|
+
this.active = {
|
|
205
|
+
attemptId: startAttemptId,
|
|
206
|
+
turn: frame.turn,
|
|
207
|
+
step: frame.step,
|
|
208
|
+
nextIndex: 0,
|
|
209
|
+
partials: /* @__PURE__ */ new Map(),
|
|
210
|
+
order: []
|
|
211
|
+
};
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
if (type === "chunk") {
|
|
215
|
+
const active = this.active;
|
|
216
|
+
if (active === void 0 || frame.attemptId !== active.attemptId || frame.index !== active.nextIndex) {
|
|
217
|
+
this.active = void 0;
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
pushStreamChunk(active.partials, active.order, frame.chunk);
|
|
221
|
+
active.nextIndex += 1;
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
this.active = void 0;
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Drop the active attempt once its message committed. The loop commits the
|
|
229
|
+
* complete `assistant/message` / `assistant/attempt` before emitting the
|
|
230
|
+
* matching `end` frame, so the committed event is the first settlement signal.
|
|
231
|
+
* @param turn - durable turn of the committed event.
|
|
232
|
+
* @param step - durable step of the committed event.
|
|
233
|
+
* @returns whether the active attempt was settled.
|
|
234
|
+
*/
|
|
235
|
+
settle(turn, step) {
|
|
236
|
+
const active = this.active;
|
|
237
|
+
if (active === void 0 || active.turn !== turn || active.step !== step) return false;
|
|
238
|
+
this.active = void 0;
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
/** Snapshot the live state, both for `live` frames and the snapshot route. */
|
|
242
|
+
snapshot() {
|
|
243
|
+
const active = this.active;
|
|
244
|
+
if (active === void 0) return { revision: this.revision, activeAttempt: null };
|
|
245
|
+
const blocks = [];
|
|
246
|
+
for (const index of active.order) {
|
|
247
|
+
const partial = active.partials.get(index);
|
|
248
|
+
if (partial === void 0) continue;
|
|
249
|
+
const block = renderStreamBlock(partial);
|
|
250
|
+
if (block !== void 0) blocks.push(block);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
revision: this.revision,
|
|
254
|
+
activeAttempt: {
|
|
255
|
+
attemptId: active.attemptId,
|
|
256
|
+
turn: active.turn,
|
|
257
|
+
step: active.step,
|
|
258
|
+
nextIndex: active.nextIndex,
|
|
259
|
+
blocks
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
function pushStreamChunk(partials, order, chunk) {
|
|
265
|
+
if (!isRecord(chunk) || typeof chunk.type !== "string") return;
|
|
266
|
+
const index = chunk.index;
|
|
267
|
+
if (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0) return;
|
|
268
|
+
const create = (blockType) => {
|
|
269
|
+
const partial = {
|
|
270
|
+
blockType,
|
|
271
|
+
text: "",
|
|
272
|
+
toolCallId: void 0,
|
|
273
|
+
toolCallName: void 0,
|
|
274
|
+
toolCallArgs: "",
|
|
275
|
+
finalized: false
|
|
276
|
+
};
|
|
277
|
+
partials.set(index, partial);
|
|
278
|
+
order.push(index);
|
|
279
|
+
return partial;
|
|
280
|
+
};
|
|
281
|
+
const ensure = (blockType) => partials.get(index) ?? create(blockType);
|
|
282
|
+
switch (chunk.type) {
|
|
283
|
+
case "block-start":
|
|
284
|
+
if (!partials.has(index)) create(typeof chunk.blockType === "string" ? chunk.blockType : void 0);
|
|
285
|
+
return;
|
|
286
|
+
case "text-delta":
|
|
287
|
+
case "reasoning-delta": {
|
|
288
|
+
const partial = ensure(chunk.type === "text-delta" ? "text" : "reasoning");
|
|
289
|
+
if (partial.finalized) return;
|
|
290
|
+
if (typeof chunk.text === "string") partial.text += chunk.text;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
case "tool-call-delta": {
|
|
294
|
+
const partial = ensure("tool-call");
|
|
295
|
+
if (partial.finalized) return;
|
|
296
|
+
if (typeof chunk.id === "string") partial.toolCallId = chunk.id;
|
|
297
|
+
if (typeof chunk.name === "string" && chunk.name !== "") partial.toolCallName = chunk.name;
|
|
298
|
+
if (typeof chunk.argumentsDelta === "string") partial.toolCallArgs += chunk.argumentsDelta;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
case "block-end": {
|
|
302
|
+
const block = chunk.block;
|
|
303
|
+
const blockType = isRecord(block) ? block.type : void 0;
|
|
304
|
+
if (typeof blockType !== "string") return;
|
|
305
|
+
ensure(blockType).finalized = true;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function renderStreamBlock(partial) {
|
|
313
|
+
switch (partial.blockType) {
|
|
314
|
+
case "text":
|
|
315
|
+
return partial.text === "" ? void 0 : { kind: "text", text: partial.text };
|
|
316
|
+
case "reasoning":
|
|
317
|
+
return partial.text === "" ? void 0 : { kind: "reasoning", text: partial.text };
|
|
318
|
+
case "tool-call":
|
|
319
|
+
return {
|
|
320
|
+
kind: "tool-call",
|
|
321
|
+
callId: partial.toolCallId ?? "",
|
|
322
|
+
name: partial.toolCallName ?? "",
|
|
323
|
+
argsRaw: partial.toolCallArgs
|
|
324
|
+
};
|
|
325
|
+
default:
|
|
326
|
+
return void 0;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/host/seed.ts
|
|
331
|
+
var RECENT_CONTEXT_MAX_CHARS = 6e3;
|
|
332
|
+
function completedTurnPrefix(events) {
|
|
333
|
+
const list = asArray(events);
|
|
334
|
+
if (list === void 0) return [];
|
|
335
|
+
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
336
|
+
const event = list[index];
|
|
337
|
+
if (isRecord(event) && event.type === "turn/end") return list.slice(0, index + 1);
|
|
338
|
+
}
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
function isSystemNoise(text) {
|
|
342
|
+
if (typeof text !== "string") return false;
|
|
343
|
+
return text.includes("<system-reminder>") || text.includes("<system_reminder>") || text.trimStart().startsWith("Current runtime context");
|
|
344
|
+
}
|
|
345
|
+
function contextLine(event) {
|
|
346
|
+
const data = isRecord(event.data) ? event.data : void 0;
|
|
347
|
+
switch (event.type) {
|
|
348
|
+
case "user/message": {
|
|
349
|
+
const source = isRecord(data?.source) ? data.source : void 0;
|
|
350
|
+
if (source?.kind !== "user") return void 0;
|
|
351
|
+
const text = textBlocks(data?.content).filter((block) => !isSystemNoise(block)).join(" ").trim();
|
|
352
|
+
return text === "" ? void 0 : `User: ${clip(text, 500)}`;
|
|
353
|
+
}
|
|
354
|
+
case "assistant/message": {
|
|
355
|
+
const message = isRecord(data?.message) ? data.message : void 0;
|
|
356
|
+
const text = textBlocks(message?.content).join(" ").trim();
|
|
357
|
+
return text === "" ? void 0 : `Assistant: ${clip(text, 300)}`;
|
|
358
|
+
}
|
|
359
|
+
case "tool/call": {
|
|
360
|
+
const toolName = data?.name;
|
|
361
|
+
return typeof toolName === "string" && toolName !== "" ? `Tool call: ${toolName}` : void 0;
|
|
362
|
+
}
|
|
363
|
+
case "tool/result": {
|
|
364
|
+
const message = isRecord(data?.message) ? data.message : void 0;
|
|
365
|
+
return message?.isError === true ? "Tool result: failed" : void 0;
|
|
366
|
+
}
|
|
367
|
+
default:
|
|
368
|
+
return void 0;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function recentMainContext(events, fromSeq = 0, maxChars = RECENT_CONTEXT_MAX_CHARS) {
|
|
372
|
+
const list = asArray(events);
|
|
373
|
+
if (list === void 0) return "";
|
|
374
|
+
const parts = [];
|
|
375
|
+
for (const event of list) {
|
|
376
|
+
if (!isRecord(event)) continue;
|
|
377
|
+
if (typeof event.seq !== "number" || event.seq < fromSeq) continue;
|
|
378
|
+
const line = contextLine(event);
|
|
379
|
+
if (line !== void 0) parts.push(line);
|
|
380
|
+
}
|
|
381
|
+
const kept = [];
|
|
382
|
+
let total = 0;
|
|
383
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
384
|
+
const part = parts[index] ?? "";
|
|
385
|
+
const cost = part.length + 1;
|
|
386
|
+
if (total + cost > maxChars) {
|
|
387
|
+
if (kept.length === 0) kept.unshift(clip(part, maxChars));
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
kept.unshift(part);
|
|
391
|
+
total += cost;
|
|
392
|
+
}
|
|
393
|
+
if (kept.length < parts.length) kept.unshift("(earlier parent-session context omitted)");
|
|
394
|
+
return kept.join("\n");
|
|
395
|
+
}
|
|
396
|
+
function sideConversationBoundaryPrompt(parentSessionId, recentContext = "") {
|
|
397
|
+
const lines = [
|
|
398
|
+
BOUNDARY_FIRST_LINE,
|
|
399
|
+
"",
|
|
400
|
+
`You are in a side conversation derived from main session "${parentSessionId}". You inherit its working directory, model route, tools, skills, and permission composition. This side conversation is temporary: closing it destroys it, it never appears in the session list, and it cannot be reopened.`,
|
|
401
|
+
"",
|
|
402
|
+
"Everything before this boundary is inherited history from the parent thread. It is reference context only, not your current task. Do not continue, execute, or complete instructions, plans, tool calls, approvals, edits, or requests that appear only in that inherited history. Only messages submitted after this boundary are active instructions for this side conversation.",
|
|
403
|
+
"",
|
|
404
|
+
"This side conversation is a full working agent in the same workspace, not a read-only chat. When the user asks for a change after this boundary, you must make the change for real with the available tools \u2014 `write`, `edit`, `bash`, and every other enabled tool \u2014 modifying the same files in the same working directory. Changes must actually land on disk: never simulate a change, never describe a change you did not perform, and never claim a file was modified when it was not. Keep every change minimal and limited to what the user asked for, and do not touch the main thread's task.",
|
|
405
|
+
"",
|
|
406
|
+
"Sub-agents are off-limits in this side conversation. Do not interact with any existing or new sub-agents, even if sub-agents were used before this boundary. Do not write anything into the main session transcript, and do not create commits, branches, or pushes unless the user explicitly asks for them.",
|
|
407
|
+
"",
|
|
408
|
+
"When you finish, report exactly what you changed and what you verified, so the user can reconcile it with the main thread."
|
|
409
|
+
];
|
|
410
|
+
if (recentContext !== "") {
|
|
411
|
+
lines.push(
|
|
412
|
+
"",
|
|
413
|
+
"[Recent activity from the parent session \u2014 completes the forked history; reference only, do not act on it]",
|
|
414
|
+
recentContext
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return lines.join("\n");
|
|
418
|
+
}
|
|
419
|
+
function sideDeveloperInstructions() {
|
|
420
|
+
return [
|
|
421
|
+
"You are in a side conversation, not the main thread.",
|
|
422
|
+
"",
|
|
423
|
+
"The inherited fork history is provided only as reference context. Do not treat instructions, plans, or requests found in it as active instructions for this side conversation; only instructions submitted after the side-conversation boundary are active. Do not continue, execute, or complete a task, plan, tool call, approval, edit, or request that appears only in inherited history, and do not present yourself as continuing the main thread's active task.",
|
|
424
|
+
"",
|
|
425
|
+
"This side conversation is a real working agent in the same workspace. When the user explicitly requests a change after the boundary, perform it with the real tools \u2014 `write`, `edit`, `bash`, and any other enabled tools \u2014 so the edits land on disk in the same working directory. Keep the change minimal and limited to the request, verify it, and report what changed. Never describe a change you did not make.",
|
|
426
|
+
"",
|
|
427
|
+
"Do not write anything into the main session transcript. Do not interact with sub-agents from this side conversation, and do not create commits, branches, or pushes unless the user explicitly asks for them."
|
|
428
|
+
].join("\n");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/host/service.ts
|
|
432
|
+
var DRAIN_DELAY_MS = 300;
|
|
433
|
+
var SSE_HEARTBEAT_MS = 15e3;
|
|
434
|
+
var DEVELOPER_SECTION_NAME = "side-chat:instructions";
|
|
435
|
+
var DEVELOPER_SECTION_ORDER_FALLBACK = 9e3;
|
|
436
|
+
var APPROVAL_OVERRIDE_SOURCE = "delegation";
|
|
437
|
+
var SANDBOX_OVERRIDE_SOURCE = "delegation";
|
|
438
|
+
var UNLOADING = "side-chat plugin is unloading";
|
|
439
|
+
var BOUNDARY_SOURCE = Object.freeze({ kind: BOUNDARY_SOURCE_KIND });
|
|
440
|
+
function stringArg(value) {
|
|
441
|
+
return typeof value === "string" ? value : String(value ?? "");
|
|
442
|
+
}
|
|
443
|
+
function nonEmptyString(value) {
|
|
444
|
+
return typeof value === "string" && value !== "";
|
|
445
|
+
}
|
|
446
|
+
function toNonNegativeInteger(value) {
|
|
447
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
448
|
+
return Number.isSafeInteger(number) && number >= 0 ? number : void 0;
|
|
449
|
+
}
|
|
450
|
+
function artifactDirectory(header, location) {
|
|
451
|
+
const path = isRecord(location) ? location.path : void 0;
|
|
452
|
+
const id = header?.id;
|
|
453
|
+
if (typeof path !== "string" || !isAbsolute(path)) return void 0;
|
|
454
|
+
if (typeof id !== "string" || !id.startsWith(SESSION_ID_PREFIX)) return void 0;
|
|
455
|
+
const dir = dirname(path);
|
|
456
|
+
return basename(dir) === id ? dir : void 0;
|
|
457
|
+
}
|
|
458
|
+
function requestContextWindow(agent) {
|
|
459
|
+
const session = agent.session;
|
|
460
|
+
if (session === void 0 || typeof session.requestContext !== "function") return void 0;
|
|
461
|
+
let context;
|
|
462
|
+
try {
|
|
463
|
+
context = session.requestContext();
|
|
464
|
+
} catch {
|
|
465
|
+
return void 0;
|
|
466
|
+
}
|
|
467
|
+
const value = isRecord(context) ? context.contextWindow : void 0;
|
|
468
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
|
|
469
|
+
}
|
|
470
|
+
function developerSectionOrder(systemPrompt) {
|
|
471
|
+
const suffixOrder = systemPrompt.getSectionOrder?.("DEPLOYMENT_PERSONA_SUFFIX");
|
|
472
|
+
return typeof suffixOrder === "number" && Number.isFinite(suffixOrder) ? suffixOrder - 10 : DEVELOPER_SECTION_ORDER_FALLBACK;
|
|
473
|
+
}
|
|
474
|
+
function routeOptions(parent) {
|
|
475
|
+
const session = parent.session;
|
|
476
|
+
const requestHeader = typeof session?.requestHeader === "function" ? session.requestHeader() : void 0;
|
|
477
|
+
const config = isRecord(requestHeader) ? requestHeader.config : void 0;
|
|
478
|
+
const route = isRecord(config) && nonEmptyString(config.provider) && nonEmptyString(config.model) ? config : isRecord(parent.options) ? parent.options : {};
|
|
479
|
+
const { provider, model, reasoningEffort, maxTokens } = route;
|
|
480
|
+
return {
|
|
481
|
+
...typeof provider === "string" ? { provider } : {},
|
|
482
|
+
...typeof model === "string" ? { model } : {},
|
|
483
|
+
...typeof reasoningEffort === "string" ? { reasoningEffort } : {},
|
|
484
|
+
...typeof maxTokens === "number" ? { maxTokens } : {}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
var SideChatService = class {
|
|
488
|
+
ctx;
|
|
489
|
+
entries = /* @__PURE__ */ new Map();
|
|
490
|
+
/** identity -> sessionId (idempotent create and attach). */
|
|
491
|
+
byIdentity = /* @__PURE__ */ new Map();
|
|
492
|
+
/** identity -> in-flight create result (deduplicates concurrent creates). */
|
|
493
|
+
pendingCreates = /* @__PURE__ */ new Map();
|
|
494
|
+
/** sessionId -> last contained agent failure, for the panel error banner. */
|
|
495
|
+
lastErrors = /* @__PURE__ */ new Map();
|
|
496
|
+
/** sessionId -> live attempt fold (fed even with no stream client connected). */
|
|
497
|
+
liveStates = /* @__PURE__ */ new Map();
|
|
498
|
+
/** sessionId -> open SSE streams. */
|
|
499
|
+
streams = /* @__PURE__ */ new Map();
|
|
500
|
+
/** Disposers of the Cordis event listeners, called at plugin unload. */
|
|
501
|
+
listenerDisposers = [];
|
|
502
|
+
/** Set at plugin unload so a creation still in flight cannot leak its agent. */
|
|
503
|
+
closing = false;
|
|
504
|
+
constructor(ctx) {
|
|
505
|
+
this.ctx = ctx;
|
|
506
|
+
this.listen("agent/error", (payload) => this.onAgentError(payload));
|
|
507
|
+
this.listen("agent/status", (payload) => this.onAgentStatus(payload));
|
|
508
|
+
this.listen("agent/assistant-stream", (payload) => this.onAssistantStream(payload));
|
|
509
|
+
this.listen("session/event", (session, event) => this.onSessionEvent(session, event));
|
|
510
|
+
}
|
|
511
|
+
listen(eventName, listener) {
|
|
512
|
+
const dispose = toDisposer(this.ctx.on?.(eventName, listener));
|
|
513
|
+
if (dispose !== void 0) this.listenerDisposers.push(dispose);
|
|
514
|
+
}
|
|
515
|
+
warn(message) {
|
|
516
|
+
this.ctx.logger?.warn?.(`[side-chat] ${message}`);
|
|
517
|
+
}
|
|
518
|
+
entryOf(sessionId) {
|
|
519
|
+
return sessionId === "" ? void 0 : this.entries.get(sessionId);
|
|
520
|
+
}
|
|
521
|
+
/** The id of an owned side agent named by an `agent/*` event payload. */
|
|
522
|
+
ownedAgentId(payload) {
|
|
523
|
+
const agent = isRecord(payload) ? payload.agent : void 0;
|
|
524
|
+
const id = isRecord(agent) ? agent.id : void 0;
|
|
525
|
+
return typeof id === "string" && this.entries.has(id) ? id : void 0;
|
|
526
|
+
}
|
|
527
|
+
/** Record one contained side-session failure and push it to open streams. */
|
|
528
|
+
onAgentError(payload) {
|
|
529
|
+
const id = this.ownedAgentId(payload);
|
|
530
|
+
if (id === void 0) return;
|
|
531
|
+
const error = isRecord(payload) ? payload.error : void 0;
|
|
532
|
+
const message = error instanceof Error ? error.message : String((isRecord(error) ? error.message : void 0) ?? error);
|
|
533
|
+
this.lastErrors.set(id, message);
|
|
534
|
+
this.broadcast(id, "error", { message });
|
|
535
|
+
}
|
|
536
|
+
/** Push one agent status transition to open streams of an owned session. */
|
|
537
|
+
onAgentStatus(payload) {
|
|
538
|
+
const id = this.ownedAgentId(payload);
|
|
539
|
+
if (id === void 0) return;
|
|
540
|
+
const status = isRecord(payload) ? payload.status : void 0;
|
|
541
|
+
this.broadcast(id, "status", { status: stringArg(status), running: status === "running" });
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Fold one attempt frame into the session's live state. State is kept even
|
|
545
|
+
* without a stream client because the snapshot route serves it as fallback.
|
|
546
|
+
*/
|
|
547
|
+
onAssistantStream(payload) {
|
|
548
|
+
const id = this.ownedAgentId(payload);
|
|
549
|
+
if (id === void 0) return;
|
|
550
|
+
let state = this.liveStates.get(id);
|
|
551
|
+
if (state === void 0) {
|
|
552
|
+
state = new LiveAttemptState();
|
|
553
|
+
this.liveStates.set(id, state);
|
|
554
|
+
}
|
|
555
|
+
if (state.accept(isRecord(payload) ? payload.frame : void 0)) this.broadcastLive(id);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Observe committed session events: settle the active live attempt when its
|
|
559
|
+
* message commits (before the matching `end` frame arrives) and forward every
|
|
560
|
+
* new committed event to open streams in sequence order.
|
|
561
|
+
*/
|
|
562
|
+
onSessionEvent(session, event) {
|
|
563
|
+
const record = isRecord(session) ? session : void 0;
|
|
564
|
+
const header = isRecord(record?.header) ? record.header : void 0;
|
|
565
|
+
const id = typeof record?.id === "string" ? record.id : header?.id;
|
|
566
|
+
if (typeof id !== "string" || !this.entries.has(id)) return;
|
|
567
|
+
if (!isRecord(event)) return;
|
|
568
|
+
const state = this.liveStates.get(id);
|
|
569
|
+
if (state !== void 0 && (event.type === "assistant/message" || event.type === "assistant/attempt")) {
|
|
570
|
+
const data = isRecord(event.data) ? event.data : void 0;
|
|
571
|
+
if (state.settle(data?.turn, data?.step)) this.broadcastLive(id);
|
|
572
|
+
}
|
|
573
|
+
this.broadcastEvent(id, event);
|
|
574
|
+
}
|
|
575
|
+
/** Push an authoritative replacement of one session's live state to streams. */
|
|
576
|
+
broadcastLive(sessionId) {
|
|
577
|
+
const streams = this.streams.get(sessionId);
|
|
578
|
+
if (streams === void 0 || streams.size === 0) return;
|
|
579
|
+
this.broadcast(sessionId, "live", { live: this.liveOf(sessionId) });
|
|
580
|
+
}
|
|
581
|
+
/** Write one SSE frame to every open stream of one side session. */
|
|
582
|
+
broadcast(sessionId, eventName, value) {
|
|
583
|
+
const streams = this.streams.get(sessionId);
|
|
584
|
+
if (streams === void 0 || streams.size === 0) return;
|
|
585
|
+
let frame;
|
|
586
|
+
try {
|
|
587
|
+
frame = sseFrame(eventName, value);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
this.warn(`${eventName} frame not serializable: ${errorMessage(error)}`);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
for (const stream of [...streams]) this.writeStream(stream, frame);
|
|
593
|
+
}
|
|
594
|
+
/** Write one committed session event to streams that have not seen its seq. */
|
|
595
|
+
broadcastEvent(sessionId, event) {
|
|
596
|
+
const streams = this.streams.get(sessionId);
|
|
597
|
+
if (streams === void 0 || streams.size === 0) return;
|
|
598
|
+
const entry = projectEntry(event);
|
|
599
|
+
if (entry === void 0) return;
|
|
600
|
+
let frame;
|
|
601
|
+
try {
|
|
602
|
+
frame = sseFrame("event", { entry });
|
|
603
|
+
} catch (error) {
|
|
604
|
+
this.warn(`event frame not serializable: ${errorMessage(error)}`);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
for (const stream of [...streams]) {
|
|
608
|
+
if (entry.seq < stream.floor || entry.seq <= stream.cursor) continue;
|
|
609
|
+
if (this.writeStream(stream, frame)) stream.cursor = entry.seq;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/** Write raw SSE text to one stream; a failed or ended carrier ends the stream. */
|
|
613
|
+
writeStream(stream, text) {
|
|
614
|
+
if (stream.closed) return false;
|
|
615
|
+
if (stream.res.writableEnded || stream.res.destroyed) {
|
|
616
|
+
this.endStream(stream);
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
stream.res.write(text);
|
|
621
|
+
return true;
|
|
622
|
+
} catch (error) {
|
|
623
|
+
this.warn(`event stream write failed: ${errorMessage(error)}`);
|
|
624
|
+
this.endStream(stream);
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** Tear down one stream: clear its heartbeat, detach listeners, end the response. */
|
|
629
|
+
endStream(stream) {
|
|
630
|
+
if (stream.closed) return;
|
|
631
|
+
stream.closed = true;
|
|
632
|
+
if (stream.heartbeat !== void 0) {
|
|
633
|
+
clearInterval(stream.heartbeat);
|
|
634
|
+
stream.heartbeat = void 0;
|
|
635
|
+
}
|
|
636
|
+
const streams = this.streams.get(stream.sessionId);
|
|
637
|
+
if (streams !== void 0) {
|
|
638
|
+
streams.delete(stream);
|
|
639
|
+
if (streams.size === 0) this.streams.delete(stream.sessionId);
|
|
640
|
+
}
|
|
641
|
+
const detach = stream.detach;
|
|
642
|
+
stream.detach = void 0;
|
|
643
|
+
try {
|
|
644
|
+
detach?.();
|
|
645
|
+
} catch {
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
if (!stream.res.writableEnded) stream.res.end();
|
|
649
|
+
} catch {
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** End every open stream of one side session (close / unload). */
|
|
653
|
+
endStreamsFor(sessionId) {
|
|
654
|
+
const streams = this.streams.get(sessionId);
|
|
655
|
+
if (streams === void 0) return;
|
|
656
|
+
for (const stream of [...streams]) this.endStream(stream);
|
|
657
|
+
this.streams.delete(sessionId);
|
|
658
|
+
}
|
|
659
|
+
/** Current live state of one side session for `live` frames and snapshots. */
|
|
660
|
+
liveOf(sessionId) {
|
|
661
|
+
return this.liveStates.get(sessionId)?.snapshot() ?? null;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Create (or re-attach to) the side session of one identity. The identity is
|
|
665
|
+
* the opaque client `instanceKey` when one was sent (one side session per
|
|
666
|
+
* client tab instance), otherwise `source:<sourceId>` (one per parent). With
|
|
667
|
+
* `attachOnly`, an existing or in-flight session is returned and a missing
|
|
668
|
+
* one is `not-found`: no agent is ever created, and the parent need not be live.
|
|
669
|
+
*/
|
|
670
|
+
async create(args) {
|
|
671
|
+
if (this.closing) return failure("internal", UNLOADING);
|
|
672
|
+
const sourceId = stringArg(args.sourceId);
|
|
673
|
+
if (sourceId === "") return failure("unknown-source", "missing sourceId");
|
|
674
|
+
const attachOnly = args.attachOnly === true;
|
|
675
|
+
let parent;
|
|
676
|
+
if (!attachOnly) {
|
|
677
|
+
parent = this.ctx.agents.get(sourceId);
|
|
678
|
+
if (parent === void 0) return failure("unknown-source", `unknown source session "${sourceId}"`);
|
|
679
|
+
}
|
|
680
|
+
const instanceKey = args.instanceKey;
|
|
681
|
+
const identity = nonEmptyString(instanceKey) ? instanceKey : `source:${sourceId}`;
|
|
682
|
+
const existingId = this.byIdentity.get(identity);
|
|
683
|
+
if (existingId !== void 0) {
|
|
684
|
+
const existing = this.entries.get(existingId);
|
|
685
|
+
if (existing !== void 0) {
|
|
686
|
+
return success({ sessionId: existingId, inheritedEventCount: existing.inheritedEventCount });
|
|
687
|
+
}
|
|
688
|
+
this.byIdentity.delete(identity);
|
|
689
|
+
}
|
|
690
|
+
const inflight = this.pendingCreates.get(identity);
|
|
691
|
+
if (inflight !== void 0) return await inflight;
|
|
692
|
+
if (parent === void 0) return failure("not-found");
|
|
693
|
+
const started = this.createSession(sourceId, parent, identity);
|
|
694
|
+
this.pendingCreates.set(identity, started);
|
|
695
|
+
try {
|
|
696
|
+
return await started;
|
|
697
|
+
} finally {
|
|
698
|
+
if (this.pendingCreates.get(identity) === started) this.pendingCreates.delete(identity);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async createSession(sourceId, parent, identity) {
|
|
702
|
+
const ctx = this.ctx;
|
|
703
|
+
const parentSession = parent.session;
|
|
704
|
+
try {
|
|
705
|
+
const events = typeof parentSession?.snapshotEvents === "function" ? parentSession.snapshotEvents() : [];
|
|
706
|
+
const seed = completedTurnPrefix(events);
|
|
707
|
+
const inheritedEventCount = seed.length;
|
|
708
|
+
const recentContext = recentMainContext(events, inheritedEventCount);
|
|
709
|
+
const header = parentSession?.header ?? {};
|
|
710
|
+
const agentPreset = ctx.get("agentPresets")?.composedPreset?.(parent.ctx);
|
|
711
|
+
const sandboxMode = ctx.get("sandboxPolicy")?.overrideOf?.(parentSession);
|
|
712
|
+
const approvalPolicy = ctx.get("approval")?.overrideOf?.(parentSession);
|
|
713
|
+
const sessionId = `${SESSION_ID_PREFIX}${randomUUID()}`;
|
|
714
|
+
const boundaryText = sideConversationBoundaryPrompt(String(header.id ?? sourceId), recentContext);
|
|
715
|
+
const meta = {
|
|
716
|
+
...typeof header.cwd === "string" ? { cwd: header.cwd } : {},
|
|
717
|
+
...nonEmptyString(agentPreset) ? { agentPreset } : {},
|
|
718
|
+
...inheritedEventCount > 0 ? { isSeeded: true } : {},
|
|
719
|
+
origin: "subagent"
|
|
720
|
+
};
|
|
721
|
+
const boundary = {
|
|
722
|
+
id: `${SESSION_ID_PREFIX}boundary-${randomUUID()}`,
|
|
723
|
+
role: "user",
|
|
724
|
+
content: [{ type: "text", text: boundaryText }],
|
|
725
|
+
source: BOUNDARY_SOURCE
|
|
726
|
+
};
|
|
727
|
+
const setup = (childCtx, agent) => {
|
|
728
|
+
childCtx.get("agentPresets")?.composeFrom?.(childCtx, parent.ctx);
|
|
729
|
+
const session = agent.session;
|
|
730
|
+
if (session === void 0) throw new Error("side agent has no session");
|
|
731
|
+
if (sandboxMode !== void 0) {
|
|
732
|
+
session.append("sandbox/mode", { mode: sandboxMode, source: SANDBOX_OVERRIDE_SOURCE });
|
|
733
|
+
}
|
|
734
|
+
if (approvalPolicy !== void 0) {
|
|
735
|
+
session.append("approval/policy", { policy: approvalPolicy, source: APPROVAL_OVERRIDE_SOURCE });
|
|
736
|
+
}
|
|
737
|
+
session.append("user/message", boundary, { surfaceOp: "append" });
|
|
738
|
+
childCtx.systemPrompt.section({
|
|
739
|
+
name: DEVELOPER_SECTION_NAME,
|
|
740
|
+
order: developerSectionOrder(childCtx.systemPrompt),
|
|
741
|
+
text: sideDeveloperInstructions()
|
|
742
|
+
});
|
|
743
|
+
};
|
|
744
|
+
const handle = await ctx.agents.create({
|
|
745
|
+
sessionId,
|
|
746
|
+
meta,
|
|
747
|
+
...inheritedEventCount > 0 ? { seed, inheritedEventCount } : {},
|
|
748
|
+
agentOptions: routeOptions(parent),
|
|
749
|
+
setup
|
|
750
|
+
});
|
|
751
|
+
if (this.closing) {
|
|
752
|
+
await this.retire(sessionId, handle);
|
|
753
|
+
return failure("internal", UNLOADING);
|
|
754
|
+
}
|
|
755
|
+
this.entries.set(sessionId, { sessionId, identity, handle, inheritedEventCount });
|
|
756
|
+
this.byIdentity.set(identity, sessionId);
|
|
757
|
+
return success({ sessionId, inheritedEventCount });
|
|
758
|
+
} catch (error) {
|
|
759
|
+
return failure("create-failed", errorMessage(error));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async prompt(args) {
|
|
763
|
+
const sessionId = stringArg(args.sessionId);
|
|
764
|
+
const text = typeof args.text === "string" ? args.text : "";
|
|
765
|
+
if (text.trim() === "") return failure("empty-prompt");
|
|
766
|
+
const entry = this.entryOf(sessionId);
|
|
767
|
+
if (entry === void 0) return failure("not-found");
|
|
768
|
+
try {
|
|
769
|
+
entry.handle.agent.followup({
|
|
770
|
+
id: `${SESSION_ID_PREFIX}message-${randomUUID()}`,
|
|
771
|
+
role: "user",
|
|
772
|
+
content: [{ type: "text", text }],
|
|
773
|
+
source: { kind: "user" }
|
|
774
|
+
});
|
|
775
|
+
} catch (error) {
|
|
776
|
+
return failure("prompt-failed", errorMessage(error));
|
|
777
|
+
}
|
|
778
|
+
return success({});
|
|
779
|
+
}
|
|
780
|
+
async cancel(args) {
|
|
781
|
+
const entry = this.entryOf(stringArg(args.sessionId));
|
|
782
|
+
if (entry === void 0) return failure("not-found");
|
|
783
|
+
entry.handle.agent.cancel({ kind: "user" });
|
|
784
|
+
return success({});
|
|
785
|
+
}
|
|
786
|
+
async snapshot(args) {
|
|
787
|
+
const sessionId = stringArg(args.sessionId);
|
|
788
|
+
const entry = this.entryOf(sessionId);
|
|
789
|
+
if (entry === void 0) return failure("not-found");
|
|
790
|
+
return success(this.baseline(entry, args.fromSeq).payload);
|
|
791
|
+
}
|
|
792
|
+
async close(args) {
|
|
793
|
+
const sessionId = stringArg(args.sessionId);
|
|
794
|
+
const entry = this.entryOf(sessionId);
|
|
795
|
+
if (entry === void 0) return failure("not-found");
|
|
796
|
+
this.entries.delete(sessionId);
|
|
797
|
+
if (this.byIdentity.get(entry.identity) === sessionId) this.byIdentity.delete(entry.identity);
|
|
798
|
+
this.lastErrors.delete(sessionId);
|
|
799
|
+
this.liveStates.delete(sessionId);
|
|
800
|
+
this.endStreamsFor(sessionId);
|
|
801
|
+
const artifactDir = this.locateArtifact(entry.handle.agent);
|
|
802
|
+
try {
|
|
803
|
+
await entry.handle.dispose();
|
|
804
|
+
} catch (error) {
|
|
805
|
+
return failure("dispose-failed", errorMessage(error));
|
|
806
|
+
}
|
|
807
|
+
if (artifactDir !== void 0) await this.removeArtifact(sessionId, artifactDir);
|
|
808
|
+
return success({});
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* The event floor of one owned session: never below the inherited prefix,
|
|
812
|
+
* never below an explicitly requested sequence.
|
|
813
|
+
*/
|
|
814
|
+
eventFloor(entry, fromSeq) {
|
|
815
|
+
const fromSession = entry.handle.agent.session?.inheritedEventCount;
|
|
816
|
+
const inheritedEventCount = Number.isSafeInteger(entry.inheritedEventCount) ? entry.inheritedEventCount : typeof fromSession === "number" && Number.isSafeInteger(fromSession) ? fromSession : 0;
|
|
817
|
+
const requested = toNonNegativeInteger(fromSeq);
|
|
818
|
+
return { inheritedEventCount, floor: Math.max(inheritedEventCount, requested ?? inheritedEventCount) };
|
|
819
|
+
}
|
|
820
|
+
/** The baseline shared by the `snapshot` route and the stream's `hello` frame. */
|
|
821
|
+
baseline(entry, fromSeq) {
|
|
822
|
+
const { sessionId, handle: { agent } } = entry;
|
|
823
|
+
const { inheritedEventCount, floor } = this.eventFloor(entry, fromSeq);
|
|
824
|
+
const session = agent.session;
|
|
825
|
+
const events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents(floor) : [];
|
|
826
|
+
const { entries, truncated } = snapshotEntries(events, floor);
|
|
827
|
+
const status = stringArg(agent.status);
|
|
828
|
+
const contextWindow = requestContextWindow(agent);
|
|
829
|
+
return {
|
|
830
|
+
floor,
|
|
831
|
+
payload: {
|
|
832
|
+
inheritedEventCount,
|
|
833
|
+
status,
|
|
834
|
+
running: status === "running",
|
|
835
|
+
lastError: this.lastErrors.get(sessionId) ?? null,
|
|
836
|
+
...contextWindow === void 0 ? {} : { contextWindow },
|
|
837
|
+
entries,
|
|
838
|
+
truncated,
|
|
839
|
+
live: this.liveOf(sessionId)
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Serve one `text/event-stream` stream of the live assistant attempt and newly
|
|
845
|
+
* committed events. `webServer.register` hands this a raw Node request and
|
|
846
|
+
* response with direct ownership, so failure before the stream starts is a
|
|
847
|
+
* normal JSON response (the client then falls back to polling) and every
|
|
848
|
+
* write/cleanup is contained here.
|
|
849
|
+
*/
|
|
850
|
+
async openEventStream(req, res) {
|
|
851
|
+
let args;
|
|
852
|
+
try {
|
|
853
|
+
const parsed = await readJsonBody(req);
|
|
854
|
+
if (!isJsonObject(parsed)) throw new Error("request body must be a JSON object");
|
|
855
|
+
args = parsed;
|
|
856
|
+
} catch (error) {
|
|
857
|
+
this.warn(`rejected event stream request: ${errorMessage(error)}`);
|
|
858
|
+
trySendJson(res, failure("internal", `invalid request body: ${errorMessage(error)}`), this.ctx.logger);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const entry = this.entryOf(stringArg(args.sessionId));
|
|
862
|
+
if (entry === void 0) {
|
|
863
|
+
trySendJson(res, failure("not-found"), this.ctx.logger);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
try {
|
|
867
|
+
this.startEventStream(req, res, entry, args.fromSeq);
|
|
868
|
+
} catch (error) {
|
|
869
|
+
this.warn(`event stream setup failed: ${errorMessage(error)}`);
|
|
870
|
+
try {
|
|
871
|
+
res.end();
|
|
872
|
+
} catch {
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Write the stream headers and the `hello` baseline, then register the stream
|
|
878
|
+
* for subsequent committed events, live frames and status/error frames. Runs
|
|
879
|
+
* synchronously so no session event can slip between the hello snapshot and
|
|
880
|
+
* the stream's registration.
|
|
881
|
+
*/
|
|
882
|
+
startEventStream(req, res, entry, fromSeq) {
|
|
883
|
+
const { sessionId } = entry;
|
|
884
|
+
const { payload, floor } = this.baseline(entry, fromSeq);
|
|
885
|
+
const last = payload.entries.at(-1);
|
|
886
|
+
const stream = {
|
|
887
|
+
sessionId,
|
|
888
|
+
res,
|
|
889
|
+
floor,
|
|
890
|
+
// Everything at or below this seq is already represented in the hello frame.
|
|
891
|
+
cursor: last === void 0 ? floor - 1 : last.seq,
|
|
892
|
+
heartbeat: void 0,
|
|
893
|
+
detach: void 0,
|
|
894
|
+
closed: false
|
|
895
|
+
};
|
|
896
|
+
res.writeHead(200, SSE_HEADERS);
|
|
897
|
+
const onClose = () => this.endStream(stream);
|
|
898
|
+
req.on("close", onClose);
|
|
899
|
+
res.on("close", onClose);
|
|
900
|
+
stream.detach = () => {
|
|
901
|
+
req.off("close", onClose);
|
|
902
|
+
res.off("close", onClose);
|
|
903
|
+
};
|
|
904
|
+
this.writeStream(stream, sseFrame("hello", success(payload)));
|
|
905
|
+
if (stream.closed) return;
|
|
906
|
+
let streams = this.streams.get(sessionId);
|
|
907
|
+
if (streams === void 0) {
|
|
908
|
+
streams = /* @__PURE__ */ new Set();
|
|
909
|
+
this.streams.set(sessionId, streams);
|
|
910
|
+
}
|
|
911
|
+
streams.add(stream);
|
|
912
|
+
const heartbeat = setInterval(() => this.writeStream(stream, SSE_HEARTBEAT), SSE_HEARTBEAT_MS);
|
|
913
|
+
heartbeat.unref();
|
|
914
|
+
stream.heartbeat = heartbeat;
|
|
915
|
+
}
|
|
916
|
+
/** Resolve the persisted artifact directory before disposal, while the header is still live. */
|
|
917
|
+
locateArtifact(agent) {
|
|
918
|
+
try {
|
|
919
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
920
|
+
const header = agent.session?.header;
|
|
921
|
+
if (typeof persistence?.locate !== "function" || header === void 0) return void 0;
|
|
922
|
+
return artifactDirectory(header, persistence.locate(header));
|
|
923
|
+
} catch {
|
|
924
|
+
return void 0;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Delete only this plugin's own persisted session directory, after a short
|
|
929
|
+
* drain delay. Runs in the host process (no shell, no sandbox escalation);
|
|
930
|
+
* `force` + `recursive` make a missing artifact a no-op, and any failure only
|
|
931
|
+
* warns because the agent is already disposed.
|
|
932
|
+
*/
|
|
933
|
+
async removeArtifact(sessionId, dir) {
|
|
934
|
+
if (!sessionId.startsWith(SESSION_ID_PREFIX) || !isAbsolute(dir) || basename(dir) !== sessionId) {
|
|
935
|
+
this.warn(`artifact cleanup for "${sessionId}" refused: unexpected directory "${dir}"`);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
await delay(DRAIN_DELAY_MS);
|
|
940
|
+
await rm(dir, { recursive: true, force: true });
|
|
941
|
+
} catch (error) {
|
|
942
|
+
this.warn(`artifact cleanup for "${sessionId}" failed: ${errorMessage(error)}`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Dispose one side agent during unload and, once disposal succeeded, remove
|
|
947
|
+
* its artifact in the background. The directory is located before disposal.
|
|
948
|
+
* Never rejects.
|
|
949
|
+
*/
|
|
950
|
+
async retire(sessionId, handle) {
|
|
951
|
+
const dir = this.locateArtifact(handle.agent);
|
|
952
|
+
try {
|
|
953
|
+
await handle.dispose();
|
|
954
|
+
} catch (error) {
|
|
955
|
+
this.warn(`dispose of "${sessionId}" during unload failed: ${errorMessage(error)}`);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (dir !== void 0) void this.removeArtifact(sessionId, dir);
|
|
959
|
+
}
|
|
960
|
+
/** Plugin unload: detach listeners, end streams, dispose every side agent. */
|
|
961
|
+
disposeAll() {
|
|
962
|
+
this.closing = true;
|
|
963
|
+
for (const dispose of this.listenerDisposers) {
|
|
964
|
+
try {
|
|
965
|
+
dispose();
|
|
966
|
+
} catch {
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
this.listenerDisposers.length = 0;
|
|
970
|
+
for (const sessionId of [...this.streams.keys()]) this.endStreamsFor(sessionId);
|
|
971
|
+
this.streams.clear();
|
|
972
|
+
this.liveStates.clear();
|
|
973
|
+
for (const entry of this.entries.values()) void this.retire(entry.sessionId, entry.handle);
|
|
974
|
+
this.entries.clear();
|
|
975
|
+
this.byIdentity.clear();
|
|
976
|
+
this.pendingCreates.clear();
|
|
977
|
+
this.lastErrors.clear();
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
// src/host/index.ts
|
|
982
|
+
var name = PLUGIN_NAME;
|
|
983
|
+
var inject = ["webServer", "agents"];
|
|
984
|
+
function apply(ctx) {
|
|
985
|
+
const warn = (message) => ctx.logger?.warn?.(`[side-chat] ${message}`);
|
|
986
|
+
const service = new SideChatService(ctx);
|
|
987
|
+
const webServer = ctx.webServer;
|
|
988
|
+
if (webServer !== void 0 && typeof webServer.register === "function") {
|
|
989
|
+
const routeDisposers = [];
|
|
990
|
+
const register = (route) => {
|
|
991
|
+
try {
|
|
992
|
+
const dispose = toDisposer(webServer.register?.(route));
|
|
993
|
+
if (dispose !== void 0) routeDisposers.push(dispose);
|
|
994
|
+
} catch (error) {
|
|
995
|
+
warn(`route ${route.path} not registered: ${errorMessage(error)}`);
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
const table = {
|
|
999
|
+
create: (args) => service.create(args),
|
|
1000
|
+
prompt: (args) => service.prompt(args),
|
|
1001
|
+
cancel: (args) => service.cancel(args),
|
|
1002
|
+
close: (args) => service.close(args),
|
|
1003
|
+
snapshot: (args) => service.snapshot(args)
|
|
1004
|
+
};
|
|
1005
|
+
for (const route of JSON_ROUTES) {
|
|
1006
|
+
register({ kind: "exact", path: `${API_PREFIX}${route}`, handler: routeHandler(table[route], ctx.logger) });
|
|
1007
|
+
}
|
|
1008
|
+
register({
|
|
1009
|
+
kind: "exact",
|
|
1010
|
+
path: `${API_PREFIX}${EVENTS_ROUTE}`,
|
|
1011
|
+
handler: (req, res) => service.openEventStream(req, res).catch((error) => {
|
|
1012
|
+
warn(`event stream failed: ${errorMessage(error)}`);
|
|
1013
|
+
try {
|
|
1014
|
+
res.end();
|
|
1015
|
+
} catch {
|
|
1016
|
+
}
|
|
1017
|
+
})
|
|
1018
|
+
});
|
|
1019
|
+
ctx.effect(() => () => {
|
|
1020
|
+
for (const dispose of routeDisposers) {
|
|
1021
|
+
try {
|
|
1022
|
+
dispose();
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
warn(`route disposal failed: ${errorMessage(error)}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
routeDisposers.length = 0;
|
|
1028
|
+
}, "side-chat: routes");
|
|
1029
|
+
} else {
|
|
1030
|
+
warn("webServer is unavailable; the client RPC surface is not mounted");
|
|
1031
|
+
}
|
|
1032
|
+
ctx.effect(() => () => service.disposeAll(), "side-chat: sessions");
|
|
1033
|
+
}
|
|
1034
|
+
export {
|
|
1035
|
+
apply,
|
|
1036
|
+
inject,
|
|
1037
|
+
name
|
|
1038
|
+
};
|