@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.
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
  }
@@ -867,7 +872,7 @@ var init_presentPublishPlan = __esm({
867
872
  presentPublishPlanTool = {
868
873
  definition: {
869
874
  name: "presentPublishPlan",
870
- 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.",
875
+ 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.",
871
876
  inputSchema: {
872
877
  type: "object",
873
878
  properties: {
@@ -1034,7 +1039,7 @@ var init_promptUser = __esm({
1034
1039
  promptUserTool = {
1035
1040
  definition: {
1036
1041
  name: "promptUser",
1037
- 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.',
1042
+ 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.',
1038
1043
  inputSchema: {
1039
1044
  type: "object",
1040
1045
  properties: {
@@ -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
  }
@@ -153,4 +153,4 @@ Consider the ways in which AI can be incorporated into backend methods to solve
153
153
 
154
154
  ### Task Agents
155
155
 
156
- For multi-step tasks where the model needs to autonomously compose actions (research + scrape + generate, enrichment pipelines, content creation), use `runTask()` instead of chaining actions manually. It runs an agent loop and returns structured JSON. Its tools can include SDK actions as well as your app's own methods, so the agent can read your data to decide what to do next and write results back itself. Load the `taskAgents` skill before writing one — it is the full reference.
156
+ For multi-step tasks where the model needs to autonomously compose actions (research + scrape + generate, enrichment pipelines, content creation), use `runTask()` instead of chaining actions manually. It runs an agent loop and returns structured JSON. Its tools can include SDK actions, your app's own methods, and inline functions defined at the call site, so the agent can read your data to decide what to do next and write results back itself. Load the `taskAgents` skill before writing one — it is the full reference.