@mindstudio-ai/remy 0.1.293 → 0.1.295

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.
@@ -2,4 +2,6 @@
2
2
  trigger: reviseFromAnnotatedImage
3
3
  ---
4
4
 
5
- This is an automated message triggered by the user submitting a screenshot of the app that they have annotated with text notes. Please analyze the provided image and make the requested revisions to the web interface.
5
+ This is an automated message triggered by the user annotating a screenshot of the app with revision notes. The attached image is a capture of the app's UI with the user's annotations drawn on top: pink (#DD2590) markers — a small pin dot or a dashed rectangle outlining an area — each with a pink speech bubble containing the note text in white. The image may be a vertical crop of a scrolled page, not necessarily the top of it.
6
+
7
+ The message params include a `notes` array with each annotation's exact text and position (pixel coordinates in the attached image; pins have `x`/`y`, areas add `w`/`h`). Treat the `notes` params as the authoritative note text and use the image to see what each note points at. Make the requested revisions to the web interface.
@@ -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
  }
@@ -1387,7 +1392,7 @@ async function listRecursive(dir) {
1387
1392
  var presentPublishPlanTool = {
1388
1393
  definition: {
1389
1394
  name: "presentPublishPlan",
1390
- description: "Present a publish changelog to the user for approval \u2014 the consent gate of the release flow, used when the user has asked to publish (the Publish button or an explicit chat request; the `publishing` skill covers the full sequence). Write a clear markdown summary of what changed since the last deploy. The user will see this in a full-screen view and can approve or dismiss. Call this BEFORE committing or pushing.",
1395
+ description: "Present a publish changelog to the user for approval \u2014 the consent gate of the release flow, used when the user has asked to publish (the Publish button or an explicit chat request; the `publishing` skill covers the full sequence). Write a clear markdown summary of what changed since the last deploy. The user reviews this in the editor area and can approve or dismiss. Call this BEFORE committing or pushing.",
1391
1396
  inputSchema: {
1392
1397
  type: "object",
1393
1398
  properties: {
@@ -1522,7 +1527,7 @@ var markBuildCompleteTool = {
1522
1527
  var promptUserTool = {
1523
1528
  definition: {
1524
1529
  name: "promptUser",
1525
- description: 'Ask the user structured questions. Choose type first: "form" for structured intake (5+ questions, takes over screen), "inline" for quick clarifications or confirmations. Blocks until the user responds. Result contains `_dismissed: true` if the user dismisses without answering.',
1530
+ description: 'Ask the user structured questions. Choose type first: "form" for structured intake (5+ questions, opens as a full form in the editor area), "inline" for quick clarifications or confirmations. Blocks until the user responds. Result contains `_dismissed: true` if the user dismisses without answering.',
1526
1531
  inputSchema: {
1527
1532
  type: "object",
1528
1533
  properties: {
@@ -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
  }