@mindstudio-ai/remy 0.1.303 → 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
@@ -9349,6 +9349,15 @@ var MessageQueue = class {
9349
9349
  this.items.push(item);
9350
9350
  this.onChange?.();
9351
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
+ }
9352
9361
  /**
9353
9362
  * Index of the first deliverable item, or -1 when there is none.
9354
9363
  *
@@ -9417,25 +9426,6 @@ var MessageQueue = class {
9417
9426
  }
9418
9427
  return held;
9419
9428
  }
9420
- /**
9421
- * Release held items so the normal drain picks them up again — all of them,
9422
- * or one by command requestId. Fires onChange only if something changed.
9423
- * Returns the released items.
9424
- */
9425
- releaseHeld(id) {
9426
- const released = [];
9427
- for (const item of this.items) {
9428
- if (!item.held || id !== void 0 && item.command.requestId !== id) {
9429
- continue;
9430
- }
9431
- delete item.held;
9432
- released.push(item);
9433
- }
9434
- if (released.length > 0) {
9435
- this.onChange?.();
9436
- }
9437
- return released;
9438
- }
9439
9429
  /** Whether anything in the queue will drain on its own (i.e. isn't held). */
9440
9430
  hasDeliverable() {
9441
9431
  return this.items.some((item) => !item.held);
@@ -9480,6 +9470,38 @@ var MessageQueue = class {
9480
9470
  this.onChange?.();
9481
9471
  return item;
9482
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
+ }
9483
9505
  /** Copy of current queue contents (for surfacing on events). */
9484
9506
  snapshot() {
9485
9507
  return [...this.items];
@@ -9529,6 +9551,20 @@ var HeadlessSession = class {
9529
9551
  * right state (the triggering turn's state, not a stale one).
9530
9552
  */
9531
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;
9532
9568
  /**
9533
9569
  * Unified message queue. Holds pending work to deliver after the current
9534
9570
  * turn completes: chained automated actions, background sub-agent results,
@@ -10094,6 +10130,16 @@ var HeadlessSession = class {
10094
10130
  return void 0;
10095
10131
  }
10096
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
+ }
10097
10143
  /**
10098
10144
  * Run one turn for a single command (without acquiring the `running` lock).
10099
10145
  * Owns the per-command machinery: @@automated:: action resolution, plan-file
@@ -10131,36 +10177,42 @@ var HeadlessSession = class {
10131
10177
  const onboardingState = parsed.onboardingState ?? "onboardingFinished";
10132
10178
  this.currentOnboardingState = onboardingState;
10133
10179
  const system = buildSystemPrompt(onboardingState);
10180
+ this.currentChainStep = resolved && (resolved.next != null || fromChain) ? { text: rawText, onboardingState } : null;
10134
10181
  if (resolved?.next && !fromChain) {
10135
10182
  for (const step of getActionChain(resolved.next)) {
10136
10183
  this.queue.push({
10137
10184
  command: {
10138
10185
  action: "message",
10139
10186
  text: sentinel(step),
10140
- onboardingState
10187
+ onboardingState,
10188
+ requestId: this.nextChainRequestId()
10141
10189
  },
10142
10190
  source: "chain",
10143
10191
  enqueuedAt: Date.now()
10144
10192
  });
10145
10193
  }
10146
10194
  }
10147
- await this.executeTurn({
10148
- entries: [
10149
- {
10150
- text: userMessage,
10151
- attachments,
10152
- attachmentHeader,
10153
- hidden: isHidden || void 0,
10154
- requestId,
10155
- queued: queued || void 0
10156
- }
10157
- ],
10158
- requestId,
10159
- absorbedRids: [],
10160
- onboardingState,
10161
- system,
10162
- buildModel
10163
- });
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
+ }
10164
10216
  }
10165
10217
  /**
10166
10218
  * Run a mailbox batch — contiguous queued user + background items — as one
@@ -10173,6 +10225,7 @@ var HeadlessSession = class {
10173
10225
  * with no side effects).
10174
10226
  */
10175
10227
  async runMergedTurn(batch) {
10228
+ this.currentChainStep = null;
10176
10229
  const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
10177
10230
  const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
10178
10231
  const entryList = [];
@@ -10327,9 +10380,6 @@ var HeadlessSession = class {
10327
10380
  }
10328
10381
  async handleMessage(parsed, requestId) {
10329
10382
  const foldIn = !this.running && !getInflightCompaction() && this.queue.length > 0 && !isAutomatedMessage(parsed.text ?? "");
10330
- if (foldIn) {
10331
- this.queue.releaseHeld();
10332
- }
10333
10383
  if (this.running || getInflightCompaction() || foldIn) {
10334
10384
  const command = { ...parsed };
10335
10385
  if (requestId && command.requestId === void 0) {
@@ -10340,6 +10390,9 @@ var HeadlessSession = class {
10340
10390
  source: "user",
10341
10391
  enqueuedAt: Date.now()
10342
10392
  });
10393
+ if (foldIn) {
10394
+ this.queue.releaseHeldDeferring((item) => item.source === "chain");
10395
+ }
10343
10396
  if (!this.running && !getInflightCompaction()) {
10344
10397
  this.kickDrain();
10345
10398
  }
@@ -10492,9 +10545,9 @@ var HeadlessSession = class {
10492
10545
  /**
10493
10546
  * Stop everything the user can see running: the turn, an in-flight
10494
10547
  * compaction, and any external tool waiting on a result. Flushes the
10495
- * follow-ups that belonged to the turn (`chain`/`background`) and HOLDS the
10496
- * `source: 'user'` items those are independent user intent, so they're
10497
- * 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.
10498
10551
  *
10499
10552
  * Holding is the difference between Stop working and Stop looking broken.
10500
10553
  * These items used to drain immediately: `executeTurn` swallows the abort,
@@ -10503,6 +10556,13 @@ var HeadlessSession = class {
10503
10556
  * and every additional press hit a turn that had just started. They now wait
10504
10557
  * in the queue card until the user sends again or promotes one.
10505
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
+ *
10506
10566
  * A compaction is cancelled here too, unconditionally. It gates every queued
10507
10567
  * message and outlives the turn that started it, so leaving it running means
10508
10568
  * Stop can't reach idle. The cost is the summary work in flight; the forced
@@ -10523,19 +10583,50 @@ var HeadlessSession = class {
10523
10583
  pending2.resolve(USER_CANCELLED_RESULT);
10524
10584
  this.pendingTools.delete(id);
10525
10585
  }
10526
- const flushed = this.queue.removeWhere((item) => item.source !== "user");
10527
- const held = this.queue.holdWhere((item) => item.source === "user");
10528
- 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
+ };
10529
10615
  }
10530
10616
  /**
10531
- * Remove pending queued messages all user messages, or one by id.
10532
- * Only `source: 'user'` items are removable; chained and background
10533
- * messages are part of a system chain and are never cancellable. Does
10534
- * 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.)
10535
10626
  */
10536
10627
  handleCancelQueued(id) {
10537
10628
  return this.queue.removeWhere(
10538
- (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)
10539
10630
  );
10540
10631
  }
10541
10632
  //////////////////////////////////////////////////////////////////////////////
@@ -10634,13 +10725,14 @@ var HeadlessSession = class {
10634
10725
  return;
10635
10726
  }
10636
10727
  if (action === "cancel") {
10637
- const { flushed, held, cancelledCompaction } = this.handleCancel();
10728
+ const { flushed, held, pausedPipeline, cancelledCompaction } = this.handleCancel();
10638
10729
  this.emit(
10639
10730
  "completed",
10640
10731
  {
10641
10732
  success: true,
10642
10733
  ...flushed.length > 0 && { cancelledMessages: flushed },
10643
10734
  ...held.length > 0 && { heldMessages: held },
10735
+ ...pausedPipeline && { pausedPipeline: true },
10644
10736
  ...cancelledCompaction && { cancelledCompaction: true }
10645
10737
  },
10646
10738
  requestId
package/dist/index.js CHANGED
@@ -10311,6 +10311,15 @@ var init_messageQueue = __esm({
10311
10311
  this.items.push(item);
10312
10312
  this.onChange?.();
10313
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
+ }
10314
10323
  /**
10315
10324
  * Index of the first deliverable item, or -1 when there is none.
10316
10325
  *
@@ -10379,25 +10388,6 @@ var init_messageQueue = __esm({
10379
10388
  }
10380
10389
  return held;
10381
10390
  }
10382
- /**
10383
- * Release held items so the normal drain picks them up again — all of them,
10384
- * or one by command requestId. Fires onChange only if something changed.
10385
- * Returns the released items.
10386
- */
10387
- releaseHeld(id) {
10388
- const released = [];
10389
- for (const item of this.items) {
10390
- if (!item.held || id !== void 0 && item.command.requestId !== id) {
10391
- continue;
10392
- }
10393
- delete item.held;
10394
- released.push(item);
10395
- }
10396
- if (released.length > 0) {
10397
- this.onChange?.();
10398
- }
10399
- return released;
10400
- }
10401
10391
  /** Whether anything in the queue will drain on its own (i.e. isn't held). */
10402
10392
  hasDeliverable() {
10403
10393
  return this.items.some((item) => !item.held);
@@ -10442,6 +10432,38 @@ var init_messageQueue = __esm({
10442
10432
  this.onChange?.();
10443
10433
  return item;
10444
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
+ }
10445
10467
  /** Copy of current queue contents (for surfacing on events). */
10446
10468
  snapshot() {
10447
10469
  return [...this.items];
@@ -10519,6 +10541,20 @@ var init_headless = __esm({
10519
10541
  * right state (the triggering turn's state, not a stale one).
10520
10542
  */
10521
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;
10522
10558
  /**
10523
10559
  * Unified message queue. Holds pending work to deliver after the current
10524
10560
  * turn completes: chained automated actions, background sub-agent results,
@@ -11084,6 +11120,16 @@ var init_headless = __esm({
11084
11120
  return void 0;
11085
11121
  }
11086
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
+ }
11087
11133
  /**
11088
11134
  * Run one turn for a single command (without acquiring the `running` lock).
11089
11135
  * Owns the per-command machinery: @@automated:: action resolution, plan-file
@@ -11121,36 +11167,42 @@ var init_headless = __esm({
11121
11167
  const onboardingState = parsed.onboardingState ?? "onboardingFinished";
11122
11168
  this.currentOnboardingState = onboardingState;
11123
11169
  const system = buildSystemPrompt(onboardingState);
11170
+ this.currentChainStep = resolved && (resolved.next != null || fromChain) ? { text: rawText, onboardingState } : null;
11124
11171
  if (resolved?.next && !fromChain) {
11125
11172
  for (const step of getActionChain(resolved.next)) {
11126
11173
  this.queue.push({
11127
11174
  command: {
11128
11175
  action: "message",
11129
11176
  text: sentinel(step),
11130
- onboardingState
11177
+ onboardingState,
11178
+ requestId: this.nextChainRequestId()
11131
11179
  },
11132
11180
  source: "chain",
11133
11181
  enqueuedAt: Date.now()
11134
11182
  });
11135
11183
  }
11136
11184
  }
11137
- await this.executeTurn({
11138
- entries: [
11139
- {
11140
- text: userMessage,
11141
- attachments,
11142
- attachmentHeader,
11143
- hidden: isHidden || void 0,
11144
- requestId,
11145
- queued: queued || void 0
11146
- }
11147
- ],
11148
- requestId,
11149
- absorbedRids: [],
11150
- onboardingState,
11151
- system,
11152
- buildModel
11153
- });
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
+ }
11154
11206
  }
11155
11207
  /**
11156
11208
  * Run a mailbox batch — contiguous queued user + background items — as one
@@ -11163,6 +11215,7 @@ var init_headless = __esm({
11163
11215
  * with no side effects).
11164
11216
  */
11165
11217
  async runMergedTurn(batch) {
11218
+ this.currentChainStep = null;
11166
11219
  const primaryRid = batch[0].command.requestId ?? (batch.every((b) => b.source === "background") ? `background-${Date.now()}` : `merged-${Date.now()}`);
11167
11220
  const absorbedRids = batch.slice(1).map((b) => b.command.requestId).filter((rid) => typeof rid === "string");
11168
11221
  const entryList = [];
@@ -11317,9 +11370,6 @@ var init_headless = __esm({
11317
11370
  }
11318
11371
  async handleMessage(parsed, requestId) {
11319
11372
  const foldIn = !this.running && !getInflightCompaction() && this.queue.length > 0 && !isAutomatedMessage(parsed.text ?? "");
11320
- if (foldIn) {
11321
- this.queue.releaseHeld();
11322
- }
11323
11373
  if (this.running || getInflightCompaction() || foldIn) {
11324
11374
  const command = { ...parsed };
11325
11375
  if (requestId && command.requestId === void 0) {
@@ -11330,6 +11380,9 @@ var init_headless = __esm({
11330
11380
  source: "user",
11331
11381
  enqueuedAt: Date.now()
11332
11382
  });
11383
+ if (foldIn) {
11384
+ this.queue.releaseHeldDeferring((item) => item.source === "chain");
11385
+ }
11333
11386
  if (!this.running && !getInflightCompaction()) {
11334
11387
  this.kickDrain();
11335
11388
  }
@@ -11482,9 +11535,9 @@ var init_headless = __esm({
11482
11535
  /**
11483
11536
  * Stop everything the user can see running: the turn, an in-flight
11484
11537
  * compaction, and any external tool waiting on a result. Flushes the
11485
- * follow-ups that belonged to the turn (`chain`/`background`) and HOLDS the
11486
- * `source: 'user'` items those are independent user intent, so they're
11487
- * 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.
11488
11541
  *
11489
11542
  * Holding is the difference between Stop working and Stop looking broken.
11490
11543
  * These items used to drain immediately: `executeTurn` swallows the abort,
@@ -11493,6 +11546,13 @@ var init_headless = __esm({
11493
11546
  * and every additional press hit a turn that had just started. They now wait
11494
11547
  * in the queue card until the user sends again or promotes one.
11495
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
+ *
11496
11556
  * A compaction is cancelled here too, unconditionally. It gates every queued
11497
11557
  * message and outlives the turn that started it, so leaving it running means
11498
11558
  * Stop can't reach idle. The cost is the summary work in flight; the forced
@@ -11513,19 +11573,50 @@ var init_headless = __esm({
11513
11573
  pending2.resolve(USER_CANCELLED_RESULT);
11514
11574
  this.pendingTools.delete(id);
11515
11575
  }
11516
- const flushed = this.queue.removeWhere((item) => item.source !== "user");
11517
- const held = this.queue.holdWhere((item) => item.source === "user");
11518
- 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
+ };
11519
11605
  }
11520
11606
  /**
11521
- * Remove pending queued messages all user messages, or one by id.
11522
- * Only `source: 'user'` items are removable; chained and background
11523
- * messages are part of a system chain and are never cancellable. Does
11524
- * 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.)
11525
11616
  */
11526
11617
  handleCancelQueued(id) {
11527
11618
  return this.queue.removeWhere(
11528
- (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)
11529
11620
  );
11530
11621
  }
11531
11622
  //////////////////////////////////////////////////////////////////////////////
@@ -11624,13 +11715,14 @@ var init_headless = __esm({
11624
11715
  return;
11625
11716
  }
11626
11717
  if (action === "cancel") {
11627
- const { flushed, held, cancelledCompaction } = this.handleCancel();
11718
+ const { flushed, held, pausedPipeline, cancelledCompaction } = this.handleCancel();
11628
11719
  this.emit(
11629
11720
  "completed",
11630
11721
  {
11631
11722
  success: true,
11632
11723
  ...flushed.length > 0 && { cancelledMessages: flushed },
11633
11724
  ...held.length > 0 && { heldMessages: held },
11725
+ ...pausedPipeline && { pausedPipeline: true },
11634
11726
  ...cancelledCompaction && { cancelledCompaction: true }
11635
11727
  },
11636
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.303",
3
+ "version": "0.1.304",
4
4
  "description": "Remy coding agent",
5
5
  "repository": {
6
6
  "type": "git",