@mindstudio-ai/remy 0.1.260 → 0.1.261

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
@@ -7614,9 +7614,7 @@ function createAgentState() {
7614
7614
  async function runTurn(params) {
7615
7615
  const {
7616
7616
  state,
7617
- userMessage,
7618
- attachments,
7619
- attachmentHeader,
7617
+ entries,
7620
7618
  apiConfig,
7621
7619
  system,
7622
7620
  model,
@@ -7625,7 +7623,6 @@ async function runTurn(params) {
7625
7623
  signal,
7626
7624
  onEvent,
7627
7625
  resolveExternalTool,
7628
- hidden,
7629
7626
  requestId,
7630
7627
  toolRegistry,
7631
7628
  onBackgroundComplete
@@ -7635,45 +7632,54 @@ async function runTurn(params) {
7635
7632
  const baseline = resolveModel("parent", state.models, model);
7636
7633
  const parentModel = buildModelOverride ?? baseline;
7637
7634
  const modelOverride = buildModelOverride && buildModelOverride !== baseline ? { from: baseline } : void 0;
7635
+ const totalAttachments = entries.reduce(
7636
+ (n, e) => n + (e.attachments?.length ?? 0),
7637
+ 0
7638
+ );
7638
7639
  log14.info("Turn started", {
7639
7640
  requestId,
7640
7641
  model,
7641
7642
  buildModel: buildModelOverride,
7642
7643
  toolCount: tools2.length,
7643
- ...attachments && attachments.length > 0 && {
7644
- attachmentCount: attachments.length
7645
- }
7644
+ ...entries.length > 1 && { entryCount: entries.length },
7645
+ ...totalAttachments > 0 && { attachmentCount: totalAttachments }
7646
7646
  });
7647
7647
  onEvent({
7648
7648
  type: "turn_started",
7649
7649
  model: parentModel,
7650
7650
  ...modelOverride && { modelOverride }
7651
7651
  });
7652
- const hasText = userMessage.trim().length > 0;
7653
- const hasAttachments = attachments && attachments.length > 0;
7654
- if (!hasText && !hasAttachments) {
7652
+ const keptEntries = entries.filter(
7653
+ (e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
7654
+ );
7655
+ if (keptEntries.length === 0) {
7655
7656
  onEvent({ type: "error", error: "Empty message" });
7656
7657
  return;
7657
7658
  }
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;
7659
+ for (const entry of keptEntries) {
7660
+ const hasAttachments = (entry.attachments?.length ?? 0) > 0;
7661
+ const userMsg = { role: "user", content: entry.text };
7662
+ if (entry.hidden) {
7663
+ userMsg.hidden = true;
7664
+ }
7665
+ if (hasAttachments) {
7666
+ userMsg.attachments = entry.attachments;
7667
+ }
7668
+ if (entry.attachmentHeader) {
7669
+ userMsg.attachmentHeader = entry.attachmentHeader;
7670
+ }
7671
+ state.messages.push(userMsg);
7672
+ onEvent({
7673
+ type: "user_message",
7674
+ text: entry.text,
7675
+ hidden: entry.hidden || void 0,
7676
+ // Include attachments so the live event can render a queued voice/image/file
7677
+ // bubble; a voice message has empty text and the transcript lives here.
7678
+ ...hasAttachments && { attachments: entry.attachments },
7679
+ ...entry.requestId && { requestId: entry.requestId },
7680
+ ...entry.queued && { queued: true }
7681
+ });
7667
7682
  }
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
7683
  const isFirstMessage = state.messages.filter((m) => m.role === "user").length === 1;
7678
7684
  const STATUS_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
7679
7685
  "setProjectOnboardingState",
@@ -7742,11 +7748,13 @@ async function runTurn(params) {
7742
7748
  if (onboardingState && onboardingState !== "onboardingFinished") {
7743
7749
  parts.push(`Build phase: ${onboardingState}`);
7744
7750
  }
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)}`);
7751
+ for (const entry of keptEntries) {
7752
+ const automated = parseSentinel(entry.text);
7753
+ if (automated) {
7754
+ parts.push(`Automated action: ${automated.name}`);
7755
+ } else if (entry.text) {
7756
+ parts.push(`User request: ${entry.text.slice(-500)}`);
7757
+ }
7750
7758
  }
7751
7759
  return parts.join("\n");
7752
7760
  },
@@ -8291,25 +8299,24 @@ async function persistAttachments(attachments) {
8291
8299
  images: settled.filter((s) => s.isImage).map((s) => s.result)
8292
8300
  };
8293
8301
  }
8294
- function buildUploadHeader(results) {
8295
- const succeeded = results.filter(Boolean);
8296
- if (succeeded.length === 0) {
8302
+ function buildUploadHeader(documents, images) {
8303
+ const entries = [
8304
+ ...documents.filter((r) => r !== null).map((r) => ({ ...r, isImage: false })),
8305
+ ...images.filter((r) => r !== null).map((r) => ({ ...r, isImage: true }))
8306
+ ];
8307
+ if (entries.length === 0) {
8297
8308
  return "";
8298
8309
  }
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 ") + "]";
8310
+ const detail = (e) => e.extractedTextPath ? `extracted text: ${e.extractedTextPath}` : e.isImage ? null : "no extracted text \u2014 raw file only";
8311
+ if (entries.length === 1) {
8312
+ const e = entries[0];
8313
+ const extra = detail(e);
8314
+ return `[Uploaded file: ${e.localPath}${extra ? ` \u2014 ${extra}` : ""}]`;
8306
8315
  }
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");
8316
+ const lines = entries.map((e) => {
8317
+ const extra = detail(e);
8318
+ return `- ${e.localPath}${extra ? `
8319
+ ${extra}` : ""}`;
8313
8320
  });
8314
8321
  return `[Uploaded files]
8315
8322
  ${lines.join("\n")}`;
@@ -8395,6 +8402,17 @@ var MessageQueue = class {
8395
8402
  }
8396
8403
  return item;
8397
8404
  }
8405
+ /** Remove and return the first `n` items; fires onChange once. */
8406
+ shiftMany(n) {
8407
+ if (n <= 0) {
8408
+ return [];
8409
+ }
8410
+ const items = this.items.splice(0, n);
8411
+ if (items.length > 0) {
8412
+ this.onChange?.();
8413
+ }
8414
+ return items;
8415
+ }
8398
8416
  /** Remove and return all queued items. */
8399
8417
  drain() {
8400
8418
  if (this.items.length === 0) {
@@ -8430,6 +8448,10 @@ var MessageQueue = class {
8430
8448
  peek() {
8431
8449
  return this.items[0];
8432
8450
  }
8451
+ /** Return the item at index `i` without removing it. */
8452
+ peekAt(i) {
8453
+ return this.items[i];
8454
+ }
8433
8455
  get length() {
8434
8456
  return this.items.length;
8435
8457
  }
@@ -8512,8 +8534,12 @@ var HeadlessSession = class {
8512
8534
  currentAbort = null;
8513
8535
  /** RequestId of the in-flight message command — injected into streamed events. */
8514
8536
  currentRequestId;
8515
- /** Guard: track whether terminal `completed` was already sent so we emit exactly one. */
8537
+ /** Guard: track whether terminal `completed` was already sent so we emit
8538
+ * exactly one per requestId. */
8516
8539
  completedEmitted = false;
8540
+ /** Outcome of the current turn's primary `completed` — read after the turn
8541
+ * to stamp the same success/error onto absorbed requestIds' terminals. */
8542
+ lastCompleted = null;
8517
8543
  turnStart = 0;
8518
8544
  /**
8519
8545
  * Onboarding state of the currently-running turn. Captured at runSingleTurn
@@ -8649,6 +8675,15 @@ var HeadlessSession = class {
8649
8675
  emitCompleted(rid, data) {
8650
8676
  this.emit("completed", { ...data }, rid);
8651
8677
  this.completedEmitted = true;
8678
+ this.lastCompleted = {
8679
+ success: data.success === true,
8680
+ ...typeof data.error === "string" && { error: data.error }
8681
+ };
8682
+ }
8683
+ /** Outcome of the turn's primary `completed`, for stamping onto absorbed
8684
+ * requestIds' terminals. Falls back to failure if none was emitted. */
8685
+ primaryOutcome() {
8686
+ return this.lastCompleted ?? { success: false };
8652
8687
  }
8653
8688
  /** Dispatch a simple (non-streaming) command: call handler, emit response + completed. */
8654
8689
  dispatchSimple(requestId, eventName, handler) {
@@ -8807,9 +8842,14 @@ var HeadlessSession = class {
8807
8842
  text: e.text,
8808
8843
  // Forward attachments so queued voice/image/file sends render live;
8809
8844
  // otherwise the bubble is blank until a get_history refresh.
8810
- ...e.attachments && { attachments: e.attachments }
8845
+ ...e.attachments && { attachments: e.attachments },
8846
+ // Queue-delivered entries are flagged so the frontend renders the
8847
+ // echo (idle sends are rendered optimistically instead).
8848
+ ...e.queued && { queued: true }
8811
8849
  },
8812
- rid
8850
+ // A merged turn emits one user_message per absorbed entry — each
8851
+ // carries its own original requestId, not the turn's.
8852
+ e.requestId ?? rid
8813
8853
  );
8814
8854
  return;
8815
8855
  // Terminal events — translate to `completed`.
@@ -8832,11 +8872,9 @@ var HeadlessSession = class {
8832
8872
  durationMs: Date.now() - this.turnStart
8833
8873
  });
8834
8874
  return;
8835
- case "turn_cancelled": {
8836
- this.emit("completed", { success: false, error: "cancelled" }, rid);
8837
- this.completedEmitted = true;
8875
+ case "turn_cancelled":
8876
+ this.emitCompleted(rid, { success: false, error: "cancelled" });
8838
8877
  return;
8839
- }
8840
8878
  // Streaming events — forward with requestId
8841
8879
  case "text":
8842
8880
  this.emit(
@@ -8956,17 +8994,34 @@ var HeadlessSession = class {
8956
8994
  // Message command handler (long-running / streaming)
8957
8995
  //////////////////////////////////////////////////////////////////////////////
8958
8996
  /**
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.
8997
+ * Persist one entry's non-voice uploads to disk and build its header. The
8998
+ * header tells the LLM where to read each file; it's kept separate so it
8999
+ * gets injected at API-send time and never persisted into the user's chat
9000
+ * content (which would leak into history restore on the frontend).
9001
+ *
9002
+ * Must be awaited sequentially across entries — persistAttachments'
9003
+ * filename de-dup set is per-call, so parallel calls race on names.
8963
9004
  */
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);
9005
+ async persistEntryAttachments(attachments) {
9006
+ if (!attachments?.some((a) => !a.isVoice)) {
9007
+ return void 0;
9008
+ }
9009
+ try {
9010
+ const { documents, images } = await persistAttachments(attachments);
9011
+ return buildUploadHeader(documents, images) || void 0;
9012
+ } catch (err) {
9013
+ log16.warn("Attachment persistence failed", { error: err.message });
9014
+ return void 0;
9015
+ }
9016
+ }
9017
+ /**
9018
+ * Run one turn for a single command (without acquiring the `running` lock).
9019
+ * Owns the per-command machinery: @@automated:: action resolution, plan-file
9020
+ * and buildModel side effects, and chain expansion — which is why
9021
+ * sentinel-bearing commands always come through here, one turn each, never
9022
+ * merged. The turn itself runs in executeTurn.
9023
+ */
9024
+ async runSingleTurn(parsed, requestId, fromChain = false, queued = false) {
8970
9025
  const attachments = parsed.attachments;
8971
9026
  if (attachments?.length) {
8972
9027
  log16.info("Message has attachments", {
@@ -8975,19 +9030,7 @@ var HeadlessSession = class {
8975
9030
  });
8976
9031
  }
8977
9032
  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
- }
9033
+ const attachmentHeader = await this.persistEntryAttachments(attachments);
8991
9034
  let resolved = null;
8992
9035
  try {
8993
9036
  resolved = resolveAction(userMessage);
@@ -9024,22 +9067,108 @@ var HeadlessSession = class {
9024
9067
  });
9025
9068
  }
9026
9069
  }
9070
+ await this.executeTurn({
9071
+ entries: [
9072
+ {
9073
+ text: userMessage,
9074
+ attachments,
9075
+ attachmentHeader,
9076
+ hidden: isHidden || void 0,
9077
+ requestId,
9078
+ queued: queued || void 0
9079
+ }
9080
+ ],
9081
+ requestId,
9082
+ absorbedRids: [],
9083
+ onboardingState,
9084
+ system,
9085
+ buildModel
9086
+ });
9087
+ }
9088
+ /**
9089
+ * Run a mailbox batch — contiguous queued user + background items — as one
9090
+ * merged turn. Every item becomes its own history entry and user_message
9091
+ * event (own requestId, attachments, hidden flag); adjacent background
9092
+ * items fold into a single background_results entry so the LLM sees one
9093
+ * combined block. No action-sentinel machinery here: batch construction
9094
+ * guarantees none (sentinel-bearing user items are drain barriers that run
9095
+ * alone via runSingleTurn, and background_results is a NON_ACTION sentinel
9096
+ * with no side effects).
9097
+ */
9098
+ async runMergedTurn(batch) {
9099
+ const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
9100
+ const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
9101
+ const entryList = [];
9102
+ for (const item of batch) {
9103
+ const text = item.command.text ?? "";
9104
+ const prev = entryList[entryList.length - 1];
9105
+ if (item.source === "background" && prev?.background) {
9106
+ prev.entry.text = mergeBackgroundResultsMessages([
9107
+ prev.entry.text,
9108
+ text
9109
+ ]);
9110
+ continue;
9111
+ }
9112
+ entryList.push({
9113
+ background: item.source === "background",
9114
+ entry: {
9115
+ text,
9116
+ attachments: item.command.attachments,
9117
+ hidden: !!item.command.hidden || void 0,
9118
+ // Background items carry no requestId — their user_message falls
9119
+ // back to the turn's primary rid on the wire.
9120
+ requestId: item.command.requestId,
9121
+ queued: true
9122
+ }
9123
+ });
9124
+ }
9125
+ const entries = entryList.map((e) => e.entry);
9126
+ for (const entry of entries) {
9127
+ entry.attachmentHeader = await this.persistEntryAttachments(
9128
+ entry.attachments
9129
+ );
9130
+ }
9131
+ const onboardingState = batch.find((b) => b.command.onboardingState !== void 0)?.command.onboardingState ?? this.currentOnboardingState ?? "onboardingFinished";
9132
+ this.currentOnboardingState = onboardingState;
9133
+ const viewContext = [...batch].reverse().find((b) => b.command.viewContext !== void 0)?.command.viewContext;
9134
+ await this.executeTurn({
9135
+ entries,
9136
+ requestId: primaryRid,
9137
+ absorbedRids,
9138
+ onboardingState,
9139
+ system: buildSystemPrompt(onboardingState, viewContext)
9140
+ });
9141
+ }
9142
+ /**
9143
+ * Run one agent turn over the given entries (without acquiring the
9144
+ * `running` lock). Owns the turn-generic lifecycle: request bookkeeping,
9145
+ * the forced-compaction gate, runTurn error handling, and terminal
9146
+ * `completed` events — the primary requestId's completed first, then one
9147
+ * `{absorbed: true}` completed per other absorbed requestId with the same
9148
+ * outcome, on every exit path (done, cancel, error, unexpected), so every
9149
+ * queued message's caller resolves.
9150
+ */
9151
+ async executeTurn(params) {
9152
+ const { entries, requestId, absorbedRids, onboardingState, system } = params;
9153
+ this.currentRequestId = requestId;
9154
+ this.currentAbort = new AbortController();
9155
+ this.completedEmitted = false;
9156
+ this.lastCompleted = null;
9157
+ this.turnStart = Date.now();
9158
+ await this.runForcedCompactionIfNeeded(requestId);
9027
9159
  try {
9028
9160
  await runTurn({
9029
9161
  state: this.state,
9030
- userMessage,
9031
- attachments,
9032
- attachmentHeader,
9162
+ entries,
9033
9163
  apiConfig: this.config,
9034
9164
  system,
9035
9165
  model: this.opts.model,
9036
- buildModel,
9166
+ buildModel: params.buildModel,
9037
9167
  onboardingState,
9038
9168
  requestId,
9039
9169
  signal: this.currentAbort.signal,
9040
9170
  onEvent: this.onEvent,
9041
9171
  resolveExternalTool: this.resolveExternalTool,
9042
- hidden: isHidden,
9043
9172
  toolRegistry: this.toolRegistry,
9044
9173
  onBackgroundComplete: this.onBackgroundComplete
9045
9174
  });
@@ -9067,6 +9196,18 @@ var HeadlessSession = class {
9067
9196
  error: err.message
9068
9197
  });
9069
9198
  }
9199
+ const outcome = this.primaryOutcome();
9200
+ for (const rid of absorbedRids) {
9201
+ this.emit(
9202
+ "completed",
9203
+ {
9204
+ success: outcome.success,
9205
+ ...outcome.error && { error: outcome.error },
9206
+ absorbed: true
9207
+ },
9208
+ rid
9209
+ );
9210
+ }
9070
9211
  applyPendingSummaries(this.state);
9071
9212
  this.applyPendingBlockUpdates();
9072
9213
  }
@@ -9093,42 +9234,61 @@ var HeadlessSession = class {
9093
9234
  this.running = false;
9094
9235
  }
9095
9236
  }
9237
+ /**
9238
+ * True for queued user items whose text is an @@automated:: action message.
9239
+ * These key per-item raw-text side effects in runSingleTurn (resolveAction,
9240
+ * plan file, buildModel, chain expansion) — they always run alone, one turn
9241
+ * each. Background items are sentinel-formatted too but background_results
9242
+ * is NON_ACTION and side-effect-free, so they merge freely.
9243
+ */
9244
+ isDrainBarrier(item) {
9245
+ return item.source === "user" && isAutomatedMessage(item.command.text ?? "");
9246
+ }
9096
9247
  /**
9097
9248
  * Drain the queue in strict FIFO order. Caller must hold `running = true`.
9098
9249
  * User messages arriving during the drain will be enqueued behind current items.
9099
9250
  *
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.
9251
+ * The queue serves two purposes with opposite delivery semantics:
9252
+ * - Sequencer: chain steps and sentinel-bearing user items are pipeline
9253
+ * stages — one item, one turn, nothing merged in.
9254
+ * - Mailbox: plain user messages and background results are accumulated
9255
+ * context and intent — everything contiguous flushes together into ONE
9256
+ * merged turn, so the model reconciles all of it at once instead of
9257
+ * burning a full turn per item (and possibly executing instructions a
9258
+ * later queued message already amended).
9103
9259
  */
9104
9260
  async drainQueueLoop() {
9105
- while (true) {
9106
- const next = this.queue.shift();
9107
- if (!next) {
9108
- break;
9261
+ while (this.queue.length > 0) {
9262
+ const head = this.queue.peek();
9263
+ if (head.source === "chain") {
9264
+ const item = this.queue.shift();
9265
+ const rid = item.command.requestId ?? `chain-${Date.now()}`;
9266
+ await this.runSingleTurn(item.command, rid, true);
9267
+ continue;
9109
9268
  }
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()}`);
9269
+ if (this.isDrainBarrier(head)) {
9270
+ const item = this.queue.shift();
9271
+ const rid = item.command.requestId ?? `user-${Date.now()}`;
9272
+ await this.runSingleTurn(item.command, rid, false, true);
9128
9273
  continue;
9129
9274
  }
9130
- const nextRid = next.command.requestId ?? `${next.source}-${Date.now()}`;
9131
- await this.runSingleTurn(next.command, nextRid, next.source === "chain");
9275
+ let n = 1;
9276
+ let batchOb = head.command.onboardingState;
9277
+ for (; ; n++) {
9278
+ const it = this.queue.peekAt(n);
9279
+ if (!it || it.source === "chain" || this.isDrainBarrier(it)) {
9280
+ break;
9281
+ }
9282
+ const ob = it.command.onboardingState;
9283
+ if (ob !== void 0 && batchOb !== void 0 && ob !== batchOb) {
9284
+ break;
9285
+ }
9286
+ if (ob !== void 0 && batchOb === void 0) {
9287
+ batchOb = ob;
9288
+ }
9289
+ }
9290
+ const batch = this.queue.shiftMany(n);
9291
+ await this.runMergedTurn(batch);
9132
9292
  }
9133
9293
  }
9134
9294
  /**
@@ -9184,9 +9344,14 @@ var HeadlessSession = class {
9184
9344
  * Cancel the running turn and flush the follow-ups that belonged to it
9185
9345
  * (`chain`/`background`), while preserving `source: 'user'` items — those are
9186
9346
  * independent user intent, not tied to the aborted run. The preserved user
9187
- * messages run next: `runSingleTurn` swallows the abort, so `handleMessage`
9347
+ * messages run next: `executeTurn` swallows the abort, so `handleMessage`
9188
9348
  * falls through to `drainQueueLoop` with `running` still held. Returns the
9189
9349
  * flushed items (for the cancel command's resume/discard UX).
9350
+ *
9351
+ * Messages already absorbed into the in-flight merged turn are NOT
9352
+ * preserved — they were delivered into the turn that's being cancelled and
9353
+ * each gets a `{cancelled, absorbed:true}` terminal. Only items still
9354
+ * sitting in the queue survive.
9190
9355
  */
9191
9356
  handleCancel() {
9192
9357
  if (this.currentAbort) {
package/dist/index.js CHANGED
@@ -8091,9 +8091,7 @@ function createAgentState() {
8091
8091
  async function runTurn(params) {
8092
8092
  const {
8093
8093
  state,
8094
- userMessage,
8095
- attachments,
8096
- attachmentHeader,
8094
+ entries,
8097
8095
  apiConfig,
8098
8096
  system,
8099
8097
  model,
@@ -8102,7 +8100,6 @@ async function runTurn(params) {
8102
8100
  signal,
8103
8101
  onEvent,
8104
8102
  resolveExternalTool,
8105
- hidden,
8106
8103
  requestId,
8107
8104
  toolRegistry,
8108
8105
  onBackgroundComplete
@@ -8112,45 +8109,54 @@ async function runTurn(params) {
8112
8109
  const baseline = resolveModel("parent", state.models, model);
8113
8110
  const parentModel = buildModelOverride ?? baseline;
8114
8111
  const modelOverride = buildModelOverride && buildModelOverride !== baseline ? { from: baseline } : void 0;
8112
+ const totalAttachments = entries.reduce(
8113
+ (n, e) => n + (e.attachments?.length ?? 0),
8114
+ 0
8115
+ );
8115
8116
  log13.info("Turn started", {
8116
8117
  requestId,
8117
8118
  model,
8118
8119
  buildModel: buildModelOverride,
8119
8120
  toolCount: tools2.length,
8120
- ...attachments && attachments.length > 0 && {
8121
- attachmentCount: attachments.length
8122
- }
8121
+ ...entries.length > 1 && { entryCount: entries.length },
8122
+ ...totalAttachments > 0 && { attachmentCount: totalAttachments }
8123
8123
  });
8124
8124
  onEvent({
8125
8125
  type: "turn_started",
8126
8126
  model: parentModel,
8127
8127
  ...modelOverride && { modelOverride }
8128
8128
  });
8129
- const hasText = userMessage.trim().length > 0;
8130
- const hasAttachments = attachments && attachments.length > 0;
8131
- if (!hasText && !hasAttachments) {
8129
+ const keptEntries = entries.filter(
8130
+ (e) => e.text.trim().length > 0 || (e.attachments?.length ?? 0) > 0
8131
+ );
8132
+ if (keptEntries.length === 0) {
8132
8133
  onEvent({ type: "error", error: "Empty message" });
8133
8134
  return;
8134
8135
  }
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;
8136
+ for (const entry of keptEntries) {
8137
+ const hasAttachments = (entry.attachments?.length ?? 0) > 0;
8138
+ const userMsg = { role: "user", content: entry.text };
8139
+ if (entry.hidden) {
8140
+ userMsg.hidden = true;
8141
+ }
8142
+ if (hasAttachments) {
8143
+ userMsg.attachments = entry.attachments;
8144
+ }
8145
+ if (entry.attachmentHeader) {
8146
+ userMsg.attachmentHeader = entry.attachmentHeader;
8147
+ }
8148
+ state.messages.push(userMsg);
8149
+ onEvent({
8150
+ type: "user_message",
8151
+ text: entry.text,
8152
+ hidden: entry.hidden || void 0,
8153
+ // Include attachments so the live event can render a queued voice/image/file
8154
+ // bubble; a voice message has empty text and the transcript lives here.
8155
+ ...hasAttachments && { attachments: entry.attachments },
8156
+ ...entry.requestId && { requestId: entry.requestId },
8157
+ ...entry.queued && { queued: true }
8158
+ });
8144
8159
  }
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
8160
  const isFirstMessage = state.messages.filter((m) => m.role === "user").length === 1;
8155
8161
  const STATUS_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
8156
8162
  "setProjectOnboardingState",
@@ -8219,11 +8225,13 @@ async function runTurn(params) {
8219
8225
  if (onboardingState && onboardingState !== "onboardingFinished") {
8220
8226
  parts.push(`Build phase: ${onboardingState}`);
8221
8227
  }
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)}`);
8228
+ for (const entry of keptEntries) {
8229
+ const automated = parseSentinel(entry.text);
8230
+ if (automated) {
8231
+ parts.push(`Automated action: ${automated.name}`);
8232
+ } else if (entry.text) {
8233
+ parts.push(`User request: ${entry.text.slice(-500)}`);
8234
+ }
8227
8235
  }
8228
8236
  return parts.join("\n");
8229
8237
  },
@@ -9144,25 +9152,24 @@ async function persistAttachments(attachments) {
9144
9152
  images: settled.filter((s) => s.isImage).map((s) => s.result)
9145
9153
  };
9146
9154
  }
9147
- function buildUploadHeader(results) {
9148
- const succeeded = results.filter(Boolean);
9149
- if (succeeded.length === 0) {
9155
+ function buildUploadHeader(documents, images) {
9156
+ const entries = [
9157
+ ...documents.filter((r) => r !== null).map((r) => ({ ...r, isImage: false })),
9158
+ ...images.filter((r) => r !== null).map((r) => ({ ...r, isImage: true }))
9159
+ ];
9160
+ if (entries.length === 0) {
9150
9161
  return "";
9151
9162
  }
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 ") + "]";
9163
+ const detail = (e) => e.extractedTextPath ? `extracted text: ${e.extractedTextPath}` : e.isImage ? null : "no extracted text \u2014 raw file only";
9164
+ if (entries.length === 1) {
9165
+ const e = entries[0];
9166
+ const extra = detail(e);
9167
+ return `[Uploaded file: ${e.localPath}${extra ? ` \u2014 ${extra}` : ""}]`;
9159
9168
  }
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");
9169
+ const lines = entries.map((e) => {
9170
+ const extra = detail(e);
9171
+ return `- ${e.localPath}${extra ? `
9172
+ ${extra}` : ""}`;
9166
9173
  });
9167
9174
  return `[Uploaded files]
9168
9175
  ${lines.join("\n")}`;
@@ -9275,6 +9282,17 @@ var init_messageQueue = __esm({
9275
9282
  }
9276
9283
  return item;
9277
9284
  }
9285
+ /** Remove and return the first `n` items; fires onChange once. */
9286
+ shiftMany(n) {
9287
+ if (n <= 0) {
9288
+ return [];
9289
+ }
9290
+ const items = this.items.splice(0, n);
9291
+ if (items.length > 0) {
9292
+ this.onChange?.();
9293
+ }
9294
+ return items;
9295
+ }
9278
9296
  /** Remove and return all queued items. */
9279
9297
  drain() {
9280
9298
  if (this.items.length === 0) {
@@ -9310,6 +9328,10 @@ var init_messageQueue = __esm({
9310
9328
  peek() {
9311
9329
  return this.items[0];
9312
9330
  }
9331
+ /** Return the item at index `i` without removing it. */
9332
+ peekAt(i) {
9333
+ return this.items[i];
9334
+ }
9313
9335
  get length() {
9314
9336
  return this.items.length;
9315
9337
  }
@@ -9427,8 +9449,12 @@ var init_headless = __esm({
9427
9449
  currentAbort = null;
9428
9450
  /** RequestId of the in-flight message command — injected into streamed events. */
9429
9451
  currentRequestId;
9430
- /** Guard: track whether terminal `completed` was already sent so we emit exactly one. */
9452
+ /** Guard: track whether terminal `completed` was already sent so we emit
9453
+ * exactly one per requestId. */
9431
9454
  completedEmitted = false;
9455
+ /** Outcome of the current turn's primary `completed` — read after the turn
9456
+ * to stamp the same success/error onto absorbed requestIds' terminals. */
9457
+ lastCompleted = null;
9432
9458
  turnStart = 0;
9433
9459
  /**
9434
9460
  * Onboarding state of the currently-running turn. Captured at runSingleTurn
@@ -9564,6 +9590,15 @@ var init_headless = __esm({
9564
9590
  emitCompleted(rid, data) {
9565
9591
  this.emit("completed", { ...data }, rid);
9566
9592
  this.completedEmitted = true;
9593
+ this.lastCompleted = {
9594
+ success: data.success === true,
9595
+ ...typeof data.error === "string" && { error: data.error }
9596
+ };
9597
+ }
9598
+ /** Outcome of the turn's primary `completed`, for stamping onto absorbed
9599
+ * requestIds' terminals. Falls back to failure if none was emitted. */
9600
+ primaryOutcome() {
9601
+ return this.lastCompleted ?? { success: false };
9567
9602
  }
9568
9603
  /** Dispatch a simple (non-streaming) command: call handler, emit response + completed. */
9569
9604
  dispatchSimple(requestId, eventName, handler) {
@@ -9722,9 +9757,14 @@ var init_headless = __esm({
9722
9757
  text: e.text,
9723
9758
  // Forward attachments so queued voice/image/file sends render live;
9724
9759
  // otherwise the bubble is blank until a get_history refresh.
9725
- ...e.attachments && { attachments: e.attachments }
9760
+ ...e.attachments && { attachments: e.attachments },
9761
+ // Queue-delivered entries are flagged so the frontend renders the
9762
+ // echo (idle sends are rendered optimistically instead).
9763
+ ...e.queued && { queued: true }
9726
9764
  },
9727
- rid
9765
+ // A merged turn emits one user_message per absorbed entry — each
9766
+ // carries its own original requestId, not the turn's.
9767
+ e.requestId ?? rid
9728
9768
  );
9729
9769
  return;
9730
9770
  // Terminal events — translate to `completed`.
@@ -9747,11 +9787,9 @@ var init_headless = __esm({
9747
9787
  durationMs: Date.now() - this.turnStart
9748
9788
  });
9749
9789
  return;
9750
- case "turn_cancelled": {
9751
- this.emit("completed", { success: false, error: "cancelled" }, rid);
9752
- this.completedEmitted = true;
9790
+ case "turn_cancelled":
9791
+ this.emitCompleted(rid, { success: false, error: "cancelled" });
9753
9792
  return;
9754
- }
9755
9793
  // Streaming events — forward with requestId
9756
9794
  case "text":
9757
9795
  this.emit(
@@ -9871,17 +9909,34 @@ var init_headless = __esm({
9871
9909
  // Message command handler (long-running / streaming)
9872
9910
  //////////////////////////////////////////////////////////////////////////////
9873
9911
  /**
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.
9912
+ * Persist one entry's non-voice uploads to disk and build its header. The
9913
+ * header tells the LLM where to read each file; it's kept separate so it
9914
+ * gets injected at API-send time and never persisted into the user's chat
9915
+ * content (which would leak into history restore on the frontend).
9916
+ *
9917
+ * Must be awaited sequentially across entries — persistAttachments'
9918
+ * filename de-dup set is per-call, so parallel calls race on names.
9878
9919
  */
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);
9920
+ async persistEntryAttachments(attachments) {
9921
+ if (!attachments?.some((a) => !a.isVoice)) {
9922
+ return void 0;
9923
+ }
9924
+ try {
9925
+ const { documents, images } = await persistAttachments(attachments);
9926
+ return buildUploadHeader(documents, images) || void 0;
9927
+ } catch (err) {
9928
+ log16.warn("Attachment persistence failed", { error: err.message });
9929
+ return void 0;
9930
+ }
9931
+ }
9932
+ /**
9933
+ * Run one turn for a single command (without acquiring the `running` lock).
9934
+ * Owns the per-command machinery: @@automated:: action resolution, plan-file
9935
+ * and buildModel side effects, and chain expansion — which is why
9936
+ * sentinel-bearing commands always come through here, one turn each, never
9937
+ * merged. The turn itself runs in executeTurn.
9938
+ */
9939
+ async runSingleTurn(parsed, requestId, fromChain = false, queued = false) {
9885
9940
  const attachments = parsed.attachments;
9886
9941
  if (attachments?.length) {
9887
9942
  log16.info("Message has attachments", {
@@ -9890,19 +9945,7 @@ var init_headless = __esm({
9890
9945
  });
9891
9946
  }
9892
9947
  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
- }
9948
+ const attachmentHeader = await this.persistEntryAttachments(attachments);
9906
9949
  let resolved = null;
9907
9950
  try {
9908
9951
  resolved = resolveAction(userMessage);
@@ -9939,22 +9982,108 @@ var init_headless = __esm({
9939
9982
  });
9940
9983
  }
9941
9984
  }
9985
+ await this.executeTurn({
9986
+ entries: [
9987
+ {
9988
+ text: userMessage,
9989
+ attachments,
9990
+ attachmentHeader,
9991
+ hidden: isHidden || void 0,
9992
+ requestId,
9993
+ queued: queued || void 0
9994
+ }
9995
+ ],
9996
+ requestId,
9997
+ absorbedRids: [],
9998
+ onboardingState,
9999
+ system,
10000
+ buildModel
10001
+ });
10002
+ }
10003
+ /**
10004
+ * Run a mailbox batch — contiguous queued user + background items — as one
10005
+ * merged turn. Every item becomes its own history entry and user_message
10006
+ * event (own requestId, attachments, hidden flag); adjacent background
10007
+ * items fold into a single background_results entry so the LLM sees one
10008
+ * combined block. No action-sentinel machinery here: batch construction
10009
+ * guarantees none (sentinel-bearing user items are drain barriers that run
10010
+ * alone via runSingleTurn, and background_results is a NON_ACTION sentinel
10011
+ * with no side effects).
10012
+ */
10013
+ async runMergedTurn(batch) {
10014
+ const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
10015
+ const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
10016
+ const entryList = [];
10017
+ for (const item of batch) {
10018
+ const text = item.command.text ?? "";
10019
+ const prev = entryList[entryList.length - 1];
10020
+ if (item.source === "background" && prev?.background) {
10021
+ prev.entry.text = mergeBackgroundResultsMessages([
10022
+ prev.entry.text,
10023
+ text
10024
+ ]);
10025
+ continue;
10026
+ }
10027
+ entryList.push({
10028
+ background: item.source === "background",
10029
+ entry: {
10030
+ text,
10031
+ attachments: item.command.attachments,
10032
+ hidden: !!item.command.hidden || void 0,
10033
+ // Background items carry no requestId — their user_message falls
10034
+ // back to the turn's primary rid on the wire.
10035
+ requestId: item.command.requestId,
10036
+ queued: true
10037
+ }
10038
+ });
10039
+ }
10040
+ const entries = entryList.map((e) => e.entry);
10041
+ for (const entry of entries) {
10042
+ entry.attachmentHeader = await this.persistEntryAttachments(
10043
+ entry.attachments
10044
+ );
10045
+ }
10046
+ const onboardingState = batch.find((b) => b.command.onboardingState !== void 0)?.command.onboardingState ?? this.currentOnboardingState ?? "onboardingFinished";
10047
+ this.currentOnboardingState = onboardingState;
10048
+ const viewContext = [...batch].reverse().find((b) => b.command.viewContext !== void 0)?.command.viewContext;
10049
+ await this.executeTurn({
10050
+ entries,
10051
+ requestId: primaryRid,
10052
+ absorbedRids,
10053
+ onboardingState,
10054
+ system: buildSystemPrompt(onboardingState, viewContext)
10055
+ });
10056
+ }
10057
+ /**
10058
+ * Run one agent turn over the given entries (without acquiring the
10059
+ * `running` lock). Owns the turn-generic lifecycle: request bookkeeping,
10060
+ * the forced-compaction gate, runTurn error handling, and terminal
10061
+ * `completed` events — the primary requestId's completed first, then one
10062
+ * `{absorbed: true}` completed per other absorbed requestId with the same
10063
+ * outcome, on every exit path (done, cancel, error, unexpected), so every
10064
+ * queued message's caller resolves.
10065
+ */
10066
+ async executeTurn(params) {
10067
+ const { entries, requestId, absorbedRids, onboardingState, system } = params;
10068
+ this.currentRequestId = requestId;
10069
+ this.currentAbort = new AbortController();
10070
+ this.completedEmitted = false;
10071
+ this.lastCompleted = null;
10072
+ this.turnStart = Date.now();
10073
+ await this.runForcedCompactionIfNeeded(requestId);
9942
10074
  try {
9943
10075
  await runTurn({
9944
10076
  state: this.state,
9945
- userMessage,
9946
- attachments,
9947
- attachmentHeader,
10077
+ entries,
9948
10078
  apiConfig: this.config,
9949
10079
  system,
9950
10080
  model: this.opts.model,
9951
- buildModel,
10081
+ buildModel: params.buildModel,
9952
10082
  onboardingState,
9953
10083
  requestId,
9954
10084
  signal: this.currentAbort.signal,
9955
10085
  onEvent: this.onEvent,
9956
10086
  resolveExternalTool: this.resolveExternalTool,
9957
- hidden: isHidden,
9958
10087
  toolRegistry: this.toolRegistry,
9959
10088
  onBackgroundComplete: this.onBackgroundComplete
9960
10089
  });
@@ -9982,6 +10111,18 @@ var init_headless = __esm({
9982
10111
  error: err.message
9983
10112
  });
9984
10113
  }
10114
+ const outcome = this.primaryOutcome();
10115
+ for (const rid of absorbedRids) {
10116
+ this.emit(
10117
+ "completed",
10118
+ {
10119
+ success: outcome.success,
10120
+ ...outcome.error && { error: outcome.error },
10121
+ absorbed: true
10122
+ },
10123
+ rid
10124
+ );
10125
+ }
9985
10126
  applyPendingSummaries(this.state);
9986
10127
  this.applyPendingBlockUpdates();
9987
10128
  }
@@ -10008,42 +10149,61 @@ var init_headless = __esm({
10008
10149
  this.running = false;
10009
10150
  }
10010
10151
  }
10152
+ /**
10153
+ * True for queued user items whose text is an @@automated:: action message.
10154
+ * These key per-item raw-text side effects in runSingleTurn (resolveAction,
10155
+ * plan file, buildModel, chain expansion) — they always run alone, one turn
10156
+ * each. Background items are sentinel-formatted too but background_results
10157
+ * is NON_ACTION and side-effect-free, so they merge freely.
10158
+ */
10159
+ isDrainBarrier(item) {
10160
+ return item.source === "user" && isAutomatedMessage(item.command.text ?? "");
10161
+ }
10011
10162
  /**
10012
10163
  * Drain the queue in strict FIFO order. Caller must hold `running = true`.
10013
10164
  * User messages arriving during the drain will be enqueued behind current items.
10014
10165
  *
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.
10166
+ * The queue serves two purposes with opposite delivery semantics:
10167
+ * - Sequencer: chain steps and sentinel-bearing user items are pipeline
10168
+ * stages — one item, one turn, nothing merged in.
10169
+ * - Mailbox: plain user messages and background results are accumulated
10170
+ * context and intent — everything contiguous flushes together into ONE
10171
+ * merged turn, so the model reconciles all of it at once instead of
10172
+ * burning a full turn per item (and possibly executing instructions a
10173
+ * later queued message already amended).
10018
10174
  */
10019
10175
  async drainQueueLoop() {
10020
- while (true) {
10021
- const next = this.queue.shift();
10022
- if (!next) {
10023
- break;
10176
+ while (this.queue.length > 0) {
10177
+ const head = this.queue.peek();
10178
+ if (head.source === "chain") {
10179
+ const item = this.queue.shift();
10180
+ const rid = item.command.requestId ?? `chain-${Date.now()}`;
10181
+ await this.runSingleTurn(item.command, rid, true);
10182
+ continue;
10024
10183
  }
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()}`);
10184
+ if (this.isDrainBarrier(head)) {
10185
+ const item = this.queue.shift();
10186
+ const rid = item.command.requestId ?? `user-${Date.now()}`;
10187
+ await this.runSingleTurn(item.command, rid, false, true);
10043
10188
  continue;
10044
10189
  }
10045
- const nextRid = next.command.requestId ?? `${next.source}-${Date.now()}`;
10046
- await this.runSingleTurn(next.command, nextRid, next.source === "chain");
10190
+ let n = 1;
10191
+ let batchOb = head.command.onboardingState;
10192
+ for (; ; n++) {
10193
+ const it = this.queue.peekAt(n);
10194
+ if (!it || it.source === "chain" || this.isDrainBarrier(it)) {
10195
+ break;
10196
+ }
10197
+ const ob = it.command.onboardingState;
10198
+ if (ob !== void 0 && batchOb !== void 0 && ob !== batchOb) {
10199
+ break;
10200
+ }
10201
+ if (ob !== void 0 && batchOb === void 0) {
10202
+ batchOb = ob;
10203
+ }
10204
+ }
10205
+ const batch = this.queue.shiftMany(n);
10206
+ await this.runMergedTurn(batch);
10047
10207
  }
10048
10208
  }
10049
10209
  /**
@@ -10099,9 +10259,14 @@ var init_headless = __esm({
10099
10259
  * Cancel the running turn and flush the follow-ups that belonged to it
10100
10260
  * (`chain`/`background`), while preserving `source: 'user'` items — those are
10101
10261
  * independent user intent, not tied to the aborted run. The preserved user
10102
- * messages run next: `runSingleTurn` swallows the abort, so `handleMessage`
10262
+ * messages run next: `executeTurn` swallows the abort, so `handleMessage`
10103
10263
  * falls through to `drainQueueLoop` with `running` still held. Returns the
10104
10264
  * flushed items (for the cancel command's resume/discard UX).
10265
+ *
10266
+ * Messages already absorbed into the in-flight merged turn are NOT
10267
+ * preserved — they were delivered into the turn that's being cancelled and
10268
+ * each gets a `{cancelled, absorbed:true}` terminal. Only items still
10269
+ * sitting in the queue survive.
10105
10270
  */
10106
10271
  handleCancel() {
10107
10272
  if (this.currentAbort) {
@@ -10528,7 +10693,7 @@ function App({ apiConfig, model }) {
10528
10693
  try {
10529
10694
  await runTurn({
10530
10695
  state: agentState,
10531
- userMessage: message,
10696
+ entries: [{ text: message }],
10532
10697
  apiConfig,
10533
10698
  system,
10534
10699
  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.261",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",