@xmoon76/dsh-pi-tui 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -29,6 +29,7 @@ import { eastAsianWidth } from "get-east-asian-width";
29
29
  import { performance } from "node:perf_hooks";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { EventEmitter } from "events";
32
+ import { isUserInvocable } from "@deepseek-ai/dsh-skill";
32
33
  //#region src/present.ts
33
34
  /** Figma row titles per variant (design literals, not translatable copy). */
34
35
  const VARIANT_TITLES = {
@@ -12623,8 +12624,9 @@ const OTHER_ROW = "\0other";
12623
12624
  /**
12624
12625
  * Default total physical-row budget of the question flow itself. The
12625
12626
  * QuestionFrame in tui-app.ts re-derives the budget from the terminal height
12626
- * on every render (60% cap, 8..24 content rows) and pushes it through
12627
- * {@link QuestionFlow.setMaxRows}; 24 is the fallback for direct renders.
12627
+ * on every render (60% cap COLLAPSED, 8..24 content rows; up to 38 when
12628
+ * expanded) and pushes it through {@link QuestionFlow.setMaxRows}; 24 is the
12629
+ * fallback for direct renders.
12628
12630
  * The Frame wrapper adds its two border rows, and NOTHING clips the flow's
12629
12631
  * output in the editor-seat layout — so EVERYTHING (tabs, question, detail,
12630
12632
  * options with their descriptions, scroll marker, skipped note, hint) must
@@ -12638,13 +12640,24 @@ const DEFAULT_BUDGET = 24;
12638
12640
  */
12639
12641
  const MIN_SUPPORTED_BUDGET = 8;
12640
12642
  /**
12641
- * Total physical-row budget for the question body (question text + detail),
12642
- * shared so a single unbroken long line cannot push the options and hints
12643
- * out of the dialog's maxHeight (kimi MAX_BODY_LINES scale).
12643
+ * Largest supported content-row budget: the EXPANDED frame cap (80% of the
12644
+ * terminal, at most 40 frame rows) minus the two frame borders. An explicit
12645
+ * body expand ('e' / marker click) grows the panel beyond the default 60%
12646
+ * cap — the user asked for the room; the budget math is linear and holds
12647
+ * for every budget >= MIN_SUPPORTED_BUDGET.
12644
12648
  */
12645
- const MAX_BODY_LINES = 12;
12646
- /** Max visible option rows before the list windows around the cursor. */
12647
- const MAX_VISIBLE_OPTIONS = 7;
12649
+ const MAX_BUDGET = 38;
12650
+ /**
12651
+ * Cap for the WRAPPED page scrollport content (question + detail + options
12652
+ * with their descriptions). Beyond it the content ends in a `... more
12653
+ * content hidden` note — wrapping an unbounded 100KB detail every frame
12654
+ * would be wasteful. 256 rows keeps realistic questions AND all their
12655
+ * answerable options reachable (7 options × ~19 wrapped description rows
12656
+ * at 50 columns fits); the cap is a hard wall, so a page whose content
12657
+ * alone exceeds 256 rows ends in the note — that page is unreachable in
12658
+ * practice, and the note makes the cut non-silent.
12659
+ */
12660
+ const MAX_CONTENT_ROWS = 256;
12648
12661
  /**
12649
12662
  * Budgeted push of `content` to `lines`, wrapping it to `width`: pushes at
12650
12663
  * most `budget` PHYSICAL (wrapped) rows, appends a `... N more` marker when
@@ -12736,13 +12749,46 @@ var QuestionFlow = class {
12736
12749
  /** Review-page action highlight (0 = Submit, 1 = Cancel). */
12737
12750
  submitIdx = 0;
12738
12751
  /**
12739
- * Current content-row budget (8..24). The editor-seat QuestionFrame in
12752
+ * Current content-row budget (8..38). The editor-seat QuestionFrame in
12740
12753
  * tui-app.ts re-derives it from the terminal height on every render and
12741
12754
  * pushes it through {@link setMaxRows}; nothing clips the flow's output
12742
12755
  * in that layout, so every render must fit the budget exactly.
12743
12756
  */
12744
12757
  budget = DEFAULT_BUDGET;
12745
12758
  _focused = false;
12759
+ /**
12760
+ * Scroll offset (content rows) into the UNIFIED page scrollport (question,
12761
+ * detail, then every option with its description, then the free-text row).
12762
+ * PageUp/PageDown move it; ↑↓/digits/click move the cursor and the render
12763
+ * follows it into view (pendingCursorScroll).
12764
+ */
12765
+ bodyScroll = 0;
12766
+ /**
12767
+ * Expanded mode ('e' / marker click): the FRAME grows toward 80% (the
12768
+ * QuestionFrame reads isBodyExpanded each render), so the scrollport is
12769
+ * taller and more of the page — including option descriptions — is visible
12770
+ * at once. Everything stays reachable by scrolling either way.
12771
+ */
12772
+ bodyExpanded = false;
12773
+ /** Cursor moved since the last render: the next render must scroll the
12774
+ * cursor's rows into view. */
12775
+ pendingCursorScroll = false;
12776
+ /** Hit map from the last render: content row -> option row key. Built each
12777
+ * render; drives fullscreen click-to-select. */
12778
+ hitMap = /* @__PURE__ */ new Map();
12779
+ /** Content row of the scroll marker in the last render (-1 = none);
12780
+ * clicking it toggles the expanded panel. */
12781
+ lastMarkerRow = -1;
12782
+ /** Scrollport height from the last render (scroll page math). */
12783
+ lastRegionHeight = 0;
12784
+ /** Wrapped page content length from the last render. */
12785
+ lastContentRows = 0;
12786
+ /** Content rows visible in the region from the last render (region minus
12787
+ * the marker row when overflowing). */
12788
+ lastVisibleRows = 0;
12789
+ /** Whether the page content overflows the scrollport on the last render —
12790
+ * 'e' (and the scroll verbs) are available when true. */
12791
+ lastExpandable = false;
12746
12792
  constructor(questions, onDone, onCancel) {
12747
12793
  this.questions = questions;
12748
12794
  this.onDone = onDone;
@@ -12773,10 +12819,144 @@ var QuestionFlow = class {
12773
12819
  * Replace the content-row budget (the Frame wrapper adds its two border
12774
12820
  * rows on top). Called by QuestionFrame on every render immediately
12775
12821
  * before the flow renders, so no invalidation is needed; values outside
12776
- * [MIN_SUPPORTED_BUDGET, DEFAULT_BUDGET] are clamped.
12822
+ * [MIN_SUPPORTED_BUDGET, MAX_BUDGET] are clamped.
12777
12823
  */
12778
12824
  setMaxRows(rows) {
12779
- this.budget = Math.max(MIN_SUPPORTED_BUDGET, Math.min(DEFAULT_BUDGET, Math.floor(rows)));
12825
+ this.budget = Math.max(MIN_SUPPORTED_BUDGET, Math.min(MAX_BUDGET, Math.floor(rows)));
12826
+ }
12827
+ /** Whether the panel is expanded (the frame grows toward 80% of the
12828
+ * terminal — QuestionFrame reads this every render). */
12829
+ isBodyExpanded() {
12830
+ return this.bodyExpanded;
12831
+ }
12832
+ /**
12833
+ * Reset the view (scroll + expanded + pending cursor follow) — called on
12834
+ * every tab change so each question starts at its top, collapsed.
12835
+ */
12836
+ resetBodyView() {
12837
+ this.bodyScroll = 0;
12838
+ this.bodyExpanded = false;
12839
+ this.pendingCursorScroll = false;
12840
+ }
12841
+ /**
12842
+ * Page the unified scrollport. One page = the visible content rows. No-op
12843
+ * when the content fits the region.
12844
+ */
12845
+ scrollBody(direction) {
12846
+ const visible = this.lastVisibleRows;
12847
+ if (visible <= 0 || this.lastContentRows <= visible) return;
12848
+ const maxScroll = this.lastContentRows - visible;
12849
+ this.bodyScroll = Math.max(0, Math.min(this.bodyScroll + direction * visible, maxScroll));
12850
+ }
12851
+ /** Toggle the expanded panel ('e' or a click on the scroll marker).
12852
+ * Expanding is only meaningful when the page content overflows the
12853
+ * scrollport (everything fits -> a taller panel shows nothing new). The
12854
+ * SCROLL POSITION is kept: the taller region reveals MORE rows where the
12855
+ * user is looking (e.g. the option descriptions they scrolled to), instead
12856
+ * of jumping back to the top — the render clamps the offset to the new
12857
+ * maxScroll. */
12858
+ toggleExpanded() {
12859
+ if (!this.bodyExpanded && !this.lastExpandable) return;
12860
+ this.bodyExpanded = !this.bodyExpanded;
12861
+ }
12862
+ /**
12863
+ * Primary-click routing (fullscreen): an option row selects it (single-
12864
+ * select advances, multi-select toggles, the "Type something." row enters
12865
+ * free-text), and the scroll marker toggles the expanded panel. The hit
12866
+ * map reflects the LAST rendered frame, which is what the user sees.
12867
+ */
12868
+ clickRow(row) {
12869
+ if (row < 0 || this.tab >= this.questions.length) return;
12870
+ const key = this.hitMap.get(row);
12871
+ if (key === OTHER_ROW) {
12872
+ if (this.editingOther) return;
12873
+ const index = this.rows().findIndex((candidate) => candidate.key === OTHER_ROW);
12874
+ if (index >= 0) {
12875
+ this.cursor = index;
12876
+ this.pendingCursorScroll = true;
12877
+ this.enterOther();
12878
+ }
12879
+ return;
12880
+ }
12881
+ if (key !== void 0) {
12882
+ this.cursor = Number(key);
12883
+ this.pendingCursorScroll = true;
12884
+ this.confirm();
12885
+ return;
12886
+ }
12887
+ if (row === this.lastMarkerRow) this.toggleExpanded();
12888
+ }
12889
+ /**
12890
+ * Build the UNIFIED page scrollport content: the question + detail first
12891
+ * (required-first — row 0 is always the question's first physical row),
12892
+ * a separator, then every option row (label + description), then the
12893
+ * free-text row. Each option row maps to its key for click hit-testing.
12894
+ * Capped at MAX_CONTENT_ROWS with a trailing `... more content hidden`
12895
+ * note, so wrapping an unbounded detail/description stays cheap.
12896
+ */
12897
+ buildPageContent(question, rows, draft, multi, width) {
12898
+ const lines = [];
12899
+ const hits = [];
12900
+ let left = MAX_CONTENT_ROWS;
12901
+ left = appendContentFirst(lines, `${color.primary("?")} `, " ", color.textStrong(question.question), width, left);
12902
+ hits.push(...new Array(lines.length).fill(void 0));
12903
+ if (question.detail !== void 0 && question.detail !== "") for (const line of question.detail.split("\n")) {
12904
+ if (left <= 1) {
12905
+ appendWrappedBudgeted(lines, " ", " ", color.textDim("... more content hidden"), width, left);
12906
+ break;
12907
+ }
12908
+ left = appendWrappedBudgeted(lines, " ", " ", color.textDim(line), width, left);
12909
+ }
12910
+ hits.push(...new Array(lines.length - hits.length).fill(void 0));
12911
+ lines.push("");
12912
+ hits.push(void 0);
12913
+ for (const row of rows) {
12914
+ const isCursor = rows[this.cursor] === row;
12915
+ const selected = row.key === OTHER_ROW ? draft.custom !== "" : draft.selected.has(question.options?.[Number(row.key)]?.label ?? "");
12916
+ const marker = multi || row.key === OTHER_ROW ? selected ? color.success("[✓]") : color.textDim("[ ]") : selected ? color.success(`[${Number(row.key) + 1}]`) : color.textDim(`[${Number(row.key) + 1}]`);
12917
+ const prefix = `${isCursor ? color.primary("→") : " "} ${marker} `;
12918
+ const indent = " ".repeat(visibleWidth(prefix));
12919
+ const badge = row.recommended ? ` ${color.primary("[recommended]")}` : "";
12920
+ const label = isCursor ? color.textStrong(row.label) : row.label;
12921
+ if (row.key === OTHER_ROW && this.editingOther) {
12922
+ const inputLine = this.otherInput.render(Math.max(1, width - visibleWidth(prefix)))[0] ?? "";
12923
+ const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
12924
+ lines.push(prefix + (this.otherInput.getValue() === "" ? color.textDim(row.label) : stripped));
12925
+ hits.push(OTHER_ROW);
12926
+ continue;
12927
+ }
12928
+ const labelWidth = Math.max(1, width - visibleWidth(prefix));
12929
+ for (const wrapped of wrapTextWithAnsi(`${label}${badge}`, labelWidth)) if (!this.pushPageRow(lines, hits, prefix + wrapped, row.key)) return {
12930
+ lines,
12931
+ hits
12932
+ };
12933
+ if (row.description !== void 0 && row.description !== "" && !(row.key === OTHER_ROW && this.editingOther)) {
12934
+ const descWidth = Math.max(1, width - visibleWidth(indent));
12935
+ for (const wrapped of wrapTextWithAnsi(color.textDim(row.description), descWidth)) if (!this.pushPageRow(lines, hits, indent + wrapped, row.key)) return {
12936
+ lines,
12937
+ hits
12938
+ };
12939
+ }
12940
+ }
12941
+ return {
12942
+ lines,
12943
+ hits
12944
+ };
12945
+ }
12946
+ /** Push one page-content row, capping at MAX_CONTENT_ROWS with a single
12947
+ * `... more content hidden` note (never a second one after the detail's).
12948
+ * @returns false when the cap was hit (callers stop building). */
12949
+ pushPageRow(lines, hits, line, key) {
12950
+ if (lines.length >= MAX_CONTENT_ROWS) {
12951
+ if (lines[lines.length - 1]?.includes("more content hidden") !== true) {
12952
+ lines.push(color.textDim("... more content hidden"));
12953
+ hits.push(void 0);
12954
+ }
12955
+ return false;
12956
+ }
12957
+ lines.push(line);
12958
+ hits.push(key);
12959
+ return true;
12780
12960
  }
12781
12961
  /** The rows of the current question (options plus the free-text row). */
12782
12962
  rows() {
@@ -12846,6 +13026,7 @@ var QuestionFlow = class {
12846
13026
  }
12847
13027
  /** Optionless questions edit text directly; options start in list mode. */
12848
13028
  syncEditMode() {
13029
+ this.resetBodyView();
12849
13030
  const question = this.questions[this.tab];
12850
13031
  const optionless = question !== void 0 && (question.options?.length ?? 0) === 0;
12851
13032
  this.editingOther = optionless;
@@ -12920,6 +13101,7 @@ var QuestionFlow = class {
12920
13101
  if (this.editingOther) {
12921
13102
  if (data === "\r" || data === "\n") this.commitOther(this.otherInput.getValue());
12922
13103
  else if (data === "\x1B") this.exitOther();
13104
+ else if (data === "\x1B[5~" || data === "\x1B[6~") this.scrollBody(data === "\x1B[5~" ? -1 : 1);
12923
13105
  else if (data === "\x1B[D") {
12924
13106
  if (this.tab > 0) {
12925
13107
  this.tab -= 1;
@@ -12962,16 +13144,27 @@ var QuestionFlow = class {
12962
13144
  const row = rows[Number(digit[0]) - 1];
12963
13145
  if (row !== void 0) {
12964
13146
  this.cursor = rows.indexOf(row);
13147
+ this.pendingCursorScroll = true;
12965
13148
  this.confirm();
12966
13149
  }
12967
13150
  return;
12968
13151
  }
12969
13152
  if (data === "\x1B[A" || data === "k") {
12970
13153
  if (rows.length > 0) this.cursor = (this.cursor - 1 + rows.length) % rows.length;
13154
+ this.pendingCursorScroll = true;
12971
13155
  return;
12972
13156
  }
12973
13157
  if (data === "\x1B[B" || data === "j") {
12974
13158
  if (rows.length > 0) this.cursor = (this.cursor + 1) % rows.length;
13159
+ this.pendingCursorScroll = true;
13160
+ return;
13161
+ }
13162
+ if (data === "\x1B[5~" || data === "\x1B[6~") {
13163
+ this.scrollBody(data === "\x1B[5~" ? -1 : 1);
13164
+ return;
13165
+ }
13166
+ if (data === "e") {
13167
+ this.toggleExpanded();
12975
13168
  return;
12976
13169
  }
12977
13170
  if (data === "\r" || data === "\n") {
@@ -12992,6 +13185,7 @@ var QuestionFlow = class {
12992
13185
  this.tab += 1;
12993
13186
  this.cursor = 0;
12994
13187
  this.submitIdx = 0;
13188
+ this.syncEditMode();
12995
13189
  } else {
12996
13190
  this.tab = this.questions.length;
12997
13191
  this.cursor = 0;
@@ -13012,6 +13206,8 @@ var QuestionFlow = class {
13012
13206
  render(width) {
13013
13207
  const safeWidth = Math.max(1, width);
13014
13208
  const lines = [];
13209
+ this.hitMap.clear();
13210
+ this.lastMarkerRow = -1;
13015
13211
  const tabs = this.questions.map((_, index) => {
13016
13212
  const draft = this.drafts[index];
13017
13213
  const answered = draft !== void 0 && (draft.selected.size > 0 || draft.custom !== "" || draft.skipped);
@@ -13053,102 +13249,73 @@ var QuestionFlow = class {
13053
13249
  if (question === void 0 || draft === void 0) return lines;
13054
13250
  const rows = this.rows();
13055
13251
  const multi = question.multiSelect === true;
13056
- this.editingOther && rows.some((row) => row.key === OTHER_ROW);
13057
13252
  const skippedRow = draft.skipped ? 1 : 0;
13058
13253
  const optionless = rows.length === 0 || rows.length === 1 && rows[0]?.key === OTHER_ROW;
13059
- const tail = optionless ? 3 + skippedRow : 4 + skippedRow;
13060
- let bodyAllowance = this.budget - lines.length - tail;
13254
+ const tail = optionless ? 3 + skippedRow : 2 + skippedRow;
13061
13255
  const header = question.header;
13062
- if (header !== void 0 && header !== "" && bodyAllowance >= 2) {
13063
- lines.push(color.textDim(header));
13064
- bodyAllowance -= 1;
13065
- }
13066
- const bodyBudget = Math.min(MAX_BODY_LINES, Math.max(1, bodyAllowance));
13067
- let bodyLeft = appendContentFirst(lines, `${color.primary("?")} `, " ", color.textStrong(question.question), safeWidth, bodyBudget);
13068
- if (question.detail !== void 0 && question.detail !== "") for (const line of question.detail.split("\n")) {
13069
- if (bodyLeft <= 1) {
13070
- appendWrappedBudgeted(lines, " ", " ", color.textDim("... more content hidden"), safeWidth, bodyLeft);
13071
- break;
13072
- }
13073
- bodyLeft = appendWrappedBudgeted(lines, " ", " ", color.textDim(line), safeWidth, bodyLeft);
13074
- }
13075
- if (!optionless) {
13076
- lines.push("");
13077
- const optionBudget = this.budget - lines.length - skippedRow - 2;
13078
- const maxWindow = Math.max(1, Math.min(MAX_VISIBLE_OPTIONS, optionBudget));
13079
- const visibleCount = Math.min(rows.length, maxWindow);
13080
- const half = Math.floor(maxWindow / 2);
13081
- const maxStart = Math.max(0, rows.length - visibleCount);
13082
- const start = Math.max(0, Math.min(this.cursor - half, maxStart));
13083
- const end = Math.min(rows.length, start + visibleCount);
13084
- {
13085
- const cursorRow = rows[this.cursor];
13086
- let reserved = 1;
13087
- if (cursorRow !== void 0 && cursorRow.key !== OTHER_ROW) {
13088
- const prefix = `→ ${multi || cursorRow.key === OTHER_ROW ? draft.selected.has(question.options?.[Number(cursorRow.key)]?.label ?? "") ? "[✓]" : "[ ]" : draft.selected.has(question.options?.[Number(cursorRow.key)]?.label ?? "") ? `[${Number(cursorRow.key) + 1}]` : `[${Number(cursorRow.key) + 1}]`} `;
13089
- const badge = cursorRow.recommended ? ` ${color.primary("[recommended]")}` : "";
13090
- reserved = Math.min(Math.max(1, wrapTextWithAnsi(`${color.textStrong(cursorRow.label)}${badge}`, Math.max(1, safeWidth - visibleWidth(prefix))).length), maxWindow);
13091
- }
13092
- let left = optionBudget;
13093
- let cursorRendered = false;
13094
- for (let index = start; index < end; index++) {
13095
- const row = rows[index];
13096
- if (row === void 0) continue;
13097
- const isCursor = index === this.cursor;
13098
- if (!isCursor && (cursorRendered ? left <= 0 : left <= reserved)) continue;
13099
- if (row.key === OTHER_ROW && this.editingOther) {
13100
- const prefix = `${color.primary("→")} ${color.textDim(`[${index + 1}]`)} `;
13101
- const inputLine = this.otherInput.render(Math.max(1, safeWidth - visibleWidth(prefix)))[0] ?? "";
13102
- const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
13103
- left -= 1;
13104
- lines.push(prefix + stripped);
13105
- if (isCursor) cursorRendered = true;
13106
- continue;
13107
- }
13108
- const selected = row.key === OTHER_ROW ? draft.custom !== "" : draft.selected.has(question.options?.[Number(row.key)]?.label ?? "");
13109
- const marker = multi || row.key === OTHER_ROW ? selected ? color.success("[✓]") : color.textDim("[ ]") : selected ? color.success(`[${Number(row.key) + 1}]`) : color.textDim(`[${Number(row.key) + 1}]`);
13110
- const prefix = `${isCursor ? color.primary("→") : " "} ${marker} `;
13111
- const badge = row.recommended ? ` ${color.primary("[recommended]")}` : "";
13112
- const label = isCursor ? color.textStrong(row.label) : row.label;
13113
- const indent = " ".repeat(visibleWidth(prefix));
13114
- const available = isCursor || cursorRendered ? left : left - reserved;
13115
- const beforeLabel = lines.length;
13116
- if (isCursor) appendContentFirst(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
13117
- else appendWrappedBudgeted(lines, prefix, indent, `${label}${badge}`, safeWidth, available);
13118
- left -= lines.length - beforeLabel;
13119
- if (row.description !== void 0 && row.description !== "" && left > 0) {
13120
- const descCap = isCursor || cursorRendered ? left : left - reserved;
13121
- const beforeDescription = lines.length;
13122
- appendWrappedBudgeted(lines, indent, indent, color.textDim(row.description), safeWidth, descCap);
13123
- left -= lines.length - beforeDescription;
13124
- }
13125
- if (isCursor) cursorRendered = true;
13256
+ if (header !== void 0 && header !== "" && this.budget - lines.length - tail >= 2) lines.push(color.textDim(header));
13257
+ const content = this.buildPageContent(question, rows, draft, multi, safeWidth);
13258
+ const regionHeight = Math.max(1, this.budget - lines.length - tail);
13259
+ const overflow = content.lines.length > regionHeight;
13260
+ const visible = overflow && regionHeight >= 2 ? regionHeight - 1 : regionHeight;
13261
+ this.lastRegionHeight = regionHeight;
13262
+ this.lastContentRows = content.lines.length;
13263
+ this.lastVisibleRows = visible;
13264
+ this.lastExpandable = overflow && regionHeight >= 2;
13265
+ const maxScroll = Math.max(0, content.lines.length - visible);
13266
+ this.bodyScroll = Math.min(this.bodyScroll, maxScroll);
13267
+ if (this.pendingCursorScroll) {
13268
+ this.pendingCursorScroll = false;
13269
+ const cursorKey = rows[this.cursor]?.key;
13270
+ if (cursorKey !== void 0) {
13271
+ const first = content.hits.indexOf(cursorKey);
13272
+ if (first >= 0) {
13273
+ if (first < this.bodyScroll) this.bodyScroll = first;
13274
+ else if (first >= this.bodyScroll + visible) this.bodyScroll = first - visible + 1;
13275
+ this.bodyScroll = Math.max(0, Math.min(this.bodyScroll, maxScroll));
13126
13276
  }
13127
- if (rows.length > MAX_VISIBLE_OPTIONS && left > 0) left = appendWrappedBudgeted(lines, " ", " ", `showing ${start + 1}-${end} of ${rows.length}`, safeWidth, left);
13128
13277
  }
13129
- } else {
13278
+ }
13279
+ for (let index = this.bodyScroll; index < this.bodyScroll + visible && index < content.lines.length; index++) {
13280
+ const hit = content.hits[index];
13281
+ if (hit !== void 0) this.hitMap.set(lines.length, hit);
13282
+ lines.push(content.lines[index]);
13283
+ }
13284
+ if (overflow && regionHeight >= 2) {
13285
+ const above = this.bodyScroll;
13286
+ const below = content.lines.length - (this.bodyScroll + visible);
13287
+ const marker = above > 0 && below > 0 ? `↑ ${above} up · ↓ ${below} more lines` : above > 0 ? `↑ ${above} up` : `↓ ${below} more lines`;
13288
+ this.lastMarkerRow = lines.length;
13289
+ lines.push(color.textDim(marker));
13290
+ } else this.lastMarkerRow = -1;
13291
+ if (optionless) {
13130
13292
  const inputLine = this.otherInput.render(Math.max(1, safeWidth - 2))[0] ?? "";
13131
13293
  const stripped = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
13132
- lines.push(` ${stripped}`);
13294
+ lines.push(this.otherInput.getValue() === "" ? color.textDim(" Type your answer…") : ` ${stripped}`);
13133
13295
  }
13134
13296
  if (draft.skipped) lines.push(color.textDim("(skipped)"));
13135
13297
  lines.push("");
13136
13298
  const optionCount = Math.min(rows.length, 9);
13299
+ const scrollable = this.lastExpandable;
13137
13300
  const hintParts = [
13138
13301
  "↑↓ select",
13139
13302
  optionCount > 0 ? `1-${optionCount} choose` : "",
13140
13303
  multi ? "↵ toggle" : "↵ confirm",
13304
+ scrollable ? "pgup/pgdn scroll" : "",
13305
+ !this.editingOther ? this.bodyExpanded ? "e collapse" : scrollable ? "e expand" : "" : "",
13141
13306
  this.questions.length > 1 ? "←→ switch" : "",
13142
13307
  "s skip",
13143
13308
  "esc cancel"
13144
13309
  ].filter((part) => part !== "");
13310
+ const cancel = "esc cancel";
13311
+ const verbs = hintParts.filter((part) => part !== cancel);
13145
13312
  let hint = "";
13146
- for (const part of hintParts) {
13313
+ for (const part of verbs) {
13147
13314
  const next = hint === "" ? part : `${hint} · ${part}`;
13148
- if (visibleWidth(next) > safeWidth) break;
13315
+ if (visibleWidth(`${next} · ${cancel}`) > safeWidth) break;
13149
13316
  hint = next;
13150
13317
  }
13151
- lines.push(color.textDim(hint === "" ? "esc cancel" : hint));
13318
+ lines.push(color.textDim(hint === "" ? cancel : `${hint} · ${cancel}`));
13152
13319
  return lines;
13153
13320
  }
13154
13321
  };
@@ -13705,6 +13872,10 @@ var Frame = class {
13705
13872
  var QuestionFrame = class extends Frame {
13706
13873
  flow;
13707
13874
  heightOf;
13875
+ /** Rendered height of the last frame (fullscreen click hit-testing). */
13876
+ lastRows = 0;
13877
+ /** Terminal height the last render used (click staleness guard). */
13878
+ lastTermRows = 0;
13708
13879
  constructor(flow, heightOf) {
13709
13880
  super(flow, true);
13710
13881
  this.flow = flow;
@@ -13712,9 +13883,20 @@ var QuestionFrame = class extends Frame {
13712
13883
  }
13713
13884
  render(width) {
13714
13885
  const rows = Math.max(1, this.heightOf());
13715
- const frameRows = Math.max(10, Math.min(26, Math.floor(rows * .6)));
13886
+ this.lastTermRows = rows;
13887
+ const frameRows = this.flow.isBodyExpanded() ? Math.max(10, Math.min(40, Math.floor(rows * .8))) : Math.max(10, Math.min(26, Math.floor(rows * .6)));
13716
13888
  this.flow.setMaxRows(frameRows - 2);
13717
- return super.render(width);
13889
+ const out = super.render(width);
13890
+ this.lastRows = out.length;
13891
+ return out;
13892
+ }
13893
+ /** The frame's height from the last render (0 before the first one). */
13894
+ get rows() {
13895
+ return this.lastRows;
13896
+ }
13897
+ /** The terminal height the last render used. */
13898
+ get termRows() {
13899
+ return this.lastTermRows;
13718
13900
  }
13719
13901
  get focused() {
13720
13902
  return this.flow.focused;
@@ -14714,6 +14896,23 @@ var TuiApp = class TuiApp {
14714
14896
  * The global Ctrl+O fold still wins, so keyboard behavior is unchanged.
14715
14897
  */
14716
14898
  handleFullscreenClick(x, y) {
14899
+ const question = this.activeQuestions;
14900
+ if (question?.frame !== void 0) {
14901
+ if (this.terminal.rows !== question.frame.termRows) return;
14902
+ const width = this.terminal.columns;
14903
+ const height = this.terminal.rows;
14904
+ const footerHeight = this.footer.render(width).length;
14905
+ const seatHeight = question.frame.rows;
14906
+ const seatBottom = height - footerHeight;
14907
+ const seatTop = seatBottom - seatHeight;
14908
+ if (y >= seatTop && y < seatBottom) {
14909
+ if (x >= 2 && x <= width - 3) {
14910
+ question.flow.clickRow(y - seatTop - 1);
14911
+ this.requestRender();
14912
+ }
14913
+ return;
14914
+ }
14915
+ }
14717
14916
  const width = this.terminal.columns;
14718
14917
  const rowInScroll = y - this.header.render(width).length;
14719
14918
  if (rowInScroll < 0) return;
@@ -16240,6 +16439,262 @@ async function loadSessionTitles(query, persistence, ids, signal) {
16240
16439
  return titles;
16241
16440
  }
16242
16441
  //#endregion
16442
+ //#region src/skill-catalog.ts
16443
+ /**
16444
+ * The standing-scope skill catalog adapter: the SINGLE narrow seam between
16445
+ * the TUI and the dsh skill services (the non-public-API isolation
16446
+ * contract, plan appendix B).
16447
+ *
16448
+ * Everything the TUI knows about dsh-skill and agent-presets lives here:
16449
+ * - structural service types (never upstream classes) with OPTIONAL
16450
+ * capability members — `snapshot` and `standingKeyFor` are detected at
16451
+ * call time, so an upstream change degrades instead of breaking;
16452
+ * - `readHumanSkillCatalog()`: snapshot-first collector (list() fallback),
16453
+ * official `isUserInvocable` filter, field validation, stable sort,
16454
+ * deep freeze, distinct abort propagation;
16455
+ * - `resolveColdSkillTarget()`: capability-gated cold target selection
16456
+ * (standing scope → rosterless global → degraded global with a notice);
16457
+ * - `resolveLiveSkillTarget()`: the agent's own registry + agent scope.
16458
+ *
16459
+ * Failure contract: ordinary provider/composition failures surface as
16460
+ * degraded targets or rethrown errors for the coordinator to classify;
16461
+ * aborts ALWAYS propagate as aborts. No failure may hang or crash the TUI —
16462
+ * the worst outcome is "the corresponding commands are missing".
16463
+ * @module @xmoon76/dsh-pi-tui/skill-catalog
16464
+ */
16465
+ /**
16466
+ * Subscribe to the dsh-skill invalidation notification. The event carries
16467
+ * no scope or cwd — consumers refetch for their own cwd and scope — and the
16468
+ * subscription itself is optional: an unavailable `on` or a throwing
16469
+ * subscribe degrades to NO subscription (owner switches and `/reload` still
16470
+ * refresh). The CALLER owns the listener's failure totality (route the
16471
+ * refresh through `runOwned`).
16472
+ * @param ctx - the context surface exposing the event bus.
16473
+ * @param listener - the notification listener (must be synchronous and
16474
+ * never throw).
16475
+ */
16476
+ function subscribeSkillsChange(ctx, listener) {
16477
+ ctx.on?.("skills/change", listener);
16478
+ }
16479
+ /**
16480
+ * Whether one summary passes the OFFICIAL user-invocation policy — the
16481
+ * adapter's exported guard, used by every human entry point (direct
16482
+ * wrappers, `/skill`, the picker, and final body loads). A malformed
16483
+ * `invocation` (missing, non-object, non-boolean flag) is treated as NOT
16484
+ * user-invocable: the policy defaults are never reinterpreted, and a
16485
+ * hostile entry can neither throw nor sneak into a human surface.
16486
+ */
16487
+ function isUserInvocableSkill(skill) {
16488
+ const invocation = skill.invocation;
16489
+ if (typeof invocation !== "object" || invocation === null) return false;
16490
+ if (typeof invocation.userInvocable !== "boolean") return false;
16491
+ return isUserInvocable(skill);
16492
+ }
16493
+ /** Copy one summary, keeping ONLY the supported display fields. An entry
16494
+ * with a non-string name or description is rejected (never copied, never
16495
+ * trusted); a non-string `whenToUse` is dropped. */
16496
+ function toHumanSummary(skill) {
16497
+ if (typeof skill.name !== "string" || skill.name === "") return void 0;
16498
+ if (typeof skill.description !== "string") return void 0;
16499
+ return Object.freeze({
16500
+ name: skill.name,
16501
+ description: skill.description,
16502
+ ...typeof skill.whenToUse === "string" && skill.whenToUse !== "" ? { whenToUse: skill.whenToUse } : {}
16503
+ });
16504
+ }
16505
+ /** Whether one summary passes the OFFICIAL user-invocation policy. A
16506
+ * malformed `invocation` (missing, non-object, non-boolean flag) is treated
16507
+ * as NOT user-invocable — the policy defaults are never reinterpreted, and
16508
+ * a hostile entry can neither throw nor sneak into the human catalog. */
16509
+ function userInvocable(skill) {
16510
+ return isUserInvocableSkill(skill);
16511
+ }
16512
+ /**
16513
+ * Read one human skill catalog from a registry: snapshot-first, official
16514
+ * policy filter, validated detached copies, stable name sort, deep freeze.
16515
+ * @param registry - the registry to read (the adapter's structural type).
16516
+ * @param options - cwd/scope/signal for the read.
16517
+ * @returns the detached catalog.
16518
+ * @throws on cancellation (ALWAYS, distinctly) and on non-abort read
16519
+ * failures (the coordinator classifies them: retain last-good + diag).
16520
+ */
16521
+ async function readHumanSkillCatalog(registry, options) {
16522
+ options.signal?.throwIfAborted();
16523
+ const observed = typeof registry.snapshot === "function" ? await registry.snapshot(options) : {
16524
+ skills: await registry.list?.(options) ?? [],
16525
+ complete: true
16526
+ };
16527
+ options.signal?.throwIfAborted();
16528
+ const skills = observed.skills.filter(userInvocable).map(toHumanSummary).filter((summary) => summary !== void 0).sort((left, right) => left.name < right.name ? -1 : 1);
16529
+ return Object.freeze({
16530
+ skills: Object.freeze(skills),
16531
+ complete: observed.complete === true
16532
+ });
16533
+ }
16534
+ /**
16535
+ * Resolve the COLD (sessionless) skill target with capability detection:
16536
+ * - a preset roster with `standingKeyFor` → the standing scope of the
16537
+ * effective preset;
16538
+ * - no roster / no `standingKeyFor` / a standing resolution failure → the
16539
+ * global layer (with a one-shot degradation notice on failure);
16540
+ * - no skill registry at all → no target (nothing to read, no notice).
16541
+ * @param ctx - the context surface.
16542
+ * @param presetId - the effective preset id, or undefined for the default.
16543
+ * @param cwd - the workspace cwd for provider selection.
16544
+ */
16545
+ async function resolveColdSkillTarget(ctx, presetId, cwd) {
16546
+ const registry = ctx.get("skills");
16547
+ if (registry === void 0) return {};
16548
+ const presets = ctx.get("agentPresets");
16549
+ if (presets === void 0 || typeof presets.standingKeyFor !== "function") return { target: {
16550
+ kind: "cold-global",
16551
+ registry,
16552
+ cwd,
16553
+ scope: void 0
16554
+ } };
16555
+ try {
16556
+ return { target: {
16557
+ kind: "cold-standing",
16558
+ registry,
16559
+ cwd,
16560
+ scope: await presets.standingKeyFor(presetId)
16561
+ } };
16562
+ } catch (error) {
16563
+ const message = safeErrorMessage(error);
16564
+ return {
16565
+ target: {
16566
+ kind: "cold-global",
16567
+ registry,
16568
+ cwd,
16569
+ scope: void 0
16570
+ },
16571
+ degraded: `skill catalog unavailable for preset "${presetId ?? "default"}": ${message}`
16572
+ };
16573
+ }
16574
+ }
16575
+ /**
16576
+ * Resolve the LIVE skill target: the registry the agent actually sees
16577
+ * (its preset's scoped instance when the preset mounts one, else the host
16578
+ * registry), with the AGENT OBJECT as the view scope.
16579
+ * @param ctx - the context surface.
16580
+ * @param agent - the live agent.
16581
+ * @param cwd - the session workspace cwd.
16582
+ * @returns the live target, or undefined when no registry is reachable.
16583
+ */
16584
+ function resolveLiveSkillTarget(ctx, agent, cwd) {
16585
+ const registry = ctx.get("agentPresets")?.serviceFor?.(agent, "skills") ?? ctx.get("skills");
16586
+ if (registry === void 0) return void 0;
16587
+ return {
16588
+ kind: "live",
16589
+ registry,
16590
+ cwd,
16591
+ scope: agent
16592
+ };
16593
+ }
16594
+ //#endregion
16595
+ //#region src/surface-catalog.ts
16596
+ /**
16597
+ * The in-process global-layer command view. The public type requires an
16598
+ * `Agent`, but `commands.list(undefined)` resolves the global layer only
16599
+ * (ScopedLayers merges no overlays for an undefined key); the current TUI
16600
+ * already depends on this in-process behavior. The cast is isolated HERE so
16601
+ * the undefined key never reaches a remote RPC path, and the helper is the
16602
+ * single seam to replace if upstream ever ships a typed global-list API.
16603
+ * @param commands - the commands service.
16604
+ * @returns the global-layer descriptors (name-sorted by the registry).
16605
+ */
16606
+ function listGlobalCommands(commands) {
16607
+ return commands.list(void 0);
16608
+ }
16609
+ /** Copy one descriptor into a fresh frozen summary (never borrowed). */
16610
+ function commandSummaryOf(descriptor) {
16611
+ return Object.freeze({
16612
+ name: descriptor.name,
16613
+ description: descriptor.description,
16614
+ ...descriptor.input === void 0 ? {} : { input: Object.freeze({ hint: descriptor.input.hint }) }
16615
+ });
16616
+ }
16617
+ /** Whether two descriptors expose identical display fields (origin-blind:
16618
+ * an identical scoped entry needs no override because the visible result and
16619
+ * the real-agent execution are the same either way). */
16620
+ function sameCommand(left, right) {
16621
+ return left.name === right.name && left.description === right.description && left.input?.hint === right.input?.hint;
16622
+ }
16623
+ /**
16624
+ * Read one agent's effective surface catalog: global + scoped commands and
16625
+ * human-invocable skills, fully detached and frozen.
16626
+ *
16627
+ * Provider isolation (per the probe contract):
16628
+ * - lifecycle/refresh cancellation terminates the WHOLE read and propagates;
16629
+ * - an ordinary provider failure empties only that field and records a
16630
+ * detached issue; other providers continue;
16631
+ * - a missing service is that provider's successful empty result (no issue).
16632
+ *
16633
+ * This function only READS: it registers nothing, mutates nothing, and never
16634
+ * touches a runner's live agent. Probe and live agent share this collector
16635
+ * so the two surfaces cannot drift apart.
16636
+ * @param agent - the agent whose effective view to read.
16637
+ * @param signal - lifecycle/refresh cancellation.
16638
+ * @param ctx - the context surface resolving the services.
16639
+ * @returns a frozen, detached snapshot.
16640
+ */
16641
+ async function readSurfaceCatalog(agent, signal, ctx) {
16642
+ signal.throwIfAborted();
16643
+ const issues = [];
16644
+ let commands = [];
16645
+ let scopedCommands = [];
16646
+ const commandsService = ctx.get("commands");
16647
+ if (commandsService !== void 0) try {
16648
+ const global = listGlobalCommands(commandsService);
16649
+ const globalBy = new Map(global.map((descriptor) => [descriptor.name, descriptor]));
16650
+ const effective = commandsService.list(agent);
16651
+ const scoped = [];
16652
+ for (const descriptor of effective) {
16653
+ const summary = commandSummaryOf(descriptor);
16654
+ const baseline = globalBy.get(descriptor.name);
16655
+ if (baseline === void 0 || !sameCommand(descriptor, baseline)) scoped.push(summary);
16656
+ }
16657
+ commands = sortCommands(effective.map(commandSummaryOf));
16658
+ scopedCommands = sortCommands(scoped);
16659
+ } catch (error) {
16660
+ issues.push({
16661
+ provider: "commands",
16662
+ message: safeErrorMessage(error)
16663
+ });
16664
+ }
16665
+ let skills = [];
16666
+ const skillTarget = resolveLiveSkillTarget(ctx, agent, agent.session.header.cwd ?? process.cwd());
16667
+ if (skillTarget !== void 0) try {
16668
+ signal.throwIfAborted();
16669
+ const catalog = await readHumanSkillCatalog(skillTarget.registry, {
16670
+ cwd: skillTarget.cwd,
16671
+ scope: skillTarget.scope,
16672
+ signal
16673
+ });
16674
+ skills = catalog.skills;
16675
+ if (catalog.complete !== true) issues.push({
16676
+ provider: "skills",
16677
+ message: "incomplete skill observation"
16678
+ });
16679
+ } catch (error) {
16680
+ if (signal.aborted) throw error;
16681
+ issues.push({
16682
+ provider: "skills",
16683
+ message: safeErrorMessage(error)
16684
+ });
16685
+ }
16686
+ return Object.freeze({
16687
+ commands,
16688
+ scopedCommands,
16689
+ skills,
16690
+ issues: Object.freeze(issues.map((issue) => Object.freeze({ ...issue })))
16691
+ });
16692
+ }
16693
+ /** Name-stable sort for command summaries (copies, never mutates input). */
16694
+ function sortCommands(commands) {
16695
+ return Object.freeze([...commands].sort((left, right) => left.name < right.name ? -1 : 1));
16696
+ }
16697
+ //#endregion
16243
16698
  //#region src/commands.ts
16244
16699
  /**
16245
16700
  * The TUI-owned slash commands (/exit /settings /sessions /skill /model
@@ -16317,11 +16772,16 @@ function presetDisplayText(preset) {
16317
16772
  * commands appear in the editor's tab list. Registration is sessionless:
16318
16773
  * the commands service's global layer needs no agent, so the whole surface
16319
16774
  * is available before the first session exists (deferred start).
16775
+ *
16776
+ * When an `initial` catalog was prefetched (resume snapshot or cold
16777
+ * standing-scope skills), it installs SYNCHRONOUSLY at the end of
16778
+ * registration — direct skill wrappers plus the completion merge — so the
16779
+ * first input is served by the complete catalog with zero async I/O in
16780
+ * between.
16320
16781
  * @param runner - the live runner surface.
16321
- * @returns a hook that rebuilds the per-skill slash commands (a no-op until
16322
- * the first session exists, since the skill catalog scope is the agent).
16782
+ * @param initial - optional prefetched catalogs installed synchronously.
16323
16783
  */
16324
- function registerTuiCommands(runner) {
16784
+ function registerTuiCommands(runner, initial) {
16325
16785
  const { ctx, app } = runner;
16326
16786
  const cwd = runner.cwd;
16327
16787
  const signal = runner.signal;
@@ -16365,12 +16825,60 @@ function registerTuiCommands(runner) {
16365
16825
  if (agent === void 0) throw new Error("session could not be created");
16366
16826
  return agent;
16367
16827
  };
16368
- const refreshCompletions = () => {
16369
- app.setCommandCompletions(commands.list(runner.liveAgent).map((command) => ({
16828
+ /** The advertised names of the currently installed completion list. */
16829
+ let claims = /* @__PURE__ */ new Set();
16830
+ /**
16831
+ * Install one completion list (sorted, claims refreshed). The single
16832
+ * synchronous seam every catalog commit funnels through.
16833
+ */
16834
+ const installCompletions = (entries) => {
16835
+ const sorted = [...entries].sort((left, right) => left.name < right.name ? -1 : 1);
16836
+ app.setCommandCompletions(sorted.map((command) => ({
16370
16837
  name: command.name,
16371
16838
  description: command.description,
16372
16839
  argumentHint: command.input?.hint
16373
16840
  })), runner.sessionCwd(), fdPath);
16841
+ claims = new Set(sorted.map((command) => command.name));
16842
+ };
16843
+ /** The saved probed scoped overrides (see installSurfaceSnapshot). */
16844
+ let savedScopedCommands = [];
16845
+ /**
16846
+ * The sessionless completion view: the CURRENT global layer (fresh read —
16847
+ * TUI built-ins, global plugins and installed skill wrappers all flow in)
16848
+ * overlaid with the saved scoped overrides from the latest snapshot.
16849
+ */
16850
+ const mergeGlobalAndSavedScoped = () => {
16851
+ const byName = /* @__PURE__ */ new Map();
16852
+ for (const descriptor of listGlobalCommands(commands)) byName.set(descriptor.name, commandSummaryOf(descriptor));
16853
+ for (const scoped of savedScopedCommands) byName.set(scoped.name, scoped);
16854
+ return [...byName.values()];
16855
+ };
16856
+ /**
16857
+ * Refresh completions from the registry: the LIVE agent's effective view
16858
+ * when one exists (global + its scoped shadows), else the global layer
16859
+ * overlaid with the saved scoped overrides (sessionless merge). The agent
16860
+ * may be undefined before the first session exists: in-process
16861
+ * `commands.list(undefined)` safely returns the global layer only (the
16862
+ * remote RPC path's lookup guard does not apply in-process).
16863
+ */
16864
+ const refreshCompletions = () => {
16865
+ const liveAgent = runner.liveAgent;
16866
+ installCompletions(liveAgent === void 0 ? mergeGlobalAndSavedScoped() : commands.list(liveAgent).map(commandSummaryOf));
16867
+ };
16868
+ let commandCommitDepth = 0;
16869
+ let commandCommitDirty = false;
16870
+ /** Run one bulk command commit; registry changes inside are coalesced. */
16871
+ const withCommandCommit = (phase) => {
16872
+ commandCommitDepth += 1;
16873
+ try {
16874
+ phase();
16875
+ } finally {
16876
+ commandCommitDepth -= 1;
16877
+ if (commandCommitDepth === 0 && commandCommitDirty) {
16878
+ commandCommitDirty = false;
16879
+ refreshCompletions();
16880
+ }
16881
+ }
16374
16882
  };
16375
16883
  refreshCompletions();
16376
16884
  const exitHandler = () => {
@@ -16633,29 +17141,44 @@ function registerTuiCommands(runner) {
16633
17141
  return openSessionPicker(invocation, "resume");
16634
17142
  }
16635
17143
  });
16636
- const skillService = (agent) => {
16637
- return ctx.get("agentPresets")?.serviceFor(agent, "skills") ?? ctx.get("skills");
16638
- };
16639
17144
  /** The workspace the live session runs in; fallback to the TUI's cwd. */
16640
17145
  const sessionCwd = (agent) => agent.session.header.cwd ?? cwd;
17146
+ const skillTarget = (agent) => resolveLiveSkillTarget(ctx, agent, sessionCwd(agent));
17147
+ /**
17148
+ * The execution boundary for loading one skill into the live session;
17149
+ * shared by /skill and the per-skill slash commands. The skill is fetched
17150
+ * from the CURRENT agent's registry and its invocation policy is RE-CHECKED
17151
+ * here — a summary that passed the cold/live filter is never execution
17152
+ * authorization. A model-only skill is refused with an explicit error and
17153
+ * never injected.
17154
+ */
16641
17155
  const loadSkill = async (agent, name) => {
16642
- const skills = skillService(agent);
16643
- if (skills === void 0) return {
17156
+ const target = skillTarget(agent);
17157
+ if (target === void 0) return {
16644
17158
  kind: "error",
16645
17159
  text: "skill service unavailable"
16646
17160
  };
16647
- const skill = await skills.get(name, {
16648
- cwd: sessionCwd(agent),
16649
- scope: agent
17161
+ const skill = await target.registry.get?.(name, {
17162
+ cwd: target.cwd,
17163
+ scope: target.scope
16650
17164
  });
16651
17165
  if (skill === void 0) return {
16652
17166
  kind: "error",
16653
17167
  text: "unknown skill \"" + name + "\""
16654
17168
  };
17169
+ if (!isUserInvocableSkill(skill)) return {
17170
+ kind: "error",
17171
+ text: `skill "${name}" is not invocable by the user`
17172
+ };
17173
+ if (typeof skill.name !== "string" || skill.name === "" || typeof skill.description !== "string") return {
17174
+ kind: "error",
17175
+ text: `skill "${name}" returned a malformed definition`
17176
+ };
17177
+ const body = typeof skill.content === "string" && skill.content !== "" ? skill.content : skill.description;
16655
17178
  agent.inject(createUserMessage({
16656
17179
  content: [{
16657
17180
  type: "text",
16658
- text: "Skill loaded by the user: **" + skill.name + "**\n\n" + (skill.content ?? skill.description)
17181
+ text: "Skill loaded by the user: **" + skill.name + "**\n\n" + body
16659
17182
  }],
16660
17183
  source: {
16661
17184
  kind: "plugin",
@@ -16668,21 +17191,30 @@ function registerTuiCommands(runner) {
16668
17191
  };
16669
17192
  };
16670
17193
  const skillDisposers = /* @__PURE__ */ new Map();
16671
- const registerSkillCommands = async () => {
16672
- const generation = runner.sessionGeneration;
17194
+ /**
17195
+ * Synchronously replace every direct skill wrapper from a snapshot. Pure
17196
+ * install: no catalog fetch, no await — the callers (the initial snapshot
17197
+ * commit, the live refresh, the coordinator) provide the data. The target
17198
+ * set is computed FIRST (current global/effective view minus the wrappers
17199
+ * this surface owns, plus the snapshot's scoped overrides), then the old
17200
+ * wrappers are disposed and the new ones registered in one synchronous
17201
+ * commit.
17202
+ * @param skills - the human-invocable summaries to install.
17203
+ * @param scopedNames - the snapshot's scoped command names: a preset-scoped
17204
+ * command must block a same-name wrapper even though the global view
17205
+ * cannot see it yet (sessionless install).
17206
+ * @returns the number of wrappers installed.
17207
+ */
17208
+ const replaceSkillCommands = (skills, scopedNames) => {
17209
+ const owned = new Set(skillDisposers.keys());
17210
+ const taken = /* @__PURE__ */ new Set();
17211
+ const view = commands.list(runner.liveAgent);
17212
+ for (const command of view) if (!owned.has(command.name)) taken.add(command.name);
17213
+ for (const name of scopedNames) taken.add(name);
16673
17214
  for (const dispose of skillDisposers.values()) dispose();
16674
17215
  skillDisposers.clear();
16675
- const liveAgent = runner.liveAgent;
16676
- if (liveAgent === void 0) return 0;
16677
- const skills = skillService(liveAgent);
16678
- if (skills === void 0) return 0;
16679
- const taken = new Set(commands.list(liveAgent).map((command) => command.name));
16680
- const catalog = await skills.list({
16681
- cwd: sessionCwd(liveAgent),
16682
- scope: liveAgent
16683
- });
16684
- if (generation !== runner.sessionGeneration) return 0;
16685
- for (const skill of catalog) {
17216
+ let count = 0;
17217
+ for (const skill of skills) {
16686
17218
  if (taken.has(skill.name)) continue;
16687
17219
  try {
16688
17220
  const dispose = commands.register({
@@ -16694,34 +17226,83 @@ function registerTuiCommands(runner) {
16694
17226
  }
16695
17227
  });
16696
17228
  skillDisposers.set(skill.name, dispose);
17229
+ count += 1;
16697
17230
  } catch {}
16698
17231
  }
16699
- refreshCompletions();
16700
- return catalog.length;
17232
+ return count;
17233
+ };
17234
+ /**
17235
+ * Install one whole snapshot on the surface in ONE synchronous commit:
17236
+ * replace the direct skill wrappers, save the scoped overrides, and merge
17237
+ * the completions (fresh global view + saved overrides). No await between
17238
+ * the pieces, so a first input can never observe a half-installed catalog.
17239
+ * A FAILED skills provider keeps the current wrappers (transitions or the
17240
+ * previous catalog): they re-validate against the current agent at
17241
+ * execution time, so a submitted skill name can never fall through to a
17242
+ * plain model message while the catalog is unavailable.
17243
+ */
17244
+ const installSurfaceSnapshot = (snapshot) => {
17245
+ const scopedNames = new Set(snapshot.scopedCommands.map((command) => command.name));
17246
+ const skillsFailed = snapshot.issues.some((issue) => issue.provider === "skills");
17247
+ withCommandCommit(() => {
17248
+ if (!skillsFailed) replaceSkillCommands(snapshot.skills, scopedNames);
17249
+ savedScopedCommands = snapshot.scopedCommands;
17250
+ installCompletions(mergeGlobalAndSavedScoped());
17251
+ });
16701
17252
  };
16702
- detach("skill catalog refresh", () => registerSkillCommands());
17253
+ /**
17254
+ * The revalidating transition (target/owner change): scoped previews clear
17255
+ * so new inputs complete against the current global view only, and every
17256
+ * old skill wrapper becomes a revalidating transition command whose
17257
+ * handler re-fetches from the CURRENT live agent and re-checks the policy
17258
+ * at execution time. The names survive so an already-submitted skill
17259
+ * command can never become a plain model message mid-switch.
17260
+ */
17261
+ const enterCatalogTransition = () => {
17262
+ const names = [...skillDisposers.keys()];
17263
+ withCommandCommit(() => {
17264
+ for (const dispose of skillDisposers.values()) dispose();
17265
+ skillDisposers.clear();
17266
+ savedScopedCommands = [];
17267
+ for (const name of names) try {
17268
+ const dispose = commands.register({
17269
+ name,
17270
+ description: `[skill: revalidating] ${name}`,
17271
+ handler: async () => {
17272
+ const agent = await requireAgent();
17273
+ return loadSkill(agent, name);
17274
+ }
17275
+ });
17276
+ skillDisposers.set(name, dispose);
17277
+ } catch {}
17278
+ installCompletions(mergeGlobalAndSavedScoped());
17279
+ });
17280
+ };
17281
+ /** Whether one command name is advertised by the CURRENT completion list
17282
+ * (the claim captured at submit time, before any session creation). */
17283
+ const wasAdvertised = (name) => claims.has(name);
16703
17284
  commands.register({
16704
17285
  name: "skill",
16705
17286
  description: "Load a skill into the session context",
16706
17287
  input: { hint: "<name>" },
16707
17288
  handler: async (invocation) => {
16708
17289
  const liveAgent = await requireAgent();
16709
- const skills = skillService(liveAgent);
16710
- if (skills === void 0) return {
17290
+ const target = skillTarget(liveAgent);
17291
+ if (target === void 0) return {
16711
17292
  kind: "error",
16712
17293
  text: "skill service unavailable"
16713
17294
  };
16714
17295
  const name = invocation.rawInput.trim();
16715
17296
  if (name !== "") return loadSkill(liveAgent, name);
16716
- const catalog = await skills.list({
16717
- cwd: sessionCwd(liveAgent),
16718
- scope: liveAgent
17297
+ const catalog = await readHumanSkillCatalog(target.registry, {
17298
+ cwd: target.cwd,
17299
+ scope: target.scope
16719
17300
  });
16720
- if (catalog.length === 0) return {
17301
+ if (catalog.skills.length === 0) return {
16721
17302
  kind: "error",
16722
17303
  text: "no skills available"
16723
17304
  };
16724
- app.openSettings(catalog.map((skill) => ({
17305
+ app.openSettings(catalog.skills.map((skill) => ({
16725
17306
  id: skill.name,
16726
17307
  label: skill.name,
16727
17308
  description: skill.description,
@@ -16737,12 +17318,38 @@ function registerTuiCommands(runner) {
16737
17318
  });
16738
17319
  commands.register({
16739
17320
  name: "reload",
16740
- description: "Reload TUI settings and refresh skill commands",
17321
+ description: "Reload TUI settings and refresh the live command/skill catalog",
16741
17322
  handler: async () => {
16742
- let skillCount = 0;
16743
- try {
16744
- skillCount = await registerSkillCommands();
16745
- } catch {}
17323
+ let catalogText = "";
17324
+ const liveAgent = runner.liveAgent;
17325
+ if (liveAgent !== void 0) {
17326
+ const outcome = await runner.refreshCatalog({
17327
+ source: "reload",
17328
+ target: {
17329
+ kind: "agent",
17330
+ key: runner.sessionGeneration
17331
+ },
17332
+ agent: liveAgent
17333
+ });
17334
+ if (outcome.kind === "applied") {
17335
+ catalogText = `${outcome.snapshot.commands.length} commands \u00b7 ${outcome.snapshot.skills.length} skills`;
17336
+ if (outcome.snapshot.issues.length > 0) catalogText += ` \u00b7 partial: ${outcome.snapshot.issues.map((issue) => issue.provider).join(", ")} unavailable`;
17337
+ } else if (outcome.kind === "failed") catalogText = `catalog refresh failed: ${outcome.error}`;
17338
+ else catalogText = "catalog refresh superseded";
17339
+ } else {
17340
+ const outcome = await runner.refreshCatalog({
17341
+ source: "reload",
17342
+ target: {
17343
+ kind: "preset",
17344
+ presetId: runner.effectivePresetId
17345
+ }
17346
+ });
17347
+ if (outcome.kind === "applied") {
17348
+ catalogText = `${outcome.snapshot.skills.length} human skills`;
17349
+ if (outcome.notice !== void 0) catalogText += ` \u00b7 ${outcome.notice}`;
17350
+ } else if (outcome.kind === "failed") catalogText = `catalog refresh failed: ${outcome.error}`;
17351
+ else catalogText = "catalog refresh superseded";
17352
+ }
16746
17353
  const settings = runner.tuiSettings;
16747
17354
  if (settings !== void 0) {
16748
17355
  const doc = settings.get();
@@ -16761,7 +17368,7 @@ function registerTuiCommands(runner) {
16761
17368
  app.setFooterPreset(doc.footer === "compact" ? "compact" : "full");
16762
17369
  app.setFullscreen(doc.fullscreen === "on");
16763
17370
  }
16764
- app.notify(`reloaded — ${skillCount} skills \u00b7 settings reapplied`, "info");
17371
+ app.notify(`reloaded — ${catalogText} \u00b7 settings reapplied`, "info");
16765
17372
  return { kind: "success" };
16766
17373
  }
16767
17374
  });
@@ -17012,11 +17619,28 @@ function registerTuiCommands(runner) {
17012
17619
  kind: "success",
17013
17620
  text: `default preset: ${settings.get(ns)?.default ?? presets.defaultId}`
17014
17621
  };
17015
- await settings.mutate(ns, [{
17016
- op: "set",
17017
- path: ["default"],
17018
- value: rest
17019
- }]);
17622
+ try {
17623
+ await settings.mutate(ns, [{
17624
+ op: "set",
17625
+ path: ["default"],
17626
+ value: rest
17627
+ }]);
17628
+ } catch (error) {
17629
+ return {
17630
+ kind: "error",
17631
+ text: safeErrorMessage(error)
17632
+ };
17633
+ }
17634
+ if (runner.effectivePresetId === void 0) {
17635
+ const outcome = await runner.refreshCatalog({
17636
+ source: "preset",
17637
+ target: {
17638
+ kind: "preset",
17639
+ presetId: rest
17640
+ }
17641
+ });
17642
+ if (outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
17643
+ }
17020
17644
  return {
17021
17645
  kind: "success",
17022
17646
  text: `default preset set: ${rest}`
@@ -17026,6 +17650,14 @@ function registerTuiCommands(runner) {
17026
17650
  if (liveAgent === void 0) {
17027
17651
  const resolved = await presets.resolve(id);
17028
17652
  runner.pendingPreset = resolved.id;
17653
+ const outcome = await runner.refreshCatalog({
17654
+ source: "preset",
17655
+ target: {
17656
+ kind: "preset",
17657
+ presetId: resolved.id
17658
+ }
17659
+ });
17660
+ if (outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
17029
17661
  return {
17030
17662
  kind: "pending",
17031
17663
  preset: resolved.id
@@ -17036,7 +17668,14 @@ function registerTuiCommands(runner) {
17036
17668
  kind: "locked",
17037
17669
  sessionId: liveAgent.session.id
17038
17670
  };
17039
- refreshCompletions();
17671
+ await runner.refreshCatalog({
17672
+ source: "preset",
17673
+ target: {
17674
+ kind: "agent",
17675
+ key: runner.sessionGeneration
17676
+ },
17677
+ agent: runner.liveAgent
17678
+ });
17040
17679
  runner.updateWelcomeCard();
17041
17680
  return {
17042
17681
  kind: "switched",
@@ -17109,7 +17748,11 @@ function registerTuiCommands(runner) {
17109
17748
  };
17110
17749
  }), (id) => {
17111
17750
  close();
17112
- detach("preset pick", () => pickPreset(id));
17751
+ runOwned("preset pick", () => pickPreset(id), {
17752
+ diag: runner.diag,
17753
+ sessionId: () => runner.liveAgent?.session.id,
17754
+ onError: (error) => app.notify(`preset selection failed: ${safeErrorMessage(error)}`, "error")
17755
+ });
17113
17756
  }, () => {});
17114
17757
  return { kind: "success" };
17115
17758
  }
@@ -17551,8 +18194,28 @@ function registerTuiCommands(runner) {
17551
18194
  return { kind: "success" };
17552
18195
  }
17553
18196
  });
17554
- refreshCompletions();
17555
- return { refreshSkills: () => registerSkillCommands() };
18197
+ if (initial?.snapshot !== void 0) installSurfaceSnapshot(initial.snapshot);
18198
+ else if (initial?.skills !== void 0) withCommandCommit(() => {
18199
+ replaceSkillCommands(initial.skills.skills, /* @__PURE__ */ new Set());
18200
+ savedScopedCommands = [];
18201
+ installCompletions(mergeGlobalAndSavedScoped());
18202
+ });
18203
+ else refreshCompletions();
18204
+ ctx.on("commands/change", () => {
18205
+ if (commandCommitDepth > 0) {
18206
+ commandCommitDirty = true;
18207
+ return;
18208
+ }
18209
+ refreshCompletions();
18210
+ });
18211
+ return {
18212
+ /** The claim test for the dispatch: is /name advertised right now? */
18213
+ wasAdvertised,
18214
+ /** One synchronous catalog commit (the coordinator's install hook). */
18215
+ installSnapshot: (snapshot) => installSurfaceSnapshot(snapshot),
18216
+ /** The revalidating transition (the coordinator's target-change hook). */
18217
+ enterTransition: () => enterCatalogTransition()
18218
+ };
17556
18219
  }
17557
18220
  //#endregion
17558
18221
  //#region src/diag.ts
@@ -18509,6 +19172,191 @@ function parseShellWords(line) {
18509
19172
  return words;
18510
19173
  }
18511
19174
  //#endregion
19175
+ //#region src/skill-catalog-refresh.ts
19176
+ /** Whether two targets are the same owner (no transition needed). The
19177
+ * preset identity is the caller-provided presetId compared EXACTLY —
19178
+ * `undefined` (the deployment default) never conflates with a user preset
19179
+ * literally named `'default'`, and a resolved id never conflates with the
19180
+ * raw id it came from (the worst case is a benign extra transition). */
19181
+ function sameTarget(left, right) {
19182
+ if (left === void 0) return false;
19183
+ if (left.kind !== right.kind) return false;
19184
+ return left.kind === "agent" ? left.key === right.key : left.presetId === right.presetId;
19185
+ }
19186
+ /**
19187
+ * The coordinator. One instance per runner; `dispose()` aborts the active
19188
+ * refresh on teardown.
19189
+ */
19190
+ var CatalogRefreshCoordinator = class {
19191
+ epoch = 0;
19192
+ active;
19193
+ appliedTarget;
19194
+ snapshot;
19195
+ hooks;
19196
+ lifecycleSignal;
19197
+ diag;
19198
+ constructor(hooks, lifecycleSignal, diag) {
19199
+ this.hooks = hooks;
19200
+ this.lifecycleSignal = lifecycleSignal;
19201
+ this.diag = diag;
19202
+ }
19203
+ /** The last applied snapshot (for same-target partial merges and the
19204
+ * `/reload` report). */
19205
+ get currentSnapshot() {
19206
+ return this.snapshot;
19207
+ }
19208
+ /**
19209
+ * Run one refresh request. A new request aborts the active one; only the
19210
+ * LATEST epoch may commit. Returns the settled outcome; never rejects
19211
+ * (failures and supersessions are outcomes, not exceptions).
19212
+ */
19213
+ async refresh(request) {
19214
+ this.active?.controller.abort();
19215
+ const epoch = ++this.epoch;
19216
+ const controller = new AbortController();
19217
+ const signal = AbortSignal.any([this.lifecycleSignal, controller.signal]);
19218
+ this.active = {
19219
+ epoch,
19220
+ controller
19221
+ };
19222
+ const targetChanged = !sameTarget(this.appliedTarget, request.target);
19223
+ try {
19224
+ if (targetChanged) this.hooks.enterCatalogTransition();
19225
+ const committed = request.target.kind === "agent" ? { snapshot: await this.hooks.readAgent(request.agent, signal) } : await this.readStandingSnapshot(request.target.presetId, signal);
19226
+ if (signal.aborted || epoch !== this.epoch) {
19227
+ this.diag.debug("catalog refresh superseded", {
19228
+ epoch,
19229
+ source: request.source
19230
+ });
19231
+ return { kind: "superseded" };
19232
+ }
19233
+ const merged = targetChanged ? committed.snapshot : this.mergePartial(committed.snapshot);
19234
+ this.hooks.installSnapshot(merged);
19235
+ this.snapshot = merged;
19236
+ this.appliedTarget = request.target;
19237
+ this.diag.info("catalog applied", {
19238
+ epoch,
19239
+ source: request.source,
19240
+ commands: merged.commands.length,
19241
+ scopedCommands: merged.scopedCommands.length,
19242
+ skills: merged.skills.length,
19243
+ issues: merged.issues.length
19244
+ });
19245
+ return {
19246
+ kind: "applied",
19247
+ snapshot: merged,
19248
+ ...committed.notice === void 0 ? {} : { notice: committed.notice }
19249
+ };
19250
+ } catch (error) {
19251
+ if (signal.aborted || epoch !== this.epoch) {
19252
+ this.diag.debug("catalog refresh superseded", {
19253
+ epoch,
19254
+ source: request.source
19255
+ });
19256
+ return { kind: "superseded" };
19257
+ }
19258
+ const message = safeErrorMessage(error);
19259
+ this.diag.warn("catalog unavailable", {
19260
+ epoch,
19261
+ source: request.source,
19262
+ error: message
19263
+ });
19264
+ return {
19265
+ kind: "failed",
19266
+ error: message
19267
+ };
19268
+ } finally {
19269
+ if (this.active?.epoch === epoch) this.active = void 0;
19270
+ }
19271
+ }
19272
+ /** Abort the active refresh (app teardown). Late results are dropped. */
19273
+ dispose() {
19274
+ this.active?.controller.abort();
19275
+ }
19276
+ /** Same-target partial failure: a provider issue keeps the OLD field for
19277
+ * that provider (the old snapshot is still more useful than a blank
19278
+ * catalog); the successful providers update as usual. */
19279
+ mergePartial(snapshot) {
19280
+ const old = this.snapshot;
19281
+ if (old === void 0) return snapshot;
19282
+ const commandsFailed = snapshot.issues.some((issue) => issue.provider === "commands");
19283
+ const skillsFailed = snapshot.issues.some((issue) => issue.provider === "skills");
19284
+ if (!commandsFailed && !skillsFailed) return snapshot;
19285
+ return Object.freeze({
19286
+ commands: commandsFailed ? old.commands : snapshot.commands,
19287
+ scopedCommands: commandsFailed ? old.scopedCommands : snapshot.scopedCommands,
19288
+ skills: skillsFailed ? old.skills : snapshot.skills,
19289
+ issues: snapshot.issues
19290
+ });
19291
+ }
19292
+ /** One standing read wrapped into a skills-only surface snapshot: the
19293
+ * sessionless view has no agent-scoped commands, and the global commands
19294
+ * layer is already registered — only the skill wrappers are installed.
19295
+ * An INCOMPLETE observation (`complete !== true`) is never an
19296
+ * authoritative blank catalog: it carries a detached skills issue, so a
19297
+ * same-target reload keeps the last-good skills (mergePartial) and a
19298
+ * target change keeps the revalidating transition wrappers
19299
+ * (installSurfaceSnapshot's skillsFailed guard). */
19300
+ async readStandingSnapshot(presetId, signal) {
19301
+ const read = await this.hooks.readStanding(presetId, signal);
19302
+ const incomplete = read.catalog.complete !== true;
19303
+ return {
19304
+ snapshot: Object.freeze({
19305
+ commands: Object.freeze([]),
19306
+ scopedCommands: Object.freeze([]),
19307
+ skills: read.catalog.skills,
19308
+ issues: Object.freeze(incomplete ? [Object.freeze({
19309
+ provider: "skills",
19310
+ message: "incomplete skill observation"
19311
+ })] : [])
19312
+ }),
19313
+ ...read.notice === void 0 ? {} : { notice: read.notice }
19314
+ };
19315
+ }
19316
+ };
19317
+ /**
19318
+ * The deterministic, timer-free coalescer for invalidation notifications
19319
+ * (`skills/change`): while one refresh is in flight, further notifications
19320
+ * mark the gate DIRTY; when the in-flight refresh settles, ONE more refresh
19321
+ * runs if the gate is dirty. A burst therefore costs at most two reads, and
19322
+ * the re-run always observes the CURRENT ownership because the runner's
19323
+ * `start` callback reads live state at run time.
19324
+ */
19325
+ var CoalescingRefreshGate = class {
19326
+ inFlight = false;
19327
+ dirty = false;
19328
+ start;
19329
+ /** @param start - invoked synchronously to START one owned refresh. Must
19330
+ * never throw and must not itself be re-entrant (the gate owns the
19331
+ * in-flight bookkeeping; `start` only launches the owned task). */
19332
+ constructor(start) {
19333
+ this.start = start;
19334
+ }
19335
+ /** One invalidation notification arrived. */
19336
+ notify() {
19337
+ if (this.inFlight) {
19338
+ this.dirty = true;
19339
+ return;
19340
+ }
19341
+ this.inFlight = true;
19342
+ this.start();
19343
+ }
19344
+ /** Terminal settle of the in-flight refresh (called from EVERY terminal
19345
+ * runOwned callback: onResult / onCancel / onError). Clears the in-flight
19346
+ * flag; a dirty gate starts exactly one follow-up refresh. IDEMPOTENT: a
19347
+ * second settle (e.g. onResult throws and runOwned routes to onError)
19348
+ * must not clear the in-flight flag of a follow-up refresh it did not
19349
+ * start. */
19350
+ settled() {
19351
+ if (!this.inFlight) return;
19352
+ this.inFlight = false;
19353
+ if (this.dirty) {
19354
+ this.dirty = false;
19355
+ this.notify();
19356
+ }
19357
+ }
19358
+ };
19359
+ //#endregion
18512
19360
  //#region src/index.ts
18513
19361
  /**
18514
19362
  * @xmoon76/dsh-pi-tui — the bundle's runner plugin. Waits for the startup
@@ -18615,11 +19463,65 @@ function shouldSteerOnEnter(parsed, running, busyEnter, forceQueue) {
18615
19463
  return running && busyEnter === "steer";
18616
19464
  }
18617
19465
  /**
18618
- * Read an explicit child-session reference from a future/extended job record.
18619
- * Current dsh JobSnapshot records do not expose one, so this normally returns
18620
- * undefined. Crucially, label, registration order and timestamps are never
18621
- * accepted as identity signals.
19466
+ * The advertised-claim miss decision (pure, exported for the headless
19467
+ * suite): a slash input whose name was advertised by the completion list at
19468
+ * submit time but that the REAL session's catalog lacks is CONSUMED with an
19469
+ * explicit error — never sent to the model as a plain user message. An
19470
+ * unadvertised miss keeps the existing plain-input fallback (the user may
19471
+ * deliberately send slash text to the model).
19472
+ * @param execution - the settled `commands.execute` outcome.
19473
+ * @param wasAdvertised - the claim captured BEFORE session creation.
19474
+ * @returns whether the miss must be consumed as an advertised miss.
18622
19475
  */
19476
+ function shouldConsumeAdvertisedMiss(execution, wasAdvertised) {
19477
+ return execution === void 0 && wasAdvertised;
19478
+ }
19479
+ async function resolveInitialCatalog(options) {
19480
+ const { liveAgent, presetId, signal, ctx, diag } = options;
19481
+ if (liveAgent !== void 0) try {
19482
+ const snapshot = await readSurfaceCatalog(liveAgent, signal, ctx);
19483
+ diag.info("surface catalog prefetched", {
19484
+ commands: snapshot.commands.length,
19485
+ scopedCommands: snapshot.scopedCommands.length,
19486
+ skills: snapshot.skills.length
19487
+ });
19488
+ return { snapshot };
19489
+ } catch (error) {
19490
+ if (isCancellation(error)) return {};
19491
+ const message = safeErrorMessage(error);
19492
+ diag.warn("surface catalog unavailable", {
19493
+ phase: "resume",
19494
+ error: message
19495
+ });
19496
+ return { notice: `surface catalog unavailable: ${message}` };
19497
+ }
19498
+ const target = await resolveColdSkillTarget(ctx, presetId, process.cwd());
19499
+ if (target.target === void 0) return {};
19500
+ try {
19501
+ const catalog = await readHumanSkillCatalog(target.target.registry, {
19502
+ cwd: target.target.cwd,
19503
+ scope: target.target.scope,
19504
+ signal
19505
+ });
19506
+ diag.info("skill catalog standing ready", {
19507
+ preset: presetId ?? "default",
19508
+ skills: catalog.skills.length,
19509
+ complete: catalog.complete
19510
+ });
19511
+ return {
19512
+ skills: catalog,
19513
+ ...target.degraded === void 0 ? {} : { notice: target.degraded }
19514
+ };
19515
+ } catch (error) {
19516
+ if (isCancellation(error)) return {};
19517
+ const message = safeErrorMessage(error);
19518
+ diag.warn("skill catalog unavailable", {
19519
+ phase: "cold",
19520
+ error: message
19521
+ });
19522
+ return { notice: `skill catalog unavailable: ${message}` };
19523
+ }
19524
+ }
18623
19525
  function subagentJobTranscriptId(snapshot) {
18624
19526
  if (typeof snapshot !== "object" || snapshot === null) return void 0;
18625
19527
  const childSessionId = snapshot.childSessionId;
@@ -19007,6 +19909,29 @@ function apply(ctx, config) {
19007
19909
  let liveHandle = handle;
19008
19910
  let liveAgent = handle?.agent;
19009
19911
  if (liveAgent !== void 0) await liveAgent.whenIdle();
19912
+ let initialSnapshot;
19913
+ let initialSkills;
19914
+ let surfaceNotice;
19915
+ {
19916
+ let effectivePresetId;
19917
+ try {
19918
+ const launched = await launchComposition();
19919
+ if (launched.failure !== void 0) resumeFailure = launched.failure;
19920
+ effectivePresetId = launched.composition.agentPreset;
19921
+ } catch (error) {
19922
+ diag.warn("preset resolution failed at startup", { error: safeErrorMessage(error) });
19923
+ }
19924
+ const resolution = await resolveInitialCatalog({
19925
+ liveAgent,
19926
+ presetId: effectivePresetId,
19927
+ signal: lifecycleController.signal,
19928
+ ctx,
19929
+ diag
19930
+ });
19931
+ initialSnapshot = resolution.snapshot;
19932
+ initialSkills = resolution.skills;
19933
+ surfaceNotice = resolution.notice;
19934
+ }
19010
19935
  let guardState = freshGuardState();
19011
19936
  let guardToken;
19012
19937
  /**
@@ -19143,6 +20068,7 @@ function apply(ctx, config) {
19143
20068
  guardToken = void 0;
19144
20069
  bumpSessionGeneration();
19145
20070
  await initLiveSession(next.agent);
20071
+ await refreshLiveCatalog(next.agent);
19146
20072
  diag.info("switch ok", {
19147
20073
  from: from ?? "(none)",
19148
20074
  to: next.agent.session.id,
@@ -19229,6 +20155,7 @@ function apply(ctx, config) {
19229
20155
  cleanedUp = true;
19230
20156
  guardToken = void 0;
19231
20157
  lifecycleController.abort();
20158
+ catalogCoordinator?.dispose();
19232
20159
  localShellController?.abort();
19233
20160
  for (const file of shellTempFiles) try {
19234
20161
  rmSync(file, { force: true });
@@ -19528,6 +20455,8 @@ function apply(ctx, config) {
19528
20455
  * user input is the deferred trigger), guard against cross-process
19529
20456
  * divergence, then execute a registered slash command or follow up. */
19530
20457
  const dispatchViaSession = (text) => {
20458
+ const parsedAtSubmit = parseCommand(text);
20459
+ const wasAdvertised = parsedAtSubmit !== void 0 && wasAdvertisedClaim?.(parsedAtSubmit.name) === true;
19531
20460
  runOwned("submit", () => ensureSession().then(async () => {
19532
20461
  const agent = liveAgent;
19533
20462
  if (agent === void 0) return;
@@ -19557,6 +20486,10 @@ function apply(ctx, config) {
19557
20486
  diag,
19558
20487
  sessionId: () => agent.session.id,
19559
20488
  onResult: (execution) => {
20489
+ if (shouldConsumeAdvertisedMiss(execution, wasAdvertised)) {
20490
+ app.notify(`/${parsedAtSubmit?.name ?? "?"} is not available in the created session`, "error");
20491
+ return;
20492
+ }
19560
20493
  if (execution === void 0) {
19561
20494
  if (sessionUnchanged({
19562
20495
  agent,
@@ -20063,7 +20996,12 @@ function apply(ctx, config) {
20063
20996
  app.setQueueItems(queue);
20064
20997
  };
20065
20998
  refreshQueue();
20066
- /** Rebuild every live-session surface after resume, create, or swap. */
20999
+ /**
21000
+ * Rebuild every live-session surface after resume, create, or swap.
21001
+ * The surface catalog is NOT touched here: the initial owner's catalog
21002
+ * came from the pre-mount prefetch/probe, and the first deferred create
21003
+ * plus every switch await the coordinator refresh themselves.
21004
+ */
20067
21005
  const initLiveSession = async (agent) => {
20068
21006
  folder = new TranscriptFolder();
20069
21007
  folder.apply(agent.session.events);
@@ -20082,8 +21020,10 @@ function apply(ctx, config) {
20082
21020
  app.resetInputHistory(tuiSettings?.get().history[sessionCwd()] ?? []);
20083
21021
  setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())} · ${agent.session.id}`);
20084
21022
  updateWelcomeCard();
20085
- registerCommands();
20086
- refreshSkillCommands();
21023
+ registerCommands({
21024
+ snapshot: initialSnapshot,
21025
+ skills: initialSkills
21026
+ });
20087
21027
  };
20088
21028
  /**
20089
21029
  * Create the first session lazily — the FIRST user message triggers it
@@ -20112,6 +21052,7 @@ function apply(ctx, config) {
20112
21052
  await liveAgent.whenIdle();
20113
21053
  bumpSessionGeneration();
20114
21054
  await initLiveSession(liveAgent);
21055
+ await refreshLiveCatalog(liveAgent);
20115
21056
  } catch (error) {
20116
21057
  const message = safeErrorMessage(error);
20117
21058
  ctx.logger.warn(`tui-runner: failed to start with preset "${launchPreset ?? "default"}": ${message}`);
@@ -20135,6 +21076,7 @@ function apply(ctx, config) {
20135
21076
  await liveAgent.whenIdle();
20136
21077
  bumpSessionGeneration();
20137
21078
  await initLiveSession(liveAgent);
21079
+ await refreshLiveCatalog(liveAgent);
20138
21080
  }
20139
21081
  if (resumeFailure !== void 0) {
20140
21082
  app.notify(resumeFailure, "error");
@@ -20146,7 +21088,67 @@ function apply(ctx, config) {
20146
21088
  return creating;
20147
21089
  };
20148
21090
  let commandsRegistered = false;
20149
- let refreshSkills;
21091
+ /** The claim test installed by registerTuiCommands: is a slash name
21092
+ * advertised by the CURRENT completion list? The dispatch captures it
21093
+ * BEFORE any session creation (see dispatchViaSession). */
21094
+ let wasAdvertisedClaim;
21095
+ /** The catalog refresh coordinator: the ONE post-mount refresh owner
21096
+ * (first session, switches, /preset, /reload). Built inside
21097
+ * registerCommands once the surface hooks exist. */
21098
+ let catalogRefreshRequest;
21099
+ let catalogCoordinator;
21100
+ /** `skills/change` coalescing: bursts of invalidation notifications cost
21101
+ * at most two reads, and the follow-up re-read observes the CURRENT
21102
+ * ownership (live agent vs standing preset). */
21103
+ let skillsChangeSubscribed = false;
21104
+ const skillsChangeGate = new CoalescingRefreshGate(() => {
21105
+ runOwned("skills/change refresh", async () => {
21106
+ const refresh = catalogRefreshRequest;
21107
+ if (refresh === void 0) return void 0;
21108
+ const target = liveAgent === void 0 ? {
21109
+ kind: "preset",
21110
+ presetId: pendingPreset ?? launchPreset
21111
+ } : {
21112
+ kind: "agent",
21113
+ key: sessionGeneration
21114
+ };
21115
+ return refresh({
21116
+ source: "invalidation",
21117
+ target,
21118
+ ...target.kind === "agent" ? { agent: liveAgent } : {}
21119
+ });
21120
+ }, {
21121
+ diag,
21122
+ sessionId: () => liveAgent?.session.id,
21123
+ onResult: (outcome) => {
21124
+ if (outcome !== void 0 && outcome.kind === "applied" && outcome.notice !== void 0) app.notify(outcome.notice, "error");
21125
+ skillsChangeGate.settled();
21126
+ },
21127
+ onCancel: () => {
21128
+ skillsChangeGate.settled();
21129
+ },
21130
+ onError: (error) => {
21131
+ skillsChangeGate.settled();
21132
+ app.notify(`skill catalog refresh failed: ${safeErrorMessage(error)}`, "error");
21133
+ }
21134
+ });
21135
+ });
21136
+ /** Subscribe to the dsh-skill invalidation notification once, through
21137
+ * the single-point adapter (plan appendix B.1). The event carries no
21138
+ * scope or cwd, so the refresh target follows the CURRENT ownership; an
21139
+ * unavailable or throwing subscription degrades to no subscription —
21140
+ * owner switches and /reload still refresh. The flag is set only after
21141
+ * a successful subscribe, so a throwing subscribe can retry on a later
21142
+ * registration attempt. */
21143
+ const subscribeSkillsChangeEvents = () => {
21144
+ if (skillsChangeSubscribed) return;
21145
+ try {
21146
+ subscribeSkillsChange(ctx, () => skillsChangeGate.notify());
21147
+ skillsChangeSubscribed = true;
21148
+ } catch (error) {
21149
+ diag.warn("skills/change subscription unavailable", { error: safeErrorMessage(error) });
21150
+ }
21151
+ };
20150
21152
  const runner = {
20151
21153
  ctx,
20152
21154
  app,
@@ -20180,6 +21182,20 @@ function apply(ctx, config) {
20180
21182
  set pendingPreset(id) {
20181
21183
  pendingPreset = id;
20182
21184
  },
21185
+ /** The effective preset id for COLD (sessionless) reads: the run-local
21186
+ * pending override ahead of the launch-time --preset (the SAME
21187
+ * precedence ensureSession uses); undefined = the saved/default
21188
+ * preset applies. */
21189
+ get effectivePresetId() {
21190
+ return pendingPreset ?? launchPreset;
21191
+ },
21192
+ refreshCatalog: (request) => {
21193
+ const refresh = catalogRefreshRequest;
21194
+ return refresh === void 0 ? Promise.resolve({
21195
+ kind: "failed",
21196
+ error: "catalog refresh unavailable"
21197
+ }) : refresh(request);
21198
+ },
20183
21199
  switchSession,
20184
21200
  swapTo: (next) => swapTo(next),
20185
21201
  currentPreset,
@@ -20191,12 +21207,32 @@ function apply(ctx, config) {
20191
21207
  requestExit,
20192
21208
  exit
20193
21209
  };
20194
- const registerCommands = () => {
21210
+ const registerCommands = (initial) => {
20195
21211
  if (commandsRegistered) return;
20196
21212
  if (ctx.get("commands") === void 0) return;
20197
21213
  commandsRegistered = true;
20198
21214
  try {
20199
- refreshSkills = registerTuiCommands(runner).refreshSkills;
21215
+ const installed = registerTuiCommands(runner, initial);
21216
+ wasAdvertisedClaim = installed.wasAdvertised;
21217
+ catalogCoordinator = new CatalogRefreshCoordinator({
21218
+ readAgent: (agent, readSignal) => readSurfaceCatalog(agent, readSignal, ctx),
21219
+ readStanding: async (presetId, readSignal) => {
21220
+ const target = await resolveColdSkillTarget(ctx, presetId, process.cwd());
21221
+ if (target.target === void 0) throw new Error("skill service unavailable");
21222
+ return {
21223
+ catalog: await readHumanSkillCatalog(target.target.registry, {
21224
+ cwd: target.target.cwd,
21225
+ scope: target.target.scope,
21226
+ signal: readSignal
21227
+ }),
21228
+ ...target.degraded === void 0 ? {} : { notice: target.degraded }
21229
+ };
21230
+ },
21231
+ installSnapshot: (next) => installed.installSnapshot(next),
21232
+ enterCatalogTransition: () => installed.enterTransition()
21233
+ }, lifecycleController.signal, diag);
21234
+ catalogRefreshRequest = (request) => catalogCoordinator.refresh(request);
21235
+ subscribeSkillsChangeEvents();
20200
21236
  } catch (error) {
20201
21237
  commandsRegistered = false;
20202
21238
  const message = safeErrorMessage(error);
@@ -20205,13 +21241,21 @@ function apply(ctx, config) {
20205
21241
  app.notify(`command registration failed: ${message}`, "error");
20206
21242
  }
20207
21243
  };
20208
- /** Rebuild the agent-scoped per-skill commands after a session exists. */
20209
- const refreshSkillCommands = () => {
20210
- const refresh = refreshSkills;
21244
+ /** Await one live-owner catalog refresh through the coordinator (the
21245
+ * first deferred create and every session switch): the refresh attempt
21246
+ * settles before the caller continues, and its outcome is an outcome —
21247
+ * provider issues degrade fields, failures warn, the submission or the
21248
+ * switch proceeds either way. */
21249
+ const refreshLiveCatalog = async (agent) => {
21250
+ const refresh = catalogRefreshRequest;
20211
21251
  if (refresh === void 0) return;
20212
- runDetached("skill command refresh", () => refresh(), {
20213
- diag,
20214
- sessionId: () => liveAgent?.session.id
21252
+ await refresh({
21253
+ source: "live-session",
21254
+ target: {
21255
+ kind: "agent",
21256
+ key: sessionGeneration
21257
+ },
21258
+ agent
20215
21259
  });
20216
21260
  };
20217
21261
  if (liveAgent !== void 0) await initLiveSession(liveAgent);
@@ -20220,7 +21264,14 @@ function apply(ctx, config) {
20220
21264
  refreshStatus();
20221
21265
  setTerminalTitle(`dsh-pi-tui · ${shortCwd(sessionCwd())}`);
20222
21266
  }
20223
- registerCommands();
21267
+ registerCommands({
21268
+ snapshot: initialSnapshot,
21269
+ skills: initialSkills
21270
+ });
21271
+ if (surfaceNotice !== void 0) {
21272
+ app.notify(surfaceNotice, "error");
21273
+ surfaceNotice = void 0;
21274
+ }
20224
21275
  if (resumeFailure !== void 0) {
20225
21276
  app.notify(resumeFailure, "error");
20226
21277
  resumeFailure = void 0;
@@ -20330,4 +21381,4 @@ function apply(ctx, config) {
20330
21381
  });
20331
21382
  }
20332
21383
  //#endregion
20333
- export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resumeCommand, runningProfile, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };
21384
+ export { Config, LOCAL_COMMANDS, SESSIONLESS_COMMANDS, apply, composeAgent, dangerCommand, forkSeed, inject, name, recomposeBlank, recordedPreset, resolveInitialCatalog, resumeCommand, runningProfile, shouldConsumeAdvertisedMiss, shouldSteerOnEnter, subagentJobTranscriptId, subagentJobViewHint };