@mindstudio-ai/remy 0.1.293 → 0.1.294

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.
@@ -173,8 +173,9 @@ declare class HeadlessSession {
173
173
  */
174
174
  private isDrainBarrier;
175
175
  /**
176
- * Drain the queue in strict FIFO order. Caller must hold `running = true`.
177
- * User messages arriving during the drain will be enqueued behind current items.
176
+ * Drain the queue in FIFO order over its deliverable items. Caller must hold
177
+ * `running = true`. User messages arriving during the drain will be enqueued
178
+ * behind current items.
178
179
  *
179
180
  * The queue serves two purposes with opposite delivery semantics:
180
181
  * - Sequencer: chain steps and sentinel-bearing user items are pipeline
@@ -184,6 +185,11 @@ declare class HeadlessSession {
184
185
  * merged turn, so the model reconciles all of it at once instead of
185
186
  * burning a full turn per item (and possibly executing instructions a
186
187
  * later queued message already amended).
188
+ *
189
+ * Held items are not in the delivery sequence at all: the drain starts at the
190
+ * first deliverable item and never merges across a held one. It skips rather
191
+ * than stops because a held message sits at the head of the array — stopping
192
+ * there would strand the chain steps and background results behind it.
187
193
  */
188
194
  private drainQueueLoop;
189
195
  /**
@@ -205,17 +211,28 @@ declare class HeadlessSession {
205
211
  * every agent to "use server defaults". */
206
212
  private handleChangeModels;
207
213
  /**
208
- * Cancel the running turn and flush the follow-ups that belonged to it
209
- * (`chain`/`background`), while preserving `source: 'user'` items those are
210
- * independent user intent, not tied to the aborted run. The preserved user
211
- * messages run next: `executeTurn` swallows the abort, so `handleMessage`
212
- * falls through to `drainQueueLoop` with `running` still held. Returns the
213
- * flushed items (for the cancel command's resume/discard UX).
214
+ * Stop everything the user can see running: the turn, an in-flight
215
+ * compaction, and any external tool waiting on a result. Flushes the
216
+ * follow-ups that belonged to the turn (`chain`/`background`) and HOLDS the
217
+ * `source: 'user'` items those are independent user intent, so they're
218
+ * kept, but they no longer run on their own.
219
+ *
220
+ * Holding is the difference between Stop working and Stop looking broken.
221
+ * These items used to drain immediately: `executeTurn` swallows the abort,
222
+ * so `handleMessage` fell through to `drainQueueLoop` with `running` still
223
+ * held and the next turn began in the same tick — the spinner never stopped,
224
+ * and every additional press hit a turn that had just started. They now wait
225
+ * in the queue card until the user sends again or promotes one.
226
+ *
227
+ * A compaction is cancelled here too, unconditionally. It gates every queued
228
+ * message and outlives the turn that started it, so leaving it running means
229
+ * Stop can't reach idle. The cost is the summary work in flight; the forced
230
+ * gate re-compacts on the next turn if the context is still too big.
214
231
  *
215
- * Messages already absorbed into the in-flight merged turn are NOT
216
- * preserved — they were delivered into the turn that's being cancelled and
217
- * each gets a `{cancelled, absorbed:true}` terminal. Only items still
218
- * sitting in the queue survive.
232
+ * Messages already absorbed into the in-flight merged turn are NOT held —
233
+ * they were delivered into the turn that's being cancelled and each gets a
234
+ * `{cancelled, absorbed:true}` terminal. Only items still sitting in the
235
+ * queue survive.
219
236
  */
220
237
  private handleCancel;
221
238
  /**
package/dist/headless.js CHANGED
@@ -212,12 +212,17 @@ async function* streamChat(params) {
212
212
  }
213
213
  const isStall = err?.message === "stream_stall";
214
214
  const errorMessage = isStall ? "Stream stalled \u2014 no data received for 5 minutes" : `Network error: stream interrupted \u2014 ${err?.message ?? "unknown"}`;
215
- log2.error(isStall ? "Stream stalled" : "Stream interrupted", {
216
- requestId,
217
- ...subAgentId && { subAgentId },
218
- durationMs: Date.now() - startTime,
219
- error: errorMessage
220
- });
215
+ const wasAborted = !isStall && !!signal?.aborted;
216
+ const logAt = wasAborted ? log2.warn : log2.error;
217
+ logAt(
218
+ wasAborted ? "Request aborted mid-stream" : isStall ? "Stream stalled" : "Stream interrupted",
219
+ {
220
+ requestId,
221
+ ...subAgentId && { subAgentId },
222
+ durationMs: Date.now() - startTime,
223
+ error: errorMessage
224
+ }
225
+ );
221
226
  yield { type: "error", error: errorMessage };
222
227
  return;
223
228
  }
@@ -2075,7 +2080,9 @@ var compactConversationTool = {
2075
2080
  onBackgroundComplete?.(
2076
2081
  toolCallId,
2077
2082
  "compactConversation",
2078
- `Error: ${err.message || "Compaction failed"}`
2083
+ // A cancel is the user's own doing — report it as an outcome, not as
2084
+ // an error the block renders as a failure.
2085
+ err instanceof CompactionCancelledError ? err.message : `Error: ${err.message || "Compaction failed"}`
2079
2086
  );
2080
2087
  }).finally(() => {
2081
2088
  toolRegistry?.unregister(toolCallId);
@@ -6856,7 +6863,7 @@ var log10 = createLogger("compaction");
6856
6863
  var CONVERSATION_SUMMARY_PROMPT = readAsset("compaction", "conversation.md");
6857
6864
  var SUBAGENT_SUMMARY_PROMPT = readAsset("compaction", "subagent.md");
6858
6865
  var SUMMARIZABLE_SUBAGENTS = ["visualDesignExpert", "productVision"];
6859
- async function compactConversation(messages, apiConfig, model) {
6866
+ async function compactConversation(messages, apiConfig, model, signal) {
6860
6867
  const endIndex = findSafeInsertionPoint(messages);
6861
6868
  const boundary = endIndex > 0 ? messages[endIndex - 1] : null;
6862
6869
  const summaries = [];
@@ -6873,7 +6880,8 @@ async function compactConversation(messages, apiConfig, model) {
6873
6880
  "conversation",
6874
6881
  CONVERSATION_SUMMARY_PROMPT,
6875
6882
  conversationMessages,
6876
- model
6883
+ model,
6884
+ { signal }
6877
6885
  ).then((text) => {
6878
6886
  if (text) {
6879
6887
  summaries.push({ name: "conversation", text });
@@ -6896,7 +6904,8 @@ async function compactConversation(messages, apiConfig, model) {
6896
6904
  name,
6897
6905
  SUBAGENT_SUMMARY_PROMPT,
6898
6906
  subagentMessages,
6899
- model
6907
+ model,
6908
+ { signal }
6900
6909
  ).then((text) => {
6901
6910
  if (text) {
6902
6911
  summaries.push({ name, text });
@@ -6912,7 +6921,7 @@ async function compactConversation(messages, apiConfig, model) {
6912
6921
  await Promise.all(tasks);
6913
6922
  if (conversationFailed) {
6914
6923
  throw new Error(
6915
- "Could not summarize the conversation \u2014 the model did not return a usable summary. History left intact."
6924
+ signal?.aborted ? "Compaction cancelled. History left intact." : "Could not summarize the conversation \u2014 the model did not return a usable summary. History left intact."
6916
6925
  );
6917
6926
  }
6918
6927
  const recent = collectRecentNarrative(conversationMessages);
@@ -7152,7 +7161,10 @@ async function generateSummary(apiConfig, name, compactionPrompt, messagesToSumm
7152
7161
  // A split driven by size gets its children a retry of their own. A
7153
7162
  // split that IS the retry does not, or a model that will not
7154
7163
  // summarize at any size fans out call after call before giving up.
7155
- { allowRetry: opts.forceChunk ? false : allowRetry }
7164
+ {
7165
+ allowRetry: opts.forceChunk ? false : allowRetry,
7166
+ ...opts.signal && { signal: opts.signal }
7167
+ }
7156
7168
  )
7157
7169
  )
7158
7170
  );
@@ -7175,7 +7187,8 @@ async function generateSummary(apiConfig, name, compactionPrompt, messagesToSumm
7175
7187
  name,
7176
7188
  compactionPrompt,
7177
7189
  serialized,
7178
- model
7190
+ model,
7191
+ opts.signal
7179
7192
  );
7180
7193
  if (summaryText === null) {
7181
7194
  return null;
@@ -7203,7 +7216,7 @@ async function generateSummary(apiConfig, name, compactionPrompt, messagesToSumm
7203
7216
  { forceChunk: true, allowRetry: false }
7204
7217
  );
7205
7218
  }
7206
- async function runSummaryCall(apiConfig, name, compactionPrompt, serialized, model) {
7219
+ async function runSummaryCall(apiConfig, name, compactionPrompt, serialized, model, signal) {
7207
7220
  const userContent = `Conversation to summarize:
7208
7221
 
7209
7222
  ${serialized}
@@ -7225,7 +7238,8 @@ Write the summary of the conversation above, following your instructions.`;
7225
7238
  // Each summary call carries a unique 100-200KB chunk that is never
7226
7239
  // re-read (parallel siblings can't read each other's in-flight writes
7227
7240
  // either) — a cache write here is pure waste.
7228
- cachePolicy: "oneshot"
7241
+ cachePolicy: "oneshot",
7242
+ ...signal && { signal }
7229
7243
  })) {
7230
7244
  if (event.type === "text") {
7231
7245
  summaryText += event.text;
@@ -7249,7 +7263,11 @@ Write the summary of the conversation above, following your instructions.`;
7249
7263
  }
7250
7264
  }
7251
7265
  if (!summaryText.trim()) {
7252
- log10.warn("Empty summary generated", { name });
7266
+ if (signal?.aborted) {
7267
+ log10.info("Summary cancelled", { name });
7268
+ } else {
7269
+ log10.warn("Empty summary generated", { name });
7270
+ }
7253
7271
  return null;
7254
7272
  }
7255
7273
  return summaryText.trim();
@@ -7610,11 +7628,26 @@ function clearSession(state) {
7610
7628
 
7611
7629
  // src/compaction/trigger.ts
7612
7630
  var log12 = createLogger("compaction:trigger");
7631
+ var CompactionCancelledError = class extends Error {
7632
+ constructor() {
7633
+ super("Compaction cancelled \u2014 no checkpoint was created.");
7634
+ this.name = "CompactionCancelledError";
7635
+ }
7636
+ };
7613
7637
  var pending = null;
7614
7638
  var inflightCompaction = null;
7639
+ var inflightAbort = null;
7615
7640
  function getInflightCompaction() {
7616
7641
  return inflightCompaction;
7617
7642
  }
7643
+ function cancelInflightCompaction() {
7644
+ if (!inflightCompaction || !inflightAbort) {
7645
+ return false;
7646
+ }
7647
+ log12.info("Cancelling in-flight compaction");
7648
+ inflightAbort.abort();
7649
+ return true;
7650
+ }
7618
7651
  function summariesOf(result) {
7619
7652
  const out = [];
7620
7653
  for (const msg of result.checkpoints) {
@@ -7687,10 +7720,13 @@ function triggerCompaction(state, apiConfig, opts = {}) {
7687
7720
  }
7688
7721
  const { blocking = false, requestId, model, origin, toolCallId } = opts;
7689
7722
  listener?.({ type: "started", blocking, requestId, origin, toolCallId });
7723
+ const abort = new AbortController();
7724
+ inflightAbort = abort;
7690
7725
  inflightCompaction = compactConversation(
7691
7726
  state.messages,
7692
7727
  apiConfig,
7693
- resolveModel("conversationSummarizer", state.models, model)
7728
+ resolveModel("conversationSummarizer", state.models, model),
7729
+ abort.signal
7694
7730
  ).then((result) => {
7695
7731
  pending = result;
7696
7732
  const summaries = summariesOf(result);
@@ -7702,12 +7738,23 @@ function triggerCompaction(state, apiConfig, opts = {}) {
7702
7738
  log12.info("Compaction complete");
7703
7739
  return summaries;
7704
7740
  }).catch((err) => {
7705
- const message = err.message || "Compaction failed";
7706
- listener?.({ type: "complete", error: message, requestId });
7707
- log12.error("Compaction failed", { error: message });
7708
- throw err;
7741
+ const cancelled = abort.signal.aborted;
7742
+ const message = cancelled ? "Compaction cancelled" : err.message || "Compaction failed";
7743
+ listener?.({
7744
+ type: "complete",
7745
+ error: message,
7746
+ ...cancelled && { cancelled: true },
7747
+ requestId
7748
+ });
7749
+ if (cancelled) {
7750
+ log12.info("Compaction cancelled");
7751
+ } else {
7752
+ log12.error("Compaction failed", { error: message });
7753
+ }
7754
+ throw cancelled ? new CompactionCancelledError() : err;
7709
7755
  }).finally(() => {
7710
7756
  inflightCompaction = null;
7757
+ inflightAbort = null;
7711
7758
  });
7712
7759
  return inflightCompaction;
7713
7760
  }
@@ -9197,6 +9244,11 @@ function writeStats(stats, queue, passiveResults) {
9197
9244
  }
9198
9245
 
9199
9246
  // src/headless/messageQueue.ts
9247
+ function holdRestoredUserItems(items) {
9248
+ return items.map(
9249
+ (item) => item.source === "user" ? { ...item, held: true } : item
9250
+ );
9251
+ }
9200
9252
  var MessageQueue = class {
9201
9253
  items = [];
9202
9254
  onChange;
@@ -9208,33 +9260,36 @@ var MessageQueue = class {
9208
9260
  this.items.push(item);
9209
9261
  this.onChange?.();
9210
9262
  }
9211
- shift() {
9212
- const item = this.items.shift();
9263
+ /**
9264
+ * Index of the first deliverable item, or -1 when there is none.
9265
+ *
9266
+ * Held items are out of the delivery sequence, so the drain skips past them
9267
+ * rather than stopping at them: a message the user parked sits at the head
9268
+ * of the array, and stopping there would strand Remy's own chain steps and
9269
+ * background results queued behind it.
9270
+ */
9271
+ firstDeliverableIndex() {
9272
+ return this.items.findIndex((item) => !item.held);
9273
+ }
9274
+ /** Remove and return the item at `i`; fires onChange. */
9275
+ takeAt(i) {
9276
+ const [item] = this.items.splice(i, 1);
9213
9277
  if (item) {
9214
9278
  this.onChange?.();
9215
9279
  }
9216
9280
  return item;
9217
9281
  }
9218
- /** Remove and return the first `n` items; fires onChange once. */
9219
- shiftMany(n) {
9220
- if (n <= 0) {
9282
+ /** Remove and return `count` items starting at `start`; fires onChange once. */
9283
+ takeRange(start, count) {
9284
+ if (count <= 0) {
9221
9285
  return [];
9222
9286
  }
9223
- const items = this.items.splice(0, n);
9287
+ const items = this.items.splice(start, count);
9224
9288
  if (items.length > 0) {
9225
9289
  this.onChange?.();
9226
9290
  }
9227
9291
  return items;
9228
9292
  }
9229
- /** Remove and return all queued items. */
9230
- drain() {
9231
- if (this.items.length === 0) {
9232
- return [];
9233
- }
9234
- const all = this.items.splice(0);
9235
- this.onChange?.();
9236
- return all;
9237
- }
9238
9293
  /**
9239
9294
  * Remove all items matching `predicate`. Fires onChange only if something
9240
9295
  * was removed. Returns the removed items.
@@ -9253,6 +9308,49 @@ var MessageQueue = class {
9253
9308
  }
9254
9309
  return removed;
9255
9310
  }
9311
+ /**
9312
+ * Mark matching items `held` — waiting on the user rather than on the agent.
9313
+ * Fires onChange only if something changed. Returns the held items.
9314
+ */
9315
+ holdWhere(predicate) {
9316
+ const held = [];
9317
+ let changed = false;
9318
+ for (const item of this.items) {
9319
+ if (!predicate(item)) {
9320
+ continue;
9321
+ }
9322
+ changed = changed || !item.held;
9323
+ item.held = true;
9324
+ held.push(item);
9325
+ }
9326
+ if (changed) {
9327
+ this.onChange?.();
9328
+ }
9329
+ return held;
9330
+ }
9331
+ /**
9332
+ * Release held items so the normal drain picks them up again — all of them,
9333
+ * or one by command requestId. Fires onChange only if something changed.
9334
+ * Returns the released items.
9335
+ */
9336
+ releaseHeld(id) {
9337
+ const released = [];
9338
+ for (const item of this.items) {
9339
+ if (!item.held || id !== void 0 && item.command.requestId !== id) {
9340
+ continue;
9341
+ }
9342
+ delete item.held;
9343
+ released.push(item);
9344
+ }
9345
+ if (released.length > 0) {
9346
+ this.onChange?.();
9347
+ }
9348
+ return released;
9349
+ }
9350
+ /** Whether anything in the queue will drain on its own (i.e. isn't held). */
9351
+ hasDeliverable() {
9352
+ return this.items.some((item) => !item.held);
9353
+ }
9256
9354
  /**
9257
9355
  * Change a queued item's delivery semantics, keyed by its command
9258
9356
  * requestId. Fires onChange (→ persist + queue_changed) on success.
@@ -9268,14 +9366,35 @@ var MessageQueue = class {
9268
9366
  this.onChange?.();
9269
9367
  return item;
9270
9368
  }
9369
+ /**
9370
+ * Promote an item to ASAP: release any hold, and move it to the head so it
9371
+ * is the next thing delivered. One onChange for all of it.
9372
+ *
9373
+ * Position matters only when no turn is running — mid-turn, ASAP items are
9374
+ * pulled by predicate at the next tool boundary regardless of where they sit
9375
+ * (`takeSteering`), jumping whatever is queued ahead of them. Moving to the
9376
+ * head makes the idle case behave the same way, and matches the card, which
9377
+ * already renders promoted items above everything else.
9378
+ *
9379
+ * Returns the item, or undefined if nothing matches (e.g. it was already
9380
+ * consumed by the running turn).
9381
+ */
9382
+ promoteToFront(id) {
9383
+ const idx = this.items.findIndex((it) => it.command.requestId === id);
9384
+ if (idx === -1) {
9385
+ return void 0;
9386
+ }
9387
+ const [item] = this.items.splice(idx, 1);
9388
+ item.delivery = "asap";
9389
+ delete item.held;
9390
+ this.items.unshift(item);
9391
+ this.onChange?.();
9392
+ return item;
9393
+ }
9271
9394
  /** Copy of current queue contents (for surfacing on events). */
9272
9395
  snapshot() {
9273
9396
  return [...this.items];
9274
9397
  }
9275
- /** Return the next item without removing it. */
9276
- peek() {
9277
- return this.items[0];
9278
- }
9279
9398
  /** Return the item at index `i` without removing it. */
9280
9399
  peekAt(i) {
9281
9400
  return this.items[i];
@@ -9375,7 +9494,7 @@ var HeadlessSession = class {
9375
9494
  });
9376
9495
  await initOrgContext(this.config);
9377
9496
  const resumed = loadSession(this.state);
9378
- this.queue = new MessageQueue(loadQueue(), () => {
9497
+ this.queue = new MessageQueue(holdRestoredUserItems(loadQueue()), () => {
9379
9498
  this.persistStats();
9380
9499
  this.emit("queue_changed", { queuedMessages: this.queue.snapshot() });
9381
9500
  });
@@ -9428,13 +9547,16 @@ var HeadlessSession = class {
9428
9547
  });
9429
9548
  }
9430
9549
  } else {
9431
- const data = event.error ? { error: event.error } : {};
9550
+ const data = {
9551
+ ...event.error && { error: event.error },
9552
+ ...event.cancelled && { cancelled: true }
9553
+ };
9432
9554
  this.emit("compaction_complete", data, event.requestId);
9433
9555
  if (this.syntheticCompactionId) {
9434
9556
  const id = this.syntheticCompactionId;
9435
9557
  this.syntheticCompactionId = null;
9436
- const result = event.error ? `Error: ${event.error}` : formatSummariesResult(event.summaries ?? []);
9437
- const isError = !!event.error;
9558
+ const result = event.cancelled ? "Compaction cancelled \u2014 no checkpoint was created." : event.error ? `Error: ${event.error}` : formatSummariesResult(event.summaries ?? []);
9559
+ const isError = !!event.error && !event.cancelled;
9438
9560
  for (let i = this.state.messages.length - 1; i >= 0; i--) {
9439
9561
  const msg = this.state.messages[i];
9440
9562
  if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
@@ -10115,7 +10237,11 @@ var HeadlessSession = class {
10115
10237
  this.applyPendingBlockUpdates();
10116
10238
  }
10117
10239
  async handleMessage(parsed, requestId) {
10118
- if (this.running || getInflightCompaction()) {
10240
+ const foldIn = !this.running && !getInflightCompaction() && this.queue.length > 0 && !isAutomatedMessage(parsed.text ?? "");
10241
+ if (foldIn) {
10242
+ this.queue.releaseHeld();
10243
+ }
10244
+ if (this.running || getInflightCompaction() || foldIn) {
10119
10245
  const command = { ...parsed };
10120
10246
  if (requestId && command.requestId === void 0) {
10121
10247
  command.requestId = requestId;
@@ -10152,8 +10278,9 @@ var HeadlessSession = class {
10152
10278
  return item.source === "user" && isAutomatedMessage(item.command.text ?? "");
10153
10279
  }
10154
10280
  /**
10155
- * Drain the queue in strict FIFO order. Caller must hold `running = true`.
10156
- * User messages arriving during the drain will be enqueued behind current items.
10281
+ * Drain the queue in FIFO order over its deliverable items. Caller must hold
10282
+ * `running = true`. User messages arriving during the drain will be enqueued
10283
+ * behind current items.
10157
10284
  *
10158
10285
  * The queue serves two purposes with opposite delivery semantics:
10159
10286
  * - Sequencer: chain steps and sentinel-bearing user items are pipeline
@@ -10163,12 +10290,21 @@ var HeadlessSession = class {
10163
10290
  * merged turn, so the model reconciles all of it at once instead of
10164
10291
  * burning a full turn per item (and possibly executing instructions a
10165
10292
  * later queued message already amended).
10293
+ *
10294
+ * Held items are not in the delivery sequence at all: the drain starts at the
10295
+ * first deliverable item and never merges across a held one. It skips rather
10296
+ * than stops because a held message sits at the head of the array — stopping
10297
+ * there would strand the chain steps and background results behind it.
10166
10298
  */
10167
10299
  async drainQueueLoop() {
10168
- while (this.queue.length > 0) {
10169
- const head = this.queue.peek();
10300
+ for (; ; ) {
10301
+ const at = this.queue.firstDeliverableIndex();
10302
+ if (at === -1) {
10303
+ return;
10304
+ }
10305
+ const head = this.queue.peekAt(at);
10170
10306
  if (head.command.action === "compact") {
10171
- this.queue.shift();
10307
+ this.queue.takeAt(at);
10172
10308
  await triggerCompaction(this.state, this.config, {
10173
10309
  blocking: true,
10174
10310
  requestId: head.command.requestId,
@@ -10180,13 +10316,13 @@ var HeadlessSession = class {
10180
10316
  continue;
10181
10317
  }
10182
10318
  if (head.source === "chain") {
10183
- const item = this.queue.shift();
10319
+ const item = this.queue.takeAt(at);
10184
10320
  const rid = item.command.requestId ?? `chain-${Date.now()}`;
10185
10321
  await this.runSingleTurn(item.command, rid, true);
10186
10322
  continue;
10187
10323
  }
10188
10324
  if (this.isDrainBarrier(head)) {
10189
- const item = this.queue.shift();
10325
+ const item = this.queue.takeAt(at);
10190
10326
  const rid = item.command.requestId ?? `user-${Date.now()}`;
10191
10327
  await this.runSingleTurn(item.command, rid, false, true);
10192
10328
  continue;
@@ -10194,8 +10330,8 @@ var HeadlessSession = class {
10194
10330
  let n = 1;
10195
10331
  let batchOb = head.command.onboardingState;
10196
10332
  for (; ; n++) {
10197
- const it = this.queue.peekAt(n);
10198
- if (!it || it.source === "chain" || this.isDrainBarrier(it)) {
10333
+ const it = this.queue.peekAt(at + n);
10334
+ if (!it || it.held || it.source === "chain" || this.isDrainBarrier(it)) {
10199
10335
  break;
10200
10336
  }
10201
10337
  const ob = it.command.onboardingState;
@@ -10206,7 +10342,7 @@ var HeadlessSession = class {
10206
10342
  batchOb = ob;
10207
10343
  }
10208
10344
  }
10209
- const batch = this.queue.shiftMany(n);
10345
+ const batch = this.queue.takeRange(at, n);
10210
10346
  await this.runMergedTurn(batch);
10211
10347
  }
10212
10348
  }
@@ -10216,7 +10352,7 @@ var HeadlessSession = class {
10216
10352
  * and by kickDrain (background-completion-initiated).
10217
10353
  */
10218
10354
  async resumeQueue() {
10219
- if (this.running || this.queue.length === 0) {
10355
+ if (this.running || !this.queue.hasDeliverable()) {
10220
10356
  return;
10221
10357
  }
10222
10358
  this.running = true;
@@ -10235,7 +10371,7 @@ var HeadlessSession = class {
10235
10371
  * racing any currently-synchronous path.
10236
10372
  */
10237
10373
  kickDrain() {
10238
- if (this.running || this.queue.length === 0) {
10374
+ if (this.running || !this.queue.hasDeliverable()) {
10239
10375
  return;
10240
10376
  }
10241
10377
  setTimeout(() => this.resumeQueue(), 0);
@@ -10264,28 +10400,42 @@ var HeadlessSession = class {
10264
10400
  };
10265
10401
  }
10266
10402
  /**
10267
- * Cancel the running turn and flush the follow-ups that belonged to it
10268
- * (`chain`/`background`), while preserving `source: 'user'` items those are
10269
- * independent user intent, not tied to the aborted run. The preserved user
10270
- * messages run next: `executeTurn` swallows the abort, so `handleMessage`
10271
- * falls through to `drainQueueLoop` with `running` still held. Returns the
10272
- * flushed items (for the cancel command's resume/discard UX).
10403
+ * Stop everything the user can see running: the turn, an in-flight
10404
+ * compaction, and any external tool waiting on a result. Flushes the
10405
+ * follow-ups that belonged to the turn (`chain`/`background`) and HOLDS the
10406
+ * `source: 'user'` items those are independent user intent, so they're
10407
+ * kept, but they no longer run on their own.
10408
+ *
10409
+ * Holding is the difference between Stop working and Stop looking broken.
10410
+ * These items used to drain immediately: `executeTurn` swallows the abort,
10411
+ * so `handleMessage` fell through to `drainQueueLoop` with `running` still
10412
+ * held and the next turn began in the same tick — the spinner never stopped,
10413
+ * and every additional press hit a turn that had just started. They now wait
10414
+ * in the queue card until the user sends again or promotes one.
10273
10415
  *
10274
- * Messages already absorbed into the in-flight merged turn are NOT
10275
- * preserved they were delivered into the turn that's being cancelled and
10276
- * each gets a `{cancelled, absorbed:true}` terminal. Only items still
10277
- * sitting in the queue survive.
10416
+ * A compaction is cancelled here too, unconditionally. It gates every queued
10417
+ * message and outlives the turn that started it, so leaving it running means
10418
+ * Stop can't reach idle. The cost is the summary work in flight; the forced
10419
+ * gate re-compacts on the next turn if the context is still too big.
10420
+ *
10421
+ * Messages already absorbed into the in-flight merged turn are NOT held —
10422
+ * they were delivered into the turn that's being cancelled and each gets a
10423
+ * `{cancelled, absorbed:true}` terminal. Only items still sitting in the
10424
+ * queue survive.
10278
10425
  */
10279
10426
  handleCancel() {
10280
10427
  if (this.currentAbort) {
10281
10428
  this.currentAbort.abort();
10282
10429
  }
10430
+ const cancelledCompaction = cancelInflightCompaction();
10283
10431
  for (const [id, pending2] of this.pendingTools) {
10284
10432
  clearTimeout(pending2.timeout);
10285
10433
  pending2.resolve(USER_CANCELLED_RESULT);
10286
10434
  this.pendingTools.delete(id);
10287
10435
  }
10288
- return this.queue.removeWhere((item) => item.source !== "user");
10436
+ const flushed = this.queue.removeWhere((item) => item.source !== "user");
10437
+ const held = this.queue.holdWhere((item) => item.source === "user");
10438
+ return { flushed, held, cancelledCompaction };
10289
10439
  }
10290
10440
  /**
10291
10441
  * Remove pending queued messages — all user messages, or one by id.
@@ -10394,12 +10544,14 @@ var HeadlessSession = class {
10394
10544
  return;
10395
10545
  }
10396
10546
  if (action === "cancel") {
10397
- const cancelled = this.handleCancel();
10547
+ const { flushed, held, cancelledCompaction } = this.handleCancel();
10398
10548
  this.emit(
10399
10549
  "completed",
10400
10550
  {
10401
10551
  success: true,
10402
- ...cancelled.length > 0 && { cancelledMessages: cancelled }
10552
+ ...flushed.length > 0 && { cancelledMessages: flushed },
10553
+ ...held.length > 0 && { heldMessages: held },
10554
+ ...cancelledCompaction && { cancelledCompaction: true }
10403
10555
  },
10404
10556
  requestId
10405
10557
  );
@@ -10438,7 +10590,12 @@ var HeadlessSession = class {
10438
10590
  );
10439
10591
  return;
10440
10592
  }
10441
- this.queue.setDelivery(id, delivery);
10593
+ if (delivery === "asap") {
10594
+ this.queue.promoteToFront(id);
10595
+ this.kickDrain();
10596
+ } else {
10597
+ this.queue.setDelivery(id, delivery);
10598
+ }
10442
10599
  this.emit("completed", { success: true }, requestId);
10443
10600
  return;
10444
10601
  }
@@ -10504,7 +10661,7 @@ var HeadlessSession = class {
10504
10661
  }
10505
10662
  this.emit("completed", { success: true }, requestId);
10506
10663
  } catch (err) {
10507
- const error = err.message || "Compaction failed";
10664
+ const error = err instanceof CompactionCancelledError ? "cancelled" : err.message || "Compaction failed";
10508
10665
  this.emit("completed", { success: false, error }, requestId);
10509
10666
  }
10510
10667
  return;
@@ -10522,7 +10679,7 @@ var HeadlessSession = class {
10522
10679
  );
10523
10680
  return;
10524
10681
  }
10525
- if (this.queue.length === 0) {
10682
+ if (!this.queue.hasDeliverable()) {
10526
10683
  this.emit("completed", { success: true }, requestId);
10527
10684
  return;
10528
10685
  }
package/dist/index.js CHANGED
@@ -195,12 +195,17 @@ async function* streamChat(params) {
195
195
  }
196
196
  const isStall = err?.message === "stream_stall";
197
197
  const errorMessage = isStall ? "Stream stalled \u2014 no data received for 5 minutes" : `Network error: stream interrupted \u2014 ${err?.message ?? "unknown"}`;
198
- log.error(isStall ? "Stream stalled" : "Stream interrupted", {
199
- requestId,
200
- ...subAgentId && { subAgentId },
201
- durationMs: Date.now() - startTime,
202
- error: errorMessage
203
- });
198
+ const wasAborted = !isStall && !!signal?.aborted;
199
+ const logAt = wasAborted ? log.warn : log.error;
200
+ logAt(
201
+ wasAborted ? "Request aborted mid-stream" : isStall ? "Stream stalled" : "Stream interrupted",
202
+ {
203
+ requestId,
204
+ ...subAgentId && { subAgentId },
205
+ durationMs: Date.now() - startTime,
206
+ error: errorMessage
207
+ }
208
+ );
204
209
  yield { type: "error", error: errorMessage };
205
210
  return;
206
211
  }
@@ -1677,7 +1682,7 @@ var init_sentinel = __esm({
1677
1682
  });
1678
1683
 
1679
1684
  // src/compaction/index.ts
1680
- async function compactConversation(messages, apiConfig, model) {
1685
+ async function compactConversation(messages, apiConfig, model, signal) {
1681
1686
  const endIndex = findSafeInsertionPoint(messages);
1682
1687
  const boundary = endIndex > 0 ? messages[endIndex - 1] : null;
1683
1688
  const summaries = [];
@@ -1694,7 +1699,8 @@ async function compactConversation(messages, apiConfig, model) {
1694
1699
  "conversation",
1695
1700
  CONVERSATION_SUMMARY_PROMPT,
1696
1701
  conversationMessages,
1697
- model
1702
+ model,
1703
+ { signal }
1698
1704
  ).then((text) => {
1699
1705
  if (text) {
1700
1706
  summaries.push({ name: "conversation", text });
@@ -1717,7 +1723,8 @@ async function compactConversation(messages, apiConfig, model) {
1717
1723
  name,
1718
1724
  SUBAGENT_SUMMARY_PROMPT,
1719
1725
  subagentMessages,
1720
- model
1726
+ model,
1727
+ { signal }
1721
1728
  ).then((text) => {
1722
1729
  if (text) {
1723
1730
  summaries.push({ name, text });
@@ -1733,7 +1740,7 @@ async function compactConversation(messages, apiConfig, model) {
1733
1740
  await Promise.all(tasks);
1734
1741
  if (conversationFailed) {
1735
1742
  throw new Error(
1736
- "Could not summarize the conversation \u2014 the model did not return a usable summary. History left intact."
1743
+ signal?.aborted ? "Compaction cancelled. History left intact." : "Could not summarize the conversation \u2014 the model did not return a usable summary. History left intact."
1737
1744
  );
1738
1745
  }
1739
1746
  const recent = collectRecentNarrative(conversationMessages);
@@ -1969,7 +1976,10 @@ async function generateSummary(apiConfig, name, compactionPrompt, messagesToSumm
1969
1976
  // A split driven by size gets its children a retry of their own. A
1970
1977
  // split that IS the retry does not, or a model that will not
1971
1978
  // summarize at any size fans out call after call before giving up.
1972
- { allowRetry: opts.forceChunk ? false : allowRetry }
1979
+ {
1980
+ allowRetry: opts.forceChunk ? false : allowRetry,
1981
+ ...opts.signal && { signal: opts.signal }
1982
+ }
1973
1983
  )
1974
1984
  )
1975
1985
  );
@@ -1992,7 +2002,8 @@ async function generateSummary(apiConfig, name, compactionPrompt, messagesToSumm
1992
2002
  name,
1993
2003
  compactionPrompt,
1994
2004
  serialized,
1995
- model
2005
+ model,
2006
+ opts.signal
1996
2007
  );
1997
2008
  if (summaryText === null) {
1998
2009
  return null;
@@ -2020,7 +2031,7 @@ async function generateSummary(apiConfig, name, compactionPrompt, messagesToSumm
2020
2031
  { forceChunk: true, allowRetry: false }
2021
2032
  );
2022
2033
  }
2023
- async function runSummaryCall(apiConfig, name, compactionPrompt, serialized, model) {
2034
+ async function runSummaryCall(apiConfig, name, compactionPrompt, serialized, model, signal) {
2024
2035
  const userContent = `Conversation to summarize:
2025
2036
 
2026
2037
  ${serialized}
@@ -2042,7 +2053,8 @@ Write the summary of the conversation above, following your instructions.`;
2042
2053
  // Each summary call carries a unique 100-200KB chunk that is never
2043
2054
  // re-read (parallel siblings can't read each other's in-flight writes
2044
2055
  // either) — a cache write here is pure waste.
2045
- cachePolicy: "oneshot"
2056
+ cachePolicy: "oneshot",
2057
+ ...signal && { signal }
2046
2058
  })) {
2047
2059
  if (event.type === "text") {
2048
2060
  summaryText += event.text;
@@ -2066,7 +2078,11 @@ Write the summary of the conversation above, following your instructions.`;
2066
2078
  }
2067
2079
  }
2068
2080
  if (!summaryText.trim()) {
2069
- log2.warn("Empty summary generated", { name });
2081
+ if (signal?.aborted) {
2082
+ log2.info("Summary cancelled", { name });
2083
+ } else {
2084
+ log2.warn("Empty summary generated", { name });
2085
+ }
2070
2086
  return null;
2071
2087
  }
2072
2088
  return summaryText.trim();
@@ -2861,6 +2877,14 @@ var init_session = __esm({
2861
2877
  function getInflightCompaction() {
2862
2878
  return inflightCompaction;
2863
2879
  }
2880
+ function cancelInflightCompaction() {
2881
+ if (!inflightCompaction || !inflightAbort) {
2882
+ return false;
2883
+ }
2884
+ log4.info("Cancelling in-flight compaction");
2885
+ inflightAbort.abort();
2886
+ return true;
2887
+ }
2864
2888
  function summariesOf(result) {
2865
2889
  const out = [];
2866
2890
  for (const msg of result.checkpoints) {
@@ -2927,10 +2951,13 @@ function triggerCompaction(state, apiConfig, opts = {}) {
2927
2951
  }
2928
2952
  const { blocking = false, requestId, model, origin, toolCallId } = opts;
2929
2953
  listener?.({ type: "started", blocking, requestId, origin, toolCallId });
2954
+ const abort = new AbortController();
2955
+ inflightAbort = abort;
2930
2956
  inflightCompaction = compactConversation(
2931
2957
  state.messages,
2932
2958
  apiConfig,
2933
- resolveModel("conversationSummarizer", state.models, model)
2959
+ resolveModel("conversationSummarizer", state.models, model),
2960
+ abort.signal
2934
2961
  ).then((result) => {
2935
2962
  pending = result;
2936
2963
  const summaries = summariesOf(result);
@@ -2942,16 +2969,27 @@ function triggerCompaction(state, apiConfig, opts = {}) {
2942
2969
  log4.info("Compaction complete");
2943
2970
  return summaries;
2944
2971
  }).catch((err) => {
2945
- const message = err.message || "Compaction failed";
2946
- listener?.({ type: "complete", error: message, requestId });
2947
- log4.error("Compaction failed", { error: message });
2948
- throw err;
2972
+ const cancelled = abort.signal.aborted;
2973
+ const message = cancelled ? "Compaction cancelled" : err.message || "Compaction failed";
2974
+ listener?.({
2975
+ type: "complete",
2976
+ error: message,
2977
+ ...cancelled && { cancelled: true },
2978
+ requestId
2979
+ });
2980
+ if (cancelled) {
2981
+ log4.info("Compaction cancelled");
2982
+ } else {
2983
+ log4.error("Compaction failed", { error: message });
2984
+ }
2985
+ throw cancelled ? new CompactionCancelledError() : err;
2949
2986
  }).finally(() => {
2950
2987
  inflightCompaction = null;
2988
+ inflightAbort = null;
2951
2989
  });
2952
2990
  return inflightCompaction;
2953
2991
  }
2954
- var log4, pending, inflightCompaction, SUMMARY_SECTION_LABELS, listener;
2992
+ var log4, CompactionCancelledError, pending, inflightCompaction, inflightAbort, SUMMARY_SECTION_LABELS, listener;
2955
2993
  var init_trigger = __esm({
2956
2994
  "src/compaction/trigger.ts"() {
2957
2995
  "use strict";
@@ -2960,8 +2998,15 @@ var init_trigger = __esm({
2960
2998
  init_surfaces();
2961
2999
  init_session();
2962
3000
  log4 = createLogger("compaction:trigger");
3001
+ CompactionCancelledError = class extends Error {
3002
+ constructor() {
3003
+ super("Compaction cancelled \u2014 no checkpoint was created.");
3004
+ this.name = "CompactionCancelledError";
3005
+ }
3006
+ };
2963
3007
  pending = null;
2964
3008
  inflightCompaction = null;
3009
+ inflightAbort = null;
2965
3010
  SUMMARY_SECTION_LABELS = {
2966
3011
  conversation: "Conversation",
2967
3012
  visualDesignExpert: "Design Agent Thread",
@@ -3018,7 +3063,9 @@ var init_compactConversation = __esm({
3018
3063
  onBackgroundComplete?.(
3019
3064
  toolCallId,
3020
3065
  "compactConversation",
3021
- `Error: ${err.message || "Compaction failed"}`
3066
+ // A cancel is the user's own doing — report it as an outcome, not as
3067
+ // an error the block renders as a failure.
3068
+ err instanceof CompactionCancelledError ? err.message : `Error: ${err.message || "Compaction failed"}`
3022
3069
  );
3023
3070
  }).finally(() => {
3024
3071
  toolRegistry?.unregister(toolCallId);
@@ -10147,6 +10194,11 @@ var init_stats = __esm({
10147
10194
  });
10148
10195
 
10149
10196
  // src/headless/messageQueue.ts
10197
+ function holdRestoredUserItems(items) {
10198
+ return items.map(
10199
+ (item) => item.source === "user" ? { ...item, held: true } : item
10200
+ );
10201
+ }
10150
10202
  var MessageQueue;
10151
10203
  var init_messageQueue = __esm({
10152
10204
  "src/headless/messageQueue.ts"() {
@@ -10162,33 +10214,36 @@ var init_messageQueue = __esm({
10162
10214
  this.items.push(item);
10163
10215
  this.onChange?.();
10164
10216
  }
10165
- shift() {
10166
- const item = this.items.shift();
10217
+ /**
10218
+ * Index of the first deliverable item, or -1 when there is none.
10219
+ *
10220
+ * Held items are out of the delivery sequence, so the drain skips past them
10221
+ * rather than stopping at them: a message the user parked sits at the head
10222
+ * of the array, and stopping there would strand Remy's own chain steps and
10223
+ * background results queued behind it.
10224
+ */
10225
+ firstDeliverableIndex() {
10226
+ return this.items.findIndex((item) => !item.held);
10227
+ }
10228
+ /** Remove and return the item at `i`; fires onChange. */
10229
+ takeAt(i) {
10230
+ const [item] = this.items.splice(i, 1);
10167
10231
  if (item) {
10168
10232
  this.onChange?.();
10169
10233
  }
10170
10234
  return item;
10171
10235
  }
10172
- /** Remove and return the first `n` items; fires onChange once. */
10173
- shiftMany(n) {
10174
- if (n <= 0) {
10236
+ /** Remove and return `count` items starting at `start`; fires onChange once. */
10237
+ takeRange(start, count) {
10238
+ if (count <= 0) {
10175
10239
  return [];
10176
10240
  }
10177
- const items = this.items.splice(0, n);
10241
+ const items = this.items.splice(start, count);
10178
10242
  if (items.length > 0) {
10179
10243
  this.onChange?.();
10180
10244
  }
10181
10245
  return items;
10182
10246
  }
10183
- /** Remove and return all queued items. */
10184
- drain() {
10185
- if (this.items.length === 0) {
10186
- return [];
10187
- }
10188
- const all = this.items.splice(0);
10189
- this.onChange?.();
10190
- return all;
10191
- }
10192
10247
  /**
10193
10248
  * Remove all items matching `predicate`. Fires onChange only if something
10194
10249
  * was removed. Returns the removed items.
@@ -10207,6 +10262,49 @@ var init_messageQueue = __esm({
10207
10262
  }
10208
10263
  return removed;
10209
10264
  }
10265
+ /**
10266
+ * Mark matching items `held` — waiting on the user rather than on the agent.
10267
+ * Fires onChange only if something changed. Returns the held items.
10268
+ */
10269
+ holdWhere(predicate) {
10270
+ const held = [];
10271
+ let changed = false;
10272
+ for (const item of this.items) {
10273
+ if (!predicate(item)) {
10274
+ continue;
10275
+ }
10276
+ changed = changed || !item.held;
10277
+ item.held = true;
10278
+ held.push(item);
10279
+ }
10280
+ if (changed) {
10281
+ this.onChange?.();
10282
+ }
10283
+ return held;
10284
+ }
10285
+ /**
10286
+ * Release held items so the normal drain picks them up again — all of them,
10287
+ * or one by command requestId. Fires onChange only if something changed.
10288
+ * Returns the released items.
10289
+ */
10290
+ releaseHeld(id) {
10291
+ const released = [];
10292
+ for (const item of this.items) {
10293
+ if (!item.held || id !== void 0 && item.command.requestId !== id) {
10294
+ continue;
10295
+ }
10296
+ delete item.held;
10297
+ released.push(item);
10298
+ }
10299
+ if (released.length > 0) {
10300
+ this.onChange?.();
10301
+ }
10302
+ return released;
10303
+ }
10304
+ /** Whether anything in the queue will drain on its own (i.e. isn't held). */
10305
+ hasDeliverable() {
10306
+ return this.items.some((item) => !item.held);
10307
+ }
10210
10308
  /**
10211
10309
  * Change a queued item's delivery semantics, keyed by its command
10212
10310
  * requestId. Fires onChange (→ persist + queue_changed) on success.
@@ -10222,14 +10320,35 @@ var init_messageQueue = __esm({
10222
10320
  this.onChange?.();
10223
10321
  return item;
10224
10322
  }
10323
+ /**
10324
+ * Promote an item to ASAP: release any hold, and move it to the head so it
10325
+ * is the next thing delivered. One onChange for all of it.
10326
+ *
10327
+ * Position matters only when no turn is running — mid-turn, ASAP items are
10328
+ * pulled by predicate at the next tool boundary regardless of where they sit
10329
+ * (`takeSteering`), jumping whatever is queued ahead of them. Moving to the
10330
+ * head makes the idle case behave the same way, and matches the card, which
10331
+ * already renders promoted items above everything else.
10332
+ *
10333
+ * Returns the item, or undefined if nothing matches (e.g. it was already
10334
+ * consumed by the running turn).
10335
+ */
10336
+ promoteToFront(id) {
10337
+ const idx = this.items.findIndex((it) => it.command.requestId === id);
10338
+ if (idx === -1) {
10339
+ return void 0;
10340
+ }
10341
+ const [item] = this.items.splice(idx, 1);
10342
+ item.delivery = "asap";
10343
+ delete item.held;
10344
+ this.items.unshift(item);
10345
+ this.onChange?.();
10346
+ return item;
10347
+ }
10225
10348
  /** Copy of current queue contents (for surfacing on events). */
10226
10349
  snapshot() {
10227
10350
  return [...this.items];
10228
10351
  }
10229
- /** Return the next item without removing it. */
10230
- peek() {
10231
- return this.items[0];
10232
- }
10233
10352
  /** Return the item at index `i` without removing it. */
10234
10353
  peekAt(i) {
10235
10354
  return this.items[i];
@@ -10357,7 +10476,7 @@ var init_headless = __esm({
10357
10476
  });
10358
10477
  await initOrgContext(this.config);
10359
10478
  const resumed = loadSession(this.state);
10360
- this.queue = new MessageQueue(loadQueue(), () => {
10479
+ this.queue = new MessageQueue(holdRestoredUserItems(loadQueue()), () => {
10361
10480
  this.persistStats();
10362
10481
  this.emit("queue_changed", { queuedMessages: this.queue.snapshot() });
10363
10482
  });
@@ -10410,13 +10529,16 @@ var init_headless = __esm({
10410
10529
  });
10411
10530
  }
10412
10531
  } else {
10413
- const data = event.error ? { error: event.error } : {};
10532
+ const data = {
10533
+ ...event.error && { error: event.error },
10534
+ ...event.cancelled && { cancelled: true }
10535
+ };
10414
10536
  this.emit("compaction_complete", data, event.requestId);
10415
10537
  if (this.syntheticCompactionId) {
10416
10538
  const id = this.syntheticCompactionId;
10417
10539
  this.syntheticCompactionId = null;
10418
- const result = event.error ? `Error: ${event.error}` : formatSummariesResult(event.summaries ?? []);
10419
- const isError = !!event.error;
10540
+ const result = event.cancelled ? "Compaction cancelled \u2014 no checkpoint was created." : event.error ? `Error: ${event.error}` : formatSummariesResult(event.summaries ?? []);
10541
+ const isError = !!event.error && !event.cancelled;
10420
10542
  for (let i = this.state.messages.length - 1; i >= 0; i--) {
10421
10543
  const msg = this.state.messages[i];
10422
10544
  if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
@@ -11097,7 +11219,11 @@ var init_headless = __esm({
11097
11219
  this.applyPendingBlockUpdates();
11098
11220
  }
11099
11221
  async handleMessage(parsed, requestId) {
11100
- if (this.running || getInflightCompaction()) {
11222
+ const foldIn = !this.running && !getInflightCompaction() && this.queue.length > 0 && !isAutomatedMessage(parsed.text ?? "");
11223
+ if (foldIn) {
11224
+ this.queue.releaseHeld();
11225
+ }
11226
+ if (this.running || getInflightCompaction() || foldIn) {
11101
11227
  const command = { ...parsed };
11102
11228
  if (requestId && command.requestId === void 0) {
11103
11229
  command.requestId = requestId;
@@ -11134,8 +11260,9 @@ var init_headless = __esm({
11134
11260
  return item.source === "user" && isAutomatedMessage(item.command.text ?? "");
11135
11261
  }
11136
11262
  /**
11137
- * Drain the queue in strict FIFO order. Caller must hold `running = true`.
11138
- * User messages arriving during the drain will be enqueued behind current items.
11263
+ * Drain the queue in FIFO order over its deliverable items. Caller must hold
11264
+ * `running = true`. User messages arriving during the drain will be enqueued
11265
+ * behind current items.
11139
11266
  *
11140
11267
  * The queue serves two purposes with opposite delivery semantics:
11141
11268
  * - Sequencer: chain steps and sentinel-bearing user items are pipeline
@@ -11145,12 +11272,21 @@ var init_headless = __esm({
11145
11272
  * merged turn, so the model reconciles all of it at once instead of
11146
11273
  * burning a full turn per item (and possibly executing instructions a
11147
11274
  * later queued message already amended).
11275
+ *
11276
+ * Held items are not in the delivery sequence at all: the drain starts at the
11277
+ * first deliverable item and never merges across a held one. It skips rather
11278
+ * than stops because a held message sits at the head of the array — stopping
11279
+ * there would strand the chain steps and background results behind it.
11148
11280
  */
11149
11281
  async drainQueueLoop() {
11150
- while (this.queue.length > 0) {
11151
- const head = this.queue.peek();
11282
+ for (; ; ) {
11283
+ const at = this.queue.firstDeliverableIndex();
11284
+ if (at === -1) {
11285
+ return;
11286
+ }
11287
+ const head = this.queue.peekAt(at);
11152
11288
  if (head.command.action === "compact") {
11153
- this.queue.shift();
11289
+ this.queue.takeAt(at);
11154
11290
  await triggerCompaction(this.state, this.config, {
11155
11291
  blocking: true,
11156
11292
  requestId: head.command.requestId,
@@ -11162,13 +11298,13 @@ var init_headless = __esm({
11162
11298
  continue;
11163
11299
  }
11164
11300
  if (head.source === "chain") {
11165
- const item = this.queue.shift();
11301
+ const item = this.queue.takeAt(at);
11166
11302
  const rid = item.command.requestId ?? `chain-${Date.now()}`;
11167
11303
  await this.runSingleTurn(item.command, rid, true);
11168
11304
  continue;
11169
11305
  }
11170
11306
  if (this.isDrainBarrier(head)) {
11171
- const item = this.queue.shift();
11307
+ const item = this.queue.takeAt(at);
11172
11308
  const rid = item.command.requestId ?? `user-${Date.now()}`;
11173
11309
  await this.runSingleTurn(item.command, rid, false, true);
11174
11310
  continue;
@@ -11176,8 +11312,8 @@ var init_headless = __esm({
11176
11312
  let n = 1;
11177
11313
  let batchOb = head.command.onboardingState;
11178
11314
  for (; ; n++) {
11179
- const it = this.queue.peekAt(n);
11180
- if (!it || it.source === "chain" || this.isDrainBarrier(it)) {
11315
+ const it = this.queue.peekAt(at + n);
11316
+ if (!it || it.held || it.source === "chain" || this.isDrainBarrier(it)) {
11181
11317
  break;
11182
11318
  }
11183
11319
  const ob = it.command.onboardingState;
@@ -11188,7 +11324,7 @@ var init_headless = __esm({
11188
11324
  batchOb = ob;
11189
11325
  }
11190
11326
  }
11191
- const batch = this.queue.shiftMany(n);
11327
+ const batch = this.queue.takeRange(at, n);
11192
11328
  await this.runMergedTurn(batch);
11193
11329
  }
11194
11330
  }
@@ -11198,7 +11334,7 @@ var init_headless = __esm({
11198
11334
  * and by kickDrain (background-completion-initiated).
11199
11335
  */
11200
11336
  async resumeQueue() {
11201
- if (this.running || this.queue.length === 0) {
11337
+ if (this.running || !this.queue.hasDeliverable()) {
11202
11338
  return;
11203
11339
  }
11204
11340
  this.running = true;
@@ -11217,7 +11353,7 @@ var init_headless = __esm({
11217
11353
  * racing any currently-synchronous path.
11218
11354
  */
11219
11355
  kickDrain() {
11220
- if (this.running || this.queue.length === 0) {
11356
+ if (this.running || !this.queue.hasDeliverable()) {
11221
11357
  return;
11222
11358
  }
11223
11359
  setTimeout(() => this.resumeQueue(), 0);
@@ -11246,28 +11382,42 @@ var init_headless = __esm({
11246
11382
  };
11247
11383
  }
11248
11384
  /**
11249
- * Cancel the running turn and flush the follow-ups that belonged to it
11250
- * (`chain`/`background`), while preserving `source: 'user'` items those are
11251
- * independent user intent, not tied to the aborted run. The preserved user
11252
- * messages run next: `executeTurn` swallows the abort, so `handleMessage`
11253
- * falls through to `drainQueueLoop` with `running` still held. Returns the
11254
- * flushed items (for the cancel command's resume/discard UX).
11385
+ * Stop everything the user can see running: the turn, an in-flight
11386
+ * compaction, and any external tool waiting on a result. Flushes the
11387
+ * follow-ups that belonged to the turn (`chain`/`background`) and HOLDS the
11388
+ * `source: 'user'` items those are independent user intent, so they're
11389
+ * kept, but they no longer run on their own.
11390
+ *
11391
+ * Holding is the difference between Stop working and Stop looking broken.
11392
+ * These items used to drain immediately: `executeTurn` swallows the abort,
11393
+ * so `handleMessage` fell through to `drainQueueLoop` with `running` still
11394
+ * held and the next turn began in the same tick — the spinner never stopped,
11395
+ * and every additional press hit a turn that had just started. They now wait
11396
+ * in the queue card until the user sends again or promotes one.
11255
11397
  *
11256
- * Messages already absorbed into the in-flight merged turn are NOT
11257
- * preserved they were delivered into the turn that's being cancelled and
11258
- * each gets a `{cancelled, absorbed:true}` terminal. Only items still
11259
- * sitting in the queue survive.
11398
+ * A compaction is cancelled here too, unconditionally. It gates every queued
11399
+ * message and outlives the turn that started it, so leaving it running means
11400
+ * Stop can't reach idle. The cost is the summary work in flight; the forced
11401
+ * gate re-compacts on the next turn if the context is still too big.
11402
+ *
11403
+ * Messages already absorbed into the in-flight merged turn are NOT held —
11404
+ * they were delivered into the turn that's being cancelled and each gets a
11405
+ * `{cancelled, absorbed:true}` terminal. Only items still sitting in the
11406
+ * queue survive.
11260
11407
  */
11261
11408
  handleCancel() {
11262
11409
  if (this.currentAbort) {
11263
11410
  this.currentAbort.abort();
11264
11411
  }
11412
+ const cancelledCompaction = cancelInflightCompaction();
11265
11413
  for (const [id, pending2] of this.pendingTools) {
11266
11414
  clearTimeout(pending2.timeout);
11267
11415
  pending2.resolve(USER_CANCELLED_RESULT);
11268
11416
  this.pendingTools.delete(id);
11269
11417
  }
11270
- return this.queue.removeWhere((item) => item.source !== "user");
11418
+ const flushed = this.queue.removeWhere((item) => item.source !== "user");
11419
+ const held = this.queue.holdWhere((item) => item.source === "user");
11420
+ return { flushed, held, cancelledCompaction };
11271
11421
  }
11272
11422
  /**
11273
11423
  * Remove pending queued messages — all user messages, or one by id.
@@ -11376,12 +11526,14 @@ var init_headless = __esm({
11376
11526
  return;
11377
11527
  }
11378
11528
  if (action === "cancel") {
11379
- const cancelled = this.handleCancel();
11529
+ const { flushed, held, cancelledCompaction } = this.handleCancel();
11380
11530
  this.emit(
11381
11531
  "completed",
11382
11532
  {
11383
11533
  success: true,
11384
- ...cancelled.length > 0 && { cancelledMessages: cancelled }
11534
+ ...flushed.length > 0 && { cancelledMessages: flushed },
11535
+ ...held.length > 0 && { heldMessages: held },
11536
+ ...cancelledCompaction && { cancelledCompaction: true }
11385
11537
  },
11386
11538
  requestId
11387
11539
  );
@@ -11420,7 +11572,12 @@ var init_headless = __esm({
11420
11572
  );
11421
11573
  return;
11422
11574
  }
11423
- this.queue.setDelivery(id, delivery);
11575
+ if (delivery === "asap") {
11576
+ this.queue.promoteToFront(id);
11577
+ this.kickDrain();
11578
+ } else {
11579
+ this.queue.setDelivery(id, delivery);
11580
+ }
11424
11581
  this.emit("completed", { success: true }, requestId);
11425
11582
  return;
11426
11583
  }
@@ -11486,7 +11643,7 @@ var init_headless = __esm({
11486
11643
  }
11487
11644
  this.emit("completed", { success: true }, requestId);
11488
11645
  } catch (err) {
11489
- const error = err.message || "Compaction failed";
11646
+ const error = err instanceof CompactionCancelledError ? "cancelled" : err.message || "Compaction failed";
11490
11647
  this.emit("completed", { success: false, error }, requestId);
11491
11648
  }
11492
11649
  return;
@@ -11504,7 +11661,7 @@ var init_headless = __esm({
11504
11661
  );
11505
11662
  return;
11506
11663
  }
11507
- if (this.queue.length === 0) {
11664
+ if (!this.queue.hasDeliverable()) {
11508
11665
  this.emit("completed", { success: true }, requestId);
11509
11666
  return;
11510
11667
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstudio-ai/remy",
3
- "version": "0.1.293",
3
+ "version": "0.1.294",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",