@mindstudio-ai/remy 0.1.260 → 0.1.262

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/README.md CHANGED
@@ -264,6 +264,7 @@ The headless IPC protocol uses request correlation and a unified response patter
264
264
  - Every stdout response to a command includes the same `requestId`
265
265
  - System events (lifecycle, shutdown) never have a `requestId`
266
266
  - Every command ends with exactly one `completed` event: `{event:"completed", requestId, success, error?}`
267
+ - Messages sent while a turn is running are queued. When the turn ends, all contiguous queued user messages and background results are delivered together as **one merged turn**: the first queued message's `requestId` becomes the turn's primary id (stamped on `turn_started` and all streaming events), each absorbed message echoes its own `user_message` with its original `requestId` and `queued: true`, and at turn end the primary `completed` is emitted first, followed immediately by one `completed {…same outcome, absorbed: true}` per other absorbed `requestId`. Automated-action (`@@automated::…@@`) messages and chain steps never merge — they always run one turn each.
267
268
  - The caller distinguishes command responses from system events with a single check: `if (msg.requestId)`
268
269
 
269
270
  This enables a simple promise-based RPC layer: send a command with a unique ID, store a pending promise keyed by that ID, resolve it when you see `completed` with the matching ID.
@@ -364,6 +365,7 @@ All command responses include the `requestId` from the originating command.
364
365
  |-------|--------|-------------|
365
366
  | `text` | `text`, `parentToolId?` | Streaming text chunk |
366
367
  | `thinking` | `text`, `parentToolId?` | Agent's internal reasoning |
368
+ | `user_message` | `text`, `attachments?`, `queued?` | Echo of a user message entering the turn. Queue-delivered messages carry `queued: true` and their own original `requestId` (a merged turn emits one per absorbed message); idle sends echo with the turn's requestId and no `queued` flag. |
367
369
  | `tool_start` | `id`, `name`, `input`, `partial?`, `parentToolId?` | Tool execution started. `partial: true` means more `tool_start` events will follow for this id (progressive input streaming). |
368
370
  | `tool_input_delta` | `id`, `name`, `result`, `parentToolId?` | Progressive tool content (streaming tools only) |
369
371
  | `tool_done` | `id`, `name`, `result`, `isError`, `parentToolId?` | Tool execution completed |
@@ -372,7 +374,7 @@ All command responses include the `requestId` from the originating command.
372
374
  | `history` | `messages` | Response to `get_history` |
373
375
  | `session_cleared` | | Response to `clear` |
374
376
  | `models_changed` | `models?`, `modelSurfaces`, `allowedModelsByType` | Response to `changeModels` |
375
- | `completed` | `success`, `error?` | Terminal event — exactly one per command |
377
+ | `completed` | `success`, `error?`, `absorbed?` | Terminal event — exactly one per command. For a merged turn, the primary requestId's `completed` comes first, then one `{absorbed: true}` completed per other absorbed requestId with the same outcome — consumers should resolve their pending request but skip turn-lifecycle handling for absorbed terminals. |
376
378
 
377
379
  #### Example Session
378
380
 
@@ -11,6 +11,10 @@
11
11
  * - System events (ready, session_restored, stopping, stopped) never have a requestId.
12
12
  * - Every command ends with exactly one `completed` event:
13
13
  * {event:"completed", requestId, success:true|false, error?:string}
14
+ * - When contiguous queued user/background messages merge into one turn, the
15
+ * turn's primary requestId gets the real `completed` first, then each other
16
+ * absorbed requestId gets one `{...same outcome, absorbed:true}` completed
17
+ * immediately after — one terminal per command either way.
14
18
  * - `tool_result` is fire-and-forget (resolves an in-flight promise, no completed event).
15
19
  *
16
20
  * `get_history` is paginated. Request: {action:"get_history", before?:number,
@@ -46,8 +50,12 @@ declare class HeadlessSession {
46
50
  private currentAbort;
47
51
  /** RequestId of the in-flight message command — injected into streamed events. */
48
52
  private currentRequestId;
49
- /** Guard: track whether terminal `completed` was already sent so we emit exactly one. */
53
+ /** Guard: track whether terminal `completed` was already sent so we emit
54
+ * exactly one per requestId. */
50
55
  private completedEmitted;
56
+ /** Outcome of the current turn's primary `completed` — read after the turn
57
+ * to stamp the same success/error onto absorbed requestIds' terminals. */
58
+ private lastCompleted;
51
59
  private turnStart;
52
60
  /**
53
61
  * Onboarding state of the currently-running turn. Captured at runSingleTurn
@@ -74,6 +82,9 @@ declare class HeadlessSession {
74
82
  /** Emit a `completed` event and mark completedEmitted. Queue state is
75
83
  * surfaced separately via the `queue_changed` event, not on `completed`. */
76
84
  private emitCompleted;
85
+ /** Outcome of the turn's primary `completed`, for stamping onto absorbed
86
+ * requestIds' terminals. Falls back to failure if none was emitted. */
87
+ private primaryOutcome;
77
88
  /** Dispatch a simple (non-streaming) command: call handler, emit response + completed. */
78
89
  private dispatchSimple;
79
90
  /** Persist sessionStats + queue snapshot to .remy-stats.json. */
@@ -98,20 +109,65 @@ declare class HeadlessSession {
98
109
  private resolveExternalTool;
99
110
  private onEvent;
100
111
  /**
101
- * Run one turn (without acquiring the `running` lock). Called by
102
- * handleMessage for the initial turn, then repeatedly for each queued
103
- * message `running` stays held across the queue drain so no user
104
- * message can slip in mid-pipeline.
112
+ * Persist one entry's non-voice uploads to disk and build its header. The
113
+ * header tells the LLM where to read each file; it's kept separate so it
114
+ * gets injected at API-send time and never persisted into the user's chat
115
+ * content (which would leak into history restore on the frontend).
116
+ *
117
+ * Must be awaited sequentially across entries — persistAttachments'
118
+ * filename de-dup set is per-call, so parallel calls race on names.
119
+ */
120
+ private persistEntryAttachments;
121
+ /**
122
+ * Run one turn for a single command (without acquiring the `running` lock).
123
+ * Owns the per-command machinery: @@automated:: action resolution, plan-file
124
+ * and buildModel side effects, and chain expansion — which is why
125
+ * sentinel-bearing commands always come through here, one turn each, never
126
+ * merged. The turn itself runs in executeTurn.
105
127
  */
106
128
  private runSingleTurn;
129
+ /**
130
+ * Run a mailbox batch — contiguous queued user + background items — as one
131
+ * merged turn. Every item becomes its own history entry and user_message
132
+ * event (own requestId, attachments, hidden flag); adjacent background
133
+ * items fold into a single background_results entry so the LLM sees one
134
+ * combined block. No action-sentinel machinery here: batch construction
135
+ * guarantees none (sentinel-bearing user items are drain barriers that run
136
+ * alone via runSingleTurn, and background_results is a NON_ACTION sentinel
137
+ * with no side effects).
138
+ */
139
+ private runMergedTurn;
140
+ /**
141
+ * Run one agent turn over the given entries (without acquiring the
142
+ * `running` lock). Owns the turn-generic lifecycle: request bookkeeping,
143
+ * the forced-compaction gate, runTurn error handling, and terminal
144
+ * `completed` events — the primary requestId's completed first, then one
145
+ * `{absorbed: true}` completed per other absorbed requestId with the same
146
+ * outcome, on every exit path (done, cancel, error, unexpected), so every
147
+ * queued message's caller resolves.
148
+ */
149
+ private executeTurn;
107
150
  private handleMessage;
151
+ /**
152
+ * True for queued user items whose text is an @@automated:: action message.
153
+ * These key per-item raw-text side effects in runSingleTurn (resolveAction,
154
+ * plan file, buildModel, chain expansion) — they always run alone, one turn
155
+ * each. Background items are sentinel-formatted too but background_results
156
+ * is NON_ACTION and side-effect-free, so they merge freely.
157
+ */
158
+ private isDrainBarrier;
108
159
  /**
109
160
  * Drain the queue in strict FIFO order. Caller must hold `running = true`.
110
161
  * User messages arriving during the drain will be enqueued behind current items.
111
162
  *
112
- * Consecutive background-source items are coalesced into a single turn so
113
- * the LLM sees all the background results together and produces one
114
- * acknowledgment, not N separate ones.
163
+ * The queue serves two purposes with opposite delivery semantics:
164
+ * - Sequencer: chain steps and sentinel-bearing user items are pipeline
165
+ * stages — one item, one turn, nothing merged in.
166
+ * - Mailbox: plain user messages and background results are accumulated
167
+ * context and intent — everything contiguous flushes together into ONE
168
+ * merged turn, so the model reconciles all of it at once instead of
169
+ * burning a full turn per item (and possibly executing instructions a
170
+ * later queued message already amended).
115
171
  */
116
172
  private drainQueueLoop;
117
173
  /**
@@ -136,9 +192,14 @@ declare class HeadlessSession {
136
192
  * Cancel the running turn and flush the follow-ups that belonged to it
137
193
  * (`chain`/`background`), while preserving `source: 'user'` items — those are
138
194
  * independent user intent, not tied to the aborted run. The preserved user
139
- * messages run next: `runSingleTurn` swallows the abort, so `handleMessage`
195
+ * messages run next: `executeTurn` swallows the abort, so `handleMessage`
140
196
  * falls through to `drainQueueLoop` with `running` still held. Returns the
141
197
  * flushed items (for the cancel command's resume/discard UX).
198
+ *
199
+ * Messages already absorbed into the in-flight merged turn are NOT
200
+ * preserved — they were delivered into the turn that's being cancelled and
201
+ * each gets a `{cancelled, absorbed:true}` terminal. Only items still
202
+ * sitting in the queue survive.
142
203
  */
143
204
  private handleCancel;
144
205
  /**
package/dist/headless.js CHANGED
@@ -483,13 +483,17 @@ var ALLOWED_MODELS_BY_TYPE = {
483
483
  "gemini-3.1-pro",
484
484
  "gemini-3-flash",
485
485
  "gemini-3.5-flash",
486
+ "gemini-3.7-flash",
486
487
  "grok-build-0.1",
487
488
  "grok-4.5",
489
+ "grok-4.6",
488
490
  "glm-5.2",
489
491
  "muse-spark-1.1",
490
492
  "kimi-k2-7-code",
491
493
  "kimi-k3",
492
- "deepseek-v4-flash-0731"
494
+ "deepseek-v4-flash-0731",
495
+ "qwen3.8-2.4t-a95b-deepinfra",
496
+ "minimax-m3"
493
497
  ]
494
498
  // vision: undefined — unconstrained
495
499
  // image_generation: undefined — unconstrained
@@ -7614,9 +7618,7 @@ function createAgentState() {
7614
7618
  async function runTurn(params) {
7615
7619
  const {
7616
7620
  state,
7617
- userMessage,
7618
- attachments,
7619
- attachmentHeader,
7621
+ entries,
7620
7622
  apiConfig,
7621
7623
  system,
7622
7624
  model,
@@ -7625,7 +7627,6 @@ async function runTurn(params) {
7625
7627
  signal,
7626
7628
  onEvent,
7627
7629
  resolveExternalTool,
7628
- hidden,
7629
7630
  requestId,
7630
7631
  toolRegistry,
7631
7632
  onBackgroundComplete
@@ -7635,45 +7636,54 @@ async function runTurn(params) {
7635
7636
  const baseline = resolveModel("parent", state.models, model);
7636
7637
  const parentModel = buildModelOverride ?? baseline;
7637
7638
  const modelOverride = buildModelOverride && buildModelOverride !== baseline ? { from: baseline } : void 0;
7639
+ const totalAttachments = entries.reduce(
7640
+ (n, e) => n + (e.attachments?.length ?? 0),
7641
+ 0
7642
+ );
7638
7643
  log14.info("Turn started", {
7639
7644
  requestId,
7640
7645
  model,
7641
7646
  buildModel: buildModelOverride,
7642
7647
  toolCount: tools2.length,
7643
- ...attachments && attachments.length > 0 && {
7644
- attachmentCount: attachments.length
7645
- }
7648
+ ...entries.length > 1 && { entryCount: entries.length },
7649
+ ...totalAttachments > 0 && { attachmentCount: totalAttachments }
7646
7650
  });
7647
7651
  onEvent({
7648
7652
  type: "turn_started",
7649
7653
  model: parentModel,
7650
7654
  ...modelOverride && { modelOverride }
7651
7655
  });
7652
- const hasText = userMessage.trim().length > 0;
7653
- const hasAttachments = attachments && attachments.length > 0;
7654
- if (!hasText && !hasAttachments) {
7656
+ const keptEntries = entries.filter(
7657
+ (e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
7658
+ );
7659
+ if (keptEntries.length === 0) {
7655
7660
  onEvent({ type: "error", error: "Empty message" });
7656
7661
  return;
7657
7662
  }
7658
- const userMsg = { role: "user", content: userMessage };
7659
- if (hidden) {
7660
- userMsg.hidden = true;
7661
- }
7662
- if (hasAttachments) {
7663
- userMsg.attachments = attachments;
7664
- }
7665
- if (attachmentHeader) {
7666
- userMsg.attachmentHeader = attachmentHeader;
7663
+ for (const entry of keptEntries) {
7664
+ const hasAttachments = (entry.attachments?.length ?? 0) > 0;
7665
+ const userMsg = { role: "user", content: entry.text };
7666
+ if (entry.hidden) {
7667
+ userMsg.hidden = true;
7668
+ }
7669
+ if (hasAttachments) {
7670
+ userMsg.attachments = entry.attachments;
7671
+ }
7672
+ if (entry.attachmentHeader) {
7673
+ userMsg.attachmentHeader = entry.attachmentHeader;
7674
+ }
7675
+ state.messages.push(userMsg);
7676
+ onEvent({
7677
+ type: "user_message",
7678
+ text: entry.text,
7679
+ hidden: entry.hidden || void 0,
7680
+ // Include attachments so the live event can render a queued voice/image/file
7681
+ // bubble; a voice message has empty text and the transcript lives here.
7682
+ ...hasAttachments && { attachments: entry.attachments },
7683
+ ...entry.requestId && { requestId: entry.requestId },
7684
+ ...entry.queued && { queued: true }
7685
+ });
7667
7686
  }
7668
- state.messages.push(userMsg);
7669
- onEvent({
7670
- type: "user_message",
7671
- text: userMessage,
7672
- hidden: hidden || void 0,
7673
- // Include attachments so the live event can render a queued voice/image/file
7674
- // bubble; a voice message has empty text and the transcript lives here.
7675
- ...hasAttachments && { attachments }
7676
- });
7677
7687
  const isFirstMessage = state.messages.filter((m) => m.role === "user").length === 1;
7678
7688
  const STATUS_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
7679
7689
  "setProjectOnboardingState",
@@ -7742,11 +7752,13 @@ async function runTurn(params) {
7742
7752
  if (onboardingState && onboardingState !== "onboardingFinished") {
7743
7753
  parts.push(`Build phase: ${onboardingState}`);
7744
7754
  }
7745
- const automated = parseSentinel(userMessage);
7746
- if (automated) {
7747
- parts.push(`Automated action: ${automated.name}`);
7748
- } else if (userMessage) {
7749
- parts.push(`User request: ${userMessage.slice(-500)}`);
7755
+ for (const entry of keptEntries) {
7756
+ const automated = parseSentinel(entry.text);
7757
+ if (automated) {
7758
+ parts.push(`Automated action: ${automated.name}`);
7759
+ } else if (entry.text) {
7760
+ parts.push(`User request: ${entry.text.slice(-500)}`);
7761
+ }
7750
7762
  }
7751
7763
  return parts.join("\n");
7752
7764
  },
@@ -8291,25 +8303,24 @@ async function persistAttachments(attachments) {
8291
8303
  images: settled.filter((s) => s.isImage).map((s) => s.result)
8292
8304
  };
8293
8305
  }
8294
- function buildUploadHeader(results) {
8295
- const succeeded = results.filter(Boolean);
8296
- if (succeeded.length === 0) {
8306
+ function buildUploadHeader(documents, images) {
8307
+ const entries = [
8308
+ ...documents.filter((r) => r !== null).map((r) => ({ ...r, isImage: false })),
8309
+ ...images.filter((r) => r !== null).map((r) => ({ ...r, isImage: true }))
8310
+ ];
8311
+ if (entries.length === 0) {
8297
8312
  return "";
8298
8313
  }
8299
- if (succeeded.length === 1) {
8300
- const r = succeeded[0];
8301
- const parts = [`[Uploaded file: ${r.localPath}`];
8302
- if (r.extractedTextPath) {
8303
- parts.push(`extracted text: ${r.extractedTextPath}`);
8304
- }
8305
- return parts.join(" \u2014 ") + "]";
8314
+ const detail = (e) => e.extractedTextPath ? `extracted text: ${e.extractedTextPath}` : e.isImage ? null : "no extracted text \u2014 raw file only";
8315
+ if (entries.length === 1) {
8316
+ const e = entries[0];
8317
+ const extra = detail(e);
8318
+ return `[Uploaded file: ${e.localPath}${extra ? ` \u2014 ${extra}` : ""}]`;
8306
8319
  }
8307
- const lines = succeeded.map((r) => {
8308
- const parts = [`- ${r.localPath}`];
8309
- if (r.extractedTextPath) {
8310
- parts.push(` extracted text: ${r.extractedTextPath}`);
8311
- }
8312
- return parts.join("\n");
8320
+ const lines = entries.map((e) => {
8321
+ const extra = detail(e);
8322
+ return `- ${e.localPath}${extra ? `
8323
+ ${extra}` : ""}`;
8313
8324
  });
8314
8325
  return `[Uploaded files]
8315
8326
  ${lines.join("\n")}`;
@@ -8395,6 +8406,17 @@ var MessageQueue = class {
8395
8406
  }
8396
8407
  return item;
8397
8408
  }
8409
+ /** Remove and return the first `n` items; fires onChange once. */
8410
+ shiftMany(n) {
8411
+ if (n <= 0) {
8412
+ return [];
8413
+ }
8414
+ const items = this.items.splice(0, n);
8415
+ if (items.length > 0) {
8416
+ this.onChange?.();
8417
+ }
8418
+ return items;
8419
+ }
8398
8420
  /** Remove and return all queued items. */
8399
8421
  drain() {
8400
8422
  if (this.items.length === 0) {
@@ -8430,6 +8452,10 @@ var MessageQueue = class {
8430
8452
  peek() {
8431
8453
  return this.items[0];
8432
8454
  }
8455
+ /** Return the item at index `i` without removing it. */
8456
+ peekAt(i) {
8457
+ return this.items[i];
8458
+ }
8433
8459
  get length() {
8434
8460
  return this.items.length;
8435
8461
  }
@@ -8512,8 +8538,12 @@ var HeadlessSession = class {
8512
8538
  currentAbort = null;
8513
8539
  /** RequestId of the in-flight message command — injected into streamed events. */
8514
8540
  currentRequestId;
8515
- /** Guard: track whether terminal `completed` was already sent so we emit exactly one. */
8541
+ /** Guard: track whether terminal `completed` was already sent so we emit
8542
+ * exactly one per requestId. */
8516
8543
  completedEmitted = false;
8544
+ /** Outcome of the current turn's primary `completed` — read after the turn
8545
+ * to stamp the same success/error onto absorbed requestIds' terminals. */
8546
+ lastCompleted = null;
8517
8547
  turnStart = 0;
8518
8548
  /**
8519
8549
  * Onboarding state of the currently-running turn. Captured at runSingleTurn
@@ -8649,6 +8679,15 @@ var HeadlessSession = class {
8649
8679
  emitCompleted(rid, data) {
8650
8680
  this.emit("completed", { ...data }, rid);
8651
8681
  this.completedEmitted = true;
8682
+ this.lastCompleted = {
8683
+ success: data.success === true,
8684
+ ...typeof data.error === "string" && { error: data.error }
8685
+ };
8686
+ }
8687
+ /** Outcome of the turn's primary `completed`, for stamping onto absorbed
8688
+ * requestIds' terminals. Falls back to failure if none was emitted. */
8689
+ primaryOutcome() {
8690
+ return this.lastCompleted ?? { success: false };
8652
8691
  }
8653
8692
  /** Dispatch a simple (non-streaming) command: call handler, emit response + completed. */
8654
8693
  dispatchSimple(requestId, eventName, handler) {
@@ -8807,9 +8846,14 @@ var HeadlessSession = class {
8807
8846
  text: e.text,
8808
8847
  // Forward attachments so queued voice/image/file sends render live;
8809
8848
  // otherwise the bubble is blank until a get_history refresh.
8810
- ...e.attachments && { attachments: e.attachments }
8849
+ ...e.attachments && { attachments: e.attachments },
8850
+ // Queue-delivered entries are flagged so the frontend renders the
8851
+ // echo (idle sends are rendered optimistically instead).
8852
+ ...e.queued && { queued: true }
8811
8853
  },
8812
- rid
8854
+ // A merged turn emits one user_message per absorbed entry — each
8855
+ // carries its own original requestId, not the turn's.
8856
+ e.requestId ?? rid
8813
8857
  );
8814
8858
  return;
8815
8859
  // Terminal events — translate to `completed`.
@@ -8832,11 +8876,9 @@ var HeadlessSession = class {
8832
8876
  durationMs: Date.now() - this.turnStart
8833
8877
  });
8834
8878
  return;
8835
- case "turn_cancelled": {
8836
- this.emit("completed", { success: false, error: "cancelled" }, rid);
8837
- this.completedEmitted = true;
8879
+ case "turn_cancelled":
8880
+ this.emitCompleted(rid, { success: false, error: "cancelled" });
8838
8881
  return;
8839
- }
8840
8882
  // Streaming events — forward with requestId
8841
8883
  case "text":
8842
8884
  this.emit(
@@ -8956,17 +8998,34 @@ var HeadlessSession = class {
8956
8998
  // Message command handler (long-running / streaming)
8957
8999
  //////////////////////////////////////////////////////////////////////////////
8958
9000
  /**
8959
- * Run one turn (without acquiring the `running` lock). Called by
8960
- * handleMessage for the initial turn, then repeatedly for each queued
8961
- * message `running` stays held across the queue drain so no user
8962
- * message can slip in mid-pipeline.
9001
+ * Persist one entry's non-voice uploads to disk and build its header. The
9002
+ * header tells the LLM where to read each file; it's kept separate so it
9003
+ * gets injected at API-send time and never persisted into the user's chat
9004
+ * content (which would leak into history restore on the frontend).
9005
+ *
9006
+ * Must be awaited sequentially across entries — persistAttachments'
9007
+ * filename de-dup set is per-call, so parallel calls race on names.
8963
9008
  */
8964
- async runSingleTurn(parsed, requestId, fromChain = false) {
8965
- this.currentRequestId = requestId;
8966
- this.currentAbort = new AbortController();
8967
- this.completedEmitted = false;
8968
- this.turnStart = Date.now();
8969
- await this.runForcedCompactionIfNeeded(requestId);
9009
+ async persistEntryAttachments(attachments) {
9010
+ if (!attachments?.some((a) => !a.isVoice)) {
9011
+ return void 0;
9012
+ }
9013
+ try {
9014
+ const { documents, images } = await persistAttachments(attachments);
9015
+ return buildUploadHeader(documents, images) || void 0;
9016
+ } catch (err) {
9017
+ log16.warn("Attachment persistence failed", { error: err.message });
9018
+ return void 0;
9019
+ }
9020
+ }
9021
+ /**
9022
+ * Run one turn for a single command (without acquiring the `running` lock).
9023
+ * Owns the per-command machinery: @@automated:: action resolution, plan-file
9024
+ * and buildModel side effects, and chain expansion — which is why
9025
+ * sentinel-bearing commands always come through here, one turn each, never
9026
+ * merged. The turn itself runs in executeTurn.
9027
+ */
9028
+ async runSingleTurn(parsed, requestId, fromChain = false, queued = false) {
8970
9029
  const attachments = parsed.attachments;
8971
9030
  if (attachments?.length) {
8972
9031
  log16.info("Message has attachments", {
@@ -8975,19 +9034,7 @@ var HeadlessSession = class {
8975
9034
  });
8976
9035
  }
8977
9036
  let userMessage = parsed.text ?? "";
8978
- let attachmentHeader;
8979
- if (attachments?.some((a) => !a.isVoice)) {
8980
- try {
8981
- const { documents, images } = await persistAttachments(attachments);
8982
- const all = [...documents, ...images];
8983
- const header = buildUploadHeader(all);
8984
- if (header) {
8985
- attachmentHeader = header;
8986
- }
8987
- } catch (err) {
8988
- log16.warn("Attachment persistence failed", { error: err.message });
8989
- }
8990
- }
9037
+ const attachmentHeader = await this.persistEntryAttachments(attachments);
8991
9038
  let resolved = null;
8992
9039
  try {
8993
9040
  resolved = resolveAction(userMessage);
@@ -9024,22 +9071,108 @@ var HeadlessSession = class {
9024
9071
  });
9025
9072
  }
9026
9073
  }
9074
+ await this.executeTurn({
9075
+ entries: [
9076
+ {
9077
+ text: userMessage,
9078
+ attachments,
9079
+ attachmentHeader,
9080
+ hidden: isHidden || void 0,
9081
+ requestId,
9082
+ queued: queued || void 0
9083
+ }
9084
+ ],
9085
+ requestId,
9086
+ absorbedRids: [],
9087
+ onboardingState,
9088
+ system,
9089
+ buildModel
9090
+ });
9091
+ }
9092
+ /**
9093
+ * Run a mailbox batch — contiguous queued user + background items — as one
9094
+ * merged turn. Every item becomes its own history entry and user_message
9095
+ * event (own requestId, attachments, hidden flag); adjacent background
9096
+ * items fold into a single background_results entry so the LLM sees one
9097
+ * combined block. No action-sentinel machinery here: batch construction
9098
+ * guarantees none (sentinel-bearing user items are drain barriers that run
9099
+ * alone via runSingleTurn, and background_results is a NON_ACTION sentinel
9100
+ * with no side effects).
9101
+ */
9102
+ async runMergedTurn(batch) {
9103
+ const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
9104
+ const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
9105
+ const entryList = [];
9106
+ for (const item of batch) {
9107
+ const text = item.command.text ?? "";
9108
+ const prev = entryList[entryList.length - 1];
9109
+ if (item.source === "background" && prev?.background) {
9110
+ prev.entry.text = mergeBackgroundResultsMessages([
9111
+ prev.entry.text,
9112
+ text
9113
+ ]);
9114
+ continue;
9115
+ }
9116
+ entryList.push({
9117
+ background: item.source === "background",
9118
+ entry: {
9119
+ text,
9120
+ attachments: item.command.attachments,
9121
+ hidden: !!item.command.hidden || void 0,
9122
+ // Background items carry no requestId — their user_message falls
9123
+ // back to the turn's primary rid on the wire.
9124
+ requestId: item.command.requestId,
9125
+ queued: true
9126
+ }
9127
+ });
9128
+ }
9129
+ const entries = entryList.map((e) => e.entry);
9130
+ for (const entry of entries) {
9131
+ entry.attachmentHeader = await this.persistEntryAttachments(
9132
+ entry.attachments
9133
+ );
9134
+ }
9135
+ const onboardingState = batch.find((b) => b.command.onboardingState !== void 0)?.command.onboardingState ?? this.currentOnboardingState ?? "onboardingFinished";
9136
+ this.currentOnboardingState = onboardingState;
9137
+ const viewContext = [...batch].reverse().find((b) => b.command.viewContext !== void 0)?.command.viewContext;
9138
+ await this.executeTurn({
9139
+ entries,
9140
+ requestId: primaryRid,
9141
+ absorbedRids,
9142
+ onboardingState,
9143
+ system: buildSystemPrompt(onboardingState, viewContext)
9144
+ });
9145
+ }
9146
+ /**
9147
+ * Run one agent turn over the given entries (without acquiring the
9148
+ * `running` lock). Owns the turn-generic lifecycle: request bookkeeping,
9149
+ * the forced-compaction gate, runTurn error handling, and terminal
9150
+ * `completed` events — the primary requestId's completed first, then one
9151
+ * `{absorbed: true}` completed per other absorbed requestId with the same
9152
+ * outcome, on every exit path (done, cancel, error, unexpected), so every
9153
+ * queued message's caller resolves.
9154
+ */
9155
+ async executeTurn(params) {
9156
+ const { entries, requestId, absorbedRids, onboardingState, system } = params;
9157
+ this.currentRequestId = requestId;
9158
+ this.currentAbort = new AbortController();
9159
+ this.completedEmitted = false;
9160
+ this.lastCompleted = null;
9161
+ this.turnStart = Date.now();
9162
+ await this.runForcedCompactionIfNeeded(requestId);
9027
9163
  try {
9028
9164
  await runTurn({
9029
9165
  state: this.state,
9030
- userMessage,
9031
- attachments,
9032
- attachmentHeader,
9166
+ entries,
9033
9167
  apiConfig: this.config,
9034
9168
  system,
9035
9169
  model: this.opts.model,
9036
- buildModel,
9170
+ buildModel: params.buildModel,
9037
9171
  onboardingState,
9038
9172
  requestId,
9039
9173
  signal: this.currentAbort.signal,
9040
9174
  onEvent: this.onEvent,
9041
9175
  resolveExternalTool: this.resolveExternalTool,
9042
- hidden: isHidden,
9043
9176
  toolRegistry: this.toolRegistry,
9044
9177
  onBackgroundComplete: this.onBackgroundComplete
9045
9178
  });
@@ -9067,6 +9200,18 @@ var HeadlessSession = class {
9067
9200
  error: err.message
9068
9201
  });
9069
9202
  }
9203
+ const outcome = this.primaryOutcome();
9204
+ for (const rid of absorbedRids) {
9205
+ this.emit(
9206
+ "completed",
9207
+ {
9208
+ success: outcome.success,
9209
+ ...outcome.error && { error: outcome.error },
9210
+ absorbed: true
9211
+ },
9212
+ rid
9213
+ );
9214
+ }
9070
9215
  applyPendingSummaries(this.state);
9071
9216
  this.applyPendingBlockUpdates();
9072
9217
  }
@@ -9093,42 +9238,61 @@ var HeadlessSession = class {
9093
9238
  this.running = false;
9094
9239
  }
9095
9240
  }
9241
+ /**
9242
+ * True for queued user items whose text is an @@automated:: action message.
9243
+ * These key per-item raw-text side effects in runSingleTurn (resolveAction,
9244
+ * plan file, buildModel, chain expansion) — they always run alone, one turn
9245
+ * each. Background items are sentinel-formatted too but background_results
9246
+ * is NON_ACTION and side-effect-free, so they merge freely.
9247
+ */
9248
+ isDrainBarrier(item) {
9249
+ return item.source === "user" && isAutomatedMessage(item.command.text ?? "");
9250
+ }
9096
9251
  /**
9097
9252
  * Drain the queue in strict FIFO order. Caller must hold `running = true`.
9098
9253
  * User messages arriving during the drain will be enqueued behind current items.
9099
9254
  *
9100
- * Consecutive background-source items are coalesced into a single turn so
9101
- * the LLM sees all the background results together and produces one
9102
- * acknowledgment, not N separate ones.
9255
+ * The queue serves two purposes with opposite delivery semantics:
9256
+ * - Sequencer: chain steps and sentinel-bearing user items are pipeline
9257
+ * stages — one item, one turn, nothing merged in.
9258
+ * - Mailbox: plain user messages and background results are accumulated
9259
+ * context and intent — everything contiguous flushes together into ONE
9260
+ * merged turn, so the model reconciles all of it at once instead of
9261
+ * burning a full turn per item (and possibly executing instructions a
9262
+ * later queued message already amended).
9103
9263
  */
9104
9264
  async drainQueueLoop() {
9105
- while (true) {
9106
- const next = this.queue.shift();
9107
- if (!next) {
9108
- break;
9265
+ while (this.queue.length > 0) {
9266
+ const head = this.queue.peek();
9267
+ if (head.source === "chain") {
9268
+ const item = this.queue.shift();
9269
+ const rid = item.command.requestId ?? `chain-${Date.now()}`;
9270
+ await this.runSingleTurn(item.command, rid, true);
9271
+ continue;
9109
9272
  }
9110
- if (next.source === "background") {
9111
- const batch = [next];
9112
- while (this.queue.peek()?.source === "background") {
9113
- const more = this.queue.shift();
9114
- if (more) {
9115
- batch.push(more);
9116
- }
9117
- }
9118
- const combinedCommand = {
9119
- action: "message",
9120
- text: mergeBackgroundResultsMessages(
9121
- batch.map((b) => b.command.text ?? "")
9122
- ),
9123
- ...this.currentOnboardingState && {
9124
- onboardingState: this.currentOnboardingState
9125
- }
9126
- };
9127
- await this.runSingleTurn(combinedCommand, `background-${Date.now()}`);
9273
+ if (this.isDrainBarrier(head)) {
9274
+ const item = this.queue.shift();
9275
+ const rid = item.command.requestId ?? `user-${Date.now()}`;
9276
+ await this.runSingleTurn(item.command, rid, false, true);
9128
9277
  continue;
9129
9278
  }
9130
- const nextRid = next.command.requestId ?? `${next.source}-${Date.now()}`;
9131
- await this.runSingleTurn(next.command, nextRid, next.source === "chain");
9279
+ let n = 1;
9280
+ let batchOb = head.command.onboardingState;
9281
+ for (; ; n++) {
9282
+ const it = this.queue.peekAt(n);
9283
+ if (!it || it.source === "chain" || this.isDrainBarrier(it)) {
9284
+ break;
9285
+ }
9286
+ const ob = it.command.onboardingState;
9287
+ if (ob !== void 0 && batchOb !== void 0 && ob !== batchOb) {
9288
+ break;
9289
+ }
9290
+ if (ob !== void 0 && batchOb === void 0) {
9291
+ batchOb = ob;
9292
+ }
9293
+ }
9294
+ const batch = this.queue.shiftMany(n);
9295
+ await this.runMergedTurn(batch);
9132
9296
  }
9133
9297
  }
9134
9298
  /**
@@ -9184,9 +9348,14 @@ var HeadlessSession = class {
9184
9348
  * Cancel the running turn and flush the follow-ups that belonged to it
9185
9349
  * (`chain`/`background`), while preserving `source: 'user'` items — those are
9186
9350
  * independent user intent, not tied to the aborted run. The preserved user
9187
- * messages run next: `runSingleTurn` swallows the abort, so `handleMessage`
9351
+ * messages run next: `executeTurn` swallows the abort, so `handleMessage`
9188
9352
  * falls through to `drainQueueLoop` with `running` still held. Returns the
9189
9353
  * flushed items (for the cancel command's resume/discard UX).
9354
+ *
9355
+ * Messages already absorbed into the in-flight merged turn are NOT
9356
+ * preserved — they were delivered into the turn that's being cancelled and
9357
+ * each gets a `{cancelled, absorbed:true}` terminal. Only items still
9358
+ * sitting in the queue survive.
9190
9359
  */
9191
9360
  handleCancel() {
9192
9361
  if (this.currentAbort) {
package/dist/index.js CHANGED
@@ -2195,13 +2195,17 @@ var init_surfaces = __esm({
2195
2195
  "gemini-3.1-pro",
2196
2196
  "gemini-3-flash",
2197
2197
  "gemini-3.5-flash",
2198
+ "gemini-3.7-flash",
2198
2199
  "grok-build-0.1",
2199
2200
  "grok-4.5",
2201
+ "grok-4.6",
2200
2202
  "glm-5.2",
2201
2203
  "muse-spark-1.1",
2202
2204
  "kimi-k2-7-code",
2203
2205
  "kimi-k3",
2204
- "deepseek-v4-flash-0731"
2206
+ "deepseek-v4-flash-0731",
2207
+ "qwen3.8-2.4t-a95b-deepinfra",
2208
+ "minimax-m3"
2205
2209
  ]
2206
2210
  // vision: undefined — unconstrained
2207
2211
  // image_generation: undefined — unconstrained
@@ -8091,9 +8095,7 @@ function createAgentState() {
8091
8095
  async function runTurn(params) {
8092
8096
  const {
8093
8097
  state,
8094
- userMessage,
8095
- attachments,
8096
- attachmentHeader,
8098
+ entries,
8097
8099
  apiConfig,
8098
8100
  system,
8099
8101
  model,
@@ -8102,7 +8104,6 @@ async function runTurn(params) {
8102
8104
  signal,
8103
8105
  onEvent,
8104
8106
  resolveExternalTool,
8105
- hidden,
8106
8107
  requestId,
8107
8108
  toolRegistry,
8108
8109
  onBackgroundComplete
@@ -8112,45 +8113,54 @@ async function runTurn(params) {
8112
8113
  const baseline = resolveModel("parent", state.models, model);
8113
8114
  const parentModel = buildModelOverride ?? baseline;
8114
8115
  const modelOverride = buildModelOverride && buildModelOverride !== baseline ? { from: baseline } : void 0;
8116
+ const totalAttachments = entries.reduce(
8117
+ (n, e) => n + (e.attachments?.length ?? 0),
8118
+ 0
8119
+ );
8115
8120
  log13.info("Turn started", {
8116
8121
  requestId,
8117
8122
  model,
8118
8123
  buildModel: buildModelOverride,
8119
8124
  toolCount: tools2.length,
8120
- ...attachments && attachments.length > 0 && {
8121
- attachmentCount: attachments.length
8122
- }
8125
+ ...entries.length > 1 && { entryCount: entries.length },
8126
+ ...totalAttachments > 0 && { attachmentCount: totalAttachments }
8123
8127
  });
8124
8128
  onEvent({
8125
8129
  type: "turn_started",
8126
8130
  model: parentModel,
8127
8131
  ...modelOverride && { modelOverride }
8128
8132
  });
8129
- const hasText = userMessage.trim().length > 0;
8130
- const hasAttachments = attachments && attachments.length > 0;
8131
- if (!hasText && !hasAttachments) {
8133
+ const keptEntries = entries.filter(
8134
+ (e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
8135
+ );
8136
+ if (keptEntries.length === 0) {
8132
8137
  onEvent({ type: "error", error: "Empty message" });
8133
8138
  return;
8134
8139
  }
8135
- const userMsg = { role: "user", content: userMessage };
8136
- if (hidden) {
8137
- userMsg.hidden = true;
8138
- }
8139
- if (hasAttachments) {
8140
- userMsg.attachments = attachments;
8141
- }
8142
- if (attachmentHeader) {
8143
- userMsg.attachmentHeader = attachmentHeader;
8140
+ for (const entry of keptEntries) {
8141
+ const hasAttachments = (entry.attachments?.length ?? 0) > 0;
8142
+ const userMsg = { role: "user", content: entry.text };
8143
+ if (entry.hidden) {
8144
+ userMsg.hidden = true;
8145
+ }
8146
+ if (hasAttachments) {
8147
+ userMsg.attachments = entry.attachments;
8148
+ }
8149
+ if (entry.attachmentHeader) {
8150
+ userMsg.attachmentHeader = entry.attachmentHeader;
8151
+ }
8152
+ state.messages.push(userMsg);
8153
+ onEvent({
8154
+ type: "user_message",
8155
+ text: entry.text,
8156
+ hidden: entry.hidden || void 0,
8157
+ // Include attachments so the live event can render a queued voice/image/file
8158
+ // bubble; a voice message has empty text and the transcript lives here.
8159
+ ...hasAttachments && { attachments: entry.attachments },
8160
+ ...entry.requestId && { requestId: entry.requestId },
8161
+ ...entry.queued && { queued: true }
8162
+ });
8144
8163
  }
8145
- state.messages.push(userMsg);
8146
- onEvent({
8147
- type: "user_message",
8148
- text: userMessage,
8149
- hidden: hidden || void 0,
8150
- // Include attachments so the live event can render a queued voice/image/file
8151
- // bubble; a voice message has empty text and the transcript lives here.
8152
- ...hasAttachments && { attachments }
8153
- });
8154
8164
  const isFirstMessage = state.messages.filter((m) => m.role === "user").length === 1;
8155
8165
  const STATUS_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
8156
8166
  "setProjectOnboardingState",
@@ -8219,11 +8229,13 @@ async function runTurn(params) {
8219
8229
  if (onboardingState && onboardingState !== "onboardingFinished") {
8220
8230
  parts.push(`Build phase: ${onboardingState}`);
8221
8231
  }
8222
- const automated = parseSentinel(userMessage);
8223
- if (automated) {
8224
- parts.push(`Automated action: ${automated.name}`);
8225
- } else if (userMessage) {
8226
- parts.push(`User request: ${userMessage.slice(-500)}`);
8232
+ for (const entry of keptEntries) {
8233
+ const automated = parseSentinel(entry.text);
8234
+ if (automated) {
8235
+ parts.push(`Automated action: ${automated.name}`);
8236
+ } else if (entry.text) {
8237
+ parts.push(`User request: ${entry.text.slice(-500)}`);
8238
+ }
8227
8239
  }
8228
8240
  return parts.join("\n");
8229
8241
  },
@@ -9144,25 +9156,24 @@ async function persistAttachments(attachments) {
9144
9156
  images: settled.filter((s) => s.isImage).map((s) => s.result)
9145
9157
  };
9146
9158
  }
9147
- function buildUploadHeader(results) {
9148
- const succeeded = results.filter(Boolean);
9149
- if (succeeded.length === 0) {
9159
+ function buildUploadHeader(documents, images) {
9160
+ const entries = [
9161
+ ...documents.filter((r) => r !== null).map((r) => ({ ...r, isImage: false })),
9162
+ ...images.filter((r) => r !== null).map((r) => ({ ...r, isImage: true }))
9163
+ ];
9164
+ if (entries.length === 0) {
9150
9165
  return "";
9151
9166
  }
9152
- if (succeeded.length === 1) {
9153
- const r = succeeded[0];
9154
- const parts = [`[Uploaded file: ${r.localPath}`];
9155
- if (r.extractedTextPath) {
9156
- parts.push(`extracted text: ${r.extractedTextPath}`);
9157
- }
9158
- return parts.join(" \u2014 ") + "]";
9167
+ const detail = (e) => e.extractedTextPath ? `extracted text: ${e.extractedTextPath}` : e.isImage ? null : "no extracted text \u2014 raw file only";
9168
+ if (entries.length === 1) {
9169
+ const e = entries[0];
9170
+ const extra = detail(e);
9171
+ return `[Uploaded file: ${e.localPath}${extra ? ` \u2014 ${extra}` : ""}]`;
9159
9172
  }
9160
- const lines = succeeded.map((r) => {
9161
- const parts = [`- ${r.localPath}`];
9162
- if (r.extractedTextPath) {
9163
- parts.push(` extracted text: ${r.extractedTextPath}`);
9164
- }
9165
- return parts.join("\n");
9173
+ const lines = entries.map((e) => {
9174
+ const extra = detail(e);
9175
+ return `- ${e.localPath}${extra ? `
9176
+ ${extra}` : ""}`;
9166
9177
  });
9167
9178
  return `[Uploaded files]
9168
9179
  ${lines.join("\n")}`;
@@ -9275,6 +9286,17 @@ var init_messageQueue = __esm({
9275
9286
  }
9276
9287
  return item;
9277
9288
  }
9289
+ /** Remove and return the first `n` items; fires onChange once. */
9290
+ shiftMany(n) {
9291
+ if (n <= 0) {
9292
+ return [];
9293
+ }
9294
+ const items = this.items.splice(0, n);
9295
+ if (items.length > 0) {
9296
+ this.onChange?.();
9297
+ }
9298
+ return items;
9299
+ }
9278
9300
  /** Remove and return all queued items. */
9279
9301
  drain() {
9280
9302
  if (this.items.length === 0) {
@@ -9310,6 +9332,10 @@ var init_messageQueue = __esm({
9310
9332
  peek() {
9311
9333
  return this.items[0];
9312
9334
  }
9335
+ /** Return the item at index `i` without removing it. */
9336
+ peekAt(i) {
9337
+ return this.items[i];
9338
+ }
9313
9339
  get length() {
9314
9340
  return this.items.length;
9315
9341
  }
@@ -9427,8 +9453,12 @@ var init_headless = __esm({
9427
9453
  currentAbort = null;
9428
9454
  /** RequestId of the in-flight message command — injected into streamed events. */
9429
9455
  currentRequestId;
9430
- /** Guard: track whether terminal `completed` was already sent so we emit exactly one. */
9456
+ /** Guard: track whether terminal `completed` was already sent so we emit
9457
+ * exactly one per requestId. */
9431
9458
  completedEmitted = false;
9459
+ /** Outcome of the current turn's primary `completed` — read after the turn
9460
+ * to stamp the same success/error onto absorbed requestIds' terminals. */
9461
+ lastCompleted = null;
9432
9462
  turnStart = 0;
9433
9463
  /**
9434
9464
  * Onboarding state of the currently-running turn. Captured at runSingleTurn
@@ -9564,6 +9594,15 @@ var init_headless = __esm({
9564
9594
  emitCompleted(rid, data) {
9565
9595
  this.emit("completed", { ...data }, rid);
9566
9596
  this.completedEmitted = true;
9597
+ this.lastCompleted = {
9598
+ success: data.success === true,
9599
+ ...typeof data.error === "string" && { error: data.error }
9600
+ };
9601
+ }
9602
+ /** Outcome of the turn's primary `completed`, for stamping onto absorbed
9603
+ * requestIds' terminals. Falls back to failure if none was emitted. */
9604
+ primaryOutcome() {
9605
+ return this.lastCompleted ?? { success: false };
9567
9606
  }
9568
9607
  /** Dispatch a simple (non-streaming) command: call handler, emit response + completed. */
9569
9608
  dispatchSimple(requestId, eventName, handler) {
@@ -9722,9 +9761,14 @@ var init_headless = __esm({
9722
9761
  text: e.text,
9723
9762
  // Forward attachments so queued voice/image/file sends render live;
9724
9763
  // otherwise the bubble is blank until a get_history refresh.
9725
- ...e.attachments && { attachments: e.attachments }
9764
+ ...e.attachments && { attachments: e.attachments },
9765
+ // Queue-delivered entries are flagged so the frontend renders the
9766
+ // echo (idle sends are rendered optimistically instead).
9767
+ ...e.queued && { queued: true }
9726
9768
  },
9727
- rid
9769
+ // A merged turn emits one user_message per absorbed entry — each
9770
+ // carries its own original requestId, not the turn's.
9771
+ e.requestId ?? rid
9728
9772
  );
9729
9773
  return;
9730
9774
  // Terminal events — translate to `completed`.
@@ -9747,11 +9791,9 @@ var init_headless = __esm({
9747
9791
  durationMs: Date.now() - this.turnStart
9748
9792
  });
9749
9793
  return;
9750
- case "turn_cancelled": {
9751
- this.emit("completed", { success: false, error: "cancelled" }, rid);
9752
- this.completedEmitted = true;
9794
+ case "turn_cancelled":
9795
+ this.emitCompleted(rid, { success: false, error: "cancelled" });
9753
9796
  return;
9754
- }
9755
9797
  // Streaming events — forward with requestId
9756
9798
  case "text":
9757
9799
  this.emit(
@@ -9871,17 +9913,34 @@ var init_headless = __esm({
9871
9913
  // Message command handler (long-running / streaming)
9872
9914
  //////////////////////////////////////////////////////////////////////////////
9873
9915
  /**
9874
- * Run one turn (without acquiring the `running` lock). Called by
9875
- * handleMessage for the initial turn, then repeatedly for each queued
9876
- * message `running` stays held across the queue drain so no user
9877
- * message can slip in mid-pipeline.
9916
+ * Persist one entry's non-voice uploads to disk and build its header. The
9917
+ * header tells the LLM where to read each file; it's kept separate so it
9918
+ * gets injected at API-send time and never persisted into the user's chat
9919
+ * content (which would leak into history restore on the frontend).
9920
+ *
9921
+ * Must be awaited sequentially across entries — persistAttachments'
9922
+ * filename de-dup set is per-call, so parallel calls race on names.
9878
9923
  */
9879
- async runSingleTurn(parsed, requestId, fromChain = false) {
9880
- this.currentRequestId = requestId;
9881
- this.currentAbort = new AbortController();
9882
- this.completedEmitted = false;
9883
- this.turnStart = Date.now();
9884
- await this.runForcedCompactionIfNeeded(requestId);
9924
+ async persistEntryAttachments(attachments) {
9925
+ if (!attachments?.some((a) => !a.isVoice)) {
9926
+ return void 0;
9927
+ }
9928
+ try {
9929
+ const { documents, images } = await persistAttachments(attachments);
9930
+ return buildUploadHeader(documents, images) || void 0;
9931
+ } catch (err) {
9932
+ log16.warn("Attachment persistence failed", { error: err.message });
9933
+ return void 0;
9934
+ }
9935
+ }
9936
+ /**
9937
+ * Run one turn for a single command (without acquiring the `running` lock).
9938
+ * Owns the per-command machinery: @@automated:: action resolution, plan-file
9939
+ * and buildModel side effects, and chain expansion — which is why
9940
+ * sentinel-bearing commands always come through here, one turn each, never
9941
+ * merged. The turn itself runs in executeTurn.
9942
+ */
9943
+ async runSingleTurn(parsed, requestId, fromChain = false, queued = false) {
9885
9944
  const attachments = parsed.attachments;
9886
9945
  if (attachments?.length) {
9887
9946
  log16.info("Message has attachments", {
@@ -9890,19 +9949,7 @@ var init_headless = __esm({
9890
9949
  });
9891
9950
  }
9892
9951
  let userMessage = parsed.text ?? "";
9893
- let attachmentHeader;
9894
- if (attachments?.some((a) => !a.isVoice)) {
9895
- try {
9896
- const { documents, images } = await persistAttachments(attachments);
9897
- const all = [...documents, ...images];
9898
- const header = buildUploadHeader(all);
9899
- if (header) {
9900
- attachmentHeader = header;
9901
- }
9902
- } catch (err) {
9903
- log16.warn("Attachment persistence failed", { error: err.message });
9904
- }
9905
- }
9952
+ const attachmentHeader = await this.persistEntryAttachments(attachments);
9906
9953
  let resolved = null;
9907
9954
  try {
9908
9955
  resolved = resolveAction(userMessage);
@@ -9939,22 +9986,108 @@ var init_headless = __esm({
9939
9986
  });
9940
9987
  }
9941
9988
  }
9989
+ await this.executeTurn({
9990
+ entries: [
9991
+ {
9992
+ text: userMessage,
9993
+ attachments,
9994
+ attachmentHeader,
9995
+ hidden: isHidden || void 0,
9996
+ requestId,
9997
+ queued: queued || void 0
9998
+ }
9999
+ ],
10000
+ requestId,
10001
+ absorbedRids: [],
10002
+ onboardingState,
10003
+ system,
10004
+ buildModel
10005
+ });
10006
+ }
10007
+ /**
10008
+ * Run a mailbox batch — contiguous queued user + background items — as one
10009
+ * merged turn. Every item becomes its own history entry and user_message
10010
+ * event (own requestId, attachments, hidden flag); adjacent background
10011
+ * items fold into a single background_results entry so the LLM sees one
10012
+ * combined block. No action-sentinel machinery here: batch construction
10013
+ * guarantees none (sentinel-bearing user items are drain barriers that run
10014
+ * alone via runSingleTurn, and background_results is a NON_ACTION sentinel
10015
+ * with no side effects).
10016
+ */
10017
+ async runMergedTurn(batch) {
10018
+ const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
10019
+ const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
10020
+ const entryList = [];
10021
+ for (const item of batch) {
10022
+ const text = item.command.text ?? "";
10023
+ const prev = entryList[entryList.length - 1];
10024
+ if (item.source === "background" && prev?.background) {
10025
+ prev.entry.text = mergeBackgroundResultsMessages([
10026
+ prev.entry.text,
10027
+ text
10028
+ ]);
10029
+ continue;
10030
+ }
10031
+ entryList.push({
10032
+ background: item.source === "background",
10033
+ entry: {
10034
+ text,
10035
+ attachments: item.command.attachments,
10036
+ hidden: !!item.command.hidden || void 0,
10037
+ // Background items carry no requestId — their user_message falls
10038
+ // back to the turn's primary rid on the wire.
10039
+ requestId: item.command.requestId,
10040
+ queued: true
10041
+ }
10042
+ });
10043
+ }
10044
+ const entries = entryList.map((e) => e.entry);
10045
+ for (const entry of entries) {
10046
+ entry.attachmentHeader = await this.persistEntryAttachments(
10047
+ entry.attachments
10048
+ );
10049
+ }
10050
+ const onboardingState = batch.find((b) => b.command.onboardingState !== void 0)?.command.onboardingState ?? this.currentOnboardingState ?? "onboardingFinished";
10051
+ this.currentOnboardingState = onboardingState;
10052
+ const viewContext = [...batch].reverse().find((b) => b.command.viewContext !== void 0)?.command.viewContext;
10053
+ await this.executeTurn({
10054
+ entries,
10055
+ requestId: primaryRid,
10056
+ absorbedRids,
10057
+ onboardingState,
10058
+ system: buildSystemPrompt(onboardingState, viewContext)
10059
+ });
10060
+ }
10061
+ /**
10062
+ * Run one agent turn over the given entries (without acquiring the
10063
+ * `running` lock). Owns the turn-generic lifecycle: request bookkeeping,
10064
+ * the forced-compaction gate, runTurn error handling, and terminal
10065
+ * `completed` events — the primary requestId's completed first, then one
10066
+ * `{absorbed: true}` completed per other absorbed requestId with the same
10067
+ * outcome, on every exit path (done, cancel, error, unexpected), so every
10068
+ * queued message's caller resolves.
10069
+ */
10070
+ async executeTurn(params) {
10071
+ const { entries, requestId, absorbedRids, onboardingState, system } = params;
10072
+ this.currentRequestId = requestId;
10073
+ this.currentAbort = new AbortController();
10074
+ this.completedEmitted = false;
10075
+ this.lastCompleted = null;
10076
+ this.turnStart = Date.now();
10077
+ await this.runForcedCompactionIfNeeded(requestId);
9942
10078
  try {
9943
10079
  await runTurn({
9944
10080
  state: this.state,
9945
- userMessage,
9946
- attachments,
9947
- attachmentHeader,
10081
+ entries,
9948
10082
  apiConfig: this.config,
9949
10083
  system,
9950
10084
  model: this.opts.model,
9951
- buildModel,
10085
+ buildModel: params.buildModel,
9952
10086
  onboardingState,
9953
10087
  requestId,
9954
10088
  signal: this.currentAbort.signal,
9955
10089
  onEvent: this.onEvent,
9956
10090
  resolveExternalTool: this.resolveExternalTool,
9957
- hidden: isHidden,
9958
10091
  toolRegistry: this.toolRegistry,
9959
10092
  onBackgroundComplete: this.onBackgroundComplete
9960
10093
  });
@@ -9982,6 +10115,18 @@ var init_headless = __esm({
9982
10115
  error: err.message
9983
10116
  });
9984
10117
  }
10118
+ const outcome = this.primaryOutcome();
10119
+ for (const rid of absorbedRids) {
10120
+ this.emit(
10121
+ "completed",
10122
+ {
10123
+ success: outcome.success,
10124
+ ...outcome.error && { error: outcome.error },
10125
+ absorbed: true
10126
+ },
10127
+ rid
10128
+ );
10129
+ }
9985
10130
  applyPendingSummaries(this.state);
9986
10131
  this.applyPendingBlockUpdates();
9987
10132
  }
@@ -10008,42 +10153,61 @@ var init_headless = __esm({
10008
10153
  this.running = false;
10009
10154
  }
10010
10155
  }
10156
+ /**
10157
+ * True for queued user items whose text is an @@automated:: action message.
10158
+ * These key per-item raw-text side effects in runSingleTurn (resolveAction,
10159
+ * plan file, buildModel, chain expansion) — they always run alone, one turn
10160
+ * each. Background items are sentinel-formatted too but background_results
10161
+ * is NON_ACTION and side-effect-free, so they merge freely.
10162
+ */
10163
+ isDrainBarrier(item) {
10164
+ return item.source === "user" && isAutomatedMessage(item.command.text ?? "");
10165
+ }
10011
10166
  /**
10012
10167
  * Drain the queue in strict FIFO order. Caller must hold `running = true`.
10013
10168
  * User messages arriving during the drain will be enqueued behind current items.
10014
10169
  *
10015
- * Consecutive background-source items are coalesced into a single turn so
10016
- * the LLM sees all the background results together and produces one
10017
- * acknowledgment, not N separate ones.
10170
+ * The queue serves two purposes with opposite delivery semantics:
10171
+ * - Sequencer: chain steps and sentinel-bearing user items are pipeline
10172
+ * stages — one item, one turn, nothing merged in.
10173
+ * - Mailbox: plain user messages and background results are accumulated
10174
+ * context and intent — everything contiguous flushes together into ONE
10175
+ * merged turn, so the model reconciles all of it at once instead of
10176
+ * burning a full turn per item (and possibly executing instructions a
10177
+ * later queued message already amended).
10018
10178
  */
10019
10179
  async drainQueueLoop() {
10020
- while (true) {
10021
- const next = this.queue.shift();
10022
- if (!next) {
10023
- break;
10180
+ while (this.queue.length > 0) {
10181
+ const head = this.queue.peek();
10182
+ if (head.source === "chain") {
10183
+ const item = this.queue.shift();
10184
+ const rid = item.command.requestId ?? `chain-${Date.now()}`;
10185
+ await this.runSingleTurn(item.command, rid, true);
10186
+ continue;
10024
10187
  }
10025
- if (next.source === "background") {
10026
- const batch = [next];
10027
- while (this.queue.peek()?.source === "background") {
10028
- const more = this.queue.shift();
10029
- if (more) {
10030
- batch.push(more);
10031
- }
10032
- }
10033
- const combinedCommand = {
10034
- action: "message",
10035
- text: mergeBackgroundResultsMessages(
10036
- batch.map((b) => b.command.text ?? "")
10037
- ),
10038
- ...this.currentOnboardingState && {
10039
- onboardingState: this.currentOnboardingState
10040
- }
10041
- };
10042
- await this.runSingleTurn(combinedCommand, `background-${Date.now()}`);
10188
+ if (this.isDrainBarrier(head)) {
10189
+ const item = this.queue.shift();
10190
+ const rid = item.command.requestId ?? `user-${Date.now()}`;
10191
+ await this.runSingleTurn(item.command, rid, false, true);
10043
10192
  continue;
10044
10193
  }
10045
- const nextRid = next.command.requestId ?? `${next.source}-${Date.now()}`;
10046
- await this.runSingleTurn(next.command, nextRid, next.source === "chain");
10194
+ let n = 1;
10195
+ let batchOb = head.command.onboardingState;
10196
+ for (; ; n++) {
10197
+ const it = this.queue.peekAt(n);
10198
+ if (!it || it.source === "chain" || this.isDrainBarrier(it)) {
10199
+ break;
10200
+ }
10201
+ const ob = it.command.onboardingState;
10202
+ if (ob !== void 0 && batchOb !== void 0 && ob !== batchOb) {
10203
+ break;
10204
+ }
10205
+ if (ob !== void 0 && batchOb === void 0) {
10206
+ batchOb = ob;
10207
+ }
10208
+ }
10209
+ const batch = this.queue.shiftMany(n);
10210
+ await this.runMergedTurn(batch);
10047
10211
  }
10048
10212
  }
10049
10213
  /**
@@ -10099,9 +10263,14 @@ var init_headless = __esm({
10099
10263
  * Cancel the running turn and flush the follow-ups that belonged to it
10100
10264
  * (`chain`/`background`), while preserving `source: 'user'` items — those are
10101
10265
  * independent user intent, not tied to the aborted run. The preserved user
10102
- * messages run next: `runSingleTurn` swallows the abort, so `handleMessage`
10266
+ * messages run next: `executeTurn` swallows the abort, so `handleMessage`
10103
10267
  * falls through to `drainQueueLoop` with `running` still held. Returns the
10104
10268
  * flushed items (for the cancel command's resume/discard UX).
10269
+ *
10270
+ * Messages already absorbed into the in-flight merged turn are NOT
10271
+ * preserved — they were delivered into the turn that's being cancelled and
10272
+ * each gets a `{cancelled, absorbed:true}` terminal. Only items still
10273
+ * sitting in the queue survive.
10105
10274
  */
10106
10275
  handleCancel() {
10107
10276
  if (this.currentAbort) {
@@ -10528,7 +10697,7 @@ function App({ apiConfig, model }) {
10528
10697
  try {
10529
10698
  await runTurn({
10530
10699
  state: agentState,
10531
- userMessage: message,
10700
+ entries: [{ text: message }],
10532
10701
  apiConfig,
10533
10702
  system,
10534
10703
  model,
@@ -28,7 +28,7 @@ The user can already see your tool calls, so most of your work is visible withou
28
28
  Skip the rest: narrating what you're about to do, restating what the user asked, explaining tool calls they can already see.
29
29
 
30
30
  ### User attachments
31
- When a user uploads a file (PDF, Word doc, image, etc.), it is automatically saved to `src/.user-uploads/` in the project directory. The message includes the local file path, and for documents with extractable text, a `.txt` sidecar with the extracted content. Use `readFile` on the sidecar to access document contents. Pass the file path itself to tools that take an image — `screenshot`, and the design expert's `analyzeImage` / `analyzeDesign` / `editImages` — and they host the file and hand back a URL you can reuse or embed in a spec. If a raw file from `src/` needs to be served by the web interface, copy it to `dist/interfaces/web/public/`. These files persist across the conversation — they survive compaction and session restarts. Do not ask the user to re-upload a document that has already been saved. Voice messages are not saved to disk — their transcripts appear inline in the message.
31
+ When a user uploads a file (PDF, Word doc, image, etc.), it is automatically saved to `src/.user-uploads/` in the project directory. The message includes the local file path, and for documents with extractable text, a `.txt` sidecar with the extracted content you can read with `readFile`. A document marked "no extracted text — raw file only" has no sidecar: parse the raw file yourself, and if it is genuinely unreadable, tell the user what happened. Pass the file path itself to tools that take an image — `screenshot`, and the design expert's `analyzeImage` / `analyzeDesign` / `editImages` — and they host the file and hand back a URL you can reuse or embed in a spec. If a raw file from `src/` needs to be served by the web interface, copy it to `dist/interfaces/web/public/`. These files persist across the conversation — they survive compaction and session restarts. Do not ask the user to re-upload a document that has already been saved. Voice messages are not saved to disk — their transcripts appear inline in the message.
32
32
 
33
33
  ### Automated messages
34
34
  You will occasionally receive automated messages prefixed with `@@automated_message@@` - these are triggered by things like background agents returning their work, or by the user clicking a button in the UI (e.g., the user might click a "Build Feature" button in the product roadmap UI, and you will receive a message detailing what they want to build). You will be able to see these messages in your chat history but the user will not see them, so acknowledge them appropriately and then perform the requested work.
@@ -71,6 +71,7 @@ Result text here...
71
71
  When you receive background results:
72
72
  - Acknowledge them briefly to the user if relevant to what they're doing (e.g., "By the way, the designer finished those icons..." or "Looks like the roadmap is ready...")
73
73
  - Don't interrupt the user's flow with a lengthy summary — they can see the background work in the UI
74
+ - Background results may arrive in the same turn as new user messages. When they do, the user's messages are the priority — fold the results in where they matter and get on with what the user asked.
74
75
 
75
76
  #### When You Are Allowed to Background
76
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstudio-ai/remy",
3
- "version": "0.1.260",
3
+ "version": "0.1.262",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",