@aexol/spectral 0.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/CHANGELOG.md +106 -0
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/cli.js +206 -0
- package/dist/commands/bind.js +96 -0
- package/dist/commands/login.js +109 -0
- package/dist/commands/logout.js +24 -0
- package/dist/commands/serve.js +374 -0
- package/dist/commands/unbind.js +36 -0
- package/dist/config.js +92 -0
- package/dist/extensions/aexol-mcp.js +117 -0
- package/dist/mcp-client.js +116 -0
- package/dist/preflight.js +36 -0
- package/dist/relay/client.js +240 -0
- package/dist/relay/dispatcher.js +504 -0
- package/dist/relay/machine-store.js +116 -0
- package/dist/relay/models-fetch.js +108 -0
- package/dist/relay/registration.js +135 -0
- package/dist/server/handlers/errors.js +34 -0
- package/dist/server/handlers/projects.js +86 -0
- package/dist/server/handlers/sessions.js +42 -0
- package/dist/server/paths.js +78 -0
- package/dist/server/pi-bridge.js +572 -0
- package/dist/server/session-stream.js +579 -0
- package/dist/server/shutdown.js +180 -0
- package/dist/server/storage.js +491 -0
- package/dist/server/title-generator.js +196 -0
- package/dist/server/wire.js +12 -0
- package/dist/studio-binding.js +97 -0
- package/package.json +67 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped streaming layer for `spectral serve`.
|
|
3
|
+
*
|
|
4
|
+
* Background: prior to this module each WebSocket owned its own `PiBridge`
|
|
5
|
+
* instance and the routes layer enforced single-writer-wins (4001 eviction)
|
|
6
|
+
* to keep that bridge unique per session. That model lost data on browser
|
|
7
|
+
* refresh — the WS close torn down the pi process mid-stream, and a re-open
|
|
8
|
+
* couldn't recover what hadn't yet hit `agent_end` (and thus SQLite).
|
|
9
|
+
*
|
|
10
|
+
* New model:
|
|
11
|
+
* - Pi lifecycle is per **Spectral session**, not per WS.
|
|
12
|
+
* - 0..N WebSockets may attach to the same session simultaneously. Each
|
|
13
|
+
* gets the same broadcast stream of events.
|
|
14
|
+
* - When a WS detaches (close, error, refresh), the pi process keeps
|
|
15
|
+
* running. Closing every tab does NOT cancel the in-flight turn —
|
|
16
|
+
* it runs to completion and persists on `agent_end` as before.
|
|
17
|
+
* - On `attach`, the manager hands back a replay payload: full DB history
|
|
18
|
+
* plus a snapshot of the currently in-flight turn (if any). The client
|
|
19
|
+
* replays the snapshot through the same reducer it uses for live events
|
|
20
|
+
* and continues streaming naturally.
|
|
21
|
+
* - Persistence shape is unchanged: only the final assistant message is
|
|
22
|
+
* written to SQLite on `agent_end`. In-flight events live in memory only.
|
|
23
|
+
* A server crash mid-turn discards the in-flight state — acceptable
|
|
24
|
+
* for MVP.
|
|
25
|
+
*
|
|
26
|
+
* Failure modes:
|
|
27
|
+
* - Pi throws synchronously in `prompt()` → bridge surfaces as `error`
|
|
28
|
+
* event; manager broadcasts and clears `currentTurn`.
|
|
29
|
+
* - One subscriber's `ws.send` throws → caught, logged, removed from the
|
|
30
|
+
* subscriber set; broadcast continues to the rest.
|
|
31
|
+
* - `agent_end` arrives without a current turn (defensive) → broadcast
|
|
32
|
+
* anyway so any late attachers don't get stuck.
|
|
33
|
+
*
|
|
34
|
+
* TODO (future): idle GC. A `SessionStream` with `subscribers.size === 0`
|
|
35
|
+
* and no current turn could be disposed after some grace window (e.g. 5
|
|
36
|
+
* minutes) to release pi resources for chronically-idle sessions. Skipped
|
|
37
|
+
* for now — streams accumulate for the lifetime of the server process.
|
|
38
|
+
*/
|
|
39
|
+
import { randomUUID } from "node:crypto";
|
|
40
|
+
import { PiBridge } from "./pi-bridge.js";
|
|
41
|
+
import { generateSessionTitle, isDefaultTitle, } from "./title-generator.js";
|
|
42
|
+
const DEFAULT_BRIDGE_FACTORY = (args) => new PiBridge(args);
|
|
43
|
+
/** Safety limit for autonomous refactor loop iterations per session. */
|
|
44
|
+
const MAX_AEXOL_ITERATIONS = 100;
|
|
45
|
+
export class SessionStreamManager {
|
|
46
|
+
store;
|
|
47
|
+
cwd;
|
|
48
|
+
backendUrl;
|
|
49
|
+
machineJwt;
|
|
50
|
+
bridgeFactory;
|
|
51
|
+
agentDir;
|
|
52
|
+
titleLlmCall;
|
|
53
|
+
disableAutoTitle;
|
|
54
|
+
publishMetaEvent;
|
|
55
|
+
streams = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* Sessions for which we've already attempted (or queued) auto-title
|
|
58
|
+
* generation in this server process. Per-process is intentional: a server
|
|
59
|
+
* restart resets the set, but `isDefaultTitle()` still gates the work so a
|
|
60
|
+
* since-renamed session is never overwritten.
|
|
61
|
+
*/
|
|
62
|
+
titleGenerationAttempted = new Set();
|
|
63
|
+
disposed = false;
|
|
64
|
+
constructor(opts) {
|
|
65
|
+
this.store = opts.store;
|
|
66
|
+
this.cwd = opts.cwd;
|
|
67
|
+
this.backendUrl = opts.backendUrl;
|
|
68
|
+
this.machineJwt = opts.machineJwt;
|
|
69
|
+
this.bridgeFactory = opts.bridgeFactory ?? DEFAULT_BRIDGE_FACTORY;
|
|
70
|
+
this.agentDir = opts.agentDir;
|
|
71
|
+
this.titleLlmCall = opts.titleLlmCall;
|
|
72
|
+
this.disableAutoTitle = opts.disableAutoTitle === true;
|
|
73
|
+
this.publishMetaEvent = opts.publishMetaEvent;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Attach a subscriber to a session. Lazily creates the underlying pi
|
|
77
|
+
* session on first attach. The caller is responsible for sending the
|
|
78
|
+
* initial `session_ready` frame using the returned replay payload (this
|
|
79
|
+
* keeps wire-protocol concerns in the routes layer).
|
|
80
|
+
*
|
|
81
|
+
* Throws if the session id is unknown in SQLite (caller should turn this
|
|
82
|
+
* into a wire-level error frame + close).
|
|
83
|
+
*/
|
|
84
|
+
attach(sessionId, subscriber) {
|
|
85
|
+
if (this.disposed)
|
|
86
|
+
throw new Error("SessionStreamManager disposed");
|
|
87
|
+
const detail = this.store.getSession(sessionId);
|
|
88
|
+
if (!detail)
|
|
89
|
+
throw new Error(`Unknown sessionId: ${sessionId}`);
|
|
90
|
+
let stream = this.streams.get(sessionId);
|
|
91
|
+
if (!stream) {
|
|
92
|
+
stream = this.createStream(sessionId);
|
|
93
|
+
this.streams.set(sessionId, stream);
|
|
94
|
+
}
|
|
95
|
+
stream.subscribers.add(subscriber);
|
|
96
|
+
return {
|
|
97
|
+
history: detail.messages,
|
|
98
|
+
currentTurn: stream.currentTurn ? snapshotTurn(stream.currentTurn) : null,
|
|
99
|
+
ready: stream.ready,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Detach a subscriber. Idempotent. Does NOT dispose the underlying pi
|
|
104
|
+
* session — even when subscribers reach zero, the in-flight turn must
|
|
105
|
+
* complete and persist.
|
|
106
|
+
*/
|
|
107
|
+
detach(sessionId, subscriber) {
|
|
108
|
+
const stream = this.streams.get(sessionId);
|
|
109
|
+
if (!stream)
|
|
110
|
+
return;
|
|
111
|
+
stream.subscribers.delete(subscriber);
|
|
112
|
+
// Intentional: do NOT dispose the bridge here. See file-level docs.
|
|
113
|
+
}
|
|
114
|
+
/** True if the session has an in-flight turn (manager-side; not WS-side). */
|
|
115
|
+
hasActiveTurn(sessionId) {
|
|
116
|
+
return this.streams.get(sessionId)?.currentTurn != null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Persist a user message and forward it to pi. Resolves after the user
|
|
120
|
+
* message is persisted + pi is invoked (NOT after the turn completes —
|
|
121
|
+
* the turn lifetime is observed via the broadcast stream).
|
|
122
|
+
*
|
|
123
|
+
* Broadcast ordering:
|
|
124
|
+
* 1. user message persisted to SQLite
|
|
125
|
+
* 2. `user_message_appended` broadcast to all subscribers (including
|
|
126
|
+
* the originating tab)
|
|
127
|
+
* 3. new `currentTurn` opened
|
|
128
|
+
* 4. `bridge.prompt()` invoked (events arrive asynchronously and are
|
|
129
|
+
* buffered + broadcast as they come)
|
|
130
|
+
*
|
|
131
|
+
* Sticky model selection (Phase 3 — Available Models whitelist):
|
|
132
|
+
* - When `modelId` is provided, we apply it via `bridge.setModel()` and
|
|
133
|
+
* persist to SQLite for cross-restart recovery, BEFORE invoking
|
|
134
|
+
* `bridge.prompt()`. If `setModel` fails (unknown model, registry
|
|
135
|
+
* unavailable, pi-side error) the bridge has already emitted an
|
|
136
|
+
* `error` wire event and we drop the prompt to avoid running it
|
|
137
|
+
* against the wrong model.
|
|
138
|
+
* - When `modelId` is omitted, we look up SQLite. If a previous turn
|
|
139
|
+
* persisted a value, we reapply it on this turn (this is the
|
|
140
|
+
* cross-restart recovery path: a fresh server process has lost pi's
|
|
141
|
+
* in-memory model state, so we re-pin from durable storage).
|
|
142
|
+
* - When neither envelope nor SQLite have a value, we leave model
|
|
143
|
+
* selection to pi's own settings file (pre-Phase-3 behaviour).
|
|
144
|
+
*/
|
|
145
|
+
async prompt(sessionId, content, modelId) {
|
|
146
|
+
if (this.disposed)
|
|
147
|
+
throw new Error("SessionStreamManager disposed");
|
|
148
|
+
const stream = this.streams.get(sessionId);
|
|
149
|
+
if (!stream)
|
|
150
|
+
throw new Error(`No active stream for session: ${sessionId}`);
|
|
151
|
+
// Wait for pi to be ready before we persist + invoke. If start failed,
|
|
152
|
+
// surface to all subscribers instead of throwing into the route handler.
|
|
153
|
+
try {
|
|
154
|
+
await stream.ready;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
158
|
+
this.broadcast(stream, { type: "error", message: `Agent not ready: ${e.message}` });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Sticky-model resolution & application. Phase 3 (Available Models
|
|
162
|
+
// whitelist). Order:
|
|
163
|
+
// a) If envelope carried a `modelId`, use it.
|
|
164
|
+
// b) Else, look up the per-session persisted modelId in SQLite
|
|
165
|
+
// (cross-restart recovery — server restart wipes pi's in-memory
|
|
166
|
+
// session model state, but our durable store has the last value).
|
|
167
|
+
// c) Else, leave model selection to pi (pre-Phase-3 behaviour).
|
|
168
|
+
//
|
|
169
|
+
// We apply BEFORE persisting the user message: if the bridge can't
|
|
170
|
+
// resolve the model (unknown id, registry unavailable), it has already
|
|
171
|
+
// emitted an `error` wire event and we drop the prompt rather than
|
|
172
|
+
// recording a user message we know we won't be able to respond to.
|
|
173
|
+
//
|
|
174
|
+
// Persistence: only the envelope-supplied value is written back. A
|
|
175
|
+
// recovery-only application (case b) doesn't update the row — the
|
|
176
|
+
// value is already there.
|
|
177
|
+
const effectiveModelId = modelId ?? this.store.getSessionModel(sessionId) ?? undefined;
|
|
178
|
+
if (effectiveModelId && stream.bridge.setModel) {
|
|
179
|
+
const ok = await stream.bridge.setModel(effectiveModelId);
|
|
180
|
+
if (!ok) {
|
|
181
|
+
// Bridge already emitted an error; nothing else to do. We
|
|
182
|
+
// intentionally do not persist the user message — the turn never
|
|
183
|
+
// ran.
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (modelId) {
|
|
188
|
+
try {
|
|
189
|
+
this.store.setSessionModel(sessionId, modelId);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
// Persisting the sticky model is best-effort: the live turn will
|
|
193
|
+
// still run with the model already applied above. A failure here
|
|
194
|
+
// only affects cross-restart recovery, which is non-critical.
|
|
195
|
+
console.warn(`[spectral] warn: failed to persist sticky model for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// 1. Persist user message first (survives mid-prompt failures).
|
|
199
|
+
let stored;
|
|
200
|
+
try {
|
|
201
|
+
stored = this.store.appendMessage(sessionId, { role: "user", content });
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
205
|
+
this.broadcast(stream, {
|
|
206
|
+
type: "error",
|
|
207
|
+
message: `Failed to persist user message: ${e.message}`,
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// 2. Broadcast the persisted message so every tab — including the one
|
|
212
|
+
// that sent the prompt — appends an authoritative user turn.
|
|
213
|
+
this.broadcast(stream, { type: "user_message_appended", message: stored });
|
|
214
|
+
// 3. Open a new in-flight turn. Allocated even before pi emits anything
|
|
215
|
+
// so a re-attach immediately after `prompt` sees the turn.
|
|
216
|
+
stream.currentTurn = {
|
|
217
|
+
turnId: randomUUID(),
|
|
218
|
+
startedAt: Date.now(),
|
|
219
|
+
events: [],
|
|
220
|
+
assistantText: "",
|
|
221
|
+
};
|
|
222
|
+
// 4. Fire pi. `prompt` resolves on agent_end; errors are handled inside
|
|
223
|
+
// PiBridge (it emits `error` for us). We don't await — broadcast is
|
|
224
|
+
// driven by the bridge's emit callback.
|
|
225
|
+
void stream.bridge.prompt(content);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Tear down everything. Best-effort: disposes every bridge, drops all
|
|
229
|
+
* subscribers. After this the manager is unusable.
|
|
230
|
+
*/
|
|
231
|
+
dispose() {
|
|
232
|
+
if (this.disposed)
|
|
233
|
+
return;
|
|
234
|
+
this.disposed = true;
|
|
235
|
+
for (const stream of this.streams.values()) {
|
|
236
|
+
stream.aexolActive = false;
|
|
237
|
+
try {
|
|
238
|
+
stream.bridge.dispose();
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// ignore
|
|
242
|
+
}
|
|
243
|
+
stream.subscribers.clear();
|
|
244
|
+
}
|
|
245
|
+
this.streams.clear();
|
|
246
|
+
}
|
|
247
|
+
/** Test/inspection helper: how many streams are currently tracked. */
|
|
248
|
+
streamCount() {
|
|
249
|
+
return this.streams.size;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Count of sessions with an in-flight turn (i.e. a `currentTurn` set).
|
|
253
|
+
* Used by `gracefulShutdown` to decide whether to keep waiting before
|
|
254
|
+
* tearing down — a non-zero count means at least one assistant response
|
|
255
|
+
* is mid-stream and we'd rather let it finish (within the grace window)
|
|
256
|
+
* than orphan a half-streamed message in the UI.
|
|
257
|
+
*
|
|
258
|
+
* Cheap O(streams) scan; we only call it ~50× during a 5 s graceful
|
|
259
|
+
* shutdown so the linear walk is fine.
|
|
260
|
+
*/
|
|
261
|
+
activeTurnCount() {
|
|
262
|
+
let n = 0;
|
|
263
|
+
for (const s of this.streams.values()) {
|
|
264
|
+
if (s.currentTurn != null)
|
|
265
|
+
n++;
|
|
266
|
+
}
|
|
267
|
+
return n;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Tear down a single session's stream — disposes the pi bridge and clears
|
|
271
|
+
* subscribers. Idempotent. Called by the routes layer right before
|
|
272
|
+
* `DELETE /api/sessions/:id` so the SQL cascade doesn't leave a zombie
|
|
273
|
+
* pi process driving events at a session that no longer exists.
|
|
274
|
+
*
|
|
275
|
+
* Does NOT remove the session from the store — that's the caller's job.
|
|
276
|
+
*/
|
|
277
|
+
disposeSessionStream(sessionId) {
|
|
278
|
+
const stream = this.streams.get(sessionId);
|
|
279
|
+
if (!stream)
|
|
280
|
+
return;
|
|
281
|
+
stream.aexolActive = false;
|
|
282
|
+
try {
|
|
283
|
+
stream.bridge.dispose();
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// ignore
|
|
287
|
+
}
|
|
288
|
+
// Best-effort: notify any still-open subscribers so they close cleanly
|
|
289
|
+
// rather than hanging on a dead pi process. We don't broadcast through
|
|
290
|
+
// `broadcast()` because that would re-enter the dead-subscriber pruning
|
|
291
|
+
// loop on a stream we're about to drop anyway.
|
|
292
|
+
for (const sub of stream.subscribers) {
|
|
293
|
+
if (!sub.isOpen())
|
|
294
|
+
continue;
|
|
295
|
+
try {
|
|
296
|
+
sub.send({ type: "error", message: "Session deleted" });
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
// ignore
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
stream.subscribers.clear();
|
|
303
|
+
this.streams.delete(sessionId);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Tear down every stream whose session belongs to the given list of ids.
|
|
307
|
+
* Used by the project-delete path: the route layer reads the project's
|
|
308
|
+
* session ids from `deleteProject()` and passes them here BEFORE the SQL
|
|
309
|
+
* cascade fires, so no pi process ever observes the FK cascade.
|
|
310
|
+
*/
|
|
311
|
+
disposeProjectStreams(sessionIds) {
|
|
312
|
+
for (const sid of sessionIds) {
|
|
313
|
+
this.disposeSessionStream(sid);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Set the autonomous refactor loop state for a session. When `active` is
|
|
318
|
+
* true, the manager will auto-send "continue" after each `agent_end` event
|
|
319
|
+
* until deactivated or the safety limit is reached.
|
|
320
|
+
*/
|
|
321
|
+
setAexolActive(sessionId, active) {
|
|
322
|
+
const stream = this.streams.get(sessionId);
|
|
323
|
+
if (stream) {
|
|
324
|
+
stream.aexolActive = active;
|
|
325
|
+
if (!active)
|
|
326
|
+
stream.aexolIterationCount = 0;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// --- internals ----------------------------------------------------------
|
|
330
|
+
createStream(sessionId) {
|
|
331
|
+
// Resolve cwd from the owning project. Sessions without a project
|
|
332
|
+
// shouldn't exist (FK enforces it), but we fall back to the manager's
|
|
333
|
+
// default cwd if the lookup somehow fails — better than crashing the
|
|
334
|
+
// attach.
|
|
335
|
+
const projectId = this.store.getSessionProjectId(sessionId);
|
|
336
|
+
let cwd = this.cwd;
|
|
337
|
+
if (projectId) {
|
|
338
|
+
const project = this.store.getProject(projectId);
|
|
339
|
+
if (project)
|
|
340
|
+
cwd = project.path;
|
|
341
|
+
}
|
|
342
|
+
// Forward declaration so the bridge factory's emit callback can refer to
|
|
343
|
+
// the stream object that's still being assembled.
|
|
344
|
+
const stream = {
|
|
345
|
+
sessionId,
|
|
346
|
+
cwd,
|
|
347
|
+
bridge: undefined,
|
|
348
|
+
ready: Promise.resolve(),
|
|
349
|
+
startError: null,
|
|
350
|
+
subscribers: new Set(),
|
|
351
|
+
currentTurn: null,
|
|
352
|
+
aexolActive: false,
|
|
353
|
+
aexolIterationCount: 0,
|
|
354
|
+
};
|
|
355
|
+
const bridgeOpts = {
|
|
356
|
+
cwd,
|
|
357
|
+
agentDir: this.agentDir,
|
|
358
|
+
backendUrl: this.backendUrl,
|
|
359
|
+
machineJwt: this.machineJwt,
|
|
360
|
+
emit: (event) => this.handleBridgeEvent(stream, event),
|
|
361
|
+
onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
|
|
362
|
+
try {
|
|
363
|
+
this.store.appendMessage(sessionId, {
|
|
364
|
+
id: messageId,
|
|
365
|
+
role: "assistant",
|
|
366
|
+
content,
|
|
367
|
+
eventsJsonl,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
console.error(`[spectral] error: failed to persist assistant message: ${err instanceof Error ? err.message : String(err)}`);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
onError: (err) => {
|
|
375
|
+
console.error(`[spectral] error: pi bridge error: ${err.message}`);
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
stream.bridge = this.bridgeFactory(bridgeOpts);
|
|
379
|
+
stream.ready = stream.bridge
|
|
380
|
+
.start()
|
|
381
|
+
.catch((err) => {
|
|
382
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
383
|
+
stream.startError = e;
|
|
384
|
+
// Notify any already-attached subscribers so they don't sit on a
|
|
385
|
+
// dead connection. Late attachers see startError via attach->ready.
|
|
386
|
+
this.broadcast(stream, {
|
|
387
|
+
type: "error",
|
|
388
|
+
message: `Failed to start agent: ${e.message}`,
|
|
389
|
+
});
|
|
390
|
+
throw e;
|
|
391
|
+
});
|
|
392
|
+
return stream;
|
|
393
|
+
}
|
|
394
|
+
handleBridgeEvent(stream, event) {
|
|
395
|
+
// Buffer replayable events into the in-flight turn. We intentionally
|
|
396
|
+
// accept events even if currentTurn is null (rare race: pi emits before
|
|
397
|
+
// prompt() opened the turn), in which case we open one defensively so
|
|
398
|
+
// late attachers see the events. The first event in such a case is
|
|
399
|
+
// typically `message_start`.
|
|
400
|
+
if (isReplayable(event)) {
|
|
401
|
+
if (!stream.currentTurn) {
|
|
402
|
+
stream.currentTurn = {
|
|
403
|
+
turnId: randomUUID(),
|
|
404
|
+
startedAt: Date.now(),
|
|
405
|
+
events: [],
|
|
406
|
+
assistantText: "",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
stream.currentTurn.events.push(event);
|
|
410
|
+
if (event.type === "text_delta") {
|
|
411
|
+
stream.currentTurn.assistantText += event.delta;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Broadcast first, then maybe close out the turn. agent_end clears the
|
|
415
|
+
// buffer because by that point the assistant message is already in
|
|
416
|
+
// SQLite (PiBridge calls onAssistantMessageComplete on message_end,
|
|
417
|
+
// which fires before agent_end).
|
|
418
|
+
this.broadcast(stream, event);
|
|
419
|
+
if (event.type === "agent_end") {
|
|
420
|
+
const finishedTurn = stream.currentTurn;
|
|
421
|
+
stream.currentTurn = null;
|
|
422
|
+
// Fire-and-forget auto-title generation. Runs only once per session
|
|
423
|
+
// per server lifetime, only when the session is still wearing its
|
|
424
|
+
// default title, and never blocks the user's stream (the user's
|
|
425
|
+
// turn is already complete by the time this runs).
|
|
426
|
+
this.maybeGenerateTitle(stream, finishedTurn);
|
|
427
|
+
// Autonomous refactor loop: when aexolActive is set, auto-send
|
|
428
|
+
// "continue" after each agent_end to keep the loop going.
|
|
429
|
+
if (stream.aexolActive && stream.aexolIterationCount < MAX_AEXOL_ITERATIONS) {
|
|
430
|
+
stream.aexolIterationCount++;
|
|
431
|
+
console.log(`[aexol-loop] iteration ${stream.aexolIterationCount}/${MAX_AEXOL_ITERATIONS}`);
|
|
432
|
+
void this.prompt(stream.sessionId, "continue", undefined).catch((err) => {
|
|
433
|
+
console.error(`[aexol-loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
434
|
+
stream.aexolActive = false;
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
else if (stream.aexolActive) {
|
|
438
|
+
console.log("[aexol-loop] max iterations reached, stopping");
|
|
439
|
+
stream.aexolActive = false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else if (event.type === "error") {
|
|
443
|
+
// An error event arriving outside a turn (or bubbling out of one) —
|
|
444
|
+
// discard partial buffer to avoid replaying half a turn that the
|
|
445
|
+
// client has already shown an error for. The error event itself is
|
|
446
|
+
// still broadcast above.
|
|
447
|
+
stream.currentTurn = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Auto-title the session if it's still wearing the default title and we
|
|
452
|
+
* haven't already attempted generation in this process. Fire-and-forget
|
|
453
|
+
* (errors are caught, logged, and swallowed) — the user's stream finished
|
|
454
|
+
* before this runs, so blocking would only delay the broadcast.
|
|
455
|
+
*/
|
|
456
|
+
maybeGenerateTitle(stream, finishedTurn) {
|
|
457
|
+
if (this.disableAutoTitle)
|
|
458
|
+
return;
|
|
459
|
+
if (this.titleGenerationAttempted.has(stream.sessionId))
|
|
460
|
+
return;
|
|
461
|
+
// Check the persisted title now (manual rename takes precedence).
|
|
462
|
+
const detail = this.store.getSession(stream.sessionId);
|
|
463
|
+
if (!detail || !isDefaultTitle(detail.title))
|
|
464
|
+
return;
|
|
465
|
+
// Find the first user message + the assistant text from the just-finished
|
|
466
|
+
// turn. We deliberately read user content from SQLite (authoritative)
|
|
467
|
+
// and assistant content from the in-memory turn buffer (cheap, and
|
|
468
|
+
// matches what the user just saw).
|
|
469
|
+
const firstUser = detail.messages.find((m) => m.role === "user");
|
|
470
|
+
if (!firstUser || !firstUser.content.trim())
|
|
471
|
+
return;
|
|
472
|
+
let assistantText = finishedTurn?.assistantText ?? "";
|
|
473
|
+
if (!assistantText) {
|
|
474
|
+
// Fallback: pull the most recent assistant message from SQLite. This
|
|
475
|
+
// can happen if `agent_end` fires for a turn whose buffer was cleared
|
|
476
|
+
// by an intervening error event, or when this code path is reached
|
|
477
|
+
// via a synthetic test event.
|
|
478
|
+
const lastAssistant = [...detail.messages]
|
|
479
|
+
.reverse()
|
|
480
|
+
.find((m) => m.role === "assistant");
|
|
481
|
+
assistantText = lastAssistant?.content ?? "";
|
|
482
|
+
}
|
|
483
|
+
// Mark BEFORE awaiting so a second `agent_end` arriving while we're
|
|
484
|
+
// generating doesn't double-fire. Even if generation throws, we leave
|
|
485
|
+
// the entry in place — one-shot semantics, no retries.
|
|
486
|
+
this.titleGenerationAttempted.add(stream.sessionId);
|
|
487
|
+
void this.runTitleGeneration(stream, firstUser.content, assistantText);
|
|
488
|
+
}
|
|
489
|
+
async runTitleGeneration(stream, firstUserMessage, firstAssistantMessage) {
|
|
490
|
+
try {
|
|
491
|
+
const title = await generateSessionTitle(firstUserMessage, firstAssistantMessage, {
|
|
492
|
+
cwd: stream.cwd,
|
|
493
|
+
agentDir: this.agentDir,
|
|
494
|
+
llmCall: this.titleLlmCall,
|
|
495
|
+
});
|
|
496
|
+
if (this.disposed)
|
|
497
|
+
return;
|
|
498
|
+
if (!title)
|
|
499
|
+
return;
|
|
500
|
+
// Re-check the title hasn't been changed underneath us while we were
|
|
501
|
+
// waiting on the LLM (e.g. user manually renamed mid-generation).
|
|
502
|
+
const current = this.store.getSession(stream.sessionId);
|
|
503
|
+
if (!current || !isDefaultTitle(current.title))
|
|
504
|
+
return;
|
|
505
|
+
const updated = this.store.renameSession(stream.sessionId, title);
|
|
506
|
+
if (!updated)
|
|
507
|
+
return;
|
|
508
|
+
// Broadcast to every subscriber of this session so all open tabs
|
|
509
|
+
// update their sidebar in real time. The wire event is independent
|
|
510
|
+
// of the in-flight turn lifecycle, so it's safe to fire post-agent_end.
|
|
511
|
+
this.broadcast(stream, {
|
|
512
|
+
type: "session_renamed",
|
|
513
|
+
sessionId: stream.sessionId,
|
|
514
|
+
title: updated.title,
|
|
515
|
+
});
|
|
516
|
+
// Cross-tab fan-out hint: tabs that don't have THIS session open
|
|
517
|
+
// (and so don't have a per-session ws subscription) still want to
|
|
518
|
+
// refresh their sidebar to show the new title. Best-effort; a
|
|
519
|
+
// failed publish never undoes the rename.
|
|
520
|
+
if (this.publishMetaEvent) {
|
|
521
|
+
try {
|
|
522
|
+
this.publishMetaEvent({
|
|
523
|
+
type: "session_renamed",
|
|
524
|
+
projectId: updated.projectId,
|
|
525
|
+
sessionId: stream.sessionId,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
530
|
+
console.warn(`[spectral] warn: meta publish for auto-title failed: ${msg}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
// Defensive: generateSessionTitle already swallows LLM errors. This
|
|
536
|
+
// catches any unexpected throw from rename/broadcast so the manager
|
|
537
|
+
// is never destabilized by a background title task.
|
|
538
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
539
|
+
console.warn(`[spectral] warn: auto-title pipeline failed: ${msg}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
broadcast(stream, event) {
|
|
543
|
+
const dead = [];
|
|
544
|
+
for (const sub of stream.subscribers) {
|
|
545
|
+
if (!sub.isOpen()) {
|
|
546
|
+
dead.push(sub);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
sub.send(event);
|
|
551
|
+
}
|
|
552
|
+
catch (err) {
|
|
553
|
+
console.error(`[spectral] error: failed to send WS frame to subscriber: ${err instanceof Error ? err.message : String(err)}`);
|
|
554
|
+
dead.push(sub);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
for (const sub of dead)
|
|
558
|
+
stream.subscribers.delete(sub);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function isReplayable(event) {
|
|
562
|
+
return (event.type === "message_start" ||
|
|
563
|
+
event.type === "text_delta" ||
|
|
564
|
+
event.type === "thinking_delta" ||
|
|
565
|
+
event.type === "tool_call" ||
|
|
566
|
+
event.type === "tool_result" ||
|
|
567
|
+
event.type === "message_end" ||
|
|
568
|
+
event.type === "error");
|
|
569
|
+
}
|
|
570
|
+
function snapshotTurn(turn) {
|
|
571
|
+
// Defensive copy of the events array so the snapshot can't be mutated by
|
|
572
|
+
// subsequent buffer pushes. Individual event objects are safe to share —
|
|
573
|
+
// they're never mutated after creation.
|
|
574
|
+
return {
|
|
575
|
+
turnId: turn.turnId,
|
|
576
|
+
startedAt: turn.startedAt,
|
|
577
|
+
events: turn.events.slice(),
|
|
578
|
+
};
|
|
579
|
+
}
|