@aexol/spectral 0.1.5 → 0.1.7

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.
@@ -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.
@@ -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` is set on the message, route to the spectral-loop
470
- // user model instead of the default session model.
471
- const effectiveModelId = isLoop ? "__spectral_loop__" : modelId;
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
@@ -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
- const stream = this.streams.get(sessionId);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,