@mindstudio-ai/remy 0.1.302 → 0.1.304

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.
@@ -63,6 +63,20 @@ declare class HeadlessSession {
63
63
  * right state (the triggering turn's state, not a stale one).
64
64
  */
65
65
  private currentOnboardingState;
66
+ /**
67
+ * The in-flight turn's command, when that turn is part of a build pipeline
68
+ * (its action declares a `next`, or it is itself a chain step). Captured so a
69
+ * cancel can put the interrupted step back on the queue held, instead of
70
+ * leaving the pipeline to resume at the step *after* the one that was
71
+ * stopped — which would run finalize/polish over half-built code.
72
+ *
73
+ * Null for every other turn shape, which is what scopes the whole
74
+ * paused-pipeline affordance to chains: stopping a one-off automated action
75
+ * (approvePlan, publish, sync) leaves nothing behind.
76
+ */
77
+ private currentChainStep;
78
+ /** Monotonic suffix for synthesized chain requestIds — see nextChainRequestId. */
79
+ private chainRequestSeq;
66
80
  /**
67
81
  * Unified message queue. Holds pending work to deliver after the current
68
82
  * turn completes: chained automated actions, background sub-agent results,
@@ -134,6 +148,14 @@ declare class HeadlessSession {
134
148
  * filename de-dup set is per-call, so parallel calls race on names.
135
149
  */
136
150
  private persistEntryAttachments;
151
+ /**
152
+ * Stable id for a queued chain step, so the frontend can address one (to
153
+ * discard a paused pipeline) rather than waiting for drainQueueLoop to
154
+ * synthesize an id at dequeue. Deliberately not an `ac-` id — the frontend
155
+ * keys "system turn" off that prefix — and deliberately not a bare
156
+ * Date.now(), which collides for steps enqueued in the same millisecond.
157
+ */
158
+ private nextChainRequestId;
137
159
  /**
138
160
  * Run one turn for a single command (without acquiring the `running` lock).
139
161
  * Owns the per-command machinery: @@automated:: action resolution, plan-file
@@ -213,9 +235,9 @@ declare class HeadlessSession {
213
235
  /**
214
236
  * Stop everything the user can see running: the turn, an in-flight
215
237
  * compaction, and any external tool waiting on a result. Flushes the
216
- * follow-ups that belonged to the turn (`chain`/`background`) and HOLDS the
217
- * `source: 'user'` items those are independent user intent, so they're
218
- * kept, but they no longer run on their own.
238
+ * `background` follow-ups that belonged to the turn, and HOLDS everything
239
+ * still queued — the `source: 'user'` items, which are independent user
240
+ * intent, plus the `source: 'chain'` steps of a build pipeline.
219
241
  *
220
242
  * Holding is the difference between Stop working and Stop looking broken.
221
243
  * These items used to drain immediately: `executeTurn` swallows the abort,
@@ -224,6 +246,13 @@ declare class HeadlessSession {
224
246
  * and every additional press hit a turn that had just started. They now wait
225
247
  * in the queue card until the user sends again or promotes one.
226
248
  *
249
+ * The chain steps used to be flushed with the background items, which threw
250
+ * away the rest of the build — including the step that finalizes it and
251
+ * unlocks the editor. They are now paused instead, and the interrupted step
252
+ * goes back at the head, so a Stop is a pause and the next send resumes the
253
+ * pipeline where it stopped. Holding only the remainder would resume one step
254
+ * PAST the interruption, polishing and finalizing half-built code.
255
+ *
227
256
  * A compaction is cancelled here too, unconditionally. It gates every queued
228
257
  * message and outlives the turn that started it, so leaving it running means
229
258
  * Stop can't reach idle. The cost is the summary work in flight; the forced
@@ -236,10 +265,15 @@ declare class HeadlessSession {
236
265
  */
237
266
  private handleCancel;
238
267
  /**
239
- * Remove pending queued messages all user messages, or one by id.
240
- * Only `source: 'user'` items are removable; chained and background
241
- * messages are part of a system chain and are never cancellable. Does
242
- * not affect the in-flight turn (use `cancel` for that).
268
+ * Remove pending queued messages: all user messages (no id), or a single item
269
+ * by id. Does not affect the in-flight turn (use `cancel` for that).
270
+ *
271
+ * Held chain items a paused pipeline are removable only by explicit id.
272
+ * The id-less form is the queue card's "Clear" and the pre-destroy quiesce,
273
+ * neither of which should get to decide the pipeline's fate. A DELIVERABLE
274
+ * chain item is never removable: that's live pipeline work. (The step
275
+ * actually running isn't in the queue at all — drainQueueLoop takes it out
276
+ * before running it.)
243
277
  */
244
278
  private handleCancelQueued;
245
279
  private handleStdinLine;
package/dist/headless.js CHANGED
@@ -7712,16 +7712,7 @@ function clearSession(state) {
7712
7712
  log11.warn("Session archive on clear failed", { error: err.message });
7713
7713
  }
7714
7714
  state.messages = [];
7715
- state.models = void 0;
7716
- try {
7717
- if (fs21.existsSync(SESSION_FILE)) {
7718
- fs21.unlinkSync(SESSION_FILE);
7719
- }
7720
- } catch (err) {
7721
- log11.warn("Session clear: could not remove live file", {
7722
- error: err.message
7723
- });
7724
- }
7715
+ saveSession(state);
7725
7716
  }
7726
7717
 
7727
7718
  // src/compaction/trigger.ts
@@ -9358,6 +9349,15 @@ var MessageQueue = class {
9358
9349
  this.items.push(item);
9359
9350
  this.onChange?.();
9360
9351
  }
9352
+ /**
9353
+ * Add an item at the head. Used to put an interrupted chain step back in
9354
+ * front of the rest of its pipeline on cancel; `promoteToFront` can't serve
9355
+ * that case, since it addresses an item already in the queue by requestId.
9356
+ */
9357
+ unshift(item) {
9358
+ this.items.unshift(item);
9359
+ this.onChange?.();
9360
+ }
9361
9361
  /**
9362
9362
  * Index of the first deliverable item, or -1 when there is none.
9363
9363
  *
@@ -9426,25 +9426,6 @@ var MessageQueue = class {
9426
9426
  }
9427
9427
  return held;
9428
9428
  }
9429
- /**
9430
- * Release held items so the normal drain picks them up again — all of them,
9431
- * or one by command requestId. Fires onChange only if something changed.
9432
- * Returns the released items.
9433
- */
9434
- releaseHeld(id) {
9435
- const released = [];
9436
- for (const item of this.items) {
9437
- if (!item.held || id !== void 0 && item.command.requestId !== id) {
9438
- continue;
9439
- }
9440
- delete item.held;
9441
- released.push(item);
9442
- }
9443
- if (released.length > 0) {
9444
- this.onChange?.();
9445
- }
9446
- return released;
9447
- }
9448
9429
  /** Whether anything in the queue will drain on its own (i.e. isn't held). */
9449
9430
  hasDeliverable() {
9450
9431
  return this.items.some((item) => !item.held);
@@ -9489,6 +9470,38 @@ var MessageQueue = class {
9489
9470
  this.onChange?.();
9490
9471
  return item;
9491
9472
  }
9473
+ /**
9474
+ * Fold-in release: un-hold everything, then move items matching `defer` to
9475
+ * the tail, relative order preserved. One onChange for the whole
9476
+ * rearrangement, so no observer ever sees it half-applied.
9477
+ *
9478
+ * Callers push the new user message FIRST and call this second — "behind" is
9479
+ * defined by the array at the moment this runs. Held user messages stay put
9480
+ * so they still merge with that message into one mailbox batch; a paused
9481
+ * pipeline goes to the back, so the person's words run before the build
9482
+ * continues. `defer` is applied to every item, not just the newly-released
9483
+ * ones, so a chain step that was already deliverable defers too.
9484
+ */
9485
+ releaseHeldDeferring(defer) {
9486
+ const released = [];
9487
+ for (const item of this.items) {
9488
+ if (!item.held) {
9489
+ continue;
9490
+ }
9491
+ delete item.held;
9492
+ released.push(item);
9493
+ }
9494
+ const back = this.items.filter(defer);
9495
+ const front = this.items.filter((item) => !defer(item));
9496
+ const reordered = back.length > 0 && front.length > 0;
9497
+ if (reordered) {
9498
+ this.items = [...front, ...back];
9499
+ }
9500
+ if (released.length > 0 || reordered) {
9501
+ this.onChange?.();
9502
+ }
9503
+ return released;
9504
+ }
9492
9505
  /** Copy of current queue contents (for surfacing on events). */
9493
9506
  snapshot() {
9494
9507
  return [...this.items];
@@ -9538,6 +9551,20 @@ var HeadlessSession = class {
9538
9551
  * right state (the triggering turn's state, not a stale one).
9539
9552
  */
9540
9553
  currentOnboardingState;
9554
+ /**
9555
+ * The in-flight turn's command, when that turn is part of a build pipeline
9556
+ * (its action declares a `next`, or it is itself a chain step). Captured so a
9557
+ * cancel can put the interrupted step back on the queue held, instead of
9558
+ * leaving the pipeline to resume at the step *after* the one that was
9559
+ * stopped — which would run finalize/polish over half-built code.
9560
+ *
9561
+ * Null for every other turn shape, which is what scopes the whole
9562
+ * paused-pipeline affordance to chains: stopping a one-off automated action
9563
+ * (approvePlan, publish, sync) leaves nothing behind.
9564
+ */
9565
+ currentChainStep = null;
9566
+ /** Monotonic suffix for synthesized chain requestIds — see nextChainRequestId. */
9567
+ chainRequestSeq = 0;
9541
9568
  /**
9542
9569
  * Unified message queue. Holds pending work to deliver after the current
9543
9570
  * turn completes: chained automated actions, background sub-agent results,
@@ -10103,6 +10130,16 @@ var HeadlessSession = class {
10103
10130
  return void 0;
10104
10131
  }
10105
10132
  }
10133
+ /**
10134
+ * Stable id for a queued chain step, so the frontend can address one (to
10135
+ * discard a paused pipeline) rather than waiting for drainQueueLoop to
10136
+ * synthesize an id at dequeue. Deliberately not an `ac-` id — the frontend
10137
+ * keys "system turn" off that prefix — and deliberately not a bare
10138
+ * Date.now(), which collides for steps enqueued in the same millisecond.
10139
+ */
10140
+ nextChainRequestId() {
10141
+ return `chain-${Date.now()}-${++this.chainRequestSeq}`;
10142
+ }
10106
10143
  /**
10107
10144
  * Run one turn for a single command (without acquiring the `running` lock).
10108
10145
  * Owns the per-command machinery: @@automated:: action resolution, plan-file
@@ -10140,36 +10177,42 @@ var HeadlessSession = class {
10140
10177
  const onboardingState = parsed.onboardingState ?? "onboardingFinished";
10141
10178
  this.currentOnboardingState = onboardingState;
10142
10179
  const system = buildSystemPrompt(onboardingState);
10180
+ this.currentChainStep = resolved && (resolved.next != null || fromChain) ? { text: rawText, onboardingState } : null;
10143
10181
  if (resolved?.next && !fromChain) {
10144
10182
  for (const step of getActionChain(resolved.next)) {
10145
10183
  this.queue.push({
10146
10184
  command: {
10147
10185
  action: "message",
10148
10186
  text: sentinel(step),
10149
- onboardingState
10187
+ onboardingState,
10188
+ requestId: this.nextChainRequestId()
10150
10189
  },
10151
10190
  source: "chain",
10152
10191
  enqueuedAt: Date.now()
10153
10192
  });
10154
10193
  }
10155
10194
  }
10156
- await this.executeTurn({
10157
- entries: [
10158
- {
10159
- text: userMessage,
10160
- attachments,
10161
- attachmentHeader,
10162
- hidden: isHidden || void 0,
10163
- requestId,
10164
- queued: queued || void 0
10165
- }
10166
- ],
10167
- requestId,
10168
- absorbedRids: [],
10169
- onboardingState,
10170
- system,
10171
- buildModel
10172
- });
10195
+ try {
10196
+ await this.executeTurn({
10197
+ entries: [
10198
+ {
10199
+ text: userMessage,
10200
+ attachments,
10201
+ attachmentHeader,
10202
+ hidden: isHidden || void 0,
10203
+ requestId,
10204
+ queued: queued || void 0
10205
+ }
10206
+ ],
10207
+ requestId,
10208
+ absorbedRids: [],
10209
+ onboardingState,
10210
+ system,
10211
+ buildModel
10212
+ });
10213
+ } finally {
10214
+ this.currentChainStep = null;
10215
+ }
10173
10216
  }
10174
10217
  /**
10175
10218
  * Run a mailbox batch — contiguous queued user + background items — as one
@@ -10182,6 +10225,7 @@ var HeadlessSession = class {
10182
10225
  * with no side effects).
10183
10226
  */
10184
10227
  async runMergedTurn(batch) {
10228
+ this.currentChainStep = null;
10185
10229
  const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
10186
10230
  const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
10187
10231
  const entryList = [];
@@ -10336,9 +10380,6 @@ var HeadlessSession = class {
10336
10380
  }
10337
10381
  async handleMessage(parsed, requestId) {
10338
10382
  const foldIn = !this.running && !getInflightCompaction() && this.queue.length > 0 && !isAutomatedMessage(parsed.text ?? "");
10339
- if (foldIn) {
10340
- this.queue.releaseHeld();
10341
- }
10342
10383
  if (this.running || getInflightCompaction() || foldIn) {
10343
10384
  const command = { ...parsed };
10344
10385
  if (requestId && command.requestId === void 0) {
@@ -10349,6 +10390,9 @@ var HeadlessSession = class {
10349
10390
  source: "user",
10350
10391
  enqueuedAt: Date.now()
10351
10392
  });
10393
+ if (foldIn) {
10394
+ this.queue.releaseHeldDeferring((item) => item.source === "chain");
10395
+ }
10352
10396
  if (!this.running && !getInflightCompaction()) {
10353
10397
  this.kickDrain();
10354
10398
  }
@@ -10480,6 +10524,7 @@ var HeadlessSession = class {
10480
10524
  handleClear() {
10481
10525
  clearSession(this.state);
10482
10526
  return {
10527
+ ...this.state.models && { models: this.state.models },
10483
10528
  modelSurfaces: getEffectiveModelSurfaces(),
10484
10529
  allowedModelsByType: ALLOWED_MODELS_BY_TYPE
10485
10530
  };
@@ -10500,9 +10545,9 @@ var HeadlessSession = class {
10500
10545
  /**
10501
10546
  * Stop everything the user can see running: the turn, an in-flight
10502
10547
  * compaction, and any external tool waiting on a result. Flushes the
10503
- * follow-ups that belonged to the turn (`chain`/`background`) and HOLDS the
10504
- * `source: 'user'` items those are independent user intent, so they're
10505
- * kept, but they no longer run on their own.
10548
+ * `background` follow-ups that belonged to the turn, and HOLDS everything
10549
+ * still queued — the `source: 'user'` items, which are independent user
10550
+ * intent, plus the `source: 'chain'` steps of a build pipeline.
10506
10551
  *
10507
10552
  * Holding is the difference between Stop working and Stop looking broken.
10508
10553
  * These items used to drain immediately: `executeTurn` swallows the abort,
@@ -10511,6 +10556,13 @@ var HeadlessSession = class {
10511
10556
  * and every additional press hit a turn that had just started. They now wait
10512
10557
  * in the queue card until the user sends again or promotes one.
10513
10558
  *
10559
+ * The chain steps used to be flushed with the background items, which threw
10560
+ * away the rest of the build — including the step that finalizes it and
10561
+ * unlocks the editor. They are now paused instead, and the interrupted step
10562
+ * goes back at the head, so a Stop is a pause and the next send resumes the
10563
+ * pipeline where it stopped. Holding only the remainder would resume one step
10564
+ * PAST the interruption, polishing and finalizing half-built code.
10565
+ *
10514
10566
  * A compaction is cancelled here too, unconditionally. It gates every queued
10515
10567
  * message and outlives the turn that started it, so leaving it running means
10516
10568
  * Stop can't reach idle. The cost is the summary work in flight; the forced
@@ -10531,19 +10583,50 @@ var HeadlessSession = class {
10531
10583
  pending2.resolve(USER_CANCELLED_RESULT);
10532
10584
  this.pendingTools.delete(id);
10533
10585
  }
10534
- const flushed = this.queue.removeWhere((item) => item.source !== "user");
10535
- const held = this.queue.holdWhere((item) => item.source === "user");
10536
- return { flushed, held, cancelledCompaction };
10586
+ const flushed = this.queue.removeWhere(
10587
+ (item) => item.source === "background"
10588
+ );
10589
+ const step = this.currentChainStep;
10590
+ if (step) {
10591
+ this.queue.unshift({
10592
+ command: {
10593
+ action: "message",
10594
+ text: step.text,
10595
+ onboardingState: step.onboardingState,
10596
+ // Fresh id: the original command's terminal has already gone out as
10597
+ // cancelled, and one command gets exactly one `completed`.
10598
+ requestId: this.nextChainRequestId()
10599
+ },
10600
+ source: "chain",
10601
+ enqueuedAt: Date.now(),
10602
+ held: true
10603
+ });
10604
+ this.currentChainStep = null;
10605
+ }
10606
+ const held = this.queue.holdWhere(
10607
+ (item) => item.source === "user" || item.source === "chain"
10608
+ );
10609
+ return {
10610
+ flushed,
10611
+ held,
10612
+ pausedPipeline: held.some((item) => item.source === "chain"),
10613
+ cancelledCompaction
10614
+ };
10537
10615
  }
10538
10616
  /**
10539
- * Remove pending queued messages all user messages, or one by id.
10540
- * Only `source: 'user'` items are removable; chained and background
10541
- * messages are part of a system chain and are never cancellable. Does
10542
- * not affect the in-flight turn (use `cancel` for that).
10617
+ * Remove pending queued messages: all user messages (no id), or a single item
10618
+ * by id. Does not affect the in-flight turn (use `cancel` for that).
10619
+ *
10620
+ * Held chain items a paused pipeline are removable only by explicit id.
10621
+ * The id-less form is the queue card's "Clear" and the pre-destroy quiesce,
10622
+ * neither of which should get to decide the pipeline's fate. A DELIVERABLE
10623
+ * chain item is never removable: that's live pipeline work. (The step
10624
+ * actually running isn't in the queue at all — drainQueueLoop takes it out
10625
+ * before running it.)
10543
10626
  */
10544
10627
  handleCancelQueued(id) {
10545
10628
  return this.queue.removeWhere(
10546
- (item) => item.source === "user" && (id === void 0 || item.command.requestId === id)
10629
+ (item) => id === void 0 ? item.source === "user" : item.command.requestId === id && (item.source === "user" || item.source === "chain" && !!item.held)
10547
10630
  );
10548
10631
  }
10549
10632
  //////////////////////////////////////////////////////////////////////////////
@@ -10642,13 +10725,14 @@ var HeadlessSession = class {
10642
10725
  return;
10643
10726
  }
10644
10727
  if (action === "cancel") {
10645
- const { flushed, held, cancelledCompaction } = this.handleCancel();
10728
+ const { flushed, held, pausedPipeline, cancelledCompaction } = this.handleCancel();
10646
10729
  this.emit(
10647
10730
  "completed",
10648
10731
  {
10649
10732
  success: true,
10650
10733
  ...flushed.length > 0 && { cancelledMessages: flushed },
10651
10734
  ...held.length > 0 && { heldMessages: held },
10735
+ ...pausedPipeline && { pausedPipeline: true },
10652
10736
  ...cancelledCompaction && { cancelledCompaction: true }
10653
10737
  },
10654
10738
  requestId
package/dist/index.js CHANGED
@@ -2843,16 +2843,7 @@ function clearSession(state) {
2843
2843
  log3.warn("Session archive on clear failed", { error: err.message });
2844
2844
  }
2845
2845
  state.messages = [];
2846
- state.models = void 0;
2847
- try {
2848
- if (fs10.existsSync(SESSION_FILE)) {
2849
- fs10.unlinkSync(SESSION_FILE);
2850
- }
2851
- } catch (err) {
2852
- log3.warn("Session clear: could not remove live file", {
2853
- error: err.message
2854
- });
2855
- }
2846
+ saveSession(state);
2856
2847
  }
2857
2848
  var log3, SESSION_FILE, ARCHIVE_DIR, ARCHIVE_NAME_RE, archiveSortKey, ARCHIVE_COUNT_RE, archiveCountCache, archiveMsgCache, ARCHIVE_MSG_CACHE_MAX;
2858
2849
  var init_session = __esm({
@@ -10320,6 +10311,15 @@ var init_messageQueue = __esm({
10320
10311
  this.items.push(item);
10321
10312
  this.onChange?.();
10322
10313
  }
10314
+ /**
10315
+ * Add an item at the head. Used to put an interrupted chain step back in
10316
+ * front of the rest of its pipeline on cancel; `promoteToFront` can't serve
10317
+ * that case, since it addresses an item already in the queue by requestId.
10318
+ */
10319
+ unshift(item) {
10320
+ this.items.unshift(item);
10321
+ this.onChange?.();
10322
+ }
10323
10323
  /**
10324
10324
  * Index of the first deliverable item, or -1 when there is none.
10325
10325
  *
@@ -10388,25 +10388,6 @@ var init_messageQueue = __esm({
10388
10388
  }
10389
10389
  return held;
10390
10390
  }
10391
- /**
10392
- * Release held items so the normal drain picks them up again — all of them,
10393
- * or one by command requestId. Fires onChange only if something changed.
10394
- * Returns the released items.
10395
- */
10396
- releaseHeld(id) {
10397
- const released = [];
10398
- for (const item of this.items) {
10399
- if (!item.held || id !== void 0 && item.command.requestId !== id) {
10400
- continue;
10401
- }
10402
- delete item.held;
10403
- released.push(item);
10404
- }
10405
- if (released.length > 0) {
10406
- this.onChange?.();
10407
- }
10408
- return released;
10409
- }
10410
10391
  /** Whether anything in the queue will drain on its own (i.e. isn't held). */
10411
10392
  hasDeliverable() {
10412
10393
  return this.items.some((item) => !item.held);
@@ -10451,6 +10432,38 @@ var init_messageQueue = __esm({
10451
10432
  this.onChange?.();
10452
10433
  return item;
10453
10434
  }
10435
+ /**
10436
+ * Fold-in release: un-hold everything, then move items matching `defer` to
10437
+ * the tail, relative order preserved. One onChange for the whole
10438
+ * rearrangement, so no observer ever sees it half-applied.
10439
+ *
10440
+ * Callers push the new user message FIRST and call this second — "behind" is
10441
+ * defined by the array at the moment this runs. Held user messages stay put
10442
+ * so they still merge with that message into one mailbox batch; a paused
10443
+ * pipeline goes to the back, so the person's words run before the build
10444
+ * continues. `defer` is applied to every item, not just the newly-released
10445
+ * ones, so a chain step that was already deliverable defers too.
10446
+ */
10447
+ releaseHeldDeferring(defer) {
10448
+ const released = [];
10449
+ for (const item of this.items) {
10450
+ if (!item.held) {
10451
+ continue;
10452
+ }
10453
+ delete item.held;
10454
+ released.push(item);
10455
+ }
10456
+ const back = this.items.filter(defer);
10457
+ const front = this.items.filter((item) => !defer(item));
10458
+ const reordered = back.length > 0 && front.length > 0;
10459
+ if (reordered) {
10460
+ this.items = [...front, ...back];
10461
+ }
10462
+ if (released.length > 0 || reordered) {
10463
+ this.onChange?.();
10464
+ }
10465
+ return released;
10466
+ }
10454
10467
  /** Copy of current queue contents (for surfacing on events). */
10455
10468
  snapshot() {
10456
10469
  return [...this.items];
@@ -10528,6 +10541,20 @@ var init_headless = __esm({
10528
10541
  * right state (the triggering turn's state, not a stale one).
10529
10542
  */
10530
10543
  currentOnboardingState;
10544
+ /**
10545
+ * The in-flight turn's command, when that turn is part of a build pipeline
10546
+ * (its action declares a `next`, or it is itself a chain step). Captured so a
10547
+ * cancel can put the interrupted step back on the queue held, instead of
10548
+ * leaving the pipeline to resume at the step *after* the one that was
10549
+ * stopped — which would run finalize/polish over half-built code.
10550
+ *
10551
+ * Null for every other turn shape, which is what scopes the whole
10552
+ * paused-pipeline affordance to chains: stopping a one-off automated action
10553
+ * (approvePlan, publish, sync) leaves nothing behind.
10554
+ */
10555
+ currentChainStep = null;
10556
+ /** Monotonic suffix for synthesized chain requestIds — see nextChainRequestId. */
10557
+ chainRequestSeq = 0;
10531
10558
  /**
10532
10559
  * Unified message queue. Holds pending work to deliver after the current
10533
10560
  * turn completes: chained automated actions, background sub-agent results,
@@ -11093,6 +11120,16 @@ var init_headless = __esm({
11093
11120
  return void 0;
11094
11121
  }
11095
11122
  }
11123
+ /**
11124
+ * Stable id for a queued chain step, so the frontend can address one (to
11125
+ * discard a paused pipeline) rather than waiting for drainQueueLoop to
11126
+ * synthesize an id at dequeue. Deliberately not an `ac-` id — the frontend
11127
+ * keys "system turn" off that prefix — and deliberately not a bare
11128
+ * Date.now(), which collides for steps enqueued in the same millisecond.
11129
+ */
11130
+ nextChainRequestId() {
11131
+ return `chain-${Date.now()}-${++this.chainRequestSeq}`;
11132
+ }
11096
11133
  /**
11097
11134
  * Run one turn for a single command (without acquiring the `running` lock).
11098
11135
  * Owns the per-command machinery: @@automated:: action resolution, plan-file
@@ -11130,36 +11167,42 @@ var init_headless = __esm({
11130
11167
  const onboardingState = parsed.onboardingState ?? "onboardingFinished";
11131
11168
  this.currentOnboardingState = onboardingState;
11132
11169
  const system = buildSystemPrompt(onboardingState);
11170
+ this.currentChainStep = resolved && (resolved.next != null || fromChain) ? { text: rawText, onboardingState } : null;
11133
11171
  if (resolved?.next && !fromChain) {
11134
11172
  for (const step of getActionChain(resolved.next)) {
11135
11173
  this.queue.push({
11136
11174
  command: {
11137
11175
  action: "message",
11138
11176
  text: sentinel(step),
11139
- onboardingState
11177
+ onboardingState,
11178
+ requestId: this.nextChainRequestId()
11140
11179
  },
11141
11180
  source: "chain",
11142
11181
  enqueuedAt: Date.now()
11143
11182
  });
11144
11183
  }
11145
11184
  }
11146
- await this.executeTurn({
11147
- entries: [
11148
- {
11149
- text: userMessage,
11150
- attachments,
11151
- attachmentHeader,
11152
- hidden: isHidden || void 0,
11153
- requestId,
11154
- queued: queued || void 0
11155
- }
11156
- ],
11157
- requestId,
11158
- absorbedRids: [],
11159
- onboardingState,
11160
- system,
11161
- buildModel
11162
- });
11185
+ try {
11186
+ await this.executeTurn({
11187
+ entries: [
11188
+ {
11189
+ text: userMessage,
11190
+ attachments,
11191
+ attachmentHeader,
11192
+ hidden: isHidden || void 0,
11193
+ requestId,
11194
+ queued: queued || void 0
11195
+ }
11196
+ ],
11197
+ requestId,
11198
+ absorbedRids: [],
11199
+ onboardingState,
11200
+ system,
11201
+ buildModel
11202
+ });
11203
+ } finally {
11204
+ this.currentChainStep = null;
11205
+ }
11163
11206
  }
11164
11207
  /**
11165
11208
  * Run a mailbox batch — contiguous queued user + background items — as one
@@ -11172,6 +11215,7 @@ var init_headless = __esm({
11172
11215
  * with no side effects).
11173
11216
  */
11174
11217
  async runMergedTurn(batch) {
11218
+ this.currentChainStep = null;
11175
11219
  const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
11176
11220
  const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
11177
11221
  const entryList = [];
@@ -11326,9 +11370,6 @@ var init_headless = __esm({
11326
11370
  }
11327
11371
  async handleMessage(parsed, requestId) {
11328
11372
  const foldIn = !this.running && !getInflightCompaction() && this.queue.length > 0 && !isAutomatedMessage(parsed.text ?? "");
11329
- if (foldIn) {
11330
- this.queue.releaseHeld();
11331
- }
11332
11373
  if (this.running || getInflightCompaction() || foldIn) {
11333
11374
  const command = { ...parsed };
11334
11375
  if (requestId && command.requestId === void 0) {
@@ -11339,6 +11380,9 @@ var init_headless = __esm({
11339
11380
  source: "user",
11340
11381
  enqueuedAt: Date.now()
11341
11382
  });
11383
+ if (foldIn) {
11384
+ this.queue.releaseHeldDeferring((item) => item.source === "chain");
11385
+ }
11342
11386
  if (!this.running && !getInflightCompaction()) {
11343
11387
  this.kickDrain();
11344
11388
  }
@@ -11470,6 +11514,7 @@ var init_headless = __esm({
11470
11514
  handleClear() {
11471
11515
  clearSession(this.state);
11472
11516
  return {
11517
+ ...this.state.models && { models: this.state.models },
11473
11518
  modelSurfaces: getEffectiveModelSurfaces(),
11474
11519
  allowedModelsByType: ALLOWED_MODELS_BY_TYPE
11475
11520
  };
@@ -11490,9 +11535,9 @@ var init_headless = __esm({
11490
11535
  /**
11491
11536
  * Stop everything the user can see running: the turn, an in-flight
11492
11537
  * compaction, and any external tool waiting on a result. Flushes the
11493
- * follow-ups that belonged to the turn (`chain`/`background`) and HOLDS the
11494
- * `source: 'user'` items those are independent user intent, so they're
11495
- * kept, but they no longer run on their own.
11538
+ * `background` follow-ups that belonged to the turn, and HOLDS everything
11539
+ * still queued — the `source: 'user'` items, which are independent user
11540
+ * intent, plus the `source: 'chain'` steps of a build pipeline.
11496
11541
  *
11497
11542
  * Holding is the difference between Stop working and Stop looking broken.
11498
11543
  * These items used to drain immediately: `executeTurn` swallows the abort,
@@ -11501,6 +11546,13 @@ var init_headless = __esm({
11501
11546
  * and every additional press hit a turn that had just started. They now wait
11502
11547
  * in the queue card until the user sends again or promotes one.
11503
11548
  *
11549
+ * The chain steps used to be flushed with the background items, which threw
11550
+ * away the rest of the build — including the step that finalizes it and
11551
+ * unlocks the editor. They are now paused instead, and the interrupted step
11552
+ * goes back at the head, so a Stop is a pause and the next send resumes the
11553
+ * pipeline where it stopped. Holding only the remainder would resume one step
11554
+ * PAST the interruption, polishing and finalizing half-built code.
11555
+ *
11504
11556
  * A compaction is cancelled here too, unconditionally. It gates every queued
11505
11557
  * message and outlives the turn that started it, so leaving it running means
11506
11558
  * Stop can't reach idle. The cost is the summary work in flight; the forced
@@ -11521,19 +11573,50 @@ var init_headless = __esm({
11521
11573
  pending2.resolve(USER_CANCELLED_RESULT);
11522
11574
  this.pendingTools.delete(id);
11523
11575
  }
11524
- const flushed = this.queue.removeWhere((item) => item.source !== "user");
11525
- const held = this.queue.holdWhere((item) => item.source === "user");
11526
- return { flushed, held, cancelledCompaction };
11576
+ const flushed = this.queue.removeWhere(
11577
+ (item) => item.source === "background"
11578
+ );
11579
+ const step = this.currentChainStep;
11580
+ if (step) {
11581
+ this.queue.unshift({
11582
+ command: {
11583
+ action: "message",
11584
+ text: step.text,
11585
+ onboardingState: step.onboardingState,
11586
+ // Fresh id: the original command's terminal has already gone out as
11587
+ // cancelled, and one command gets exactly one `completed`.
11588
+ requestId: this.nextChainRequestId()
11589
+ },
11590
+ source: "chain",
11591
+ enqueuedAt: Date.now(),
11592
+ held: true
11593
+ });
11594
+ this.currentChainStep = null;
11595
+ }
11596
+ const held = this.queue.holdWhere(
11597
+ (item) => item.source === "user" || item.source === "chain"
11598
+ );
11599
+ return {
11600
+ flushed,
11601
+ held,
11602
+ pausedPipeline: held.some((item) => item.source === "chain"),
11603
+ cancelledCompaction
11604
+ };
11527
11605
  }
11528
11606
  /**
11529
- * Remove pending queued messages all user messages, or one by id.
11530
- * Only `source: 'user'` items are removable; chained and background
11531
- * messages are part of a system chain and are never cancellable. Does
11532
- * not affect the in-flight turn (use `cancel` for that).
11607
+ * Remove pending queued messages: all user messages (no id), or a single item
11608
+ * by id. Does not affect the in-flight turn (use `cancel` for that).
11609
+ *
11610
+ * Held chain items a paused pipeline are removable only by explicit id.
11611
+ * The id-less form is the queue card's "Clear" and the pre-destroy quiesce,
11612
+ * neither of which should get to decide the pipeline's fate. A DELIVERABLE
11613
+ * chain item is never removable: that's live pipeline work. (The step
11614
+ * actually running isn't in the queue at all — drainQueueLoop takes it out
11615
+ * before running it.)
11533
11616
  */
11534
11617
  handleCancelQueued(id) {
11535
11618
  return this.queue.removeWhere(
11536
- (item) => item.source === "user" && (id === void 0 || item.command.requestId === id)
11619
+ (item) => id === void 0 ? item.source === "user" : item.command.requestId === id && (item.source === "user" || item.source === "chain" && !!item.held)
11537
11620
  );
11538
11621
  }
11539
11622
  //////////////////////////////////////////////////////////////////////////////
@@ -11632,13 +11715,14 @@ var init_headless = __esm({
11632
11715
  return;
11633
11716
  }
11634
11717
  if (action === "cancel") {
11635
- const { flushed, held, cancelledCompaction } = this.handleCancel();
11718
+ const { flushed, held, pausedPipeline, cancelledCompaction } = this.handleCancel();
11636
11719
  this.emit(
11637
11720
  "completed",
11638
11721
  {
11639
11722
  success: true,
11640
11723
  ...flushed.length > 0 && { cancelledMessages: flushed },
11641
11724
  ...held.length > 0 && { heldMessages: held },
11725
+ ...pausedPipeline && { pausedPipeline: true },
11642
11726
  ...cancelledCompaction && { cancelledCompaction: true }
11643
11727
  },
11644
11728
  requestId
@@ -45,6 +45,8 @@ All fields are nested under the `"web"` key.
45
45
  | `defaultPreviewMode` | `"desktop"` \| `"mobile"` | `"desktop"` | Default preview viewport in the editor. Set to `"mobile"` for mobile-first apps. |
46
46
  | `prerender` | `object` | — | Opt into prerendering the listed routes/patterns for crawlers/unfurlers. See "Prerendering" below. |
47
47
  | `mounts` | `array` | — | Serve other same-workspace apps under path prefixes of this app's hosts. See "Mounting other apps" below. |
48
+ | `redirects` | `array` | — | Path-level redirects. See "Redirects" below. |
49
+ | `trailingSlash` | `"strip"` \| `"append"` | — | Enforce a canonical trailing-slash form with a 308. Off by default. |
48
50
 
49
51
  ### Frontend SDK
50
52
 
@@ -136,7 +138,7 @@ Opt in per route in `web.json`:
136
138
  { "web": { "prerender": { "paths": ["/u/*", "/blog/*"] } } }
137
139
  ```
138
140
 
139
- `prerender` is an object with a single field, `paths`: an array of route globs (`*` = one segment, `**` = any). Only listed routes prerender.
141
+ `prerender` is an object with a single field, `paths`: an array of route globs (`*` = one segment, `**` = any). Only listed routes prerender, and only routes — a path with a file extension (`/robots.txt`, `/sitemap.xml`) never prerenders, even under `/**`.
140
142
 
141
143
  The opt-in alone is not enough. For every route in `prerender.paths`, the SPA must set `document.documentElement.setAttribute('data-prerender-ready', 'true')` once the head is written and the route has resolved — this is required, not optional. The renderer waits for this marker, so a prerendered route that never sets it times out. Set it even on routes that render synchronously, as soon as they're ready.
142
144
 
@@ -149,9 +151,28 @@ await prerender.invalidate(['/u/abc']); // omit arg to purge all
149
151
 
150
152
  `remy-admin prerender` can help you verify/manage snapshots during development.
151
153
 
154
+ ### Redirects
155
+
156
+ Declare moved URLs in `web.json` — never ship a component that redirects client-side, since that resolves after JS loads and crawlers won't follow it:
157
+
158
+ ```json
159
+ { "web": { "trailingSlash": "strip", "redirects": [
160
+ { "source": "/old-blog/*slug", "destination": "/blog/*slug", "permanent": true },
161
+ { "source": "/launch", "destination": "https://example.com/launch", "statusCode": 302 }
162
+ ] } }
163
+ ```
164
+
165
+ `source` is a `/`-prefixed pattern; `destination` is a path or absolute http(s) URL. `permanent` (true → 308, false → 307) is required unless you set an explicit `statusCode` of 301/302/307/308; you can't set both. First match wins, and the request's query carries over unless the destination has its own.
166
+
167
+ **Pattern syntax is path-to-regexp v8, NOT the Next.js dialect** — the one thing to get right here. One segment is `:name`; a multi-segment wildcard is `*name` (Next's `:name*`); optional segments use braces (`/users{/:id}`); inline regex (`:id(\\d+)`) is unsupported. The v6/Next forms fail the build with the correct syntax in the error.
168
+
169
+ `trailingSlash` picks the canonical form (`"strip"`: `/about/` → `/about`; `"append"`: the reverse, skipping the root and paths with file extensions). Off by default. Write `source` patterns without trailing slashes either way — normalization and matching resolve in one hop. Sources under `/_/` are rejected, as are self-referential and two-rule loops. A redirecting path is never prerendered, so don't list one in `prerender.paths`.
170
+
152
171
  ### Mounting other apps
153
172
 
154
- Rare: `mounts` serves another same-workspace app under a path prefix of this app's hosts — `{ "web": { "mounts": [{ "path": "/docs", "app": "docs-site" }] } }` (`app` = the target's `custom_subdomain` or appId). The child is served first-class (its own bundle, session, backend) and needs no mount-specific config; the two serving conventions above are what make an app mountable.
173
+ Rare: `mounts` serves another same-workspace app under a path prefix of this app's hosts — `{ "web": { "mounts": [{ "path": "/docs", "app": "docs-site" }] } }` (`app` = the target's `custom_subdomain` or appId). The child is served first-class (its own bundle, session, backend, prerendering) and needs no mount-specific config; the two serving conventions above are what make an app mountable.
174
+
175
+ Under a mount the child's own `web.json` governs its SEO and routing: its `prerender.paths` gates its mounted pages, its `prerender.invalidate` purges them, its `redirects`/`trailingSlash` apply within the prefix (written against its own paths, with the prefix added back to relative destinations), and its `sitemap.xml` is served with URLs rewritten to the mount and advertised in the parent's `robots.txt`. The child's `robots.txt` *rules* don't carry over (the build log lists them prefixed for the parent to adopt), and canonicals must be written from the page's own location — a hardcoded absolute canonical points crawlers back at the child's host and undoes the mount.
155
176
 
156
177
  ## API Interface
157
178
 
@@ -18,6 +18,7 @@ If dismissed, acknowledge and do nothing — no commit, no push.
18
18
 
19
19
  ## 2. Ship (on approval)
20
20
 
21
+ - On a meaningful release, glance at dependencies before committing — `npm outdated` in the methods package and in each interface's web directory. The first-party packages (`@mindstudio-ai/agent`, `@mindstudio-ai/interface`, `@madewithremy/admin`) are ours and versioned additively: a bump brings new capabilities and bug fixes, not a migration. Bring those current without asking, typecheck, and mention it in plain language when you report the deploy; if a bump does need a small code change, just make it. Third-party packages are the user's time to spend, so flag anything meaningfully behind and let them decide. Skip all of this on a hotfix — a quick fix going out doesn't need a dependency pass.
21
22
  - Stage and commit any uncommitted changes with a clean, descriptive commit message. If the committed work resolves any open issues (`remy-admin issues`), reference them in the commit message with a closing keyword — `fixes #42`, `closes #7` — so the deploy closes them automatically once it goes live.
22
23
  - Push to main.
23
24
  - Use `remy-admin releases wait` to poll the build until it completes. Let the user know it's deploying, then report back when it's live.
@@ -39,7 +39,7 @@ These are things we already know about and have decided to accept:
39
39
  - use [wouter](https://github.com/molefrog/wouter) for React routing instead of reaching for react-router
40
40
  - uploading user files should always happen via `platform.uploadFile()` from `@mindstudio-ai/interface` — not custom S3 code, not FormData to a method endpoint
41
41
  - for build-time prerendering of purely static sites (marketing pages with no dynamic content — distinct from the platform's crawler prerendering, below), roll your own with a post-build `renderToString` script — do not use `vite-prerender-plugin` (it bundles the prerender script as a client chunk, adding ~800KB to the user-facing bundle with no way to prevent it)
42
- - **Prerendering for crawlers/unfurlers is a platform feature — don't design around it.** For SEO / link-unfurl / AI-crawler visibility on a non-static SPA, the platform already handles it: routes opt in via `web.json` (`{ "web": { "prerender": { "paths": ["/blog/*"] } } }`), the SPA signals readiness by setting `data-prerender-ready` on the html element, deploys invalidate the snapshot cache automatically, and content that changes outside deploys is invalidated at runtime with `await prerender.invalidate([...])` from `@mindstudio-ai/agent` (called from the mutating method). It serves cached headless snapshots of the live SPA to bots — it is NOT build-time rendering. Do not recommend post-build render scripts, rebuild-on-content-change, or third-party prerender services for this. The developer's main context carries the full interfaces reference with exact semantics — tell them to consult it rather than improvising.
42
+ - **Prerendering for crawlers/unfurlers is a platform feature — don't design around it.** For SEO / link-unfurl / AI-crawler visibility on a non-static SPA, the platform already handles it: routes opt in via `web.json` (`{ "web": { "prerender": { "paths": ["/blog/*"] } } }`), the SPA signals readiness by setting `data-prerender-ready` on the html element, deploys invalidate the snapshot cache automatically, and content that changes outside deploys is invalidated at runtime with `await prerender.invalidate([...])` from `@mindstudio-ai/agent` (called from the mutating method). It serves cached headless snapshots of the live SPA to bots — it is NOT build-time rendering. Do not recommend post-build render scripts, rebuild-on-content-change, or third-party prerender services for this. An app mounted under another app's path prefix keeps its own `prerender.paths` and its own `prerender.invalidate` — don't recommend moving prerender config to the parent, and don't hand-roll sitemap/robots rewriting for a mount (the platform rewrites the mounted sitemap and advertises it in the parent's robots.txt). The developer's main context carries the full interfaces reference with exact semantics — tell them to consult it rather than improvising.
43
43
 
44
44
  ### Common pitfalls (always flag these)
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindstudio-ai/remy",
3
- "version": "0.1.302",
3
+ "version": "0.1.304",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",