@xmoon76/dsh-pi-tui 0.4.7-alpha.1 → 0.4.7-alpha.2

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.
@@ -1,4 +1,4 @@
1
- import { TUI_STARTUP_SERVICE } from "./startup.mjs";
1
+ import { n as TUI_STARTUP_SERVICE } from "./startup-CGeJ1k1B.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { StringDecoder } from "node:string_decoder";
@@ -65,7 +65,8 @@ async function resolvePresetRequest(presets, requestedId, signal) {
65
65
  //#endregion
66
66
  //#region src/runtime/direct/session-preset-direct.ts
67
67
  /**
68
- * Direct session-preset adapter for DSH 0.1.6-alpha.1+.
68
+ * Direct session-preset adapter for the DSH 0.1.6 session-projection API
69
+ * (introduced in `0.1.6-alpha.1`).
69
70
  *
70
71
  * The Harness owns the `agentPreset` projection. TUI callers must not fold the
71
72
  * session log themselves: the projection initializes from the creation header
@@ -5867,6 +5868,19 @@ function invokeTask(task) {
5867
5868
  }
5868
5869
  }
5869
5870
  /**
5871
+ * Observe the settlement of an ALREADY-OWNED promise without taking ownership.
5872
+ * The callback runs on fulfilment or rejection and its own result is dropped,
5873
+ * so bookkeeping (for example removing a promise from a teardown-drained set)
5874
+ * never adds a second, unhandled rejection branch next to the real owner's
5875
+ * failure handling. Never use this to START work — that is `runDetached` /
5876
+ * `runOwned`.
5877
+ * @param promise - the promise whose failure semantics already have an owner.
5878
+ * @param onSettled - runs once, on either settlement.
5879
+ */
5880
+ function observeSettled(promise, onSettled) {
5881
+ promise.then(onSettled, onSettled);
5882
+ }
5883
+ /**
5870
5884
  * Attach rejection handling to a fire-and-forget task. The factory is
5871
5885
  * invoked synchronously (a synchronous throw is a rejection like any
5872
5886
  * other); every rejection is caught and classified:
@@ -19746,6 +19760,7 @@ var TuiAltScreen = class extends TuiBase {
19746
19760
  onRightClickPaste;
19747
19761
  copyOnSelect;
19748
19762
  copySelection;
19763
+ selectionLineText;
19749
19764
  onCellClick;
19750
19765
  onCellPress;
19751
19766
  onFramePainted;
@@ -19778,6 +19793,7 @@ var TuiAltScreen = class extends TuiBase {
19778
19793
  this.onRightClickPaste = options.onRightClickPaste;
19779
19794
  this.copyOnSelect = options.copyOnSelect ?? true;
19780
19795
  this.copySelection = options.copySelection;
19796
+ this.selectionLineText = options.selectionLineText;
19781
19797
  this.onCellClick = options.onCellClick;
19782
19798
  this.onCellPress = options.onCellPress;
19783
19799
  this.onFramePainted = options.onFramePainted;
@@ -20957,8 +20973,14 @@ var TuiAltScreen = class extends TuiBase {
20957
20973
  const lines = [];
20958
20974
  for (let row = selection.start.row; row <= selection.end.row; row++) {
20959
20975
  const line = sourceLines[row] ?? "";
20976
+ const override = this.selectionLineText?.({
20977
+ row,
20978
+ line,
20979
+ ...selection.start.scrollView === void 0 ? {} : { scrollView: selection.start.scrollView }
20980
+ });
20981
+ const copyLine = override === void 0 ? line : override;
20960
20982
  const columns = this.getSelectionColumns(line, row, selection);
20961
- const sliced = stripTerminalSequences(sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true)).trimEnd();
20983
+ const sliced = stripTerminalSequences(sliceByColumn(copyLine, columns.start, Math.max(0, columns.end - columns.start), true)).trimEnd();
20962
20984
  lines.push(columns.start === 0 ? sliced.replace(/^ {1,3}/, "") : sliced);
20963
20985
  }
20964
20986
  const text = lines.join("\n");
@@ -40848,6 +40870,7 @@ var UserBubbleComponent = class {
40848
40870
  lastWidth = -1;
40849
40871
  cached;
40850
40872
  lastCompactMarkerRow;
40873
+ lastCollapseEligible = false;
40851
40874
  constructor(child, marker, bg, compactOptions) {
40852
40875
  this.child = child;
40853
40876
  this.marker = marker;
@@ -40868,6 +40891,13 @@ var UserBubbleComponent = class {
40868
40891
  compactMarkerRow() {
40869
40892
  return this.lastCompactMarkerRow;
40870
40893
  }
40894
+ /** Whether the current render is an EXPANDED compact-capable bubble: the
40895
+ * visual rows exceed the threshold, so the caller must place a tail
40896
+ * collapse control after the body (the spacer row when one follows, or one
40897
+ * dedicated presentation row for the final block). */
40898
+ showsCollapseControl() {
40899
+ return this.lastCollapseEligible;
40900
+ }
40871
40901
  render(width) {
40872
40902
  const inner = Math.max(1, width - this.markerWidth);
40873
40903
  const child = this.child.render(inner);
@@ -40886,11 +40916,17 @@ var UserBubbleComponent = class {
40886
40916
  /** Collapse the child's FULL visual rows to head + marker + tail when the
40887
40917
  * row-count threshold is exceeded. The decision and the slice both run on
40888
40918
  * the rows the current width actually produces, so a resize re-decides
40889
- * (no baked compact count/marker position). */
40919
+ * (no baked compact count/marker position). An EXPANDED bubble keeps the
40920
+ * full rows and only records that it is collapse-eligible. */
40890
40921
  compactRows(child, inner) {
40891
40922
  this.lastCompactMarkerRow = void 0;
40923
+ this.lastCollapseEligible = false;
40892
40924
  const options = this.compactOptions;
40893
40925
  if (options === void 0 || child.length <= options.thresholdRows) return child;
40926
+ if (options.expanded) {
40927
+ this.lastCollapseEligible = true;
40928
+ return child;
40929
+ }
40894
40930
  const head = Math.min(options.headRows, child.length);
40895
40931
  const tail = Math.min(options.tailRows, Math.max(0, child.length - head));
40896
40932
  if (head + tail >= child.length) return child;
@@ -40909,15 +40945,36 @@ var UserBubbleComponent = class {
40909
40945
  * The ephemeral pending user-input row: the SAME floating user bubble as a
40910
40946
  * durable human message, plus one dim pending-status line so it reads as
40911
40947
  * accepted-but-not-yet-materialized rather than as durable transcript content.
40912
- * `pendingUserComponent` is presentation-only and is never inserted into the
40913
- * transcript folder.
40914
- */
40915
- function pendingUserComponent(row, running) {
40916
- const container = new Container();
40917
- container.addChild(new UserBubbleComponent(new Text(row.text, 0, 0), `${color.roleUser("❯")} `, color.roleUserBg));
40918
- container.addChild(new Text(color.textDim(` ${pendingUserStatusText(row, running)}`), 0, 0));
40919
- return container;
40920
- }
40948
+ * `PendingUserComponent` is presentation-only and is never inserted into the
40949
+ * transcript folder. It exposes the same disclosure geometry as the durable
40950
+ * bubble (the compact marker row and the expanded collapse-control
40951
+ * eligibility) so the row map shares ONE hit model for both.
40952
+ */
40953
+ var PendingUserComponent = class {
40954
+ container;
40955
+ bubble;
40956
+ constructor(row, running, compactOptions) {
40957
+ this.bubble = new UserBubbleComponent(new Text(row.text, 0, 0), `${color.roleUser("❯")} `, color.roleUserBg, compactOptions);
40958
+ this.container = new Container();
40959
+ this.container.addChild(this.bubble);
40960
+ this.container.addChild(new Text(color.textDim(` ${pendingUserStatusText(row, running)}`), 0, 0));
40961
+ }
40962
+ render(width) {
40963
+ return this.container.render(width);
40964
+ }
40965
+ invalidate() {
40966
+ this.container.invalidate?.();
40967
+ }
40968
+ dispose() {
40969
+ this.container.dispose?.();
40970
+ }
40971
+ compactMarkerRow() {
40972
+ return this.bubble.compactMarkerRow();
40973
+ }
40974
+ showsCollapseControl() {
40975
+ return this.bubble.showsCollapseControl();
40976
+ }
40977
+ };
40921
40978
  /**
40922
40979
  * The presentation-only pending status label. An accepted steer whose subject
40923
40980
  * is RUNNING reads `steering…`. When an AUTHORITATIVE steering occurrence's
@@ -41264,6 +41321,14 @@ function approvalOverlayGeometry(columns, rows) {
41264
41321
  contentWidth: Math.max(1, width - 8)
41265
41322
  };
41266
41323
  }
41324
+ /** The stable presentation identity of one pending-user row: the rpc
41325
+ * correlation when present (a local echo and its authoritative occurrence
41326
+ * share it), the occurrence/request id otherwise. Never text, and never the
41327
+ * entry index — a projection update must not transfer disclosure state (or a
41328
+ * stale click) to a different pending row. */
41329
+ function pendingUserDisclosureKey(row) {
41330
+ return row.rpcId !== void 0 ? `rpc:${row.rpcId}` : `id:${row.id}`;
41331
+ }
41267
41332
  /**
41268
41333
  * The interactive surface: header, transcript, editor, footer. Owns the
41269
41334
  * TUI lifecycle, mode switching, folding, approval dialogs, and settings
@@ -41292,6 +41357,14 @@ function deepFreeze(value) {
41292
41357
  for (const key of Object.keys(value)) deepFreeze(value[key]);
41293
41358
  return Object.freeze(value);
41294
41359
  }
41360
+ /** The Host-owned long-user disclosure geometry. Only the two Host bubbles
41361
+ * implement it, and the row map never reaches into their children. The brand
41362
+ * is EXPLICIT (`instanceof`): a plugin renderer that happens to expose
41363
+ * same-named methods must never be mistaken for a Host disclosure component
41364
+ * (the M7 plugin-owned `kind: 'user'` contract). */
41365
+ function userDisclosureComponentOf(component) {
41366
+ if (component instanceof UserBubbleComponent || component instanceof PendingUserComponent) return component;
41367
+ }
41295
41368
  var TuiApp = class TuiApp {
41296
41369
  terminal;
41297
41370
  /** The extension surface host (M2), when the runner attached one. */
@@ -41763,6 +41836,11 @@ var TuiApp = class TuiApp {
41763
41836
  * regardless of the fold or the override.
41764
41837
  */
41765
41838
  expandedOverride = /* @__PURE__ */ new Map();
41839
+ /** Per-pending-row long-user disclosure overrides, keyed by the stable
41840
+ * {@link pendingUserDisclosureKey}. Presentation-only ephemeral state: it
41841
+ * never enters the transcript folder, persistence, or the session, and it
41842
+ * is pruned to the live pending keys on every presentation update. */
41843
+ pendingUserExpanded = /* @__PURE__ */ new Map();
41766
41844
  /** PTC sub-call bodies the user expanded by subCallId (stable across
41767
41845
  * live updates, replay and sibling insertion — never an array index). */
41768
41846
  subCallExpanded = /* @__PURE__ */ new Set();
@@ -42346,6 +42424,7 @@ var TuiApp = class TuiApp {
42346
42424
  }
42347
42425
  this.terminalSchemeListeners.clear();
42348
42426
  this.expandedOverride.clear();
42427
+ this.pendingUserExpanded.clear();
42349
42428
  this.disposeMessageComponents();
42350
42429
  this.localMessages.length = 0;
42351
42430
  this.pendingUserRows = [];
@@ -42859,19 +42938,19 @@ var TuiApp = class TuiApp {
42859
42938
  toggleTranscriptExpand: () => {
42860
42939
  if (this.fullscreen !== void 0 && this.focusModeEnabled) {
42861
42940
  if (this.hasVisibleExpandedFocusRoots()) {
42862
- this.clearUserMessageDisclosureOverrides();
42941
+ this.clearUserDisclosureOverrides();
42863
42942
  this.toggleFullscreenFocusRoots();
42864
- } else if (this.hasVisibleExpandedUserMessage()) {
42865
- this.clearUserMessageDisclosureOverrides();
42866
- this.rebuildMessages();
42867
- } else this.toggleFullscreenFocusRoots();
42943
+ } else if (this.hasVisibleExpandedUserDisclosure()) this.mutateTranscriptDisclosure(() => this.clearUserDisclosureOverrides());
42944
+ else this.toggleFullscreenFocusRoots();
42868
42945
  return true;
42869
42946
  }
42870
- if (this.toolOutputExpanded || this.hasVisibleExpandedUserMessage()) {
42947
+ if (this.toolOutputExpanded || this.hasVisibleExpandedUserDisclosure()) this.mutateTranscriptDisclosure(() => {
42871
42948
  this.toolOutputExpanded = false;
42872
- this.clearUserMessageDisclosureOverrides();
42873
- } else this.toolOutputExpanded = true;
42874
- this.rebuildMessages();
42949
+ this.clearUserDisclosureOverrides();
42950
+ });
42951
+ else this.mutateTranscriptDisclosure(() => {
42952
+ this.toolOutputExpanded = true;
42953
+ });
42875
42954
  return true;
42876
42955
  },
42877
42956
  toggleThinking: () => {
@@ -43271,7 +43350,7 @@ var TuiApp = class TuiApp {
43271
43350
  this.clearExitConfirmation();
43272
43351
  this.keybindings.cancelLeader();
43273
43352
  if (enabled === (this.fullscreen !== void 0)) return;
43274
- if (enabled && !this.userDisclosureAffordanceAvailable()) this.clearUserMessageDisclosureOverrides();
43353
+ if (enabled && !this.userDisclosureAffordanceAvailable()) this.clearUserDisclosureOverrides();
43275
43354
  this.clearFocusLiveHeightState();
43276
43355
  this.disposeTrackedKeybindingEditors();
43277
43356
  for (const close of [...this.footerConfiguratorClosers]) close();
@@ -43309,6 +43388,7 @@ var TuiApp = class TuiApp {
43309
43388
  shouldShowScrollToEndIndicator: () => this.transcriptWindow?.mode === "history",
43310
43389
  onScrollToEndIndicator: () => this.events.onTranscriptJumpLatest?.() === true,
43311
43390
  copySelection: this.copySelection,
43391
+ selectionLineText: (context) => this.selectionLineText(context),
43312
43392
  openUrl: this.openExternalUrl,
43313
43393
  onRightClickPaste: this.readClipboardText === void 0 ? void 0 : () => {
43314
43394
  this.rightClickPasteFromClipboard();
@@ -43725,30 +43805,38 @@ var TuiApp = class TuiApp {
43725
43805
  }
43726
43806
  return entry.rendererId === void 0;
43727
43807
  }
43728
- /** Whether the CURRENT projection shows a long user message that is
43808
+ /** Whether the CURRENT projection shows a long user disclosure that is
43729
43809
  * ACTUALLY compacted and explicitly expanded (the Ctrl+O user-collapse
43730
- * target, plan §6.4/§21.7). Only VISIBLE, HOST-rendered, compact-capable
43731
- * messages count: a parked override on a windowed-away message, a stale
43732
- * override on a short/resized-short bubble with no visible effect, or an
43733
- * override on a plugin-owned presentation must not consume the Ctrl+O
43734
- * press and wedge the toggle into a no-op. */
43735
- hasVisibleExpandedUserMessage() {
43810
+ * target, plan §6.4/§21.7) — a durable message OR an ephemeral pending row.
43811
+ * Only VISIBLE, HOST-rendered, compact-capable entries count: a parked
43812
+ * override on a windowed-away message, a stale override on a
43813
+ * short/resized-short bubble with no visible effect, or an override on a
43814
+ * plugin-owned presentation must not consume the Ctrl+O press and wedge the
43815
+ * toggle into a no-op. */
43816
+ hasVisibleExpandedUserDisclosure() {
43736
43817
  for (const message of this.messages) {
43737
43818
  if (!isUserMessageDisclosureCandidate(message)) continue;
43738
43819
  if (this.expandedOverride.get(message) !== true) continue;
43739
43820
  if (!this.isHostUserDisclosure(message)) continue;
43740
43821
  if (this.userMessageCompactsAtCurrentWidth(message)) return true;
43741
43822
  }
43823
+ for (const row of this.pendingUserRows) {
43824
+ if (row.foldableText !== true) continue;
43825
+ if (this.pendingUserExpanded.get(pendingUserDisclosureKey(row)) !== true) continue;
43826
+ if (this.pendingUserCompactsAtCurrentWidth(row)) return true;
43827
+ }
43742
43828
  return false;
43743
43829
  }
43744
43830
  /** Clear every long-user disclosure override (the Ctrl+O user-collapse
43745
- * pass). It filters by the user-disclosure classification ONLY — thinking,
43746
- * tool, system and compaction overrides are never touched. */
43747
- clearUserMessageDisclosureOverrides() {
43831
+ * pass): durable message overrides AND ephemeral pending-row overrides. It
43832
+ * filters by the user-disclosure classification ONLY — thinking, tool,
43833
+ * system and compaction overrides are never touched. */
43834
+ clearUserDisclosureOverrides() {
43748
43835
  for (const message of [...this.expandedOverride.keys()]) {
43749
43836
  if (!isUserMessageDisclosureCandidate(message)) continue;
43750
43837
  this.expandedOverride.delete(message);
43751
43838
  }
43839
+ this.pendingUserExpanded.clear();
43752
43840
  }
43753
43841
  /** The eligible Focus roots for Ctrl+O bulk expansion: turns with a
43754
43842
  * real TurnActivity that are CURRENTLY PROJECTED (present in the folded
@@ -43925,6 +44013,27 @@ var TuiApp = class TuiApp {
43925
44013
  const welcomeHeight = this.welcomeCard.render(width).length;
43926
44014
  this.fullscreenScroll.scrollTo(welcomeHeight + transcriptRow - 1, { disableFollow: true });
43927
44015
  }
44016
+ /** The single long-user disclosure transition (plan §10): capture the
44017
+ * fullscreen viewport intent, mutate the disclosure state, rebuild once, and
44018
+ * restore the SAME visual intent. A user who was following the live tail
44019
+ * keeps following it (never a historical browse); a historical reader is
44020
+ * restored to the SAME semantic row (durable message identity or the stable
44021
+ * pending key) and viewport offset — never a raw delta-height scrollTop.
44022
+ * Regular mode (no fullscreen scroll view) just rebuilds. Focus root
44023
+ * transitions own their own `anchor-turn` contract and never route here. */
44024
+ mutateTranscriptDisclosure(mutate) {
44025
+ const scroll = this.fullscreenScroll;
44026
+ const wasFollowingEnd = scroll?.isFollowingEnd === true;
44027
+ const anchor = scroll === void 0 || wasFollowingEnd ? void 0 : this.captureTranscriptViewportAnchor();
44028
+ mutate();
44029
+ this.clearFocusLiveHeightState();
44030
+ this.rebuildMessages();
44031
+ if (scroll !== void 0) {
44032
+ if (wasFollowingEnd) this.applyFullscreenFollowEndViewport();
44033
+ else if (anchor !== void 0) this.restoreTranscriptViewportAnchor(anchor, "top");
44034
+ }
44035
+ this.requestRender();
44036
+ }
43928
44037
  /** The projected transcript row (in `messageRows` coordinates, welcome
43929
44038
  * card excluded) where a turn's Thought header starts. Returns undefined
43930
44039
  * when the turn is not currently projected (e.g. Focus off, or the turn
@@ -44176,7 +44285,7 @@ var TuiApp = class TuiApp {
44176
44285
  let attachments = [];
44177
44286
  let subCallHits;
44178
44287
  let workflowHits;
44179
- let userDisclosureRow;
44288
+ let userDisclosureHit;
44180
44289
  const collapseFocusOwnerOnClick = this.focusOwnerForRenderBlock(block);
44181
44290
  if (block.kind === "activity") {
44182
44291
  component = this.focusActivityComponentFor(block.activity, projectionExpanded.has(block.activity.turn), this.focusToolDisplayFor(block.activity), this.focusModeEnabled && !projectionExpanded.has(block.activity.turn) ? focusPreparingSummary(this.streamingToolPreviewsForTurn(block.activity.turn)) : void 0);
@@ -44185,14 +44294,21 @@ var TuiApp = class TuiApp {
44185
44294
  component = this.streamingToolPreviewComponent(block.previews, width);
44186
44295
  rendered = component.render(width);
44187
44296
  } else if (block.kind === "pending-user") {
44188
- component = pendingUserComponent(block.row, this.queueRunning);
44297
+ component = this.pendingUserComponentFor(block.row);
44189
44298
  rendered = component.render(width);
44299
+ userDisclosureHit = this.userDisclosureHitFor(component, rendered, truncatedMarker, {
44300
+ kind: "pending",
44301
+ key: pendingUserDisclosureKey(block.row)
44302
+ });
44190
44303
  } else {
44191
44304
  component = this.componentForMessage(block.message, boundary, width, userBoundary);
44192
44305
  rendered = component.render(width);
44193
44306
  truncatedMarker = block.truncated === true;
44194
44307
  attachments = this.attachmentRangesOf(component, width);
44195
- if (component instanceof UserBubbleComponent) userDisclosureRow = component.compactMarkerRow();
44308
+ if (isUserMessageDisclosureCandidate(block.message)) userDisclosureHit = this.userDisclosureHitFor(component, rendered, truncatedMarker, {
44309
+ kind: "durable",
44310
+ message: block.message
44311
+ });
44196
44312
  const subCallInfo = this.subCallHitsByMessage.get(block.message);
44197
44313
  subCallHits = subCallInfo === void 0 ? void 0 : subCallInfo.hits.map((hit) => ({
44198
44314
  ...hit,
@@ -44213,10 +44329,36 @@ var TuiApp = class TuiApp {
44213
44329
  ...collapseFocusOwnerOnClick === void 0 ? {} : { collapseFocusOwnerOnClick },
44214
44330
  ...subCallHits === void 0 ? {} : { subCallHits },
44215
44331
  ...workflowHits === void 0 ? {} : { workflowHits },
44216
- ...userDisclosureRow === void 0 ? {} : { userDisclosureRow }
44332
+ ...userDisclosureHit === void 0 ? {} : { userDisclosureHit }
44217
44333
  };
44218
44334
  });
44219
44335
  }
44336
+ /** The ONE bidirectional disclosure control of a long-user bubble (durable
44337
+ * or pending): the compact marker while collapsed, the tail row while an
44338
+ * expanded compact-capable bubble needs one. The tail is FULLSCREEN-only:
44339
+ * regular draws into the terminal main screen, where an app-owned copy
44340
+ * filter cannot exist, so a visible label there WOULD be copied by the
44341
+ * terminal's native selection — Ctrl+O stays the regular collapse owner. In
44342
+ * fullscreen the tail row is the trailing separator row when one follows,
44343
+ * or one dedicated presentation row charged to the final block (the height
44344
+ * rule mirrors it). */
44345
+ userDisclosureHitFor(component, rendered, truncatedMarker, target) {
44346
+ const bubble = userDisclosureComponentOf(component);
44347
+ if (bubble === void 0) return void 0;
44348
+ const markerRow = bubble.compactMarkerRow();
44349
+ if (markerRow !== void 0) return {
44350
+ row: markerRow,
44351
+ action: "expand",
44352
+ target
44353
+ };
44354
+ if (this.fullscreen === void 0) return void 0;
44355
+ if (!bubble.showsCollapseControl()) return void 0;
44356
+ return {
44357
+ row: rendered.length + (truncatedMarker ? 1 : 0),
44358
+ action: "collapse",
44359
+ target
44360
+ };
44361
+ }
44220
44362
  /**
44221
44363
  * Re-measure an already-mounted transcript batch without touching component
44222
44364
  * lifecycle: the SAME `entry.component` instances are re-rendered at the
@@ -44227,12 +44369,26 @@ var TuiApp = class TuiApp {
44227
44369
  remeasureTranscriptBlocks(mounted, width) {
44228
44370
  return mounted.map((entry) => {
44229
44371
  const rendered = entry.component.render(width);
44372
+ if (entry.block.kind === "pending-user") {
44373
+ const userDisclosureHit = this.userDisclosureHitFor(entry.component, rendered, entry.truncatedMarker, {
44374
+ kind: "pending",
44375
+ key: pendingUserDisclosureKey(entry.block.row)
44376
+ });
44377
+ return {
44378
+ ...entry,
44379
+ rendered,
44380
+ ...userDisclosureHit === void 0 ? { userDisclosureHit: void 0 } : { userDisclosureHit }
44381
+ };
44382
+ }
44230
44383
  if (entry.block.kind !== "message") return {
44231
44384
  ...entry,
44232
44385
  rendered
44233
44386
  };
44234
44387
  const attachments = this.attachmentRangesOf(entry.component, width);
44235
- const userDisclosureRow = entry.component instanceof UserBubbleComponent ? entry.component.compactMarkerRow() : void 0;
44388
+ const userDisclosureHit = isUserMessageDisclosureCandidate(entry.block.message) ? this.userDisclosureHitFor(entry.component, rendered, entry.truncatedMarker, {
44389
+ kind: "durable",
44390
+ message: entry.block.message
44391
+ }) : void 0;
44236
44392
  const subCallInfo = this.subCallHitsByMessage.get(entry.block.message);
44237
44393
  const subCallHits = subCallInfo === void 0 ? void 0 : subCallInfo.hits.map((hit) => ({
44238
44394
  ...hit,
@@ -44249,7 +44405,7 @@ var TuiApp = class TuiApp {
44249
44405
  attachments,
44250
44406
  ...subCallHits === void 0 ? { subCallHits: void 0 } : { subCallHits },
44251
44407
  ...workflowHits === void 0 ? { workflowHits: void 0 } : { workflowHits },
44252
- ...userDisclosureRow === void 0 ? { userDisclosureRow: void 0 } : { userDisclosureRow }
44408
+ ...userDisclosureHit === void 0 ? { userDisclosureHit: void 0 } : { userDisclosureHit }
44253
44409
  };
44254
44410
  });
44255
44411
  }
@@ -44263,10 +44419,16 @@ var TuiApp = class TuiApp {
44263
44419
  return "turn" in block.message ? block.message.turn : void 0;
44264
44420
  }
44265
44421
  /** The normal physical height charged to one rendered block, preserving the
44266
- * existing zero-row and original-index Spacer rules exactly. */
44422
+ * existing zero-row and original-index Spacer rules exactly. An expanded
44423
+ * long-user tail control REUSES the trailing separator row when one
44424
+ * follows; for the final block it is one dedicated presentation row, so the
44425
+ * height must charge it here (the row map and the mounted tree share this
44426
+ * single rule). */
44267
44427
  normalTranscriptBlockHeight(entry, index, total) {
44268
44428
  if (entry.rendered.length === 0 && !entry.truncatedMarker) return 0;
44269
- return entry.rendered.length + (entry.truncatedMarker ? 1 : 0) + (index < total - 1 ? 1 : 0);
44429
+ const trailing = index < total - 1 ? 1 : 0;
44430
+ const tailControl = entry.userDisclosureHit?.action === "collapse" && trailing === 0 ? 1 : 0;
44431
+ return entry.rendered.length + (entry.truncatedMarker ? 1 : 0) + trailing + tailControl;
44270
44432
  }
44271
44433
  /** Drop presentation floors and their measured padding. */
44272
44434
  clearFocusLiveHeightState() {
@@ -44341,12 +44503,13 @@ var TuiApp = class TuiApp {
44341
44503
  const block = entry.block;
44342
44504
  return {
44343
44505
  ...block.kind === "message" ? { message: block.message } : block.kind === "activity" ? { activity: block.activity } : {},
44506
+ ...block.kind === "pending-user" ? { pendingKey: pendingUserDisclosureKey(block.row) } : {},
44344
44507
  ...entry.collapseFocusOwnerOnClick === void 0 ? {} : { collapseFocusOwnerOnClick: entry.collapseFocusOwnerOnClick },
44345
44508
  height,
44346
44509
  attachments: entry.attachments,
44347
44510
  ...entry.subCallHits === void 0 ? {} : { subCallHits: entry.subCallHits },
44348
44511
  ...entry.workflowHits === void 0 ? {} : { workflowHits: entry.workflowHits },
44349
- ...entry.userDisclosureRow === void 0 ? {} : { userDisclosureRow: entry.userDisclosureRow },
44512
+ ...entry.userDisclosureHit === void 0 ? {} : { userDisclosureHit: entry.userDisclosureHit },
44350
44513
  hasTrailingSpacer
44351
44514
  };
44352
44515
  }
@@ -44372,7 +44535,8 @@ var TuiApp = class TuiApp {
44372
44535
  }
44373
44536
  const hasTrailingSpacer = index < renderedBlocks.length - 1;
44374
44537
  rows.push(this.fullscreenRowEntry(entry, height, hasTrailingSpacer));
44375
- if (hasTrailingSpacer) this.messagesView.addChild(new Spacer());
44538
+ if (entry.userDisclosureHit?.action === "collapse") this.messagesView.addChild(new TranscriptGutterComponent(this.userCollapseControlText(width)));
44539
+ else if (hasTrailingSpacer) this.messagesView.addChild(new Spacer());
44376
44540
  }
44377
44541
  const padding = paddingRows.get(index) ?? 0;
44378
44542
  if (padding > 0) {
@@ -44575,19 +44739,20 @@ var TuiApp = class TuiApp {
44575
44739
  let rowTop = welcomeHeight;
44576
44740
  let candidate;
44577
44741
  for (const entry of this.messageRows) {
44578
- const rowKind = entry.activity === void 0 ? "message" : "activity";
44579
- const turn = entry.message !== void 0 && "turn" in entry.message ? entry.message.turn : entry.activity?.turn;
44580
- const occurrenceKey = turn === void 0 ? void 0 : `${rowKind}:${turn}`;
44742
+ const rowKind = entry.pendingKey !== void 0 ? "pending-user" : entry.activity === void 0 ? "message" : "activity";
44743
+ const turn = entry.pendingKey !== void 0 ? void 0 : entry.message !== void 0 && "turn" in entry.message ? entry.message.turn : entry.activity?.turn;
44744
+ const occurrenceKey = entry.pendingKey !== void 0 ? `pending-user:${entry.pendingKey}` : turn === void 0 ? void 0 : `${rowKind}:${turn}`;
44581
44745
  const occurrence = occurrenceKey === void 0 ? 0 : occurrences.get(occurrenceKey) ?? 0;
44582
44746
  if (occurrenceKey !== void 0) occurrences.set(occurrenceKey, occurrence + 1);
44583
- if (entry.height > 0 && rowTop + entry.height > viewportTop && rowTop <= viewportBottom && turn !== void 0) {
44747
+ if (entry.height > 0 && rowTop + entry.height > viewportTop && rowTop <= viewportBottom && (turn !== void 0 || entry.pendingKey !== void 0)) {
44584
44748
  const line = fromTop ? Math.max(viewportTop, rowTop) : Math.min(viewportBottom, rowTop + entry.height - 1);
44585
44749
  const point = {
44586
- turn,
44587
44750
  rowKind,
44751
+ ...turn === void 0 ? {} : { turn },
44588
44752
  occurrence,
44589
44753
  ...entry.message === void 0 ? {} : { message: entry.message },
44590
44754
  ...entry.activity === void 0 ? {} : { activity: entry.activity },
44755
+ ...entry.pendingKey === void 0 ? {} : { pendingKey: entry.pendingKey },
44591
44756
  rowOffset: line - rowTop,
44592
44757
  viewportOffset: line - viewportTop
44593
44758
  };
@@ -44621,9 +44786,9 @@ var TuiApp = class TuiApp {
44621
44786
  const occurrences = /* @__PURE__ */ new Map();
44622
44787
  let rowTop = welcomeHeight;
44623
44788
  for (const entry of this.messageRows) {
44624
- const rowKind = entry.activity === void 0 ? "message" : "activity";
44625
- const turn = entry.message !== void 0 && "turn" in entry.message ? entry.message.turn : entry.activity?.turn;
44626
- const occurrenceKey = turn === void 0 ? void 0 : `${rowKind}:${turn}`;
44789
+ const rowKind = entry.pendingKey !== void 0 ? "pending-user" : entry.activity === void 0 ? "message" : "activity";
44790
+ const turn = entry.pendingKey !== void 0 ? void 0 : entry.message !== void 0 && "turn" in entry.message ? entry.message.turn : entry.activity?.turn;
44791
+ const occurrenceKey = entry.pendingKey !== void 0 ? `pending-user:${entry.pendingKey}` : turn === void 0 ? void 0 : `${rowKind}:${turn}`;
44627
44792
  const occurrence = occurrenceKey === void 0 ? 0 : occurrences.get(occurrenceKey) ?? 0;
44628
44793
  if (occurrenceKey !== void 0) occurrences.set(occurrenceKey, occurrence + 1);
44629
44794
  if (entry.height <= 0) {
@@ -44634,20 +44799,28 @@ var TuiApp = class TuiApp {
44634
44799
  top: rowTop,
44635
44800
  height: entry.height
44636
44801
  };
44802
+ if (point.rowKind === "pending-user") {
44803
+ if (entry.pendingKey !== void 0 && entry.pendingKey === point.pendingKey) return located;
44804
+ rowTop += entry.height;
44805
+ continue;
44806
+ }
44637
44807
  if (point.rowKind === rowKind && (point.message !== void 0 ? entry.message === point.message : point.activity !== void 0 && entry.activity === point.activity)) return located;
44638
44808
  if (turn === point.turn && rowKind === point.rowKind && occurrence === point.occurrence) return located;
44639
44809
  rowTop += entry.height;
44640
44810
  }
44641
44811
  };
44642
44812
  const points = edge === "top" ? [anchor.top, anchor.bottom] : [anchor.bottom, anchor.top];
44813
+ let sawPendingPoint = false;
44643
44814
  for (const point of points) {
44644
44815
  if (point === void 0) continue;
44816
+ if (point.rowKind === "pending-user") sawPendingPoint = true;
44645
44817
  const row = rowFor(point);
44646
44818
  if (row === void 0) continue;
44647
44819
  const rowOffset = Math.max(0, Math.min(row.height - 1, point.rowOffset));
44648
44820
  scroll.scrollTo(row.top + rowOffset - point.viewportOffset, { disableFollow: true });
44649
44821
  return true;
44650
44822
  }
44823
+ if (sawPendingPoint) return false;
44651
44824
  scroll.scrollTo(anchor.scrollTop, { disableFollow: true });
44652
44825
  return false;
44653
44826
  }
@@ -44765,11 +44938,19 @@ var TuiApp = class TuiApp {
44765
44938
  const scroll = this.fullscreenScroll;
44766
44939
  const paintedHeight = (component) => this.fullscreen?.getPaintedBox(component)?.height ?? 0;
44767
44940
  this.refreshMessageRows();
44941
+ const welcomeHeight = this.welcomeCard.lastRenderedHeight;
44942
+ const copyBlankRows = /* @__PURE__ */ new Set();
44943
+ let rowTop = welcomeHeight;
44944
+ for (const entry of this.messageRows) {
44945
+ const hit = entry.userDisclosureHit;
44946
+ if (hit !== void 0 && hit.action === "collapse") copyBlankRows.add(rowTop + hit.row);
44947
+ rowTop += entry.height;
44948
+ }
44768
44949
  this.fullscreenPaintSnapshot = {
44769
44950
  columns: this.terminal.columns,
44770
44951
  termRows: this.terminal.rows,
44771
44952
  headerHeight: paintedHeight(this.header),
44772
- welcomeHeight: this.welcomeCard.lastRenderedHeight,
44953
+ welcomeHeight,
44773
44954
  footerHeight: paintedHeight(this.footer),
44774
44955
  editorHeight: paintedHeight(this.editorSeat),
44775
44956
  workingHeight: paintedHeight(this.working),
@@ -44783,7 +44964,8 @@ var TuiApp = class TuiApp {
44783
44964
  ownerId: this.fullscreenRowOwnerId(entry, index),
44784
44965
  height: entry.height,
44785
44966
  hits: this.fullscreenRowHits(entry, index)
44786
- }))
44967
+ })),
44968
+ copyBlankRows
44787
44969
  };
44788
44970
  }
44789
44971
  /** The per-row SEMANTIC hit identities of one painted transcript block
@@ -44809,13 +44991,16 @@ var TuiApp = class TuiApp {
44809
44991
  }
44810
44992
  /** The per-row SEMANTIC hit identity of one transcript row (mouse
44811
44993
  * parity): the row's ACTUAL action target, following the click-path
44812
- * priority EXACTLY (blank-row escape hatch > Focus activity > PTC
44813
- * sub-call > Workflow > attachment > Focus-secondary/card). A row
44814
- * inside a Workflow card that matches no hit is INERT (the card
44815
- * consumes every click — it must never fall through to the owner/card
44816
- * branch). Shared by the paint-snapshot commit and the release-click
44817
- * validation so both sides compute the EXACT same identity string. */
44994
+ * priority EXACTLY (long-user disclosure > blank-row escape hatch > Focus
44995
+ * activity > PTC sub-call > Workflow > attachment > Focus-secondary/card).
44996
+ * The long-user control wins first because it shares the trailing separator
44997
+ * row with the blank-row escape hatch; a row inside a Workflow card that
44998
+ * matches no hit is INERT (the card consumes every click — it must never
44999
+ * fall through to the owner/card branch). Shared by the paint-snapshot
45000
+ * commit and the release-click validation so both sides compute the EXACT
45001
+ * same identity string. */
44818
45002
  fullscreenRowHitIdentity(entry, inMessage, nextVisible) {
45003
+ if (entry.userDisclosureHit !== void 0) return inMessage === entry.userDisclosureHit.row ? this.userDisclosureHitIdentity(entry.userDisclosureHit) : "inert";
44819
45004
  if (entry.hasTrailingSpacer && inMessage === entry.height - 1) {
44820
45005
  const owner = this.blankRowFocusCollapseOwner(entry, nextVisible);
44821
45006
  return owner !== void 0 ? `focus:collapse:${owner}` : "inert";
@@ -44834,13 +45019,21 @@ var TuiApp = class TuiApp {
44834
45019
  return "inert";
44835
45020
  }
44836
45021
  for (const attachment of entry.attachments) if (inMessage >= attachment.start && inMessage < attachment.end) return `attachment:${token}:${attachment.imageIndex}`;
44837
- if (entry.userDisclosureRow !== void 0) return inMessage === entry.userDisclosureRow ? `user:expand:${token}` : "inert";
44838
45022
  if (entry.collapseFocusOwnerOnClick !== void 0) {
44839
45023
  if (isFocusSecondaryDisclosure(message)) return `message:toggle:${token}`;
44840
45024
  return `focus:collapse:${entry.collapseFocusOwnerOnClick}`;
44841
45025
  }
44842
45026
  return `message:toggle:${token}`;
44843
45027
  }
45028
+ /** The press/release semantic identity of one long-user disclosure control:
45029
+ * the durable message token or the stable pending key, ALWAYS qualified by
45030
+ * the direction. A stale press must never transfer an expand target to a
45031
+ * collapse target (or to a different pending row) that repainted onto the
45032
+ * same cell. */
45033
+ userDisclosureHitIdentity(hit) {
45034
+ if (hit.target.kind === "durable") return `user:${hit.action}:${this.identityToken(hit.target.message)}`;
45035
+ return `pending-user:${hit.action}:${hit.target.key}`;
45036
+ }
44844
45037
  /** The semantic identity of one Workflow card row hit (the durable
44845
45038
  * run/phase/member/agents target the click acts on). */
44846
45039
  workflowHitIdentity(hit) {
@@ -44874,12 +45067,12 @@ var TuiApp = class TuiApp {
44874
45067
  return token;
44875
45068
  }
44876
45069
  /** The press-time semantic identity of a transcript row (mouse parity):
44877
- * the message/activity owner (kind + turn + the per-object identity
44878
- * token, or the entry index for ownerless rows). Shared by the
44879
- * paint-snapshot commit and the release-click validation so both sides
44880
- * compute the EXACT same identity string. */
45070
+ * the message/activity/pending-user owner (kind + turn + the per-object
45071
+ * identity token, the stable pending key, or the entry index for truly
45072
+ * ownerless rows). Shared by the paint-snapshot commit and the release-click
45073
+ * validation so both sides compute the EXACT same identity string. */
44881
45074
  fullscreenRowOwnerId(entry, entryIndex) {
44882
- return entry.message !== void 0 ? `msg:${entry.message.kind}:${"turn" in entry.message ? entry.message.turn : 0}:${this.identityToken(entry.message)}` : entry.activity !== void 0 ? `activity:${entry.activity.turn}:${this.identityToken(entry.activity)}` : `entry:${entryIndex}`;
45075
+ return entry.message !== void 0 ? `msg:${entry.message.kind}:${"turn" in entry.message ? entry.message.turn : 0}:${this.identityToken(entry.message)}` : entry.activity !== void 0 ? `activity:${entry.activity.turn}:${this.identityToken(entry.activity)}` : entry.pendingKey !== void 0 ? `pending-user:${entry.pendingKey}` : `entry:${entryIndex}`;
44883
45076
  }
44884
45077
  /** Resolve a fullscreen physical row to a transcript cell (the entry
44885
45078
  * index + its block-relative row) against a GIVEN projection — a pure
@@ -45055,6 +45248,10 @@ var TuiApp = class TuiApp {
45055
45248
  }
45056
45249
  this.fullscreenCellGesture = void 0;
45057
45250
  {
45251
+ if (entry.userDisclosureHit !== void 0) {
45252
+ if (inMessage === entry.userDisclosureHit.row) this.applyUserDisclosureHit(entry.userDisclosureHit);
45253
+ return;
45254
+ }
45058
45255
  if (entry.hasTrailingSpacer && inMessage === entry.height - 1) {
45059
45256
  if (this.terminal.columns !== this.lastPaintColumns || this.terminal.rows !== this.lastPaintRows) return;
45060
45257
  let next;
@@ -45075,10 +45272,6 @@ var TuiApp = class TuiApp {
45075
45272
  }
45076
45273
  const message = entry.message;
45077
45274
  if (message === void 0) return;
45078
- if (entry.userDisclosureRow !== void 0) {
45079
- if (inMessage === entry.userDisclosureRow) this.expandUserMessage(message);
45080
- return;
45081
- }
45082
45275
  if (entry.subCallHits !== void 0) {
45083
45276
  const hit = entry.subCallHits.find((candidate) => inMessage >= candidate.top && inMessage < candidate.top + candidate.height);
45084
45277
  if (hit !== void 0) {
@@ -45124,15 +45317,22 @@ var TuiApp = class TuiApp {
45124
45317
  if (turn === void 0 || !this.focusExpandedTurns.has(turn)) return void 0;
45125
45318
  return (next?.activity?.turn ?? next?.collapseFocusOwnerOnClick) === turn ? turn : void 0;
45126
45319
  }
45127
- /** Expand one collapsed long user message (fullscreen compact-marker
45128
- * click). The marker only exists while collapsed, so this is expand-only;
45129
- * the override reuses the same per-message disclosure state as every other
45130
- * card, and the canonical `message.text` is never touched. */
45131
- expandUserMessage(message) {
45132
- if (this.expandedOverride.get(message) === true) return;
45133
- this.expandedOverride.set(message, true);
45134
- this.clearFocusLiveHeightState();
45135
- this.rebuildMessages();
45320
+ /** Apply one bidirectional long-user disclosure control click (fullscreen
45321
+ * compact marker → expand, expanded tail control → collapse). The durable
45322
+ * override reuses the per-message disclosure state; the pending override is
45323
+ * presentation-only ephemeral state keyed by the stable pending identity.
45324
+ * The canonical `message.text` is never touched. */
45325
+ applyUserDisclosureHit(hit) {
45326
+ const expanded = hit.action === "expand";
45327
+ this.mutateTranscriptDisclosure(() => {
45328
+ if (hit.target.kind === "durable") {
45329
+ if (this.expandedOverride.get(hit.target.message) === expanded) return;
45330
+ this.expandedOverride.set(hit.target.message, expanded);
45331
+ } else {
45332
+ if (this.pendingUserExpanded.get(hit.target.key) === expanded) return;
45333
+ this.pendingUserExpanded.set(hit.target.key, expanded);
45334
+ }
45335
+ });
45136
45336
  }
45137
45337
  /** Toggle one collapsible message's individual expansion (mouse click).
45138
45338
  * The same foldable set as the render rule (plan §32 — never two
@@ -45371,6 +45571,7 @@ var TuiApp = class TuiApp {
45371
45571
  clearSessionOverrides() {
45372
45572
  this.clearFocusLiveHeightState();
45373
45573
  this.expandedOverride.clear();
45574
+ this.pendingUserExpanded.clear();
45374
45575
  this.fullscreenCellGesture = void 0;
45375
45576
  this.todoClickCoalesceUntil = 0;
45376
45577
  this.focusExpandedTurns.clear();
@@ -46819,7 +47020,7 @@ var TuiApp = class TuiApp {
46819
47020
  messageRenderState(message, boundary, userBoundary) {
46820
47021
  const expanded = this.effectiveMessageExpanded(message, boundary, userBoundary);
46821
47022
  const insideFocusSecondary = "turn" in message && this.isInsideExpandedFocus(message, boundary) && isFocusSecondaryDisclosure(message);
46822
- const expandHint = message.kind === "thinking" ? this.fullscreen !== void 0 ? "click" : "thinking" : isUserMessageDisclosureCandidate(message) ? this.fullscreen === void 0 ? "fold" : this.focusModeEnabled ? "click" : "click-fold" : insideFocusSecondary ? this.fullscreen !== void 0 ? "click" : void 0 : "fold";
47023
+ const expandHint = message.kind === "thinking" ? this.fullscreen !== void 0 ? "click" : "thinking" : isUserMessageDisclosureCandidate(message) ? this.userFoldHint() : insideFocusSecondary ? this.fullscreen !== void 0 ? "click" : void 0 : "fold";
46823
47024
  return {
46824
47025
  expanded,
46825
47026
  fullReveal: this.expandedOverride.get(message) === true || this.fullscreen === void 0 && insideFocusSecondary,
@@ -47093,12 +47294,13 @@ var TuiApp = class TuiApp {
47093
47294
  renderMessage(message, expanded, expandHint, fullReveal, width) {
47094
47295
  if (message.kind === "user") {
47095
47296
  if (message.content !== void 0 && message.content.some((block) => block.type !== "text")) return this.renderUserBlocks(message.content, message);
47096
- if (!expanded && this.userDisclosureAffordanceAvailable()) {
47297
+ if (this.userDisclosureAffordanceAvailable()) {
47097
47298
  const hint = expandHint;
47098
47299
  return new UserBubbleComponent(new Text(message.text, 0, 0), `${color.roleUser("❯")} `, color.roleUserBg, {
47099
47300
  thresholdRows: USER_MESSAGE_COMPACT_THRESHOLD_ROWS,
47100
47301
  headRows: USER_MESSAGE_HEAD_ROWS,
47101
47302
  tailRows: USER_MESSAGE_TAIL_ROWS,
47303
+ expanded,
47102
47304
  compactMarker: (hiddenRows, available) => this.userCompactMarker(hiddenRows, available, hint)
47103
47305
  });
47104
47306
  }
@@ -48451,6 +48653,10 @@ var TuiApp = class TuiApp {
48451
48653
  this.queueItems = presentation.queued;
48452
48654
  this.pendingUserRows = presentation.steering;
48453
48655
  this.queueRunning = presentation.running;
48656
+ if (this.pendingUserExpanded.size > 0) {
48657
+ const liveKeys = new Set(presentation.steering.map(pendingUserDisclosureKey));
48658
+ for (const key of [...this.pendingUserExpanded.keys()]) if (!liveKeys.has(key)) this.pendingUserExpanded.delete(key);
48659
+ }
48454
48660
  this.projectActivity();
48455
48661
  this.renderQueuePane();
48456
48662
  this.rebuildMessages();
@@ -48864,6 +49070,63 @@ var TuiApp = class TuiApp {
48864
49070
  if (visibleWidth(full) <= availableWidth) return color.textDim(full);
48865
49071
  return color.textDim(`── ${hiddenRows} rows compacted ──`);
48866
49072
  }
49073
+ /** The long-user fold-hint owner for the CURRENT surface: regular is the
49074
+ * Ctrl+O recent-turn master; fullscreen without Focus offers the compact
49075
+ * marker click AND the effective expand key; fullscreen Focus is click-only
49076
+ * (Ctrl+O owns the Thought-root bulk there). Shared by the durable bubble
49077
+ * and the ephemeral pending lane so their labels never diverge. */
49078
+ userFoldHint() {
49079
+ return this.fullscreen === void 0 ? "fold" : this.focusModeEnabled ? "click" : "click-fold";
49080
+ }
49081
+ /** The expanded long-user tail control: presentation chrome naming the
49082
+ * collapse affordance of the CURRENT surface. Right-aligned within the
49083
+ * transcript content width; a narrow width truncates instead of wrapping. */
49084
+ userCollapseControlText(width) {
49085
+ const label = `▴ Collapse · ${this.expandHint(this.userFoldHint())}`;
49086
+ const clipped = visibleWidth(label) <= width ? label : truncateToWidth(label, width, "…");
49087
+ const pad = " ".repeat(Math.max(0, width - visibleWidth(clipped)));
49088
+ return new Text(color.textDim(pad + clipped), 0, 0);
49089
+ }
49090
+ /** The copy-source filter of one selected row: the expanded long-user tail
49091
+ * control is presentation chrome that must never reach the clipboard, and
49092
+ * its visual row copies as the blank separator it replaced (fork seam
49093
+ * X057). Paint, search, word/line selection and the mouse hit map are
49094
+ * untouched — only the copy source is filtered. The decision comes from the
49095
+ * LAST-PAINTED snapshot (the same epoch that produced the ScrollView's
49096
+ * `scrollContentLines`), never the live `messageRows`: a rebuild that has
49097
+ * not repainted yet must not reinterpret a painted row — otherwise the
49098
+ * painted tail chrome would leak, or a painted real row the newer
49099
+ * projection calls chrome would be wrongly blanked. */
49100
+ selectionLineText(context) {
49101
+ if (context.scrollView === void 0 || context.scrollView !== this.fullscreenScroll) return void 0;
49102
+ return this.fullscreenPaintSnapshot?.copyBlankRows.has(context.row) === true ? "" : void 0;
49103
+ }
49104
+ /** The effective disclosure state of one ephemeral pending-user row: the
49105
+ * explicit per-row override wins, otherwise the surface rule — fullscreen
49106
+ * Focus is compact-by-default (Ctrl+O owns the Thought roots there), every
49107
+ * other surface follows the Ctrl+O master exactly like a recent durable
49108
+ * user prompt. */
49109
+ pendingUserExpandedState(row) {
49110
+ const override = this.pendingUserExpanded.get(pendingUserDisclosureKey(row));
49111
+ if (override !== void 0) return override;
49112
+ if (this.fullscreen !== void 0 && this.focusModeEnabled) return false;
49113
+ return this.toolOutputExpanded;
49114
+ }
49115
+ /** Build the ephemeral pending-user component for this surface. A text-only
49116
+ * pending row joins the SAME visual-row compaction as a durable text-only
49117
+ * user bubble when this surface has a disclosure affordance; otherwise it
49118
+ * renders in full (never a dead marker). */
49119
+ pendingUserComponentFor(row) {
49120
+ if (row.foldableText !== true || !this.userDisclosureAffordanceAvailable()) return new PendingUserComponent(row, this.queueRunning);
49121
+ const hint = this.userFoldHint();
49122
+ return new PendingUserComponent(row, this.queueRunning, {
49123
+ thresholdRows: USER_MESSAGE_COMPACT_THRESHOLD_ROWS,
49124
+ headRows: USER_MESSAGE_HEAD_ROWS,
49125
+ tailRows: USER_MESSAGE_TAIL_ROWS,
49126
+ expanded: this.pendingUserExpandedState(row),
49127
+ compactMarker: (hiddenRows, available) => this.userCompactMarker(hiddenRows, available, hint)
49128
+ });
49129
+ }
48867
49130
  /** Whether the long-user fold has a usable expand affordance on THIS
48868
49131
  * surface. Fullscreen always does (the compact-marker click); regular needs
48869
49132
  * the effective `app.transcript.toggleExpand` key — compacting without one
@@ -48880,6 +49143,13 @@ var TuiApp = class TuiApp {
48880
49143
  const inner = Math.max(1, this.transcriptRenderWidth() - visibleWidth(`${color.roleUser("❯")} `));
48881
49144
  return new Text(message.text, 0, 0).render(inner).length > USER_MESSAGE_COMPACT_THRESHOLD_ROWS;
48882
49145
  }
49146
+ /** Whether one pending-user row ACTUALLY compacts at the current transcript
49147
+ * width (the same visual-row threshold the pending bubble renderer applies),
49148
+ * so a stale override on a short row never consumes the Ctrl+O press. */
49149
+ pendingUserCompactsAtCurrentWidth(row) {
49150
+ const inner = Math.max(1, this.transcriptRenderWidth() - visibleWidth(`${color.roleUser("❯")} `));
49151
+ return new Text(row.text, 0, 0).render(inner).length > USER_MESSAGE_COMPACT_THRESHOLD_ROWS;
49152
+ }
48883
49153
  /**
48884
49154
  * Install slash-command + file-path autocompletion on the editor, plus
48885
49155
  * `@`-file mentions through the Host-file port (migration M1.10 — the
@@ -54073,306 +54343,6 @@ var SessionOperationBarrier = class {
54073
54343
  }
54074
54344
  };
54075
54345
  //#endregion
54076
- //#region src/rewind.ts
54077
- /** Whether one user/message event is a DIRECT human prompt (the transcript's
54078
- * classification: `source.kind === 'user'`). Injected context, skill bodies,
54079
- * system reminders and goal continuations answer false. The single helper
54080
- * every rewind consumer must use — never scatter source-kind checks. */
54081
- function isHumanTurnMessage(event) {
54082
- return event.data.source.kind === "user";
54083
- }
54084
- /** Whether a message is empty for rewind purposes: mirror the
54085
- * transcript's finalized user-content visibility rule. */
54086
- function isEmptyMessage(blocks) {
54087
- return !userBlocksVisibleNow(blocks);
54088
- }
54089
- /** One-line, width-bounded preview of a prompt (whitespace collapsed). */
54090
- function singleLinePreview(text) {
54091
- const flat = text.replace(/\s+/g, " ").trim();
54092
- return flat.length > 120 ? `${flat.slice(0, 117)}…` : flat;
54093
- }
54094
- /**
54095
- * Derive the rewind candidates from a session's event log.
54096
- *
54097
- * Guarantees (plan §6.1):
54098
- * - only COMPLETED turns (a `turn/end` closed the span) are listed; the
54099
- * open turn is never a candidate;
54100
- * - newest turn first;
54101
- * - one candidate per turn (the turn's primary — first non-empty human
54102
- * `user/message`); injected/steer messages never form extra rows;
54103
- * - a malformed span (a new `turn/start` while one is open, an end without
54104
- * a start) is skipped, never thrown to the UI;
54105
- * - the first turn is a legal candidate (seed length 0);
54106
- * - no persistence, no side effects, no agent creation.
54107
- * @param events - the session log (chronological).
54108
- * @returns candidates newest-first.
54109
- */
54110
- function collectRewindCandidates(events) {
54111
- const candidates = [];
54112
- let open;
54113
- for (const event of events) if (event.type === "turn/start") open = {
54114
- turn: event.data.turn,
54115
- startSeq: event.seq
54116
- };
54117
- else if (event.type === "turn/end") {
54118
- const primary = open?.primary;
54119
- if (primary !== void 0) {
54120
- const blocks = primary.blocks;
54121
- candidates.push({
54122
- turnStartSeq: open.startSeq,
54123
- turn: open.turn,
54124
- messageSeq: primary.seq,
54125
- editorText: textOf(blocks),
54126
- preview: singleLinePreview(textOf(blocks)),
54127
- hasNonTextContent: blocks.some((block) => block.type !== "text")
54128
- });
54129
- }
54130
- open = void 0;
54131
- } else if (event.type === "user/message" && open !== void 0 && open.primary === void 0) {
54132
- const blocks = event.data.content;
54133
- if (isHumanTurnMessage(event) && !isEmptyMessage(blocks)) open.primary = {
54134
- seq: event.seq,
54135
- blocks
54136
- };
54137
- }
54138
- return candidates.reverse();
54139
- }
54140
- /**
54141
- * The seed for one candidate: EVERY event before the selected `turn/start`
54142
- * — including log-only state events between turns (a `todo/write`, a
54143
- * `permission/preset` record) — but never the `turn/start` itself.
54144
- * @param events - the session log the candidate was collected from.
54145
- * @param candidate - the selected rewind point.
54146
- * @returns the child session's seed events.
54147
- * @throws when the point no longer exists, or when the seed would end on
54148
- * an OPEN `turn/start` (malformed log — the fork boundary must be a
54149
- * completed turn).
54150
- */
54151
- function rewindSeed(events, candidate) {
54152
- const index = events.findIndex((event) => event.seq === candidate.turnStartSeq && event.type === "turn/start");
54153
- if (index < 0) throw new Error("rewind point no longer exists");
54154
- const seed = events.slice(0, index);
54155
- for (let i = seed.length - 1; i >= 0; i -= 1) {
54156
- const type = seed[i]?.type;
54157
- if (type === "turn/end") break;
54158
- if (type === "turn/start") throw new Error("rewind point is inside an open turn");
54159
- }
54160
- return seed;
54161
- }
54162
- /** One picker row for a candidate. The value is the `turnStartSeq` string;
54163
- * the selection resolves against the candidate list captured at open time
54164
- * (stale selections are rejected by the workflow's generation gates). */
54165
- function rewindPickerItem(candidate) {
54166
- const tag = candidate.hasNonTextContent ? "[attachment] " : "";
54167
- const preview = candidate.preview === "" && candidate.hasNonTextContent ? "(attachment only)" : candidate.preview;
54168
- return {
54169
- value: String(candidate.turnStartSeq),
54170
- label: `turn ${candidate.turn} · ${tag}${preview}`,
54171
- ...candidate.hasNonTextContent ? { description: "non-text content is not re-staged on rewind" } : {}
54172
- };
54173
- }
54174
- //#endregion
54175
- //#region src/runtime/session-lifecycle-port.ts
54176
- /** A lifecycle outcome that must ABORT the caller's transition, carrying the
54177
- * two independent axes so a Remote caller never has to parse a string. */
54178
- var LifecycleError = class extends Error {
54179
- settlement;
54180
- ownership;
54181
- publishedSessionId;
54182
- /** CORRELATION ONLY for an indeterminate create — never publication proof. */
54183
- requestedSessionId;
54184
- constructor(settlement, ownership, message, publishedSessionId, requestedSessionId) {
54185
- super(message);
54186
- this.name = "LifecycleError";
54187
- this.settlement = settlement;
54188
- this.ownership = ownership;
54189
- this.publishedSessionId = publishedSessionId;
54190
- this.requestedSessionId = requestedSessionId;
54191
- }
54192
- };
54193
- /** Unwrap a CREATE result for the runner's transition: a `created` result that
54194
- * still owns the surface yields the handle; everything else aborts with a
54195
- * machine-readable `LifecycleError` (a superseded create is NOT committed to
54196
- * the local surface, though its identity stays available). */
54197
- function requireCreated(result) {
54198
- const { ownership, outcome } = result;
54199
- switch (outcome.kind) {
54200
- case "created":
54201
- if (ownership === "superseded") throw new LifecycleError("superseded", ownership, `Session "${outcome.handle.session.id}" was created, but the local surface was superseded — do not retry creation`, outcome.handle.session.id);
54202
- return outcome.handle;
54203
- case "published-with-error": throw new LifecycleError(outcome.kind, ownership, `Session "${outcome.sessionId}" was published, but workspace attach/reconcile failed: ${outcome.error.message} (${outcome.error.code}) — do not retry creation`, outcome.sessionId);
54204
- case "indeterminate": throw new LifecycleError(outcome.kind, ownership, `the create is indeterminate — do not retry: ${outcome.error.message} (${outcome.error.code})`, void 0, outcome.requestedSessionId);
54205
- case "rejected": throw new LifecycleError(outcome.kind, ownership, `${outcome.error.message} (${outcome.error.code})`, void 0);
54206
- case "cancelled": throw new LifecycleError("cancelled", ownership, "the Session creation was cancelled before dispatch", void 0);
54207
- }
54208
- }
54209
- /** Unwrap an OPEN result for the runner's transition. */
54210
- function requireOpened(result) {
54211
- const { ownership, outcome } = result;
54212
- switch (outcome.kind) {
54213
- case "opened":
54214
- if (ownership === "superseded") throw new LifecycleError("superseded", ownership, "the Session was opened but the local surface was superseded", outcome.handle.session.id);
54215
- return outcome.handle;
54216
- case "unavailable": throw new LifecycleError("unavailable", ownership, outcome.message, void 0);
54217
- case "cancelled": throw new LifecycleError("cancelled", ownership, "the Session open was cancelled before selection", void 0);
54218
- }
54219
- }
54220
- /** Extract the Direct ownership handle (the real AgentHandle with
54221
- * `dispose()`) from a lifecycle result. Accepts both the converged
54222
- * SessionHandle and a legacy AgentHandle so transition code cannot lose the
54223
- * ownership capability. Remote handles lack `direct` and yield undefined. */
54224
- function ownerHandleOf(next) {
54225
- const handle = next;
54226
- if (handle.direct?.ownerHandle !== void 0) return handle.direct.ownerHandle;
54227
- if (typeof handle.dispose === "function") return handle;
54228
- }
54229
- /** Extract the live in-process agent from a lifecycle result (the Direct
54230
- * SessionHandle via `direct.agent`, or a legacy AgentHandle's `agent`).
54231
- * Remote handles yield undefined. */
54232
- function directAgentOf(next) {
54233
- const handle = next;
54234
- return handle.direct?.agent ?? handle.agent;
54235
- }
54236
- //#endregion
54237
- //#region src/session-fork.ts
54238
- /**
54239
- * Shared forked-agent creation for `/fork` and conversation rewind.
54240
- *
54241
- * Both features create a child session from a seed through ONE chain
54242
- * (plan §6.2): resolve the source's recorded preset → compose → agents.create
54243
- * with `parentSession` + `isSeeded`/`inheritedEventCount` metadata → the
54244
- * caller swaps to it.
54245
- * Only the seed computation differs (`forkSeed` for `/fork`, `rewindSeed`
54246
- * for rewind), so the metadata, preset/base provider-model options and cwd
54247
- * rules can never drift between the two surfaces. Effective Session model
54248
- * selection is inherited separately through the transition seam.
54249
- *
54250
- * `commitRewind` is the rewind selection workflow (stale check → create →
54251
- * swap COMMIT → restore the prompt), with ONE authoritative pre-create
54252
- * stale gate: it MUST run inside the caller's session-transition gate, so
54253
- * no other transition can interleave and a stale selection never creates a
54254
- * child at all — once the create succeeds, the child is committed (no
54255
- * post-create disposal of a "ghost" child exists).
54256
- * @module @xmoon76/dsh-pi-tui/session-fork
54257
- */
54258
- /**
54259
- * Create one forked agent: the source's projected preset, the source's
54260
- * workspace cwd, and `parentSession` + `isSeeded` + `inheritedEventCount`
54261
- * metadata (the exact chain `/fork` used before the extraction). Ordinary
54262
- * create no longer carries provider/model inputs (D2.3). The effective Session model selection, when
54263
- * needed, is applied by the transition caller after the inherited seed. The
54264
- * child's session id is PRE-GENERATED by the caller so
54265
- * the transition can publish the durable child under a known identity
54266
- * (review round 6: every fallible operation happens before the durable
54267
- * child exists).
54268
- *
54269
- * The preset rides the semantic create request as the CONCRETE preset id
54270
- * (migration M1.11): the Direct adapter composes the setup internally from
54271
- * that id — a composition/setup callback never crosses this seam. `undefined`
54272
- * = the deployment default (a rosterless deployment records no preset).
54273
- * @param host - the fork host surface.
54274
- * @param source - the session being forked from (never modified).
54275
- * @param seed - the child's seed events (completed-turn prefix).
54276
- * @param sessionId - the pre-generated child session id.
54277
- * @param presetId - the concrete preset id the child composes on.
54278
- * @returns the created agent handle (NOT yet committed).
54279
- */
54280
- async function createForkedAgent(host, source, seed, sessionId, presetId) {
54281
- const cwd = source.session.header.cwd || host.sessionCwd();
54282
- const inheritedPreset = presetId ?? host.sessionPreset?.(source.session);
54283
- return host.agents.create({
54284
- sessionId: String(sessionId),
54285
- meta: {
54286
- cwd,
54287
- ...inheritedPreset === void 0 ? {} : { agentPreset: inheritedPreset },
54288
- parentSession: source.session.id,
54289
- isSeeded: true
54290
- },
54291
- inheritedEventCount: seed.length,
54292
- agentPreset: inheritedPreset,
54293
- seed
54294
- });
54295
- }
54296
- /** Whether a captured rewind identity still matches the live surface: a
54297
- * sessionless surface (`sessionId: undefined`) can never match a captured
54298
- * source, so a surface that became sessionless is refused just like a
54299
- * switched one. Pure so the race is unit-testable without the runner. */
54300
- function isRewindIdentityCurrent(live, expected) {
54301
- return live.sessionId === expected.sessionId && live.generation === expected.generation;
54302
- }
54303
- /**
54304
- * Commit one rewind selection: fork the source before the candidate's turn,
54305
- * commit the child, then restore the selected prompt into the editor.
54306
- *
54307
- * Transaction contract (review round 2 — the durable-ghost blocker): the
54308
- * stale check and the seed derivation run BEFORE anything is created; the
54309
- * create itself happens inside the runner's unified transaction, which
54310
- * flushes the old session first, commits synchronously, and never rolls a
54311
- * published child back. A stale selection therefore never creates a child
54312
- * at all, a failed create leaves the source untouched, and once the child
54313
- * exists it IS the surface. The source session is never modified at any
54314
- * point.
54315
- *
54316
- * MUST run inside the caller's session-transition gate (the runner wraps
54317
- * the commit in the gate): with the gate held, no other transition can
54318
- * interleave, so the pre-create stale check is authoritative.
54319
- *
54320
- * @param host - the commit host.
54321
- * @param source - the live agent whose session the picker listed.
54322
- * @param candidate - the selected rewind point.
54323
- * @param expected - the source identity captured when the picker opened
54324
- * (a selection must not commit into a surface another path has switched
54325
- * away from).
54326
- * @param sourceSelection - the source's current effective selection, applied
54327
- * after the historical seed when defined.
54328
- * @returns the outcome; `stale` when the source no longer owns the surface
54329
- * (detected before any child is created).
54330
- */
54331
- async function commitRewind(host, source, candidate, expected, sourceSelection) {
54332
- if (!isRewindIdentityCurrent(host.liveIdentity(), expected)) return { kind: "stale" };
54333
- let seed;
54334
- try {
54335
- seed = rewindSeed(source.session.snapshotEvents(), candidate);
54336
- } catch (error) {
54337
- return {
54338
- kind: "failed",
54339
- message: safeErrorMessage$1(error)
54340
- };
54341
- }
54342
- const sessionId = SessionId(`session-${randomUUID()}`);
54343
- let composition;
54344
- try {
54345
- composition = await host.compose(host.sessionPreset?.(source.session));
54346
- } catch (error) {
54347
- return {
54348
- kind: "failed",
54349
- message: safeErrorMessage$1(error)
54350
- };
54351
- }
54352
- const result = await host.transitionTo({
54353
- target: {
54354
- id: String(sessionId),
54355
- header: { cwd: source.session.header.cwd || host.sessionCwd() }
54356
- },
54357
- ...sourceSelection === void 0 ? {} : { inheritSelection: sourceSelection },
54358
- create: () => createForkedAgent(host, source, seed, sessionId, composition.agentPreset)
54359
- });
54360
- if (!result.ok) {
54361
- if (result.error instanceof LifecycleError && result.error.ownership === "superseded") return { kind: "superseded" };
54362
- return {
54363
- kind: "failed",
54364
- message: result.message
54365
- };
54366
- }
54367
- host.replaceDraft(candidate.editorText);
54368
- return {
54369
- kind: "rewound",
54370
- sessionId: result.next.session.id,
54371
- turn: candidate.turn,
54372
- hasNonTextContent: candidate.hasNonTextContent
54373
- };
54374
- }
54375
- //#endregion
54376
54346
  //#region src/steer.ts
54377
54347
  /**
54378
54348
  * The Ctrl+S steer-all orchestration with re-validation. Extracted
@@ -58090,6 +58060,68 @@ var ModelPicker = class {
58090
58060
  }
58091
58061
  };
58092
58062
  //#endregion
58063
+ //#region src/runtime/session-lifecycle-port.ts
58064
+ /** A lifecycle outcome that must ABORT the caller's transition, carrying the
58065
+ * two independent axes so a Remote caller never has to parse a string. */
58066
+ var LifecycleError = class extends Error {
58067
+ settlement;
58068
+ ownership;
58069
+ publishedSessionId;
58070
+ /** CORRELATION ONLY for an indeterminate create — never publication proof. */
58071
+ requestedSessionId;
58072
+ constructor(settlement, ownership, message, publishedSessionId, requestedSessionId) {
58073
+ super(message);
58074
+ this.name = "LifecycleError";
58075
+ this.settlement = settlement;
58076
+ this.ownership = ownership;
58077
+ this.publishedSessionId = publishedSessionId;
58078
+ this.requestedSessionId = requestedSessionId;
58079
+ }
58080
+ };
58081
+ /** Unwrap a CREATE result for the runner's transition: a `created` result that
58082
+ * still owns the surface yields the handle; everything else aborts with a
58083
+ * machine-readable `LifecycleError` (a superseded create is NOT committed to
58084
+ * the local surface, though its identity stays available). */
58085
+ function requireCreated(result) {
58086
+ const { ownership, outcome } = result;
58087
+ switch (outcome.kind) {
58088
+ case "created":
58089
+ if (ownership === "superseded") throw new LifecycleError("superseded", ownership, `Session "${outcome.handle.session.id}" was created, but the local surface was superseded — do not retry creation`, outcome.handle.session.id);
58090
+ return outcome.handle;
58091
+ case "published-with-error": throw new LifecycleError(outcome.kind, ownership, `Session "${outcome.sessionId}" was published, but workspace attach/reconcile failed: ${outcome.error.message} (${outcome.error.code}) — do not retry creation`, outcome.sessionId);
58092
+ case "indeterminate": throw new LifecycleError(outcome.kind, ownership, `the create is indeterminate — do not retry: ${outcome.error.message} (${outcome.error.code})`, void 0, outcome.requestedSessionId);
58093
+ case "rejected": throw new LifecycleError(outcome.kind, ownership, `${outcome.error.message} (${outcome.error.code})`, void 0);
58094
+ case "cancelled": throw new LifecycleError("cancelled", ownership, "the Session creation was cancelled before dispatch", void 0);
58095
+ }
58096
+ }
58097
+ /** Unwrap an OPEN result for the runner's transition. */
58098
+ function requireOpened(result) {
58099
+ const { ownership, outcome } = result;
58100
+ switch (outcome.kind) {
58101
+ case "opened":
58102
+ if (ownership === "superseded") throw new LifecycleError("superseded", ownership, "the Session was opened but the local surface was superseded", outcome.handle.session.id);
58103
+ return outcome.handle;
58104
+ case "unavailable": throw new LifecycleError("unavailable", ownership, outcome.message, void 0);
58105
+ case "cancelled": throw new LifecycleError("cancelled", ownership, "the Session open was cancelled before selection", void 0);
58106
+ }
58107
+ }
58108
+ /** Extract the Direct ownership handle (the real AgentHandle with
58109
+ * `dispose()`) from a lifecycle result. Accepts both the converged
58110
+ * SessionHandle and a legacy AgentHandle so transition code cannot lose the
58111
+ * ownership capability. Remote handles lack `direct` and yield undefined. */
58112
+ function ownerHandleOf(next) {
58113
+ const handle = next;
58114
+ if (handle.direct?.ownerHandle !== void 0) return handle.direct.ownerHandle;
58115
+ if (typeof handle.dispose === "function") return handle;
58116
+ }
58117
+ /** Extract the live in-process agent from a lifecycle result (the Direct
58118
+ * SessionHandle via `direct.agent`, or a legacy AgentHandle's `agent`).
58119
+ * Remote handles yield undefined. */
58120
+ function directAgentOf(next) {
58121
+ const handle = next;
58122
+ return handle.direct?.agent ?? handle.agent;
58123
+ }
58124
+ //#endregion
58093
58125
  //#region src/runtime/read-error.ts
58094
58126
  /**
58095
58127
  * A typed semantic READ failure: the read was superseded by a connection or
@@ -58262,19 +58294,22 @@ function sessionLabelParts(label) {
58262
58294
  * fork children, rewind branches AND subagents alike (plan §20: `origin`
58263
58295
  * only decides the badge, `parentSession` decides the hierarchy) — hangs
58264
58296
  * under its parent chain (depth = distance to the nearest root). Orphans —
58265
- * a parent outside the shown window, or a missing parent id — sit at
58266
- * depth 1. The input order (newest first) is preserved per level; a
58267
- * `placed` set guards against parent cycles in corrupt data.
58297
+ * a parent outside the shown window, or a missing parent id — degrade to root
58298
+ * depth 0. The input order (newest first) is preserved per level; a `placed`
58299
+ * set guards against parent cycles in corrupt data, which are emitted from
58300
+ * root depth when no ordinary root reaches them.
58268
58301
  * @param rows - the picker rows, newest first.
58269
58302
  * @returns rows in display order with their tree depth.
58270
58303
  */
58271
58304
  function buildSessionTree(rows) {
58305
+ const known = new Set(rows.map((row) => row.id));
58272
58306
  const children = /* @__PURE__ */ new Map();
58273
- for (const row of rows) if (row.parentSession !== void 0) {
58307
+ const roots = [];
58308
+ for (const row of rows) if (row.parentSession !== void 0 && known.has(row.parentSession)) {
58274
58309
  const list = children.get(row.parentSession);
58275
58310
  if (list === void 0) children.set(row.parentSession, [row]);
58276
58311
  else list.push(row);
58277
- }
58312
+ } else roots.push(row);
58278
58313
  const result = [];
58279
58314
  const placed = /* @__PURE__ */ new Set();
58280
58315
  const place = (row, depth) => {
@@ -58286,8 +58321,8 @@ function buildSessionTree(rows) {
58286
58321
  });
58287
58322
  for (const child of children.get(row.id) ?? []) place(child, depth + 1);
58288
58323
  };
58289
- for (const row of rows) if (row.parentSession === void 0) place(row, 0);
58290
- for (const row of rows) if (row.parentSession !== void 0) place(row, 1);
58324
+ for (const root of roots) place(root, 0);
58325
+ for (const row of rows) if (!placed.has(row.id)) place(row, 0);
58291
58326
  return result;
58292
58327
  }
58293
58328
  /**
@@ -59010,19 +59045,10 @@ function sortCommands(commands) {
59010
59045
  * temp/20260907/dsh-pi-tui-search-sessions-direct-boundary-plan.md).
59011
59046
  * @module @xmoon76/dsh-pi-tui/commands
59012
59047
  */
59013
- /** A balanced completed-turn prefix for forking: the log up to (and including)
59014
- * the last `turn/end`. Undefined when no turn has completed yet.
59015
- * @param events - the session log.
59016
- * @returns the fork seed events, or undefined.
59017
- */
59018
- function forkSeed(events) {
59019
- for (let index = events.length - 1; index >= 0; index -= 1) if (events[index]?.type === "turn/end") return events.slice(0, index + 1);
59020
- }
59021
59048
  /** Shorten a session id for read-only display rows, capped at 28 characters. */
59022
59049
  function displaySessionId(id) {
59023
59050
  return id.length > 28 ? `${id.slice(0, 28)}…` : id;
59024
59051
  }
59025
- /** Session meta for a fresh/forked session: the cwd plus the preset id when composed. */
59026
59052
  /**
59027
59053
  * The `/sessions` category tabs (the 2026-08-22 plan, item 3): the session
59028
59054
  * picker is a HUMAN surface, so subagent children never appear in either
@@ -59152,8 +59178,8 @@ function presetDisplayText(preset) {
59152
59178
  }
59153
59179
  /**
59154
59180
  * Resolve one submission's delivery mode — the DSH WEB
59155
- * `ComposerSubmissionPolicy.resolve()` contract (baseline 0.1.6-alpha.1,
59156
- * shared by every UI client):
59181
+ * `ComposerSubmissionPolicy.resolve()` contract (baseline established in DSH
59182
+ * `0.1.6-alpha.1`, shared by every UI client):
59157
59183
  *
59158
59184
  * ```text
59159
59185
  * !running -> queue
@@ -61058,7 +61084,6 @@ function registerTuiCommands(runner, initial) {
61058
61084
  text: `skill "${name}" is not invocable by the user`
61059
61085
  };
61060
61086
  const line = args.trim() === "" ? "/" + skill.name : "/" + skill.name + " " + args.trimStart();
61061
- const releasePin = pinDraftAttachments(line, runner.imageStore, runner.fileStore);
61062
61087
  const hostLoadsSkillBody = runner.catalog.skills.hostLoadsSkillBody(agent.session.id);
61063
61088
  const fallbackBody = !hostLoadsSkillBody ? (() => {
61064
61089
  const body = typeof skill.content === "string" && skill.content !== "" ? skill.content : skill.description;
@@ -61080,6 +61105,7 @@ function registerTuiCommands(runner, initial) {
61080
61105
  }
61081
61106
  });
61082
61107
  })() : void 0;
61108
+ const releasePin = pinDraftAttachments(line, runner.imageStore, runner.fileStore);
61083
61109
  let userMessage;
61084
61110
  try {
61085
61111
  userMessage = await runner.prepareDraftMessage(line);
@@ -61546,7 +61572,7 @@ function registerTuiCommands(runner, initial) {
61546
61572
  await runner.awaitPendingDefaultWrite(signal);
61547
61573
  return runner.agents.create({
61548
61574
  sessionId: String(sessionId),
61549
- meta: { cwd },
61575
+ cwd,
61550
61576
  agentPreset: resolved.id
61551
61577
  });
61552
61578
  }
@@ -62146,48 +62172,19 @@ function registerTuiCommands(runner, initial) {
62146
62172
  });
62147
62173
  commands.register({
62148
62174
  name: "fork",
62149
- description: "Fork this session at the last completed turn",
62150
- handler: () => runner.withSessionTransition(async () => {
62175
+ description: "Fork this session at the Host-selected completed-turn boundary",
62176
+ handler: async () => {
62151
62177
  const source = runner.liveAgent;
62152
- const seed = source === void 0 ? void 0 : forkSeed(source.session.snapshotEvents());
62153
- if (seed === void 0 || source === void 0) return {
62178
+ if (source === void 0) return {
62154
62179
  kind: "error",
62155
- text: "no completed turn to fork from"
62180
+ text: "no conversation to fork from"
62156
62181
  };
62157
- const sourceSelection = runner.selected.current;
62158
- const sessionId = SessionId(`session-${randomUUID()}`);
62159
- const childCwd = source.session.header.cwd || runner.sessionCwd();
62160
- const sourcePreset = runner.currentPreset();
62161
- const result = await runner.transitionTo({
62162
- target: {
62163
- id: String(sessionId),
62164
- header: { cwd: childCwd }
62165
- },
62166
- ...sourceSelection === void 0 ? {} : { inheritSelection: sourceSelection },
62167
- create: () => createForkedAgent(runner, source, seed, sessionId, sourcePreset)
62168
- });
62169
- if (!result.ok) {
62170
- if (result.error instanceof LifecycleError) {
62171
- runner.diag.warn("fork create did not own the surface", {
62172
- settlement: result.error.settlement,
62173
- ownership: result.error.ownership,
62174
- publishedSessionId: result.error.publishedSessionId,
62175
- requestedSessionId: result.error.requestedSessionId
62176
- });
62177
- if (result.error.ownership === "superseded") return { kind: "success" };
62178
- }
62179
- return {
62180
- kind: "error",
62181
- text: result.message
62182
- };
62183
- }
62184
- runner.imageStore.clearUnpinned();
62185
- runner.fileStore?.clearUnpinned();
62186
- return {
62187
- kind: "success",
62188
- text: `forked as ${result.next.session.id}`
62182
+ if (runner.forkSession === void 0) return {
62183
+ kind: "error",
62184
+ text: "session fork is unavailable in this backend"
62189
62185
  };
62190
- })
62186
+ return runner.forkSession(source.session.id);
62187
+ }
62191
62188
  });
62192
62189
  commands.register({
62193
62190
  name: "rewind",
@@ -64294,7 +64291,8 @@ function isRemoteBusinessRefusalCode(code) {
64294
64291
  /**
64295
64292
  * The interactive subagent viewer's HUMAN PROMPT delivery seam — the pure,
64296
64293
  * dependency-injected layer between TuiApp's semantic submit event and the
64297
- * DSH official subagent control API (plan §17; DSH 0.1.6-alpha.1). It owns
64294
+ * DSH official subagent control API (plan §17; introduced in DSH
64295
+ * `0.1.6-alpha.1`). It owns
64298
64296
  * validation, the `ctx.subagents.prompt(...)` call, and error
64299
64297
  * classification; the runner owns the surface effects (draft restore,
64300
64298
  * notices, stale-viewer guards).
@@ -65334,6 +65332,23 @@ var DirectPendingInputReader = class {
65334
65332
  };
65335
65333
  //#endregion
65336
65334
  //#region src/runtime/direct/session-lifecycle-direct.ts
65335
+ /**
65336
+ * The Direct session lifecycle (D2.1–D2.4) — the in-process
65337
+ * implementation of `SessionLifecycle` over the dsh `agents` service.
65338
+ *
65339
+ * D2.3 moved ordinary create/open activation requirements INSIDE the adapter.
65340
+ * D2.4 does the same for fork: this adapter maps the official Host fork
65341
+ * algorithm (observation, completed-turn boundary, seed, lineage, preset,
65342
+ * default model and workspace attachment) without exposing those details
65343
+ * through the semantic port.
65344
+ *
65345
+ * The adapter is the only module in these lifecycle paths that touches `ctx`
65346
+ * and the preset composition. It keeps the real AgentHandle ownership escape
65347
+ * required by the current Direct runner.
65348
+ *
65349
+ * Full contract: docs/client-server-migration.md + docs/client-server-coupling.md.
65350
+ * @module @xmoon76/dsh-pi-tui/runtime/direct/session-lifecycle-direct
65351
+ */
65337
65352
  /** The Direct backend's session lifecycle: the `ctx.agents` service behind
65338
65353
  * the semantic `SessionLifecycle` interface. The preset composition (and
65339
65354
  * with it the agent-setup callback) is resolved here from the request's
@@ -65343,9 +65358,11 @@ var DirectPendingInputReader = class {
65343
65358
  var DirectSessionLifecycle = class {
65344
65359
  ctx;
65345
65360
  compose;
65346
- constructor(ctx, compose) {
65361
+ ownerPool;
65362
+ constructor(ctx, compose, ownerPool) {
65347
65363
  this.ctx = ctx;
65348
65364
  this.compose = compose;
65365
+ this.ownerPool = ownerPool;
65349
65366
  }
65350
65367
  /** The in-process activation fallback: the Host global default, never a
65351
65368
  * cross-backend request input. */
@@ -65366,18 +65383,14 @@ var DirectSessionLifecycle = class {
65366
65383
  const agentOptions = this.agentOptions();
65367
65384
  try {
65368
65385
  const composition = await this.compose(request.agentPreset);
65369
- const metaPreset = request.meta.agentPreset;
65370
- if (metaPreset !== void 0 && composition.agentPreset !== void 0 && metaPreset !== composition.agentPreset) throw new Error(`create meta.agentPreset "${String(metaPreset)}" disagrees with the semantic agentPreset "${composition.agentPreset}"`);
65371
65386
  const handle = await agents.create({
65372
65387
  sessionId: SessionId(request.sessionId),
65373
- meta: composition.agentPreset === void 0 ? request.meta : {
65374
- ...request.meta,
65375
- agentPreset: composition.agentPreset
65388
+ meta: {
65389
+ ...request.cwd === void 0 ? {} : { cwd: request.cwd },
65390
+ ...composition.agentPreset === void 0 ? {} : { agentPreset: composition.agentPreset }
65376
65391
  },
65377
65392
  agentOptions,
65378
65393
  setup: composition.setup,
65379
- seed: request.seed,
65380
- ...request.inheritedEventCount === void 0 ? {} : { inheritedEventCount: SessionLogOffset(request.inheritedEventCount) },
65381
65394
  signal: request.signal
65382
65395
  });
65383
65396
  return {
@@ -65401,11 +65414,50 @@ var DirectSessionLifecycle = class {
65401
65414
  return currentCreateRejected("session/create-failed", safeErrorMessage$1(error));
65402
65415
  }
65403
65416
  }
65417
+ /**
65418
+ * Await a pending Direct owner release, honoring the caller's cancellation.
65419
+ * @returns `true` when the release settled (or there was none), `false` when
65420
+ * the signal aborted first — the caller then settles as `cancelled`
65421
+ * instead of staying pending behind a slow retirement.
65422
+ */
65423
+ async waitForReleaseOrAbort(sessionId, signal) {
65424
+ const release = this.ownerPool?.waitForRelease?.(sessionId);
65425
+ if (release === void 0) return true;
65426
+ if (signal === void 0) {
65427
+ await release;
65428
+ return true;
65429
+ }
65430
+ if (signal.aborted) return false;
65431
+ let onAbort;
65432
+ const aborted = new Promise((resolve) => {
65433
+ onAbort = () => resolve(false);
65434
+ signal.addEventListener("abort", onAbort, { once: true });
65435
+ });
65436
+ try {
65437
+ return await Promise.race([release.then(() => true), aborted]);
65438
+ } finally {
65439
+ if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
65440
+ }
65441
+ }
65404
65442
  async open(request) {
65405
65443
  if (Boolean(request.signal?.aborted)) return {
65406
65444
  ownership: "current",
65407
65445
  outcome: { kind: "cancelled" }
65408
65446
  };
65447
+ const parked = this.ownerPool?.claim(request.sessionId);
65448
+ if (parked !== void 0) return {
65449
+ ownership: "current",
65450
+ outcome: {
65451
+ kind: "opened",
65452
+ handle: {
65453
+ session: { id: String(parked.agent.session.id) },
65454
+ direct: {
65455
+ agent: parked.agent,
65456
+ ownerHandle: parked
65457
+ }
65458
+ }
65459
+ }
65460
+ };
65409
65461
  const agents = this.ctx.get("agents");
65410
65462
  if (agents === void 0) return {
65411
65463
  ownership: "current",
@@ -65415,6 +65467,10 @@ var DirectSessionLifecycle = class {
65415
65467
  }
65416
65468
  };
65417
65469
  const agentOptions = this.agentOptions();
65470
+ if (!await this.waitForReleaseOrAbort(request.sessionId, request.signal)) return {
65471
+ ownership: "current",
65472
+ outcome: { kind: "cancelled" }
65473
+ };
65418
65474
  try {
65419
65475
  const recorded = await recordedSessionPreset(this.ctx, request.sessionId, request.signal);
65420
65476
  const composition = await this.compose(recorded);
@@ -65451,6 +65507,109 @@ var DirectSessionLifecycle = class {
65451
65507
  };
65452
65508
  }
65453
65509
  }
65510
+ /** Map the official Host fork algorithm into the Direct implementation. The
65511
+ * only raw seed construction in the repository lives here, immediately next
65512
+ * to `agents.create`; commands and the semantic port see only the source id
65513
+ * and optional official anchor. */
65514
+ async fork(request) {
65515
+ const atSeq = request.atSeq;
65516
+ if (atSeq !== void 0 && (!Number.isSafeInteger(atSeq) || atSeq < 0)) return currentForkRejected("gateway/bad-request", "atSeq must be a non-negative safe integer");
65517
+ const agents = this.ctx.get("agents");
65518
+ if (agents === void 0) return currentForkRejected("gateway/internal", "agents service unavailable");
65519
+ const query = this.ctx.get("sessionQuery");
65520
+ if (query === void 0) return currentForkRejected("gateway/internal", "session query service unavailable");
65521
+ let source;
65522
+ try {
65523
+ source = await query.observeSession(SessionId(request.sourceSessionId));
65524
+ } catch (error) {
65525
+ return currentForkRejected((typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : void 0) === "SESSION_QUERY_SESSION_NOT_FOUND" ? "session/not-found" : "gateway/internal", `fork source unavailable: ${safeErrorMessage$1(error)}`);
65526
+ }
65527
+ try {
65528
+ const lastSeq = source.events.at(-1)?.seq ?? -1;
65529
+ const boundary = (atSeq === void 0 ? void 0 : source.events.find((event) => event.type === "turn/end" && Number(event.seq) >= atSeq)) ?? (atSeq === void 0 || atSeq > Number(lastSeq) ? [...source.events].reverse().find((event) => event.type === "turn/end") : void 0);
65530
+ if (boundary === void 0) return currentForkRejected("session/fork-unavailable", atSeq !== void 0 && atSeq <= Number(lastSeq) ? `session "${request.sourceSessionId}" has not completed the turn containing event ${String(atSeq)}` : `session "${request.sourceSessionId}" has no completed turn to fork from`);
65531
+ let workspace;
65532
+ try {
65533
+ workspace = await this.forkWorkspace(source);
65534
+ } catch (error) {
65535
+ return currentForkRejected("gateway/internal", `failed to resolve fork workspace: ${safeErrorMessage$1(error)}`);
65536
+ }
65537
+ const preset = source.projections.values.agentPreset ?? void 0;
65538
+ let composition;
65539
+ try {
65540
+ composition = await this.compose(preset);
65541
+ } catch (error) {
65542
+ return currentForkRejected("gateway/internal", `failed to compose fork session: ${safeErrorMessage$1(error)}`);
65543
+ }
65544
+ const cut = Number(boundary.seq) + 1;
65545
+ const childMeta = {
65546
+ ...source.header.cwd === void 0 ? {} : { cwd: source.header.cwd },
65547
+ parentSession: source.header.id,
65548
+ isSeeded: true,
65549
+ ...composition.agentPreset === void 0 ? {} : { agentPreset: composition.agentPreset }
65550
+ };
65551
+ let handle;
65552
+ try {
65553
+ handle = await agents.create({
65554
+ sessionId: SessionId(`session-${randomUUID()}`),
65555
+ seed: source.events.slice(0, cut),
65556
+ inheritedEventCount: SessionLogOffset(cut),
65557
+ meta: childMeta,
65558
+ agentOptions: this.agentOptions(),
65559
+ setup: composition.setup
65560
+ });
65561
+ } catch (error) {
65562
+ return currentForkRejected("gateway/internal", `failed to fork session "${request.sourceSessionId}": ${safeErrorMessage$1(error)}`);
65563
+ }
65564
+ const child = {
65565
+ session: { id: String(handle.agent.session.id) },
65566
+ direct: {
65567
+ agent: handle.agent,
65568
+ ownerHandle: handle
65569
+ }
65570
+ };
65571
+ if (workspace !== void 0) try {
65572
+ await workspace.attachSession(SessionId(child.session.id));
65573
+ } catch (error) {
65574
+ return {
65575
+ ownership: "current",
65576
+ outcome: {
65577
+ kind: "published-with-error",
65578
+ sessionId: child.session.id,
65579
+ handle: child,
65580
+ error: {
65581
+ code: "session/workspace-attach-failed",
65582
+ message: `Session "${child.session.id}" was forked but could not attach to workspace "${workspace.id}": ${safeErrorMessage$1(error)}`,
65583
+ details: { workspaceId: workspace.id }
65584
+ }
65585
+ }
65586
+ };
65587
+ }
65588
+ return {
65589
+ ownership: "current",
65590
+ outcome: {
65591
+ kind: "forked",
65592
+ handle: child
65593
+ }
65594
+ };
65595
+ } finally {
65596
+ source[Symbol.dispose]();
65597
+ }
65598
+ }
65599
+ async forkWorkspace(source) {
65600
+ const registry = this.ctx.get("workspaceRegistry");
65601
+ if (registry === void 0) return void 0;
65602
+ const workspaces = registry.list();
65603
+ const direct = workspaces.find((workspace) => workspace.sessionIds.includes(source.header.id));
65604
+ if (direct !== void 0 || source.header.origin !== "subagent") return direct;
65605
+ const query = this.ctx.get("sessionQuery");
65606
+ if (query?.traceSession === void 0) return void 0;
65607
+ const lineage = await query.traceSession(source.header.id);
65608
+ for (const ancestor of lineage.ancestors) {
65609
+ const workspace = workspaces.find((candidate) => candidate.sessionIds.includes(ancestor.header.id));
65610
+ if (workspace !== void 0) return workspace;
65611
+ }
65612
+ }
65454
65613
  };
65455
65614
  /** A Direct create rejection that still owns the local surface. */
65456
65615
  function currentCreateRejected(code, message) {
@@ -65465,6 +65624,18 @@ function currentCreateRejected(code, message) {
65465
65624
  }
65466
65625
  };
65467
65626
  }
65627
+ function currentForkRejected(code, message) {
65628
+ return {
65629
+ ownership: "current",
65630
+ outcome: {
65631
+ kind: "rejected",
65632
+ error: {
65633
+ code,
65634
+ message
65635
+ }
65636
+ }
65637
+ };
65638
+ }
65468
65639
  //#endregion
65469
65640
  //#region src/runtime/direct/interaction-direct.ts
65470
65641
  /** The Direct backend's interaction port: the ctx services/events behind
@@ -67552,24 +67723,112 @@ var CoalescingRefreshGate = class {
67552
67723
  }
67553
67724
  };
67554
67725
  //#endregion
67726
+ //#region src/rewind.ts
67727
+ /** Whether one user/message event is a DIRECT human prompt. Injected context,
67728
+ * skill bodies, system reminders and goal continuations answer false. */
67729
+ function isHumanTurnMessage(event) {
67730
+ return event.data.source.kind === "user";
67731
+ }
67732
+ /** Whether a message is empty for rewind purposes: mirror the transcript's
67733
+ * finalized user-content visibility rule. */
67734
+ function isEmptyMessage(blocks) {
67735
+ return !userBlocksVisibleNow(blocks);
67736
+ }
67737
+ /** One-line, width-bounded preview of a prompt (whitespace collapsed). */
67738
+ function singleLinePreview(text) {
67739
+ const flat = text.replace(/\s+/g, " ").trim();
67740
+ return flat.length > 120 ? `${flat.slice(0, 117)}…` : flat;
67741
+ }
67742
+ /**
67743
+ * Derive rewind candidates from a session event log.
67744
+ *
67745
+ * Only a completed human turn after a valid completed predecessor is
67746
+ * rewindable. A non-human closed turn still becomes a legal predecessor; an
67747
+ * open or malformed span never does. The first human turn therefore has no
67748
+ * candidate because official fork omission would mean "latest boundary", not
67749
+ * an empty prefix.
67750
+ */
67751
+ function collectRewindCandidates(events) {
67752
+ const candidates = [];
67753
+ let lastValidClosedTurnEndSeq;
67754
+ let open;
67755
+ for (const event of events) {
67756
+ if (event.type === "turn/start") {
67757
+ open = {
67758
+ turn: event.data.turn,
67759
+ startSeq: Number(event.seq),
67760
+ predecessorEndSeq: lastValidClosedTurnEndSeq
67761
+ };
67762
+ continue;
67763
+ }
67764
+ if (event.type === "turn/end") {
67765
+ if (open !== void 0 && event.data.turn !== open.turn) continue;
67766
+ if (open !== void 0) {
67767
+ const primary = open.primary;
67768
+ if (primary !== void 0 && open.predecessorEndSeq !== void 0) {
67769
+ const editorText = textOf(primary.blocks);
67770
+ candidates.push({
67771
+ turnStartSeq: open.startSeq,
67772
+ forkAtSeq: open.predecessorEndSeq,
67773
+ turn: open.turn,
67774
+ messageSeq: primary.seq,
67775
+ editorText,
67776
+ preview: singleLinePreview(editorText),
67777
+ hasNonTextContent: primary.blocks.some((block) => block.type !== "text")
67778
+ });
67779
+ }
67780
+ lastValidClosedTurnEndSeq = Number(event.seq);
67781
+ }
67782
+ open = void 0;
67783
+ continue;
67784
+ }
67785
+ if (event.type === "user/message" && open !== void 0 && open.primary === void 0) {
67786
+ const blocks = event.data.content;
67787
+ if (isHumanTurnMessage(event) && !isEmptyMessage(blocks)) open.primary = {
67788
+ seq: Number(event.seq),
67789
+ blocks
67790
+ };
67791
+ }
67792
+ }
67793
+ return candidates.reverse();
67794
+ }
67795
+ /** One picker row for a candidate. The value remains the selected turn-start
67796
+ * identity; the workflow resolves the captured row before dispatch. */
67797
+ function rewindPickerItem(candidate) {
67798
+ const tag = candidate.hasNonTextContent ? "[attachment] " : "";
67799
+ const preview = candidate.preview === "" && candidate.hasNonTextContent ? "(attachment only)" : candidate.preview;
67800
+ return {
67801
+ value: String(candidate.turnStartSeq),
67802
+ label: `turn ${candidate.turn} · ${tag}${preview}`,
67803
+ ...candidate.hasNonTextContent ? { description: "non-text content is not re-staged on rewind" } : {}
67804
+ };
67805
+ }
67806
+ //#endregion
67807
+ //#region src/session-fork.ts
67808
+ /** A captured fork/rewind intent still owns the visible surface only while all
67809
+ * three local identity facts remain unchanged. */
67810
+ function isRewindIdentityCurrent(live, expected) {
67811
+ return live.sessionId === expected.sessionId && live.generation === expected.generation && live.navigationEpoch === expected.navigationEpoch;
67812
+ }
67813
+ //#endregion
67555
67814
  //#region src/transition-gate.ts
67556
67815
  /**
67557
67816
  * The process-local session-transition gate: ONE writer for the live
67558
67817
  * session at a time.
67559
67818
  *
67560
- * Every path that changes which session owns the surface — /new, /fork,
67561
- * conversation rewind, `/sessions` switch/resume, the first-session
67562
- * creation — must run its whole workflow (quiesce old → preflight →
67563
- * create/resume → COMMIT (assign new + generation bump) → retire old)
67564
- * inside {@link SessionTransitionGate.run}. Without it, two interleaved transitions can:
67819
+ * Every ordinary path that changes which session owns the surface — /new,
67820
+ * `/sessions` switch/resume, and first-session creation — must run its whole
67821
+ * workflow (quiesce old → preflight → create/resume → COMMIT (assign new +
67822
+ * generation bump) → retire old) inside {@link SessionTransitionGate.run}.
67823
+ * Host fork dispatch runs outside this FIFO; only adoption of a returned Direct
67824
+ * child enters the gate. Without this gate, two interleaved transitions can:
67565
67825
  *
67566
67826
  * - create a child whose metadata mixes two surfaces (parent captured
67567
67827
  * before an await, cwd read after a concurrent switch — the P2 "cwd
67568
67828
  * race");
67569
- * - publish a forked child (session/created → persistence starts writing
67570
- * its seed) and then detect "stale" only AFTER the create, leaving a
67571
- * durable ghost branch the user never entered — `handle.dispose()` stops
67572
- * the agent but does NOT delete the persisted session;
67829
+ * - publish an ordinary child (session/created → persistence starts writing
67830
+ * its seed) and then detect a stale surface only AFTER the create, leaving
67831
+ * two live Direct surfaces with no serialized adoption/retirement order;
67573
67832
  * - pass a stale identity check and then yield inside the swap (flush /
67574
67833
  * old-owner retirement), letting a second transition land in between and
67575
67834
  * later get overwritten by the first continuation.
@@ -67657,7 +67916,8 @@ var PendingSubmissions = class {
67657
67916
  text: input.text,
67658
67917
  createdAt: input.createdAt,
67659
67918
  ...input.sessionId === void 0 ? {} : { sessionId: input.sessionId },
67660
- ...input.generation === void 0 ? {} : { generation: input.generation }
67919
+ ...input.generation === void 0 ? {} : { generation: input.generation },
67920
+ ...input.foldableText === void 0 ? {} : { foldableText: input.foldableText }
67661
67921
  });
67662
67922
  }
67663
67923
  /** Remove one echo after its durable `user/message` became renderable. */
@@ -67723,7 +67983,8 @@ function buildPendingPresentation(input) {
67723
67983
  id: item.id,
67724
67984
  ...item.rpcId === void 0 ? {} : { rpcId: item.rpcId },
67725
67985
  text: input.textOf(item.content),
67726
- status: "steering"
67986
+ status: "steering",
67987
+ foldableText: isTextOnlyContent(item.content)
67727
67988
  });
67728
67989
  }
67729
67990
  const authoritativeRpcIds = /* @__PURE__ */ new Set();
@@ -67742,7 +68003,8 @@ function buildPendingPresentation(input) {
67742
68003
  rpcId: echo.requestId,
67743
68004
  text,
67744
68005
  local: true,
67745
- status: echo.placement === "transcript" ? "sending" : "steering"
68006
+ status: echo.placement === "transcript" ? "sending" : "steering",
68007
+ ...echo.foldableText === void 0 ? {} : { foldableText: echo.foldableText }
67746
68008
  });
67747
68009
  }
67748
68010
  return {
@@ -67752,6 +68014,17 @@ function buildPendingPresentation(input) {
67752
68014
  };
67753
68015
  }
67754
68016
  /**
68017
+ * Whether an authoritative pending occurrence's structural content is
68018
+ * text-only, so the ephemeral row may join the long-user visual-row fold. Any
68019
+ * unrecognized block (image/file/attachment/generic non-text) fails open to
68020
+ * the FULL presentation — the pending row must never be folded only to
68021
+ * materialize as a full mixed-content durable bubble.
68022
+ */
68023
+ function isTextOnlyContent(content) {
68024
+ if (content.length === 0) return false;
68025
+ return content.every((block) => typeof block === "object" && block !== null && block.type === "text");
68026
+ }
68027
+ /**
67755
68028
  * Render one local echo's display text. The Direct ledger carries attachment
67756
68029
  * markers inside `text`; the official Remote pending submission does not, so
67757
68030
  * its structured attachments are appended here — an image-only echo must never
@@ -67780,7 +68053,8 @@ var DirectSubmissionPresentation = class {
67780
68053
  placement: echo.placement,
67781
68054
  text: echo.text,
67782
68055
  createdAt: echo.createdAt,
67783
- attachments: []
68056
+ attachments: [],
68057
+ ...echo.foldableText === void 0 ? {} : { foldableText: echo.foldableText }
67784
68058
  }));
67785
68059
  }
67786
68060
  };
@@ -67852,8 +68126,8 @@ var SubmitLatencyTracker = class {
67852
68126
  //#endregion
67853
68127
  //#region src/transition.ts
67854
68128
  /**
67855
- * The session switch transaction — the canonical ordering shared by /new,
67856
- * /fork, conversation rewind and `/sessions` switch/resume:
68129
+ * The ordinary session-switch transaction — the canonical ordering shared by
68130
+ * /new and `/sessions` switch/resume:
67857
68131
  *
67858
68132
  * ```text
67859
68133
  * 1. quiesce old (whenIdle + final flush)
@@ -68434,8 +68708,8 @@ function packageVersion() {
68434
68708
  }
68435
68709
  /**
68436
68710
  * The welcome card's version line: the installed dsh version plus the
68437
- * bundle's own version (header-badge parity — `dsh-0.1.6-alpha.1 ·
68438
- * tui-v0.4.7-alpha.1`). Without a resolvable dsh launcher it degrades to
68711
+ * bundle's own version (header-badge parity — `dsh-0.1.6-alpha.2 ·
68712
+ * tui-v0.4.7-alpha.2`). Without a resolvable dsh launcher it degrades to
68439
68713
  * the bundle version alone.
68440
68714
  * @returns the combined version string.
68441
68715
  */
@@ -68793,8 +69067,8 @@ async function composeAgent(ctx, installSelection, presetId, focusState, diag) {
68793
69067
  };
68794
69068
  }
68795
69069
  /**
68796
- * The preset a persisted session actually runs, read from DSH 0.1.6-alpha.1's
68797
- * V3 session projection (header initialization plus the latest selection event).
69070
+ * The preset a persisted session actually runs, read from the DSH 0.1.6 V3
69071
+ * session projection (header initialization plus the latest selection event).
68798
69072
  * @param ctx - the runner context.
68799
69073
  * @param sessionId - the persisted session id.
68800
69074
  * @returns the recorded preset id, or undefined to compose the default.
@@ -68904,6 +69178,69 @@ function apply(ctx, config) {
68904
69178
  }
68905
69179
  const transitionGate = new SessionTransitionGate();
68906
69180
  const operationBarrier = new SessionOperationBarrier();
69181
+ const parkedDirectOwners = /* @__PURE__ */ new Map();
69182
+ const pendingOwnerReleases = /* @__PURE__ */ new Map();
69183
+ /** Register a pending release for one session; returns the resolver the
69184
+ * retirement calls when it has finished. */
69185
+ const beginOwnerRelease = (sessionId) => {
69186
+ let resolve;
69187
+ const promise = new Promise((settle) => {
69188
+ resolve = settle;
69189
+ });
69190
+ const releases = pendingOwnerReleases.get(sessionId) ?? /* @__PURE__ */ new Set();
69191
+ releases.add(promise);
69192
+ pendingOwnerReleases.set(sessionId, releases);
69193
+ return () => {
69194
+ releases.delete(promise);
69195
+ if (releases.size === 0) pendingOwnerReleases.delete(sessionId);
69196
+ resolve();
69197
+ };
69198
+ };
69199
+ const waitForOwnerRelease = async (sessionId) => {
69200
+ while (true) {
69201
+ const releases = pendingOwnerReleases.get(sessionId);
69202
+ if (releases === void 0 || releases.size === 0) return;
69203
+ await Promise.allSettled([...releases]);
69204
+ }
69205
+ };
69206
+ /** Admission-time pin for one `/fork` source. It is held from the moment the
69207
+ * fork is admitted — before the child is even created — and released when
69208
+ * the fork settles AND, if it committed, after the source owner's
69209
+ * retirement finishes. While it is held, an open/resume of that session
69210
+ * waits for the release instead of resuming a live, lease-held owner. */
69211
+ const beginForkSourcePin = (sessionId) => {
69212
+ const finish = beginOwnerRelease(sessionId);
69213
+ const state = {
69214
+ released: false,
69215
+ retirementOwnsRelease: false
69216
+ };
69217
+ return {
69218
+ state,
69219
+ release: () => {
69220
+ if (state.released) return;
69221
+ state.released = true;
69222
+ finish();
69223
+ }
69224
+ };
69225
+ };
69226
+ const directOwnerPool = {
69227
+ claim: (sessionId) => {
69228
+ const handle = parkedDirectOwners.get(sessionId);
69229
+ if (handle !== void 0) parkedDirectOwners.delete(sessionId);
69230
+ return handle;
69231
+ },
69232
+ park: (handle) => {
69233
+ const sessionId = String(handle.agent.session.id);
69234
+ const previous = parkedDirectOwners.get(sessionId);
69235
+ if (previous !== void 0 && previous !== handle) throw new Error(`duplicate parked Direct owner for session "${sessionId}"`);
69236
+ parkedDirectOwners.set(sessionId, handle);
69237
+ },
69238
+ waitForRelease: waitForOwnerRelease
69239
+ };
69240
+ let navigationEpoch = 0;
69241
+ const pendingForks = /* @__PURE__ */ new Set();
69242
+ const pendingSettlementWork = /* @__PURE__ */ new Set();
69243
+ const pendingSourceRetirements = /* @__PURE__ */ new Set();
68907
69244
  const pendingQueueRecalls = [];
68908
69245
  const settlePendingQueueRecalls = (committed) => {
68909
69246
  const recalls = pendingQueueRecalls.splice(0);
@@ -68915,6 +69252,17 @@ function apply(ctx, config) {
68915
69252
  if (retirementPromise !== void 0) return retirementPromise;
68916
69253
  retirementPromise = (async () => {
68917
69254
  if (transitionGate?.pending === true) liveAgent?.cancel({ kind: "user" });
69255
+ const retireParked = async (agent, handle) => retireDirectOwnedSession({
69256
+ cancel: () => agent.cancel({ kind: "user" }),
69257
+ whenIdle: () => agent.whenIdle(),
69258
+ drainDescendants: async () => {
69259
+ await ctx.get("subagents")?.drainContinuableDescendants?.([agent]);
69260
+ },
69261
+ flush: async () => {
69262
+ await sessions.flush(agent.session);
69263
+ },
69264
+ disposeOwner: () => handle.dispose()
69265
+ });
68918
69266
  const retire = async () => {
68919
69267
  const agent = liveAgent;
68920
69268
  const handle = liveHandle;
@@ -68962,7 +69310,18 @@ function apply(ctx, config) {
68962
69310
  return report;
68963
69311
  };
68964
69312
  try {
68965
- return await transitionGate.run(() => operationBarrier.runTransition(retire));
69313
+ while (pendingForks.size > 0) await Promise.allSettled([...pendingForks]);
69314
+ while (pendingSettlementWork.size > 0) await Promise.allSettled([...pendingSettlementWork]);
69315
+ while (pendingSourceRetirements.size > 0) await Promise.allSettled([...pendingSourceRetirements]);
69316
+ return await transitionGate.run(() => operationBarrier.runTransition(async () => {
69317
+ const failures = [...(await retire()).failures];
69318
+ for (const [sessionId, parked] of parkedDirectOwners) {
69319
+ parkedDirectOwners.delete(sessionId);
69320
+ const report = await retireParked(parked.agent, parked);
69321
+ failures.push(...report.failures);
69322
+ }
69323
+ return { failures };
69324
+ }));
68966
69325
  } catch (error) {
68967
69326
  diag.error("retire barrier failed", { error: safeErrorMessage$1(error) });
68968
69327
  return { failures: [{
@@ -69125,7 +69484,7 @@ function apply(ctx, config) {
69125
69484
  flushSession: async (session) => {
69126
69485
  await sessions.flush(session);
69127
69486
  }
69128
- }), directPendingInputReader, directSessionWriter, new DirectSessionLifecycle(ctx, (presetId) => compose(presetId)), new DirectInteractionPort(ctx, (sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0), new DirectCatalogPort(ctx, (sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0, modelSelections, diag), new DirectConfigPort(ctx, tuiSettings, (sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0), new DirectHostFilePort((sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0), new DirectSessionArchive(ctx), new DirectHostCommandPort(ctx, (sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0));
69487
+ }), directPendingInputReader, directSessionWriter, new DirectSessionLifecycle(ctx, (presetId) => compose(presetId), directOwnerPool), new DirectInteractionPort(ctx, (sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0), new DirectCatalogPort(ctx, (sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0, modelSelections, diag), new DirectConfigPort(ctx, tuiSettings, (sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0), new DirectHostFilePort((sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0), new DirectSessionArchive(ctx), new DirectHostCommandPort(ctx, (sessionId) => liveAgent?.session.id === sessionId ? liveAgent : void 0));
69129
69488
  const userFooterCustomItemsForSave = () => {
69130
69489
  const raw = backend.config.footerCustomItems.rawForPersistence();
69131
69490
  if (raw.kind === "unavailable") throw new Error("custom footer definitions unavailable; settings write aborted");
@@ -69312,59 +69671,27 @@ function apply(ctx, config) {
69312
69671
  ...currentPreset() === void 0 ? {} : { preset: currentPreset() }
69313
69672
  });
69314
69673
  };
69315
- /** The ONE writer for the live session: every path that changes which
69316
- * session owns the surface — /new, /fork, rewind, `/sessions` switch,
69317
- * the first-session creation — runs its WHOLE workflow (prepare/create
69318
- * → flush → COMMIT (assign new + generation bump) → retire old) inside
69319
- * {@link transitionGate}. Without the gate a transition can interleave
69320
- * with another: a fork child could be created (and its seed published
69321
- * to persistence) before a stale check sees the surface already moved —
69322
- * `dispose()` stops the agent but never deletes the persisted session —
69323
- * and a stale identity check could pass, then yield across an await
69324
- * inside the commit preparation, letting a concurrent switch land and
69325
- * later get overwritten. The rewind commit holds the gate from BEFORE
69326
- * `createForkedAgent` (the create itself is inside the exclusive
69327
- * section), so a stale rewind never creates a child at all.
69674
+ /** The transition gate protects ordinary session surface changes — `/new`,
69675
+ * `/sessions` switch/open and first-session creation — from interleaving.
69676
+ * Fork dispatch is intentionally outside this destructive queue: Host
69677
+ * publication may outlive local navigation, while the visible adoption and
69678
+ * old-owner retirement use a short gated handoff.
69328
69679
  * @see SessionTransitionGate */
69329
- /**
69330
- * The ONE session-transition transaction, shared by /new, /fork,
69331
- * conversation rewind and `/sessions` switch/resume. The canonical
69332
- * ordering lives in `runTransitionTo` (src/transition.ts — unit-tested
69333
- * against the exact phase order); this is the runner's host adapter.
69334
- *
69335
- * The ordering is the whole point (review rounds 2–3):
69336
- *
69337
- * 1. QUIESCE OLD — `whenIdle()` then the FINAL flush. `dispose()` is
69338
- * an async quiescence and a cancelled RUNNING turn appends its
69339
- * closure events (interrupted assistant/message, step/end,
69340
- * turn/end) in finally blocks — the old agent must be idle before
69341
- * the flush so those closures are never split across the switch.
69342
- * Old-idle-then-flush closes that window; may fail → abort with
69343
- * ZERO child side effects.
69344
- * 2. caller `prepare` (rewind's stale gate, switch pre-checks)
69345
- * — may fail → abort;
69346
- * 3. create/resume the CHILD — may fail → abort; once it SUCCEEDS the
69347
- * child is published (session/created → persistence may already
69348
- * write its seed) and there is NO failure path afterwards that may
69349
- * be interpreted as "the child never happened" (`dispose()` stops
69350
- * an agent but never deletes a persisted session; dsh has no
69351
- * durable rollback);
69352
- * 4. COMMIT — a synchronous critical section (generation bump, live
69353
- * handle/agent replacement) with no awaits between its steps;
69354
- * 5. RETIRE — retire the old Direct owner in the fixed order
69355
- * (cancel → idle → drain continuable descendants → final flush →
69356
- * dispose — see src/runtime/direct/owned-session-retirement.ts),
69357
- * then child whenIdle, surface rebuild, catalog refresh —
69358
- * failures WARN ONLY, the committed child always stands.
69359
- *
69360
- * Must be called inside {@link transitionGate} (via
69361
- * `withSessionTransition` or the rewind commit's own gate wrapper).
69362
- */
69680
+ /** The ordinary session-transition transaction. Its canonical ordering
69681
+ * lives in `runTransitionTo` (src/transition.ts — unit-tested): quiesce and
69682
+ * flush the old owner, run caller preflight, create/open the child, commit
69683
+ * the visible handle synchronously, then retire the old Direct owner and
69684
+ * refresh the child surface. Published children are never treated as if
69685
+ * they did not exist, and callers use this only inside the gate. */
69363
69686
  /** Extract the live in-process agent from a transition next value: the
69364
69687
  * Direct SessionHandle carries it via direct.agent; an AgentHandle IS the
69365
- * agent handle. Remote handles carry neither (the client runtime owns the
69366
- * session there — M2+). */
69688
+ * agent handle. A Remote handle carries no Direct agent: it carries exactly one
69689
+ * Client generation reference (`SessionHandle.client`), which a composing
69690
+ * Remote runner WILL release through `clientOwnerOf()`. This Direct runner does
69691
+ * not take that owner over yet — the Remote caller-side handoff is deferred to
69692
+ * Remote production composition (M2+/M3). */
69367
69693
  const transitionTo = async (steps) => {
69694
+ navigationEpoch += 1;
69368
69695
  const from = liveAgent?.session.id;
69369
69696
  const opening = {
69370
69697
  id: steps.target.id,
@@ -69390,18 +69717,6 @@ function apply(ctx, config) {
69390
69717
  liveAgent = directAgentOf(next);
69391
69718
  if (cleanedUp) return;
69392
69719
  completionController.setLiveAgent(liveAgent.id);
69393
- if (steps.inheritSelection !== void 0) {
69394
- const target = directAgentOf(next);
69395
- const folded = foldPendingModelSelection(target.session.snapshotEvents());
69396
- if (!sameModelSelection(folded.pending ?? folded.lastUsed, steps.inheritSelection)) try {
69397
- modelSelections.selectForNextRequest(target, steps.inheritSelection);
69398
- } catch (error) {
69399
- diag.error("transition selection seed failed", {
69400
- session: target.session.id,
69401
- error: safeErrorMessage$1(error)
69402
- });
69403
- }
69404
- }
69405
69720
  },
69406
69721
  retireOld: async (next) => {
69407
69722
  const retired = [];
@@ -69432,7 +69747,6 @@ function apply(ctx, config) {
69432
69747
  } catch (error) {
69433
69748
  retired.push(`surface rebuild: ${safeErrorMessage$1(error)}`);
69434
69749
  }
69435
- if (openingSession === opening) openingSession = void 0;
69436
69750
  try {
69437
69751
  await refreshLiveCatalog(directAgentOf(next));
69438
69752
  } catch (error) {
@@ -69465,15 +69779,18 @@ function apply(ctx, config) {
69465
69779
  * failure (unknown session, broken log, preset mount) returns an error
69466
69780
  * string so callers' `.then(error => ...)` need no rejection path. The
69467
69781
  * whole switch (compose → resume → commit) runs inside the
69468
- * session-transition gate, so it can never interleave with /new, /fork
69469
- * or a rewind commit (the single-writer rule). */
69470
- const switchSession = (sessionId) => transitionGate.run(() => operationBarrier.runTransition(async () => {
69471
- try {
69472
- return await switchSessionLocked(sessionId);
69473
- } finally {
69474
- settlePendingQueueRecalls(false);
69475
- }
69476
- }));
69782
+ * session-transition gate, so it can never interleave with another
69783
+ * ordinary transition (the single-writer rule). */
69784
+ const switchSession = (sessionId) => {
69785
+ navigationEpoch += 1;
69786
+ return transitionGate.run(() => operationBarrier.runTransition(async () => {
69787
+ try {
69788
+ return await switchSessionLocked(sessionId);
69789
+ } finally {
69790
+ settlePendingQueueRecalls(false);
69791
+ }
69792
+ }));
69793
+ };
69477
69794
  const switchSessionLocked = async (sessionId) => {
69478
69795
  if (liveAgent !== void 0 && liveAgent.session.id === sessionId) return "already on this session";
69479
69796
  try {
@@ -69719,6 +70036,249 @@ function apply(ctx, config) {
69719
70036
  signal
69720
70037
  }))
69721
70038
  };
70039
+ let commandExecutionDepth = 0;
70040
+ const deferredSourceRetirements = [];
70041
+ const startSourceRetirement = (sessionId, retire) => {
70042
+ let pending;
70043
+ runOwned("fork source retirement", () => {
70044
+ const task = retire();
70045
+ pending = task;
70046
+ return task;
70047
+ }, {
70048
+ diag,
70049
+ sessionId: () => void 0
70050
+ });
70051
+ if (pending !== void 0) {
70052
+ const tracked = pending;
70053
+ pendingSourceRetirements.add(tracked);
70054
+ const untrack = () => {
70055
+ pendingSourceRetirements.delete(tracked);
70056
+ };
70057
+ observeSettled(tracked, untrack);
70058
+ }
70059
+ return pending;
70060
+ };
70061
+ /** Retire the source owner. A DSH command defers it to its own settlement
70062
+ * (the executor's `command/done` append must land first) and returns
70063
+ * `undefined`; outside a command the retirement promise is returned so the
70064
+ * caller keeps the baseline ordering.
70065
+ *
70066
+ * `finishRelease` settles the fork's admission pin when the retirement has
70067
+ * FINISHED — success or a CONTAINED failure. `retireDirectOwnedSession`
70068
+ * never rejects: a failed phase (notably `disposeOwner`) is recorded in its
70069
+ * report and diag-logged, and the pin still releases. That is deliberate:
70070
+ * holding the pin on a failed dispose would leave `open`/`resume` pending
70071
+ * forever, so the reopen instead proceeds and fails LOUDLY on the official
70072
+ * exclusive write claim (`session "X" is already owned by an active write
70073
+ * handle`). A failed dispose is a leaked-handle bug in its own right; the
70074
+ * retirement helper already contains and reports it. */
70075
+ const retireSourceOwnerAfterSettlement = (sessionId, retire, finishRelease) => {
70076
+ const run = async () => {
70077
+ try {
70078
+ await retire();
70079
+ } finally {
70080
+ finishRelease();
70081
+ }
70082
+ };
70083
+ if (commandExecutionDepth === 0) return startSourceRetirement(sessionId, run);
70084
+ deferredSourceRetirements.push({
70085
+ sessionId,
70086
+ retire: run
70087
+ });
70088
+ };
70089
+ /** Start every retirement queued by the settled command(s) and return the
70090
+ * started promises so the settlement can WAIT for them. */
70091
+ const flushSourceRetirementsAfterSettlement = () => {
70092
+ const started = [];
70093
+ while (deferredSourceRetirements.length > 0) {
70094
+ const entry = deferredSourceRetirements.shift();
70095
+ if (entry === void 0) continue;
70096
+ const pending = startSourceRetirement(entry.sessionId, entry.retire);
70097
+ if (pending !== void 0) started.push(pending);
70098
+ }
70099
+ return started;
70100
+ };
70101
+ /** Close one command-settlement window EXACTLY once and WAIT for the source
70102
+ * retirements it queued. The command workflow must not report completion —
70103
+ * nor release the submit FIFO — while the old owner still holds its write
70104
+ * lease, or an immediate reopen of that Session would be refused. The
70105
+ * executor's `command/done` append already happened inside the wrapped
70106
+ * execution, so this preserves durability AND makes `/fork`'s outward
70107
+ * completion imply the source is released. */
70108
+ const settleCommandExecution = async () => {
70109
+ commandExecutionDepth -= 1;
70110
+ if (commandExecutionDepth === 0) await Promise.allSettled(flushSourceRetirementsAfterSettlement());
70111
+ };
70112
+ const parkForkOwner = (handle) => {
70113
+ const owner = handle === void 0 ? void 0 : ownerHandleOf(handle);
70114
+ if (owner !== void 0) directOwnerPool.park(owner);
70115
+ };
70116
+ const forkNavigationCurrent = (expected) => isRewindIdentityCurrent({
70117
+ sessionId: liveAgent?.session.id,
70118
+ generation: sessionGeneration,
70119
+ navigationEpoch
70120
+ }, expected);
70121
+ const adoptFork = async (handle, expected, onAdopted, pin) => {
70122
+ let adopted = false;
70123
+ try {
70124
+ await transitionGate.run(() => operationBarrier.runTransition(async () => {
70125
+ if (cleanedUp || !forkNavigationCurrent(expected)) {
70126
+ parkForkOwner(handle);
70127
+ return;
70128
+ }
70129
+ const oldAgent = liveAgent;
70130
+ const oldHandle = liveHandle;
70131
+ const nextAgent = directAgentOf(handle);
70132
+ const nextHandle = ownerHandleOf(handle);
70133
+ if (nextAgent === void 0 || nextHandle === void 0) throw new Error(`forked session "${handle.session.id}" has no Direct owner`);
70134
+ settlePendingQueueRecalls(true);
70135
+ settleLocalSubmitAck("session forked");
70136
+ submitLatencyTracker.reset();
70137
+ bumpSessionGeneration();
70138
+ liveAgent = nextAgent;
70139
+ liveHandle = nextHandle;
70140
+ completionController.setLiveAgent(nextAgent.id);
70141
+ adopted = true;
70142
+ try {
70143
+ onAdopted?.();
70144
+ } catch (error) {
70145
+ diag.error("fork adoption callback failed after child commit", {
70146
+ error: safeErrorMessage$1(error),
70147
+ session: nextAgent.session.id
70148
+ });
70149
+ }
70150
+ if (oldAgent !== void 0 && oldHandle !== void 0) {
70151
+ if (pin !== void 0) pin.state.retirementOwnsRelease = true;
70152
+ const retirement = retireSourceOwnerAfterSettlement(oldAgent.session.id, async () => {
70153
+ const report = await retireDirectOwnedSession({
70154
+ cancel: () => oldAgent.cancel({ kind: "user" }),
70155
+ whenIdle: () => oldAgent.whenIdle(),
70156
+ drainDescendants: async () => {
70157
+ await ctx.get("subagents")?.drainContinuableDescendants?.([oldAgent]);
70158
+ },
70159
+ flush: async () => {
70160
+ await sessions.flush(oldAgent.session);
70161
+ },
70162
+ disposeOwner: () => oldHandle.dispose()
70163
+ });
70164
+ if (report.failures.length > 0) diag.error("fork old-owner retirement failed (child committed)", {
70165
+ from: oldAgent.session.id,
70166
+ failures: report.failures
70167
+ });
70168
+ }, pin?.release ?? (() => {}));
70169
+ if (retirement !== void 0) await retirement;
70170
+ }
70171
+ let aborted = false;
70172
+ try {
70173
+ aborted = await whenIdleOrAbort(nextAgent, lifecycleController.signal);
70174
+ } catch (error) {
70175
+ diag.error("fork child quiescence failed after commit", {
70176
+ error: safeErrorMessage$1(error),
70177
+ session: nextAgent.session.id
70178
+ });
70179
+ }
70180
+ if (!aborted) try {
70181
+ await initLiveSession(nextAgent);
70182
+ } catch (error) {
70183
+ diag.error("fork child initialization failed after commit", {
70184
+ error: safeErrorMessage$1(error),
70185
+ session: nextAgent.session.id
70186
+ });
70187
+ }
70188
+ try {
70189
+ await refreshLiveCatalog(nextAgent);
70190
+ } catch (error) {
70191
+ diag.error("fork child catalog refresh failed after commit", {
70192
+ error: safeErrorMessage$1(error),
70193
+ session: nextAgent.session.id
70194
+ });
70195
+ }
70196
+ }));
70197
+ } catch (error) {
70198
+ if (!adopted) throw error;
70199
+ diag.error("fork post-commit handoff failed", {
70200
+ error: safeErrorMessage$1(error),
70201
+ session: handle.session.id
70202
+ });
70203
+ }
70204
+ return adopted;
70205
+ };
70206
+ const forkSession = async (sourceSessionId, atSeq, onAdopted, pickerIdentity) => {
70207
+ const before = {
70208
+ sessionId: liveAgent?.session.id,
70209
+ generation: sessionGeneration,
70210
+ navigationEpoch
70211
+ };
70212
+ const pickerCurrent = pickerIdentity === void 0 || isRewindIdentityCurrent(before, pickerIdentity);
70213
+ const expectedSessionId = pickerIdentity?.sessionId ?? before.sessionId;
70214
+ if (cleanedUp || !pickerCurrent || expectedSessionId !== sourceSessionId) return {
70215
+ kind: "error",
70216
+ text: "the session changed before fork dispatch"
70217
+ };
70218
+ const expected = {
70219
+ sessionId: expectedSessionId,
70220
+ generation: pickerIdentity?.generation ?? before.generation,
70221
+ navigationEpoch: ++navigationEpoch
70222
+ };
70223
+ const pin = beginForkSourcePin(sourceSessionId);
70224
+ let settleFork;
70225
+ let forkedHandle;
70226
+ const pending = new Promise((resolve) => {
70227
+ settleFork = resolve;
70228
+ });
70229
+ pendingForks.add(pending);
70230
+ try {
70231
+ const result = await backend.sessionLifecycle.fork({
70232
+ sourceSessionId,
70233
+ ...atSeq === void 0 ? {} : { atSeq }
70234
+ });
70235
+ const outcome = result.outcome;
70236
+ if (outcome.kind === "unavailable") {
70237
+ if (result.ownership === "superseded" || !forkNavigationCurrent(expected)) return { kind: "success" };
70238
+ return {
70239
+ kind: "error",
70240
+ text: outcome.message
70241
+ };
70242
+ }
70243
+ if (outcome.kind === "rejected" || outcome.kind === "indeterminate" || outcome.kind === "published-with-error") {
70244
+ if (outcome.kind === "published-with-error") parkForkOwner(outcome.handle);
70245
+ if (result.ownership === "superseded" || !forkNavigationCurrent(expected)) return { kind: "success" };
70246
+ return {
70247
+ kind: "error",
70248
+ text: `${outcome.error.message} (${outcome.error.code})`
70249
+ };
70250
+ }
70251
+ if (result.ownership === "superseded" || !forkNavigationCurrent(expected)) {
70252
+ parkForkOwner(outcome.handle);
70253
+ return {
70254
+ kind: "success",
70255
+ text: `forked as ${outcome.handle.session.id}; navigation stayed on the newer session`
70256
+ };
70257
+ }
70258
+ forkedHandle = outcome.handle;
70259
+ if (!await adoptFork(outcome.handle, expected, onAdopted, pin)) return {
70260
+ kind: "success",
70261
+ text: `forked as ${outcome.handle.session.id}`
70262
+ };
70263
+ draftImages.clearUnpinned();
70264
+ draftFiles.clearUnpinned();
70265
+ return {
70266
+ kind: "success",
70267
+ text: `forked as ${outcome.handle.session.id}`
70268
+ };
70269
+ } catch (error) {
70270
+ if (forkedHandle !== void 0) parkForkOwner(forkedHandle);
70271
+ if (!forkNavigationCurrent(expected)) return { kind: "success" };
70272
+ return {
70273
+ kind: "error",
70274
+ text: `fork failed: ${safeErrorMessage$1(error)}`
70275
+ };
70276
+ } finally {
70277
+ settleFork();
70278
+ pendingForks.delete(pending);
70279
+ if (!pin.state.retirementOwnsRelease) pin.release();
70280
+ }
70281
+ };
69722
70282
  let localShellController;
69723
70283
  /** Stop the captured live Agent through the SessionWriter seam. The
69724
70284
  * operation barrier keeps the async outcome inside the same session
@@ -69806,7 +70366,7 @@ function apply(ctx, config) {
69806
70366
  });
69807
70367
  if (lifecycleController.signal.aborted) {
69808
70368
  startupStatus.clear();
69809
- if (liveAgent !== void 0 && liveHandle !== void 0) await retireOwnedSession();
70369
+ if (liveAgent !== void 0 || liveHandle !== void 0 || parkedDirectOwners.size > 0 || pendingForks.size > 0) await retireOwnedSession();
69810
70370
  else diag.dispose();
69811
70371
  return;
69812
70372
  }
@@ -70099,13 +70659,24 @@ function apply(ctx, config) {
70099
70659
  };
70100
70660
  /** The display text of one client-local submission echo: the draft text
70101
70661
  * with its attachment placeholders expanded to compact markers, so an
70102
- * attachment-only submission is never an empty pending row. */
70103
- const localEchoText = (text) => {
70662
+ * attachment-only submission is never an empty pending row. The SAME
70663
+ * expansion decides the foldability fact: a submission carrying any
70664
+ * attachment marker is not text-only and must render in full. */
70665
+ const localEcho = (text) => {
70104
70666
  const parts = [];
70667
+ let foldableText = true;
70105
70668
  for (const segment of expandAttachmentPlaceholders(text, draftImages, draftFiles)) if (segment.type === "text") parts.push(segment.text);
70106
- else if (segment.type === "image") parts.push(`🖼️ ${segment.image.name ?? "image"}`);
70107
- else parts.push(`📄 ${segment.file.name} · ${formatBytes(segment.file.byteLength)}`);
70108
- return parts.join(" ");
70669
+ else if (segment.type === "image") {
70670
+ foldableText = false;
70671
+ parts.push(`🖼️ ${segment.image.name ?? "image"}`);
70672
+ } else {
70673
+ foldableText = false;
70674
+ parts.push(`📄 ${segment.file.name} · ${formatBytes(segment.file.byteLength)}`);
70675
+ }
70676
+ return {
70677
+ text: parts.join(" "),
70678
+ foldableText
70679
+ };
70109
70680
  };
70110
70681
  /**
70111
70682
  * Own pending input must become VISIBLE even when the reader deliberately
@@ -70657,10 +71228,12 @@ function apply(ctx, config) {
70657
71228
  * feedback and the durable row replaces it.
70658
71229
  */
70659
71230
  const beginLocalSubmission = (requestId, text, placement, sessionId, generation, ackToken) => {
71231
+ const echo = localEcho(text);
70660
71232
  pendingSubmissions.begin({
70661
71233
  requestId,
70662
71234
  placement,
70663
- text: localEchoText(text),
71235
+ text: echo.text,
71236
+ foldableText: echo.foldableText,
70664
71237
  createdAt: Date.now(),
70665
71238
  ...sessionId === void 0 ? {} : { sessionId },
70666
71239
  generation
@@ -70835,7 +71408,6 @@ function apply(ctx, config) {
70835
71408
  * this submission; bound for the command execution so a TUI-owned
70836
71409
  * skill handler accepts it instead of re-deriving it. */
70837
71410
  const dispatchViaSession = (text, persistHistory, delivery) => {
70838
- const submitTurn = takeSubmitTurn();
70839
71411
  const submittedAgent = liveAgent;
70840
71412
  const submittedGeneration = sessionGeneration;
70841
71413
  let submitTurnTransferred = false;
@@ -70861,6 +71433,7 @@ function apply(ctx, config) {
70861
71433
  };
70862
71434
  let commandHealthRef;
70863
71435
  let planeAdvertised = false;
71436
+ const submitTurn = takeSubmitTurn();
70864
71437
  runOwned("submit", () => runReservedSubmit({
70865
71438
  reserve: (t) => {
70866
71439
  try {
@@ -70975,28 +71548,42 @@ function apply(ctx, config) {
70975
71548
  const commandPlaneLine = commandPlaneOwnsLine();
70976
71549
  planeAdvertised = commandPlaneLine && wasAdvertisedAtSubmit;
70977
71550
  const tuiOwnedCommand = parsedAtSubmit !== void 0 && (LOCAL_COMMANDS.has(parsedAtSubmit.name) || isSkillWrapperName?.(parsedAtSubmit.name) === true);
70978
- return withCommandDelivery(delivery, () => {
70979
- if (!commandPlaneLine || parsedAtSubmit === void 0) return Promise.resolve({
70980
- kind: "committed",
70981
- matched: false
70982
- });
70983
- if (tuiOwnedCommand) return commands.execute(agent, toggled, submittedAttachments, signal).then((execution) => {
70984
- return execution === void 0 ? {
71551
+ commandExecutionDepth += 1;
71552
+ let settled;
71553
+ try {
71554
+ settled = Promise.resolve(withCommandDelivery(delivery, () => {
71555
+ if (!commandPlaneLine || parsedAtSubmit === void 0) return Promise.resolve({
70985
71556
  kind: "committed",
70986
71557
  matched: false
70987
- } : {
70988
- kind: "committed",
70989
- matched: true,
70990
- execution
70991
- };
70992
- });
70993
- return operationBarrier.runWriter(agent.session.id, () => backend.hostCommand.execute({
70994
- sessionId: agent.session.id,
70995
- line: toggled,
70996
- attachments: submittedAttachments,
70997
- signal
71558
+ });
71559
+ if (tuiOwnedCommand) return commands.execute(agent, toggled, submittedAttachments, signal).then((execution) => {
71560
+ return execution === void 0 ? {
71561
+ kind: "committed",
71562
+ matched: false
71563
+ } : {
71564
+ kind: "committed",
71565
+ matched: true,
71566
+ execution
71567
+ };
71568
+ });
71569
+ return operationBarrier.runWriter(agent.session.id, () => backend.hostCommand.execute({
71570
+ sessionId: agent.session.id,
71571
+ line: toggled,
71572
+ attachments: submittedAttachments,
71573
+ signal
71574
+ }));
70998
71575
  }));
70999
- });
71576
+ } catch (error) {
71577
+ commandExecutionDepth -= 1;
71578
+ throw error;
71579
+ }
71580
+ settled = settled.finally(settleCommandExecution);
71581
+ pendingSettlementWork.add(settled);
71582
+ const untrackSettlement = () => {
71583
+ pendingSettlementWork.delete(settled);
71584
+ };
71585
+ observeSettled(settled, untrackSettlement);
71586
+ return settled;
71000
71587
  }, {
71001
71588
  diag,
71002
71589
  sessionId: () => agent.session.id,
@@ -71056,95 +71643,106 @@ function apply(ctx, config) {
71056
71643
  if (sessionUnchanged({
71057
71644
  agent,
71058
71645
  generation
71059
- }, liveAgent, sessionGeneration)) runOwned("image submit", () => runReservedSubmit({
71060
- reserve: () => fallbackPin,
71061
- run: async () => {
71062
- try {
71063
- await operationBarrier.runWriter(agent.session.id, async () => {
71064
- if (!localEchoInstalled) {
71065
- beginLocalSubmission(submitRequestId, text, submissionPlacement("queue", agent.status === "running"), agent.session.id, generation, submitAckToken);
71066
- localEchoInstalled = true;
71067
- }
71068
- const message = await prepareUserMessage(text, draftImages, submitDeps, { requestId: submitRequestId });
71069
- if (cleanedUp) return;
71070
- if (!sessionUnchanged({
71071
- agent,
71072
- generation
71073
- }, liveAgent, sessionGeneration)) {
71074
- const merged = mergeDraft(app.getDraft(), text);
71075
- app.setEditorText(merged);
71076
- settleLocalSubmission(submitRequestId);
71077
- settleLocalSubmitAck("submit stale", {
71078
- token: submitAckToken,
71079
- terminal: true
71080
- });
71081
- app.notify(merged === text ? "the session changed while sending — try again" : "the draft changed while sending — review it before submitting again (the earlier text was preserved below)", "error");
71082
- return;
71083
- }
71084
- submitLatencyTracker.mark(agent.session.id, "dispatch");
71085
- const outcome = await backend.sessionWriter.prompt(agent.session.id, message, "queue");
71086
- if (cleanedUp) return;
71087
- if (outcome.kind !== "committed") {
71088
- if (outcome.kind === "indeterminate") {
71646
+ }, liveAgent, sessionGeneration)) {
71647
+ let nestedSettlement;
71648
+ runOwned("image submit", () => {
71649
+ const task = runReservedSubmit({
71650
+ reserve: () => fallbackPin,
71651
+ run: async () => {
71652
+ try {
71653
+ await operationBarrier.runWriter(agent.session.id, async () => {
71654
+ if (!localEchoInstalled) {
71655
+ beginLocalSubmission(submitRequestId, text, submissionPlacement("queue", agent.status === "running"), agent.session.id, generation, submitAckToken);
71656
+ localEchoInstalled = true;
71657
+ }
71658
+ const message = await prepareUserMessage(text, draftImages, submitDeps, { requestId: submitRequestId });
71659
+ if (cleanedUp) return;
71660
+ if (!sessionUnchanged({
71661
+ agent,
71662
+ generation
71663
+ }, liveAgent, sessionGeneration)) {
71664
+ const merged = mergeDraft(app.getDraft(), text);
71665
+ app.setEditorText(merged);
71666
+ settleLocalSubmission(submitRequestId);
71667
+ settleLocalSubmitAck("submit stale", {
71668
+ token: submitAckToken,
71669
+ terminal: true
71670
+ });
71671
+ app.notify(merged === text ? "the session changed while sending — try again" : "the draft changed while sending — review it before submitting again (the earlier text was preserved below)", "error");
71672
+ return;
71673
+ }
71674
+ submitLatencyTracker.mark(agent.session.id, "dispatch");
71675
+ const outcome = await backend.sessionWriter.prompt(agent.session.id, message, "queue");
71089
71676
  if (cleanedUp) return;
71677
+ if (outcome.kind !== "committed") {
71678
+ if (outcome.kind === "indeterminate") {
71679
+ if (cleanedUp) return;
71680
+ settleLocalSubmission(submitRequestId);
71681
+ settleLocalSubmitAck("session write result indeterminate", {
71682
+ token: submitAckToken,
71683
+ terminal: true
71684
+ });
71685
+ app.notify("session write result is indeterminate — do not retry automatically", "error");
71686
+ return;
71687
+ }
71688
+ if (outcome.kind === "cancelled") throw cancellationError("session write cancelled");
71689
+ const failure = outcome.kind === "rejected" ? outcome.error.message : outcome.reason;
71690
+ throw new Error(failure);
71691
+ }
71692
+ consumeDraftAttachments(text, draftImages, draftFiles);
71693
+ });
71694
+ } catch (error) {
71695
+ if (cleanedUp) {
71696
+ fallbackPin();
71697
+ return;
71698
+ }
71699
+ if (error instanceof TransitionInProgressError) {
71700
+ fallbackPin();
71090
71701
  settleLocalSubmission(submitRequestId);
71091
- settleLocalSubmitAck("session write result indeterminate", {
71702
+ refuseByTransitionFence(text, () => app.getDraft(), (t) => app.setEditorText(t), (m, k) => app.notify(m, k));
71703
+ settleLocalSubmitAck("submit refused by transition fence", {
71092
71704
  token: submitAckToken,
71093
71705
  terminal: true
71094
71706
  });
71095
- app.notify("session write result is indeterminate — do not retry automatically", "error");
71096
71707
  return;
71097
71708
  }
71098
- if (outcome.kind === "cancelled") throw cancellationError("session write cancelled");
71099
- const failure = outcome.kind === "rejected" ? outcome.error.message : outcome.reason;
71100
- throw new Error(failure);
71709
+ throw error;
71101
71710
  }
71102
- consumeDraftAttachments(text, draftImages, draftFiles);
71711
+ },
71712
+ restore: (t) => restoreSubmissionDraft(t)
71713
+ }, text);
71714
+ nestedSettlement = task;
71715
+ const untrackNested = () => {
71716
+ if (nestedSettlement !== void 0) pendingSettlementWork.delete(nestedSettlement);
71717
+ };
71718
+ observeSettled(task, untrackNested);
71719
+ return task;
71720
+ }, {
71721
+ diag,
71722
+ sessionId: () => agent.session.id,
71723
+ onResult: () => {
71724
+ submitTurn.release();
71725
+ },
71726
+ onError: (error) => {
71727
+ submitTurn.release();
71728
+ settleLocalSubmission(submitRequestId);
71729
+ settleLocalSubmitAck("failure", {
71730
+ token: submitAckToken,
71731
+ terminal: true
71732
+ });
71733
+ notifySubmissionFailure(error);
71734
+ },
71735
+ onCancel: () => {
71736
+ submitTurn.release();
71737
+ settleLocalSubmission(submitRequestId);
71738
+ settleLocalSubmitAck("submit cancelled", {
71739
+ token: submitAckToken,
71740
+ terminal: true
71103
71741
  });
71104
- } catch (error) {
71105
- if (cleanedUp) {
71106
- fallbackPin();
71107
- return;
71108
- }
71109
- if (error instanceof TransitionInProgressError) {
71110
- fallbackPin();
71111
- settleLocalSubmission(submitRequestId);
71112
- refuseByTransitionFence(text, () => app.getDraft(), (t) => app.setEditorText(t), (m, k) => app.notify(m, k));
71113
- settleLocalSubmitAck("submit refused by transition fence", {
71114
- token: submitAckToken,
71115
- terminal: true
71116
- });
71117
- return;
71118
- }
71119
- throw error;
71120
71742
  }
71121
- },
71122
- restore: (t) => restoreSubmissionDraft(t)
71123
- }, text), {
71124
- diag,
71125
- sessionId: () => agent.session.id,
71126
- onResult: () => {
71127
- submitTurn.release();
71128
- },
71129
- onError: (error) => {
71130
- submitTurn.release();
71131
- settleLocalSubmission(submitRequestId);
71132
- settleLocalSubmitAck("failure", {
71133
- token: submitAckToken,
71134
- terminal: true
71135
- });
71136
- notifySubmissionFailure(error);
71137
- },
71138
- onCancel: () => {
71139
- submitTurn.release();
71140
- settleLocalSubmission(submitRequestId);
71141
- settleLocalSubmitAck("submit cancelled", {
71142
- token: submitAckToken,
71143
- terminal: true
71144
- });
71145
- }
71146
- });
71147
- else {
71743
+ });
71744
+ if (nestedSettlement !== void 0) pendingSettlementWork.add(nestedSettlement);
71745
+ } else {
71148
71746
  fallbackPin();
71149
71747
  submitTurn.release();
71150
71748
  const merged = mergeDraft(app.getDraft(), text);
@@ -73259,7 +73857,7 @@ function apply(ctx, config) {
73259
73857
  lifecycleController.signal.throwIfAborted();
73260
73858
  return requireCreated(await backend.sessionLifecycle.create({
73261
73859
  sessionId: String(sessionId),
73262
- meta: { cwd: process.cwd() },
73860
+ cwd: process.cwd(),
73263
73861
  agentPreset: composition.agentPreset,
73264
73862
  signal: lifecycleController.signal
73265
73863
  }));
@@ -73425,9 +74023,9 @@ function apply(ctx, config) {
73425
74023
  /**
73426
74024
  * The conversation rewind picker (the ONE entry shared by the idle
73427
74025
  * empty-editor double-Esc and `/rewind` — plan §22). Lists the completed
73428
- * user turns of the live session; a selection commits through
73429
- * `commitRewind` (create → commit → prompt restore) as an OWNED task with
73430
- * the stale-generation gates. Sessionless (deferred start) it notifies
74026
+ * user turns of the live session; a selection dispatches the semantic Host fork with the candidate's predecessor boundary
74027
+ * as an OWNED task with
74028
+ * the navigation identity gates. Sessionless (deferred start) it notifies
73431
74029
  * and never creates a session.
73432
74030
  */
73433
74031
  function openRewindPicker() {
@@ -73446,51 +74044,31 @@ function apply(ctx, config) {
73446
74044
  return;
73447
74045
  }
73448
74046
  const sourceId = source.session.id;
73449
- const sourceGeneration = sessionGeneration;
73450
- const sourceSelection = selected.current;
73451
- const commitHost = {
73452
- sessionCwd: () => sessionCwd(),
73453
- sessionPreset: (session) => session.id === sourceId ? currentPreset() : sessionPresetOf(ctx, session),
73454
- compose,
73455
- agents: lifecycleAgents,
73456
- liveIdentity: () => ({
73457
- sessionId: liveAgent?.session.id,
73458
- generation: sessionGeneration
73459
- }),
73460
- transitionTo: (steps) => transitionTo(steps),
73461
- replaceDraft: (text) => app.setDraft(text)
74047
+ const pickerIdentity = {
74048
+ sessionId: sourceId,
74049
+ generation: sessionGeneration,
74050
+ navigationEpoch
73462
74051
  };
73463
74052
  app.openPicker(candidates.map(rewindPickerItem), (value) => {
73464
74053
  const candidate = candidates.find((item) => String(item.turnStartSeq) === value);
73465
74054
  if (candidate === void 0) return;
73466
- runOwned("conversation rewind", () => {
73467
- return transitionGate.run(() => operationBarrier.runTransition(async () => {
73468
- try {
73469
- return await commitRewind(commitHost, source, candidate, {
73470
- sessionId: sourceId,
73471
- generation: sourceGeneration
73472
- }, sourceSelection);
73473
- } finally {
73474
- settlePendingQueueRecalls(false);
73475
- }
73476
- }));
73477
- }, {
74055
+ let adopted = false;
74056
+ runOwned("conversation rewind", () => forkSession(sourceId, candidate.forkAtSeq, () => {
74057
+ adopted = true;
74058
+ app.setDraft(candidate.editorText);
74059
+ }, pickerIdentity), {
73478
74060
  diag,
73479
74061
  sessionId: () => sourceId,
73480
74062
  onResult: (outcome) => {
73481
- if (outcome.kind === "superseded") return;
73482
- if (outcome.kind === "stale") {
73483
- app.notify("session changed — rewind cancelled", "info");
74063
+ if (outcome.kind === "success" && adopted) {
74064
+ if (candidate.hasNonTextContent) app.notify(`rewound to turn ${candidate.turn}; original non-text content was not re-staged — review it before sending`, "error");
74065
+ else app.notify(`rewound to turn ${candidate.turn}`, "info");
73484
74066
  return;
73485
74067
  }
73486
- if (outcome.kind === "failed") {
73487
- app.notify(outcome.message, "error");
73488
- return;
74068
+ if (outcome.kind === "error") {
74069
+ if (outcome.text === "the session changed before fork dispatch") app.notify("session changed — rewind cancelled", "info");
74070
+ else app.notify(outcome.text, "error");
73489
74071
  }
73490
- draftImages.clearUnpinned();
73491
- draftFiles.clearUnpinned();
73492
- if (outcome.hasNonTextContent) app.notify(`rewound to turn ${outcome.turn}; original non-text content was not re-staged — review it before sending`, "error");
73493
- else app.notify(`rewound to turn ${outcome.turn}`, "info");
73494
74072
  },
73495
74073
  onError: (error) => {
73496
74074
  app.notify(safeErrorMessage$1(error), "error");
@@ -73603,6 +74181,7 @@ function apply(ctx, config) {
73603
74181
  }) : refresh(request);
73604
74182
  },
73605
74183
  switchSession,
74184
+ forkSession,
73606
74185
  transitionTo,
73607
74186
  currentPreset,
73608
74187
  sessionBlank,
@@ -73968,4 +74547,4 @@ function apply(ctx, config) {
73968
74547
  });
73969
74548
  }
73970
74549
  //#endregion
73971
- export { shouldConsumeAdvertisedMiss as A, name$1 as B, normalizeSkillInvocation as C, resumeCommand as D, resolveSubmitDelivery as E, viewerActionCapability as F, API_VERSION as H, dshVersion as I, forkSeed as L, subagentJobViewHint as M, taskRowSelectionDisposition as N, runningProfile as O, teardownViewerForSessionSwap as P, apply$1 as R, name as S, resolveInitialCatalog as T, describeKey as U, PI_TUI_EXTENSIONS_SERVICE as V, interruptAgent as _, apply as a, isPlainExitPrompt as b, commandRejectsImages as c, contextRefreshKind as d, createViewerOpenToken as f, inject as g, foldQueueRows as h, SESSIONLESS_COMMANDS as i, subagentJobTranscriptId as j, settleCompactionSurface as k, compactingFromLog as l, foldCompactionEvent as m, HOST_COMMAND_CATALOG as n, busyAfterTurnBoundary as o, dangerCommand as p, LOCAL_COMMANDS as r, commandIsLocalForAttachments as s, Config as t, composeAgent as u, isBareCommandLine as v, recordedPreset as w, matchPendingSubagentCall as x, isLocalCommandLine as y, inject$1 as z };
74550
+ export { shouldConsumeAdvertisedMiss as A, PI_TUI_EXTENSIONS_SERVICE as B, normalizeSkillInvocation as C, resumeCommand as D, resolveSubmitDelivery as E, viewerActionCapability as F, describeKey as H, dshVersion as I, apply$1 as L, subagentJobViewHint as M, taskRowSelectionDisposition as N, runningProfile as O, teardownViewerForSessionSwap as P, inject$1 as R, name as S, resolveInitialCatalog as T, API_VERSION as V, interruptAgent as _, apply as a, isPlainExitPrompt as b, commandRejectsImages as c, contextRefreshKind as d, createViewerOpenToken as f, inject as g, foldQueueRows as h, SESSIONLESS_COMMANDS as i, subagentJobTranscriptId as j, settleCompactionSurface as k, compactingFromLog as l, foldCompactionEvent as m, HOST_COMMAND_CATALOG as n, busyAfterTurnBoundary as o, dangerCommand as p, LOCAL_COMMANDS as r, commandIsLocalForAttachments as s, Config as t, composeAgent as u, isBareCommandLine as v, recordedPreset as w, matchPendingSubagentCall as x, isLocalCommandLine as y, name$1 as z };