@aexol/spectral 0.1.5 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands/serve.js
CHANGED
|
@@ -38,7 +38,7 @@ import { fileURLToPath } from "node:url";
|
|
|
38
38
|
import { getConfigDir } from "../config.js";
|
|
39
39
|
import { requireLogin } from "../preflight.js";
|
|
40
40
|
import { RelayClient } from "../relay/client.js";
|
|
41
|
-
import { detachAllSubscribers, handleClientMessage, handleRestRequest, handleSubscribe, } from "../relay/dispatcher.js";
|
|
41
|
+
import { detachAllSubscribers, handleCancelTurn, handleClientMessage, handleRestRequest, handleSubscribe, } from "../relay/dispatcher.js";
|
|
42
42
|
import { ensureMachineRegistered } from "../relay/registration.js";
|
|
43
43
|
import { SessionStreamManager } from "../server/session-stream.js";
|
|
44
44
|
import { gracefulShutdown } from "../server/shutdown.js";
|
|
@@ -252,6 +252,10 @@ export async function runServe(opts = {}) {
|
|
|
252
252
|
});
|
|
253
253
|
return;
|
|
254
254
|
}
|
|
255
|
+
if (frame.kind === "cancel_turn") {
|
|
256
|
+
handleCancelTurn(frame, { manager });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
255
259
|
// Other frames (error, machine_disconnected addressed to us, etc.)
|
|
256
260
|
// are ignored at this layer. Future batches may surface them in
|
|
257
261
|
// structured logs.
|
package/dist/relay/dispatcher.js
CHANGED
|
@@ -466,10 +466,9 @@ export function handleClientMessage(frame, deps) {
|
|
|
466
466
|
// against the team-scoped whitelist; the CLI resolves it via pi's
|
|
467
467
|
// own model registry inside the manager → bridge.
|
|
468
468
|
//
|
|
469
|
-
// When `loop: true
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
manager.prompt(sessionId, content, effectiveModelId, validImages).catch((err) => {
|
|
469
|
+
// When `loop: true`, loop state is set before prompting; the normal
|
|
470
|
+
// model is used — loop replay is handled by session-stream on agent_end.
|
|
471
|
+
manager.prompt(sessionId, content, modelId, validImages).catch((err) => {
|
|
473
472
|
logger.error?.(`[dispatcher] manager.prompt failed for ${sessionId}:`, err);
|
|
474
473
|
});
|
|
475
474
|
}
|
|
@@ -533,6 +532,16 @@ export function handleSubscribe(frame, deps) {
|
|
|
533
532
|
});
|
|
534
533
|
}
|
|
535
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Dispatch a `cancel_turn` frame. Disposes the session's pi bridge and
|
|
537
|
+
* removes the stream so the next user message creates a fresh one. The
|
|
538
|
+
* bridge dispose triggers `agent_end` broadcast to all subscribers.
|
|
539
|
+
*
|
|
540
|
+
* Idempotent: no-ops when no stream exists for the session.
|
|
541
|
+
*/
|
|
542
|
+
export function handleCancelTurn(frame, deps) {
|
|
543
|
+
deps.manager.cancelTurn(frame.sessionId);
|
|
544
|
+
}
|
|
536
545
|
/**
|
|
537
546
|
* Detach every subscriber the dispatcher has attached. Called by
|
|
538
547
|
* `serve.ts` on relay disconnect / shutdown so the underlying pi
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -70,7 +70,6 @@ import { fetchAllowedModels as defaultFetchAllowedModels, } from "../relay/model
|
|
|
70
70
|
const SPECTRAL_PROXY_ANTHROPIC = "spectral-proxy-anthropic";
|
|
71
71
|
const SPECTRAL_PROXY_OPENAI = "spectral-proxy-openai";
|
|
72
72
|
const SPECTRAL_PROXY_USER_MODEL = "spectral-proxy-user-model";
|
|
73
|
-
const SPECTRAL_PROXY_LOOP = "spectral-proxy-loop";
|
|
74
73
|
/**
|
|
75
74
|
* Concatenate text from an `AssistantMessage.content` array. Returns the
|
|
76
75
|
* empty string when no text blocks are present (tool-only turns) or when
|
|
@@ -388,30 +387,6 @@ export class PiBridge {
|
|
|
388
387
|
})),
|
|
389
388
|
});
|
|
390
389
|
}
|
|
391
|
-
// Loop model — dedicated endpoint for Spectral agent loop toggle.
|
|
392
|
-
// Routes to /models/built-in/loop/v1 using machine JWT auth.
|
|
393
|
-
{
|
|
394
|
-
const loopBaseUrl = `${this.opts.backendUrl.replace(/\/$/, "")}/models/built-in/loop/v1`;
|
|
395
|
-
this.modelRegistry.registerProvider(SPECTRAL_PROXY_LOOP, {
|
|
396
|
-
baseUrl: loopBaseUrl,
|
|
397
|
-
apiKey: this.opts.machineJwt,
|
|
398
|
-
authHeader: true,
|
|
399
|
-
api: "openai-completions",
|
|
400
|
-
models: [
|
|
401
|
-
{
|
|
402
|
-
id: "__spectral_loop__",
|
|
403
|
-
name: "Spectral Loop",
|
|
404
|
-
api: "openai-completions",
|
|
405
|
-
baseUrl: loopBaseUrl,
|
|
406
|
-
reasoning: false,
|
|
407
|
-
input: ["text", "image"],
|
|
408
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
409
|
-
contextWindow: 0,
|
|
410
|
-
maxTokens: 0,
|
|
411
|
-
},
|
|
412
|
-
],
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
390
|
}
|
|
416
391
|
/**
|
|
417
392
|
* Apply a sticky model selection to the underlying pi session, if it
|
|
@@ -147,9 +147,52 @@ export class SessionStreamManager {
|
|
|
147
147
|
async prompt(sessionId, content, modelId, images) {
|
|
148
148
|
if (this.disposed)
|
|
149
149
|
throw new Error("SessionStreamManager disposed");
|
|
150
|
-
|
|
150
|
+
let stream = this.streams.get(sessionId);
|
|
151
151
|
if (!stream)
|
|
152
152
|
throw new Error(`No active stream for session: ${sessionId}`);
|
|
153
|
+
// If the bridge was disposed (e.g. by cancelTurn), recreate it before
|
|
154
|
+
// proceeding. We rebuild only the bridge inside the existing stream so
|
|
155
|
+
// subscribers, cwd, and other metadata are preserved.
|
|
156
|
+
if (stream.startError) {
|
|
157
|
+
const history = this.store.getSession(sessionId)?.messages;
|
|
158
|
+
const bridgeOpts = {
|
|
159
|
+
cwd: stream.cwd,
|
|
160
|
+
agentDir: this.agentDir,
|
|
161
|
+
backendUrl: this.backendUrl,
|
|
162
|
+
machineJwt: this.machineJwt,
|
|
163
|
+
history,
|
|
164
|
+
emit: (event) => this.handleBridgeEvent(stream, event),
|
|
165
|
+
onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
|
|
166
|
+
try {
|
|
167
|
+
this.store.appendMessage(sessionId, {
|
|
168
|
+
id: messageId,
|
|
169
|
+
role: "assistant",
|
|
170
|
+
content,
|
|
171
|
+
eventsJsonl,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
console.error(`[spectral] error: failed to persist assistant message: ${err instanceof Error ? err.message : String(err)}`);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
onError: (err) => {
|
|
179
|
+
console.error(`[spectral] error: pi bridge error: ${err.message}`);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
stream.bridge = this.bridgeFactory(bridgeOpts);
|
|
183
|
+
stream.startError = null;
|
|
184
|
+
stream.ready = stream.bridge
|
|
185
|
+
.start()
|
|
186
|
+
.catch((err) => {
|
|
187
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
188
|
+
stream.startError = e;
|
|
189
|
+
this.broadcast(stream, {
|
|
190
|
+
type: "error",
|
|
191
|
+
message: `Failed to start agent: ${e.message}`,
|
|
192
|
+
});
|
|
193
|
+
throw e;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
153
196
|
// Wait for pi to be ready before we persist + invoke. If start failed,
|
|
154
197
|
// surface to all subscribers instead of throwing into the route handler.
|
|
155
198
|
try {
|
|
@@ -272,6 +315,49 @@ export class SessionStreamManager {
|
|
|
272
315
|
}
|
|
273
316
|
return n;
|
|
274
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Cancel the in-flight turn for a session (user pressed Stop in the UI).
|
|
320
|
+
* Disposes the pi bridge and broadcasts `agent_end` so all subscribers
|
|
321
|
+
* see the turn close. The stream itself is kept alive — the next user
|
|
322
|
+
* message (via `prompt()`) will lazily create a fresh bridge.
|
|
323
|
+
*
|
|
324
|
+
* Idempotent: if no stream exists for the session, or no turn is in
|
|
325
|
+
* flight, this is a no-op.
|
|
326
|
+
*/
|
|
327
|
+
cancelTurn(sessionId) {
|
|
328
|
+
const stream = this.streams.get(sessionId);
|
|
329
|
+
if (!stream)
|
|
330
|
+
return;
|
|
331
|
+
// Stop any active loop before disposing — if the loop was mid-iteration
|
|
332
|
+
// the next agent_end would otherwise trigger another prompt.
|
|
333
|
+
stream.loopActive = false;
|
|
334
|
+
stream.loopOriginalPrompt = null;
|
|
335
|
+
stream.loopIterationCount = 0;
|
|
336
|
+
// Dispose the pi bridge immediately — this tears down pi's session and
|
|
337
|
+
// unsubscribe. The bridge's own event handler is detached; no further
|
|
338
|
+
// events will flow. We broadcast agent_end ourselves below.
|
|
339
|
+
try {
|
|
340
|
+
stream.bridge.dispose();
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// ignore
|
|
344
|
+
}
|
|
345
|
+
// Set startError so any late `await stream.ready` in the loop path
|
|
346
|
+
// (if the loop was between iterations when we disposed) rejects cleanly
|
|
347
|
+
// instead of hanging. Also prevents the next prompt() from waiting on
|
|
348
|
+
// a dead bridge's ready promise.
|
|
349
|
+
stream.startError = new Error("Turn cancelled");
|
|
350
|
+
// Broadcast agent_end so all subscribers close their open turn and
|
|
351
|
+
// re-enable their composers.
|
|
352
|
+
if (stream.currentTurn) {
|
|
353
|
+
this.broadcast(stream, { type: "agent_end" });
|
|
354
|
+
stream.currentTurn = null;
|
|
355
|
+
}
|
|
356
|
+
// Don't delete the stream — the subscribers are still attached and the
|
|
357
|
+
// next `prompt()` call will create a fresh bridge via `createStream()`.
|
|
358
|
+
// We just need to signal that the bridge is gone so `prompt()` knows to
|
|
359
|
+
// recreate one.
|
|
360
|
+
}
|
|
275
361
|
/**
|
|
276
362
|
* Tear down a single session's stream — disposes the pi bridge and clears
|
|
277
363
|
* subscribers. Idempotent. Called by the routes layer right before
|