@sagmans/dsh-tui 0.6.0 → 0.8.0

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.
Files changed (271) hide show
  1. package/.agents/skills/dsh-tui-dogfood/SKILL.md +83 -0
  2. package/.agents/skills/dsh-tui-dogfood/references/home-state.md +78 -0
  3. package/.agents/skills/dsh-tui-dogfood/scripts/clone-links.mjs +133 -0
  4. package/.agents/skills/dsh-tui-dogfood/scripts/run-plugin-from-worktree.sh +417 -0
  5. package/.agents/skills/dsh-tui-update-models/SKILL.md +199 -0
  6. package/.agents/skills/dsh-tui-update-models/references/model-wiring.md +292 -0
  7. package/.agents/skills/dsh-tui-update-models/scripts/dump-model-catalog.mjs +314 -0
  8. package/README.md +154 -27
  9. package/lib/agent/present.d.ts.map +1 -1
  10. package/lib/agent/present.js +2 -1
  11. package/lib/agent/present.js.map +1 -1
  12. package/lib/cards/composition.d.ts +68 -0
  13. package/lib/cards/composition.d.ts.map +1 -0
  14. package/lib/cards/composition.js +117 -0
  15. package/lib/cards/composition.js.map +1 -0
  16. package/lib/cards/presenter.d.ts +23 -0
  17. package/lib/cards/presenter.d.ts.map +1 -0
  18. package/lib/cards/presenter.js +394 -0
  19. package/lib/cards/presenter.js.map +1 -0
  20. package/lib/cards/preview.d.ts +42 -0
  21. package/lib/cards/preview.d.ts.map +1 -0
  22. package/lib/cards/preview.js +51 -0
  23. package/lib/cards/preview.js.map +1 -0
  24. package/lib/cards.d.ts +12 -120
  25. package/lib/cards.d.ts.map +1 -1
  26. package/lib/cards.js +4 -556
  27. package/lib/cards.js.map +1 -1
  28. package/lib/compat/section.d.ts +29 -0
  29. package/lib/compat/section.d.ts.map +1 -0
  30. package/lib/compat/section.js +82 -0
  31. package/lib/compat/section.js.map +1 -0
  32. package/lib/config.d.ts +27 -2
  33. package/lib/config.d.ts.map +1 -1
  34. package/lib/config.js +61 -1
  35. package/lib/config.js.map +1 -1
  36. package/lib/contracts.d.ts +28 -0
  37. package/lib/contracts.d.ts.map +1 -1
  38. package/lib/fold-cursor.d.ts +8 -0
  39. package/lib/fold-cursor.d.ts.map +1 -1
  40. package/lib/fold-cursor.js +18 -0
  41. package/lib/fold-cursor.js.map +1 -1
  42. package/lib/gates/question-card.d.ts +77 -0
  43. package/lib/gates/question-card.d.ts.map +1 -0
  44. package/lib/gates/question-card.js +113 -0
  45. package/lib/gates/question-card.js.map +1 -0
  46. package/lib/gates/questions.d.ts +127 -0
  47. package/lib/gates/questions.d.ts.map +1 -0
  48. package/lib/gates/questions.js +418 -0
  49. package/lib/gates/questions.js.map +1 -0
  50. package/lib/gates.d.ts +1 -113
  51. package/lib/gates.d.ts.map +1 -1
  52. package/lib/gates.js +1 -484
  53. package/lib/gates.js.map +1 -1
  54. package/lib/herdr/client.d.ts +8 -0
  55. package/lib/herdr/client.d.ts.map +1 -1
  56. package/lib/herdr/client.js +13 -1
  57. package/lib/herdr/client.js.map +1 -1
  58. package/lib/herdr/constants.d.ts +18 -0
  59. package/lib/herdr/constants.d.ts.map +1 -1
  60. package/lib/herdr/constants.js +18 -0
  61. package/lib/herdr/constants.js.map +1 -1
  62. package/lib/herdr/reporter.d.ts +20 -4
  63. package/lib/herdr/reporter.d.ts.map +1 -1
  64. package/lib/herdr/reporter.js +70 -20
  65. package/lib/herdr/reporter.js.map +1 -1
  66. package/lib/herdr/state.d.ts +10 -0
  67. package/lib/herdr/state.d.ts.map +1 -1
  68. package/lib/herdr/state.js +21 -4
  69. package/lib/herdr/state.js.map +1 -1
  70. package/lib/index.d.ts +1 -0
  71. package/lib/index.d.ts.map +1 -1
  72. package/lib/index.js +348 -2602
  73. package/lib/index.js.map +1 -1
  74. package/lib/input/action-catalog.d.ts +74 -0
  75. package/lib/input/action-catalog.d.ts.map +1 -0
  76. package/lib/input/action-catalog.js +137 -0
  77. package/lib/input/action-catalog.js.map +1 -0
  78. package/lib/input/actions.d.ts +5 -81
  79. package/lib/input/actions.d.ts.map +1 -1
  80. package/lib/input/actions.js +4 -625
  81. package/lib/input/actions.js.map +1 -1
  82. package/lib/input/completion.d.ts +1 -1
  83. package/lib/input/completion.d.ts.map +1 -1
  84. package/lib/input/completion.js +2 -1
  85. package/lib/input/completion.js.map +1 -1
  86. package/lib/input/file-index.d.ts +40 -0
  87. package/lib/input/file-index.d.ts.map +1 -0
  88. package/lib/input/file-index.js +141 -0
  89. package/lib/input/file-index.js.map +1 -0
  90. package/lib/input/file-search.d.ts +1 -38
  91. package/lib/input/file-search.d.ts.map +1 -1
  92. package/lib/input/file-search.js +1 -487
  93. package/lib/input/file-search.js.map +1 -1
  94. package/lib/input/key-press.d.ts +57 -0
  95. package/lib/input/key-press.d.ts.map +1 -0
  96. package/lib/input/key-press.js +218 -0
  97. package/lib/input/key-press.js.map +1 -0
  98. package/lib/input/keymap-conflicts.d.ts +59 -0
  99. package/lib/input/keymap-conflicts.d.ts.map +1 -0
  100. package/lib/input/keymap-conflicts.js +292 -0
  101. package/lib/input/keymap-conflicts.js.map +1 -0
  102. package/lib/input/keymap-settings.js +1 -1
  103. package/lib/input/keymap-settings.js.map +1 -1
  104. package/lib/input/keymap.d.ts.map +1 -1
  105. package/lib/input/keymap.js +3 -1
  106. package/lib/input/keymap.js.map +1 -1
  107. package/lib/input/workspace-files.d.ts +10 -0
  108. package/lib/input/workspace-files.d.ts.map +1 -0
  109. package/lib/input/workspace-files.js +359 -0
  110. package/lib/input/workspace-files.js.map +1 -0
  111. package/lib/install-skills.d.ts +39 -0
  112. package/lib/install-skills.d.ts.map +1 -0
  113. package/lib/install-skills.js +273 -0
  114. package/lib/install-skills.js.map +1 -0
  115. package/lib/keys-command.d.ts +2 -1
  116. package/lib/keys-command.d.ts.map +1 -1
  117. package/lib/keys-command.js +2 -1
  118. package/lib/keys-command.js.map +1 -1
  119. package/lib/model-list.d.ts +37 -0
  120. package/lib/model-list.d.ts.map +1 -0
  121. package/lib/model-list.js +60 -0
  122. package/lib/model-list.js.map +1 -0
  123. package/lib/startup.d.ts +8 -0
  124. package/lib/startup.d.ts.map +1 -1
  125. package/lib/startup.js +120 -24
  126. package/lib/startup.js.map +1 -1
  127. package/lib/subagents.d.ts +7 -3
  128. package/lib/subagents.d.ts.map +1 -1
  129. package/lib/subagents.js +28 -4
  130. package/lib/subagents.js.map +1 -1
  131. package/lib/surface/appearance-preferences.d.ts +26 -0
  132. package/lib/surface/appearance-preferences.d.ts.map +1 -0
  133. package/lib/surface/appearance-preferences.js +139 -0
  134. package/lib/surface/appearance-preferences.js.map +1 -0
  135. package/lib/surface/appearance.d.ts +87 -0
  136. package/lib/surface/appearance.d.ts.map +1 -0
  137. package/lib/surface/appearance.js +353 -0
  138. package/lib/surface/appearance.js.map +1 -0
  139. package/lib/surface/background-work.d.ts +51 -0
  140. package/lib/surface/background-work.d.ts.map +1 -0
  141. package/lib/surface/background-work.js +172 -0
  142. package/lib/surface/background-work.js.map +1 -0
  143. package/lib/surface/commands.d.ts +82 -0
  144. package/lib/surface/commands.d.ts.map +1 -0
  145. package/lib/surface/commands.js +370 -0
  146. package/lib/surface/commands.js.map +1 -0
  147. package/lib/surface/modal-input.d.ts +59 -0
  148. package/lib/surface/modal-input.d.ts.map +1 -0
  149. package/lib/surface/modal-input.js +243 -0
  150. package/lib/surface/modal-input.js.map +1 -0
  151. package/lib/surface/model-choice.d.ts +54 -0
  152. package/lib/surface/model-choice.d.ts.map +1 -0
  153. package/lib/surface/model-choice.js +325 -0
  154. package/lib/surface/model-choice.js.map +1 -0
  155. package/lib/surface/preset-choice.d.ts +78 -0
  156. package/lib/surface/preset-choice.d.ts.map +1 -0
  157. package/lib/surface/preset-choice.js +163 -0
  158. package/lib/surface/preset-choice.js.map +1 -0
  159. package/lib/surface/prompt-input.d.ts +97 -0
  160. package/lib/surface/prompt-input.d.ts.map +1 -0
  161. package/lib/surface/prompt-input.js +215 -0
  162. package/lib/surface/prompt-input.js.map +1 -0
  163. package/lib/surface/prompt-memory.d.ts +76 -0
  164. package/lib/surface/prompt-memory.d.ts.map +1 -0
  165. package/lib/surface/prompt-memory.js +167 -0
  166. package/lib/surface/prompt-memory.js.map +1 -0
  167. package/lib/surface/session-lifecycle.d.ts +120 -0
  168. package/lib/surface/session-lifecycle.d.ts.map +1 -0
  169. package/lib/surface/session-lifecycle.js +324 -0
  170. package/lib/surface/session-lifecycle.js.map +1 -0
  171. package/lib/surface/session-picker.d.ts +39 -0
  172. package/lib/surface/session-picker.d.ts.map +1 -0
  173. package/lib/surface/session-picker.js +107 -0
  174. package/lib/surface/session-picker.js.map +1 -0
  175. package/lib/surface/session-view.d.ts +86 -0
  176. package/lib/surface/session-view.d.ts.map +1 -0
  177. package/lib/surface/session-view.js +215 -0
  178. package/lib/surface/session-view.js.map +1 -0
  179. package/lib/surface/staged-turns.d.ts +81 -0
  180. package/lib/surface/staged-turns.d.ts.map +1 -0
  181. package/lib/surface/staged-turns.js +206 -0
  182. package/lib/surface/staged-turns.js.map +1 -0
  183. package/lib/surface/terminal-lifecycle.d.ts +76 -0
  184. package/lib/surface/terminal-lifecycle.d.ts.map +1 -0
  185. package/lib/surface/terminal-lifecycle.js +222 -0
  186. package/lib/surface/terminal-lifecycle.js.map +1 -0
  187. package/lib/terminal-text/scan.d.ts +44 -0
  188. package/lib/terminal-text/scan.d.ts.map +1 -0
  189. package/lib/terminal-text/scan.js +195 -0
  190. package/lib/terminal-text/scan.js.map +1 -0
  191. package/lib/terminal-text/sgr.d.ts +32 -0
  192. package/lib/terminal-text/sgr.d.ts.map +1 -0
  193. package/lib/terminal-text/sgr.js +203 -0
  194. package/lib/terminal-text/sgr.js.map +1 -0
  195. package/lib/terminal-text.d.ts +1 -1
  196. package/lib/terminal-text.d.ts.map +1 -1
  197. package/lib/terminal-text.js +2 -379
  198. package/lib/terminal-text.js.map +1 -1
  199. package/lib/theme-command.js +2 -1
  200. package/lib/theme-command.js.map +1 -1
  201. package/lib/theme-defaults.d.ts +64 -0
  202. package/lib/theme-defaults.d.ts.map +1 -0
  203. package/lib/theme-defaults.js +248 -0
  204. package/lib/theme-defaults.js.map +1 -0
  205. package/lib/theme-files.d.ts +1 -1
  206. package/lib/theme-files.d.ts.map +1 -1
  207. package/lib/theme-files.js +1 -1
  208. package/lib/theme-files.js.map +1 -1
  209. package/lib/theme-resolver.d.ts +36 -0
  210. package/lib/theme-resolver.d.ts.map +1 -0
  211. package/lib/theme-resolver.js +112 -0
  212. package/lib/theme-resolver.js.map +1 -0
  213. package/lib/theme-schema.d.ts.map +1 -1
  214. package/lib/theme-schema.js +3 -2
  215. package/lib/theme-schema.js.map +1 -1
  216. package/lib/theme-settings.d.ts +5 -7
  217. package/lib/theme-settings.d.ts.map +1 -1
  218. package/lib/theme-settings.js +37 -21
  219. package/lib/theme-settings.js.map +1 -1
  220. package/lib/theme-tokens.d.ts +0 -89
  221. package/lib/theme-tokens.d.ts.map +1 -1
  222. package/lib/theme-tokens.js +0 -356
  223. package/lib/theme-tokens.js.map +1 -1
  224. package/lib/theme.d.ts +1 -1
  225. package/lib/theme.d.ts.map +1 -1
  226. package/lib/theme.js +2 -1
  227. package/lib/theme.js.map +1 -1
  228. package/lib/tool-display.d.ts.map +1 -1
  229. package/lib/tool-display.js +2 -1
  230. package/lib/tool-display.js.map +1 -1
  231. package/lib/transcript/message-content.d.ts +21 -0
  232. package/lib/transcript/message-content.d.ts.map +1 -0
  233. package/lib/transcript/message-content.js +61 -0
  234. package/lib/transcript/message-content.js.map +1 -0
  235. package/lib/transcript/tool-calls.d.ts +106 -0
  236. package/lib/transcript/tool-calls.d.ts.map +1 -0
  237. package/lib/transcript/tool-calls.js +254 -0
  238. package/lib/transcript/tool-calls.js.map +1 -0
  239. package/lib/transcript.d.ts +14 -55
  240. package/lib/transcript.d.ts.map +1 -1
  241. package/lib/transcript.js +31 -253
  242. package/lib/transcript.js.map +1 -1
  243. package/lib/ui/dock.d.ts +14 -2
  244. package/lib/ui/dock.d.ts.map +1 -1
  245. package/lib/ui/dock.js +52 -10
  246. package/lib/ui/dock.js.map +1 -1
  247. package/lib/ui/editor.d.ts.map +1 -1
  248. package/lib/ui/editor.js +2 -1
  249. package/lib/ui/editor.js.map +1 -1
  250. package/lib/ui/keymap-picker.d.ts +2 -1
  251. package/lib/ui/keymap-picker.d.ts.map +1 -1
  252. package/lib/ui/keymap-picker.js +1 -0
  253. package/lib/ui/keymap-picker.js.map +1 -1
  254. package/lib/ui/view/gate-card.d.ts +21 -0
  255. package/lib/ui/view/gate-card.d.ts.map +1 -0
  256. package/lib/ui/view/gate-card.js +104 -0
  257. package/lib/ui/view/gate-card.js.map +1 -0
  258. package/lib/ui/view/tool-card.d.ts +149 -0
  259. package/lib/ui/view/tool-card.d.ts.map +1 -0
  260. package/lib/ui/view/tool-card.js +429 -0
  261. package/lib/ui/view/tool-card.js.map +1 -0
  262. package/lib/ui/view/transcript-message.d.ts +62 -0
  263. package/lib/ui/view/transcript-message.d.ts.map +1 -0
  264. package/lib/ui/view/transcript-message.js +158 -0
  265. package/lib/ui/view/transcript-message.js.map +1 -0
  266. package/lib/ui/view.d.ts +105 -223
  267. package/lib/ui/view.d.ts.map +1 -1
  268. package/lib/ui/view.js +132 -679
  269. package/lib/ui/view.js.map +1 -1
  270. package/package.json +3 -1
  271. package/themes/violet-orbit.yaml +7 -5
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env bash
2
+ # Clone the selected dsh home so plugin runs never write into the real profile.
3
+ set -euo pipefail
4
+
5
+ name="$(basename "${BASH_SOURCE[0]}")"
6
+ # The link policy is shared with the validator, so both read one rule set.
7
+ helper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
8
+ default_repo="${DSH_DOGFOOD_DEFAULT_REPO:-$PWD}"
9
+ default_repo="$(cd "$default_repo" && pwd -P)"
10
+
11
+ die() { printf '%s: error: %s\n' "$name" "$*" >&2; exit 1; }
12
+ note() { printf '%s: %s\n' "$name" "$*" >&2; }
13
+ step() { printf '\n==> %s\n' "$*" >&2; }
14
+
15
+ usage() {
16
+ cat <<'EOF'
17
+ Run a dsh plugin from a worktree, in a clone of the real home.
18
+
19
+ Usage:
20
+ run-plugin-from-worktree.sh [options] [<worktree-name-or-path>] [-- <dsh args>]
21
+
22
+ The target is the checkout to run. A path is used as given; a name is matched
23
+ against this checkout's worktrees by directory name or branch tail (feat/copy
24
+ matches .../feat/copy). No target means the current checkout.
25
+
26
+ Options:
27
+ --home DIR scratch home to run in (default: <tmp>/dsh-dogfood/<name>-<id>)
28
+ --source-home DIR home to clone (default: ~/.dsh)
29
+ --profile NAME profile inside the scratch home (default: tui)
30
+ --dsh PATH launcher to run (default: dsh on PATH, or $DSH_BIN)
31
+ --fresh seed the scratch home with credentials and settings only
32
+ --with-sessions copy stored sessions too, so --resume reaches real history
33
+ --reseed discard an existing scratch home and clone again
34
+ --no-build do not run pnpm run build in the target first
35
+ --no-launch set the run up and print the command instead of starting
36
+ --status print what the scratch home is pointed at and exit
37
+ --dry-run print the plan without touching anything
38
+ --clean remove the scratch home and exit
39
+ --list list this repository's worktrees and exit
40
+ -h, --help this text
41
+
42
+ Examples:
43
+ run-plugin-from-worktree.sh feature-x -- --resume
44
+ run-plugin-from-worktree.sh --fresh --profile tui-copy-fix ../dsh-tui-copy
45
+ run-plugin-from-worktree.sh --with-sessions --no-launch
46
+
47
+ The scratch home holds copied credentials and is kept private. --clean requires
48
+ a marker bound to this home, source, and checkout; no unmarked home is deleted.
49
+ EOF
50
+ }
51
+
52
+ # --- arguments ---------------------------------------------------------------
53
+
54
+ target=""
55
+ home=""
56
+ source_home="$HOME/.dsh"
57
+ profile="tui"
58
+ dsh_bin="${DSH_BIN:-}"
59
+ fresh=0
60
+ with_sessions=0
61
+ reseed=0
62
+ build=1
63
+ launch=1
64
+ dry_run=0
65
+ action=""
66
+
67
+ while [[ $# -gt 0 ]]; do
68
+ case "$1" in
69
+ --home) home="${2:?--home needs a directory}"; shift 2 ;;
70
+ --source-home) source_home="${2:?--source-home needs a directory}"; shift 2 ;;
71
+ --profile) profile="${2:?--profile needs a name}"; shift 2 ;;
72
+ --dsh) dsh_bin="${2:?--dsh needs a path}"; shift 2 ;;
73
+ --fresh) fresh=1; shift ;;
74
+ --with-sessions) with_sessions=1; shift ;;
75
+ --reseed) reseed=1; shift ;;
76
+ --no-build) build=0; shift ;;
77
+ --no-launch) launch=0; shift ;;
78
+ --status) action="status"; shift ;;
79
+ --dry-run) dry_run=1; launch=0; shift ;;
80
+ --clean) action="clean"; shift ;;
81
+ --list) action="list"; shift ;;
82
+ -h|--help) usage; exit 0 ;;
83
+ --) shift; break ;;
84
+ -*) die "unknown option $1 (try --help)" ;;
85
+ *) [[ -z "$target" ]] || die "only one target is accepted (got $target and $1)"
86
+ target="$1"; shift ;;
87
+ esac
88
+ done
89
+ passthrough=("$@")
90
+
91
+ if [[ -n "$source_home" ]]; then source_home="${source_home/#\~/$HOME}"; fi
92
+
93
+ # --- the repository's worktrees ---------------------------------------------
94
+
95
+ worktree_lines() {
96
+ git -C "$default_repo" worktree list --porcelain 2>/dev/null || true
97
+ }
98
+
99
+ list_worktrees() {
100
+ local line path="" branch=""
101
+ while IFS= read -r line; do
102
+ case "$line" in
103
+ worktree\ *) path="${line#worktree }" ;;
104
+ branch\ *) branch="${line#branch refs/heads/}" ;;
105
+ "")
106
+ [[ -n "$path" ]] || continue
107
+ if [[ -n "$branch" ]]; then printf '%-44s %s\n' "$path" "($branch)"
108
+ else printf '%-44s %s\n' "$path" "(detached)"; fi
109
+ path=""; branch="" ;;
110
+ esac
111
+ done < <(worktree_lines)
112
+ if [[ -n "$path" ]]; then printf '%-44s %s\n' "$path" "(${branch:-detached})"; fi
113
+ }
114
+
115
+ # A name matches a worktree's directory name or the tail of its branch, so both
116
+ # feat/copy-columns and copy-columns reach the same checkout.
117
+ resolve_by_name() {
118
+ local want="$1" line path="" branch="" found=""
119
+ while IFS= read -r line; do
120
+ case "$line" in
121
+ worktree\ *) path="${line#worktree }" ;;
122
+ branch\ *) branch="${line#branch refs/heads/}" ;;
123
+ "")
124
+ [[ -n "$path" ]] || continue
125
+ if [[ "$(basename "$path")" == "$want" || "$branch" == "$want" || "${branch##*/}" == "$want" ]]; then
126
+ [[ -z "$found" ]] || die "'$want' matches more than one worktree; pass a path"
127
+ found="$path"
128
+ fi
129
+ path=""; branch="" ;;
130
+ esac
131
+ done < <(worktree_lines)
132
+ if [[ -n "$path" ]] && [[ "$(basename "$path")" == "$want" || "$branch" == "$want" || "${branch##*/}" == "$want" ]]; then
133
+ [[ -z "$found" ]] || die "'$want' matches more than one worktree; pass a path"
134
+ found="$path"
135
+ fi
136
+ printf '%s' "$found"
137
+ }
138
+
139
+ resolve_target() {
140
+ [[ -n "$target" ]] || { printf '%s' "$default_repo"; return; }
141
+ if [[ -d "$target" ]]; then (cd "$target" && pwd -P); return; fi
142
+ local hit sibling
143
+ hit="$(resolve_by_name "$target")"
144
+ if [[ -z "$hit" ]]; then
145
+ sibling="$(cd "$default_repo/.." 2>/dev/null && pwd -P)/$target"
146
+ if [[ -d "$sibling" ]]; then hit="$sibling"; fi
147
+ fi
148
+ [[ -n "$hit" ]] || die "no worktree or directory named '$target' (try --list)"
149
+ (cd "$hit" && pwd -P)
150
+ }
151
+
152
+ if [[ "$action" == "list" ]]; then list_worktrees; exit 0; fi
153
+
154
+ [[ "$profile" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || die "invalid profile name: $profile"
155
+ # A removed checkout cannot supply package.json. Only explicit scratch-home cleanup
156
+ # may use its private marker to recover the original, now absent target identity.
157
+ orphan_clean=0
158
+ if [[ "$action" == clean && -n "$home" && -z "$target" ]]; then
159
+ candidate_home="$(node -e "console.log(require('node:path').resolve(process.argv[1]))" "$home")"
160
+ # Orphan cleanup trusts only a private clone marker owned by this user.
161
+ if [[ -f "$candidate_home/.dsh-dogfood" ]] && node -e '
162
+ const fs = require("node:fs")
163
+ const [dir, marker] = process.argv.slice(1).map((file) => fs.lstatSync(file))
164
+ process.exit(dir.isDirectory() && dir.uid === process.getuid() && (dir.mode & 0o077) === 0 && marker.isFile() && marker.nlink === 1 && marker.uid === process.getuid() && (marker.mode & 0o077) === 0 ? 0 : 1)
165
+ ' "$candidate_home" "$candidate_home/.dsh-dogfood"; then
166
+ candidate_target="$(sed -n 's/^target=//p' "$candidate_home/.dsh-dogfood" | head -1)"
167
+ if [[ -n "$candidate_target" && ! -e "$candidate_target/package.json" ]]; then
168
+ node -e "const p=require('node:path'),s=process.argv[1];process.exit(p.isAbsolute(s) && s !== p.parse(s).root && s === p.normalize(s) ? 0 : 1)" "$candidate_target" || die "scratch marker has invalid target path"
169
+ target_path="$candidate_target"
170
+ orphan_clean=1
171
+ fi
172
+ fi
173
+ fi
174
+ if [[ "$orphan_clean" == 0 ]]; then
175
+ target_path="$(resolve_target)"
176
+ [[ -f "$target_path/package.json" ]] || die "$target_path has no package.json"
177
+ package_name="$(node -e "const p = require(process.argv[1]); if (typeof p.name !== 'string') process.exit(1); console.log(p.name)" "$target_path/package.json")" || die "$target_path has an invalid package.json"
178
+ [[ "$package_name" =~ ^(@[a-zA-Z0-9][a-zA-Z0-9._-]*/)?[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || die "invalid plugin package name: $package_name"
179
+ if [[ "$build" == 1 ]] && ! node -e "process.exit(typeof require(process.argv[1]).scripts?.build === 'string' && require(process.argv[1]).scripts.build.trim() ? 0 : 1)" "$target_path/package.json"; then
180
+ build=0
181
+ fi
182
+ fi
183
+
184
+ # The hash prevents same-named checkouts in different repositories sharing state.
185
+ target_name="$(basename "$target_path")"
186
+ target_id="$(node -e "console.log(require('node:crypto').createHash('sha256').update(process.argv[1]).digest('hex').slice(0, 12))" "$target_path")"
187
+ tmp_root="${TMPDIR:-/tmp}"
188
+ : "${home:=${tmp_root%/}/dsh-dogfood/${target_name}-${target_id}}"
189
+ if [[ "$action" == clean && ! -d "$source_home" ]]; then
190
+ [[ -n "$home" && ! -e "$source_home" && ! -L "$source_home" ]] || die "missing source home requires --clean --home and an absent source path"
191
+ # Cleanup still needs the original physical identity to match its private marker.
192
+ source_home="$(node -e "const fs=require('node:fs'),p=require('node:path');let x=p.resolve(process.argv[1]),b=x;while(!fs.existsSync(b))b=p.dirname(b);console.log(p.join(fs.realpathSync(b),p.relative(b,x)))" "$source_home")"
193
+ else
194
+ [[ -d "$source_home" ]] || die "no home to clone at $source_home; pass --source-home"
195
+ source_home="$(cd "$source_home" && pwd -P)"
196
+ fi
197
+ [[ ! -L "$home" ]] || die "scratch home cannot be a symlink: $home"
198
+ home="$(node -e "const fs=require('node:fs'),p=require('node:path');let x=p.resolve(process.argv[1]),b=x;while(!fs.existsSync(b))b=p.dirname(b);console.log(p.join(fs.realpathSync(b),p.relative(b,x)))" "$home")"
199
+ [[ ! -L "$home" ]] || die "scratch home cannot be a symlink: $home"
200
+ [[ "$home" != "$source_home" && "$home/" != "$source_home/"* && "$source_home/" != "$home/"* ]] || die "scratch home and source home must not overlap"
201
+ user_home="$(cd "$HOME" && pwd -P)"
202
+ [[ "$home/" != "$user_home/"* && "$user_home/" != "$home/"* ]] || die "scratch home cannot overlap the user home"
203
+ [[ "$home/" != "$target_path/"* && "$target_path/" != "$home/"* ]] || die "scratch home cannot overlap the target checkout"
204
+ [[ "$home" != / ]] || die "scratch home cannot be filesystem root"
205
+ for path in "$home" "$source_home" "$target_path"; do [[ "$path" != *$'\n'* ]] || die "paths cannot contain newlines"; done
206
+ marker="$home/.dsh-dogfood"
207
+ profile_json="$home/profiles/$profile/package.json"
208
+
209
+ check_credentials() {
210
+ [[ ! -L "$home/.credentials.yaml" ]] || die "cloned credentials cannot be a symlink"
211
+ [[ -e "$home/.credentials.yaml" ]] || return 0
212
+ node -e "const s=require('node:fs').lstatSync(process.argv[1]);process.exit(s.isFile() && s.nlink === 1 ? 0 : 1)" "$home/.credentials.yaml" || die "cloned credentials cannot be hardlinked or non-regular"
213
+ }
214
+
215
+ check_clone_links() {
216
+ node "$helper_dir/clone-links.mjs" check "$home" "$source_home" || die "cloned home contains unsafe symlink or hardlink"
217
+ }
218
+
219
+ materialize_cloned_links() {
220
+ node "$helper_dir/clone-links.mjs" materialize "$home" "$source_home" || die "cloned home links could not be made self-contained"
221
+ }
222
+
223
+ validate_marker() {
224
+ [[ -f "$marker" && ! -L "$marker" ]] || die "$home has no valid .dsh-dogfood marker; refusing to modify it"
225
+ node -e "const s=require('node:fs').lstatSync(process.argv[1]);process.exit(s.isFile() && s.nlink === 1 ? 0 : 1)" "$marker" || die "$home marker cannot be hardlinked or non-regular"
226
+ [[ "$(marker_value home)" == "$home" && "$(marker_value source)" == "$source_home" && "$(marker_value target)" == "$target_path" && "$(marker_value profile)" == "$profile" ]] || die "$home marker identity does not match home, source, target, and profile"
227
+ }
228
+
229
+ [[ -n "$dsh_bin" ]] || dsh_bin="$(command -v dsh || true)"
230
+
231
+ describe_bundles() {
232
+ [[ -f "$profile_json" ]] || { printf 'none\n'; return; }
233
+ node -e "console.log(require(process.argv[1]).dsh.profile.bundles.join(', '))" "$profile_json" 2>/dev/null || printf 'unreadable\n'
234
+ }
235
+
236
+ marker_value() {
237
+ [[ -f "$marker" ]] || { printf 'unknown'; return; }
238
+ sed -n "s/^$1=//p" "$marker" | head -1
239
+ }
240
+
241
+ if [[ "$action" == "status" ]]; then
242
+ [[ -d "$home" ]] || die "no scratch home at $home"
243
+ printf 'home: %s\n' "$home"
244
+ printf 'source: %s\n' "$(marker_value source)"
245
+ printf 'target: %s\n' "$(marker_value target)"
246
+ printf 'profile: %s\n' "$profile"
247
+ printf 'bundles: %s\n' "$(describe_bundles)"
248
+ printf 'link: %s\n' "$(node -e "console.log(require(process.argv[1]).dependencies?.[process.argv[2]] ?? 'absent')" "$profile_json" "$package_name" 2>/dev/null || printf 'absent')"
249
+ exit 0
250
+ fi
251
+
252
+ if [[ "$action" == "clean" ]]; then
253
+ [[ -d "$home" ]] || { note "nothing at $home"; exit 0; }
254
+ validate_marker
255
+ rm -rf "$home"
256
+ note "removed $home"
257
+ exit 0
258
+ fi
259
+
260
+ if [[ "$dry_run" == 1 ]]; then
261
+ printf 'target: %s\n' "$target_path"
262
+ printf 'home: %s%s\n' "$home" "$([[ -d "$home" ]] && printf ' (exists: reuse)' || printf ' (missing: seed)')"
263
+ printf 'source: %s%s\n' "$source_home" "$([[ "$fresh" == 1 ]] && printf ' (fresh: credentials and settings only)' || printf '')"
264
+ printf 'profile: %s\n' "$profile"
265
+ printf 'build: %s\n' "$([[ "$build" == 1 ]] && printf 'pnpm run build' || printf 'skipped')"
266
+ printf 'launch: DSH_HOME=%s %s --profile %s %s\n' "$home" "${dsh_bin:-dsh}" "$profile" "${passthrough[*]:-}"
267
+ exit 0
268
+ fi
269
+
270
+ [[ -n "$dsh_bin" ]] || die "no dsh launcher found; put dsh on PATH or pass --dsh"
271
+
272
+ # --- seed the scratch home ---------------------------------------------------
273
+
274
+ if [[ -d "$home" && "$action" != "clean" && "$dry_run" != 1 ]]; then
275
+ validate_marker
276
+ check_credentials
277
+ chmod 700 "$home"
278
+ if [[ -f "$home/.credentials.yaml" ]]; then chmod 600 "$home/.credentials.yaml"; fi
279
+ fi
280
+
281
+ if [[ "$reseed" == 1 && -d "$home" ]]; then
282
+ validate_marker
283
+ rm -rf "$home"
284
+ fi
285
+
286
+ if [[ "$fresh" == 1 && -d "$home" ]]; then
287
+ note "$home already exists: --fresh applies when a home is seeded (--reseed to re-seed it)"
288
+ fi
289
+
290
+ if [[ ! -d "$home" ]]; then
291
+ [[ -d "$source_home" ]] || die "no home to clone at $source_home; pass --source-home"
292
+ [[ "$with_sessions" != 1 || ! -L "$source_home/sessions" ]] || die "source sessions cannot be a symlink"
293
+ step "seeding $home from $source_home"
294
+ mkdir -p "$home"
295
+ chmod 700 "$home"
296
+ if [[ "$fresh" == 1 ]]; then
297
+ for seed in .credentials.yaml settings.yaml; do
298
+ if [[ -f "$source_home/$seed" ]]; then cp -p "$source_home/$seed" "$home/$seed"; fi
299
+ done
300
+ elif command -v rsync >/dev/null 2>&1; then
301
+ # Preserving the source root's mode can expose copied credentials before final chmod.
302
+ rsync -a --no-perms --exclude '/sessions/' --exclude '/.dsh-dogfood' "$source_home/" "$home/"
303
+ if [[ "$with_sessions" == 1 && -d "$source_home/sessions" ]]; then rsync -a --no-perms "$source_home/sessions/" "$home/sessions/"; fi
304
+ else
305
+ # tar carries the tree without the bulk of the sessions; BSD tar takes the
306
+ # exclude in the same form as GNU tar.
307
+ mkdir -p "$home/sessions"
308
+ (cd "$source_home" && tar -c --exclude './sessions' --exclude './.dsh-dogfood' .) | (cd "$home" && tar -x)
309
+ if [[ "$with_sessions" == 1 && -d "$source_home/sessions" ]]; then (cd "$source_home" && tar -c sessions) | (cd "$home" && tar -x); fi
310
+ fi
311
+ chmod 700 "$home"
312
+ check_credentials
313
+ chmod 600 "$home/.credentials.yaml" 2>/dev/null || true
314
+ if [[ "$with_sessions" != 1 ]]; then
315
+ note "sessions were not copied; --resume starts empty (pass --with-sessions for your history)"
316
+ fi
317
+ fi
318
+
319
+ printf 'home=%s\nsource=%s\ntarget=%s\nprofile=%s\n' "$home" "$source_home" "$target_path" "$profile" > "$marker"
320
+ chmod 600 "$marker"
321
+ materialize_cloned_links
322
+ check_clone_links
323
+
324
+ # --- build, link, run --------------------------------------------------------
325
+
326
+ if [[ "$build" == 1 && ! -d "$target_path/node_modules" ]]; then
327
+ die "$target_path has no node_modules; run pnpm install there first (or pass --no-build)"
328
+ fi
329
+
330
+ if [[ "$build" == 1 ]]; then
331
+ step "building $target_path"
332
+ (cd "$target_path" && pnpm run build) || die "pnpm run build failed in $target_path"
333
+ fi
334
+
335
+ # A profile's local bundles are installed as relative symlinks under its own
336
+ # node_modules, and those paths only resolve at the depth of the home they were
337
+ # installed in. A clone at a different depth (a /tmp scratch home) leaves every
338
+ # one dangling, so dsh refuses to mount the profile. Rebuild each link from the
339
+ # absolute 'link:' spec its package.json already carries. The profile is created
340
+ # from the shipped template when it is missing, then rewritten, because
341
+ # 'dsh plugin add' re-materialises the same relative links and drops the other
342
+ # bundles the profile had.
343
+ step "pointing profile '$profile' at $target_path"
344
+ profile_dir="$home/profiles/$profile"
345
+ [[ ! -L "$home/profiles" && ! -L "$profile_dir" && ! -L "$profile_json" ]] || die "cloned profile cannot contain a symlink in its manifest path"
346
+ if [[ ! -f "$profile_json" ]]; then
347
+ DSH_HOME="$home" "$dsh_bin" --profile "$profile" --from-default-profile "$profile" --help >/dev/null 2>&1 || true
348
+ [[ ! -L "$home/profiles" && ! -L "$profile_dir" && ! -L "$profile_json" ]] || die "cloned profile cannot contain a symlink in its manifest path"
349
+ if [[ ! -f "$profile_json" ]]; then
350
+ DSH_HOME="$home" "$dsh_bin" plugin --profile "$profile" add "$target_path" >&2 || die "could not create profile '$profile' in $home"
351
+ fi
352
+ fi
353
+ [[ -f "$profile_json" ]] || die "profile '$profile' has no package.json at $profile_json"
354
+ node -e "const s=require('node:fs').lstatSync(process.argv[1]);process.exit(s.isFile() && s.nlink === 1 ? 0 : 1)" "$profile_json" || die "cloned profile manifest cannot be hardlinked or non-regular"
355
+
356
+ # Legacy callers require a prelisted bundle; generic callers can add one in the clone.
357
+ if [[ "${DSH_DOGFOOD_REQUIRE_LISTED:-0}" == 1 ]] && ! node -e "process.exit(require(process.argv[1]).dsh?.profile?.bundles?.includes(process.argv[2]) ? 0 : 1)" "$profile_json" "$package_name"; then
358
+ die "profile '$profile' does not list $package_name (bundles: $(describe_bundles)); add it to the profile first"
359
+ fi
360
+ node -e '
361
+ const fs = require("node:fs")
362
+ const path = require("node:path")
363
+ const [manifestPath, directory, originalDirectory, packageName, targetPath] = process.argv.slice(1)
364
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"))
365
+ if (!Array.isArray(manifest.dsh?.profile?.bundles)) throw Error("invalid profile bundle list")
366
+ if (!manifest.dsh.profile.bundles.includes(packageName)) manifest.dsh.profile.bundles.push(packageName)
367
+ manifest.dependencies ||= {}
368
+ manifest.dependencies[packageName] = "link:" + targetPath
369
+ const moduleDir = path.join(directory, "node_modules")
370
+ const entries = Object.entries(manifest.dependencies).filter(([, spec]) => typeof spec === "string" && spec.startsWith("link:"))
371
+ const packagePattern = /^(@[a-zA-Z0-9][a-zA-Z0-9._-]*\/)?[a-zA-Z0-9][a-zA-Z0-9._-]*$/
372
+ const state = (file) => fs.lstatSync(file, { throwIfNoEntry: false })
373
+ for (const part of [directory, path.dirname(directory), moduleDir]) {
374
+ if (state(part)?.isSymbolicLink()) throw Error("unsafe symlink in profile path: " + part)
375
+ }
376
+ for (const [name, spec] of entries) {
377
+ if (!packagePattern.test(name)) throw Error("invalid link dependency name: " + name)
378
+ if (name.includes("\n") || spec.includes("\n") || spec.includes("\0")) throw Error("unsafe link dependency: " + name)
379
+ const destination = path.join(moduleDir, name)
380
+ if (state(path.dirname(destination))?.isSymbolicLink()) throw Error("unsafe symlink in node_modules: " + name)
381
+ const existing = state(destination)
382
+ if (existing && !existing.isSymbolicLink()) throw Error("unsafe existing module at " + destination)
383
+ }
384
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n")
385
+ for (const [name, spec] of entries) {
386
+ const destination = path.join(moduleDir, name)
387
+ const target = path.resolve(originalDirectory, spec.slice("link:".length))
388
+ fs.mkdirSync(path.dirname(destination), { recursive: true })
389
+ if (state(destination)) fs.unlinkSync(destination)
390
+ fs.symlinkSync(target, destination)
391
+ }
392
+ ' "$profile_json" "$profile_dir" "$source_home/profiles/$profile" "$package_name" "$target_path" || die "could not safely relink profile '$profile'"
393
+ # Newly materialized dependency links must obey the same source-home boundary.
394
+ check_clone_links
395
+
396
+ bundles="$(describe_bundles)"
397
+ if ! node -e "process.exit(require(process.argv[1]).dsh.profile.bundles.includes(process.argv[2]) ? 0 : 1)" "$profile_json" "$package_name"; then
398
+ die "profile '$profile' does not list $package_name after relink (bundles: $bundles)"
399
+ fi
400
+
401
+ printf '\n' >&2
402
+ printf 'home: %s\n' "$home" >&2
403
+ printf 'target: %s\n' "$target_path" >&2
404
+ printf 'profile: %s (%s)\n' "$profile" "$bundles" >&2
405
+
406
+ run_command=(env "DSH_HOME=$home" "$dsh_bin" --profile "$profile")
407
+ if [[ "${#passthrough[@]}" -gt 0 ]]; then
408
+ run_command+=("${passthrough[@]}")
409
+ fi
410
+
411
+ if [[ "$launch" != 1 ]]; then
412
+ printf '\nrun it with:\n %s\n' "${run_command[*]}" >&2
413
+ exit 0
414
+ fi
415
+
416
+ step "starting the surface"
417
+ exec "${run_command[@]}"
@@ -0,0 +1,199 @@
1
+ ---
2
+ name: dsh-tui-update-models
3
+ description: "Use when providers, models, prices, context windows, or reasoning effort levels must be added, changed, subset, or removed in a dsh profile: the llm-pi-ai provider config, the dsh-provider-extra owned catalog, settings.yaml, and a profile's cordis.patch.yml."
4
+ ---
5
+ # Update dsh providers and models
6
+
7
+ Every model the surface can pick comes from one of two owners, resolved at
8
+ composition time and read back by `/model`, `dsh --profile tui list-models`, and
9
+ the ctx gauge. Find the owner first; then edit exactly one place.
10
+
11
+ ## 1. Find the owner
12
+
13
+ | Where the wiring is | What it owns |
14
+ | --- | --- |
15
+ | `~/.dsh/profiles/<profile>/cordis.patch.yml` | The patch layer: applied after every bundle layer, last entry wins. Holds the catalog or `llm-pi-ai` config a developer actually edits. |
16
+ | `~/.dsh/settings.yaml` | Host settings, hot-reloaded. A `llm-pi-ai:` or `llm-deepseek:` section overrides that adapter's patch entry without a restart - it is what the web Models page writes, and what an additive sign-in declares a route in. The legacy `dsh-provider-extra:` section carries `extraModels` and `codexExtraModels` only. **A catalog is never settings-owned**: managed mode installs no section, reads no overlay, and turns off settings projection for its own Config, so the profile patch is the only place a catalog exists. |
17
+ | `~/.dsh/profiles/<profile>/package.json` | Bundles and their dependency specs (`link:` for a checkout, a range for a release). |
18
+ | `node_modules/<bundle>/cordis.patch.yml` | The bundle's own defaults: disabled base rows, inserted rows, default config. |
19
+ | `node_modules/@earendil-works/pi-ai/dist/providers/data/<source>.json` | Installed per-provider catalog: ids, and the cost/context/effort data a template clone or a `filter` match inherits. |
20
+ | `node_modules/@sagmans/dsh-provider-extra/docs/catalog.md` | The catalog's own field list and rejection rules, matching the plugin build the profile resolves. Read it before inventing a field. |
21
+
22
+ Entry shapes in the patch layer:
23
+
24
+ ```yaml
25
+ - id: llm-pi-ai # id-targeted config override
26
+ config: { ... } # replaces that row's config wholesale - restate every field
27
+ - { "id": "llm-deepseek", "disabled": true } # retire a row a bundle ships
28
+ - insert: # add a row the profile does not have
29
+ - id: web-search-exa
30
+ name: '@deepseek-ai/dsh-web-search-exa'
31
+ ```
32
+
33
+ A config in a patch **replaces** the base config; it never merges field by field.
34
+
35
+ ## 2. Pick the approach
36
+
37
+ | Intent | Approach |
38
+ | --- | --- |
39
+ | Add or re-declare providers additively | `llm-pi-ai` config: `providers.<route>.models[]` |
40
+ | One new id that behaves like a catalog sibling | `extraModels: [{ id, template, name? }]` (clone the sibling's wire behavior) |
41
+ | Serve only some of a route's catalog | a `models:` whitelist of ids, in the order the picker should show |
42
+ | Own the whole model directory (gateways, subscriptions, logins) | `dsh-provider-extra` `catalog` v1, and disable the base rows |
43
+ | Serve most of an installed source, minus a few ids | catalog route `filter: { include?, exclude? }` - keeps every installed fact |
44
+ | Point a route at your own endpoint | catalog route with `api` + `baseURL` and no `source` |
45
+ | Retire a model or a provider | drop it from the list, or disable the row that offers it |
46
+
47
+ A catalog route declares **exactly one** of `models` and `filter`; `models: []`
48
+ is the explicit empty selection, and `filter` needs an installed `source`.
49
+
50
+ `dsh-provider-extra` catalog skeleton, with the two override layers a correct
51
+ entry needs as soon as a vendor number differs from the inherited one:
52
+
53
+ ```yaml
54
+ - id: dsh-provider-extra
55
+ config:
56
+ catalog:
57
+ version: 1
58
+ default: { provider: opencode-go-session, model: deepseek-flash, reasoningEffort: max }
59
+ providers:
60
+ - id: openai
61
+ name: OpenAI API
62
+ source: openai # pi-ai provider id; its data is the base
63
+ auth: { apiKeyRef: OPENAI_API_KEY } # omit auth for a keyless local route
64
+ models:
65
+ - id: gpt-6-sol
66
+ name: GPT-6 Sol
67
+ aliases: [gpt-6-sol-preview] # extra selector inputs, never extra rows
68
+ template: gpt-5.6-sol # inherits wire behavior AND stale metadata
69
+ defaultMaxTokens: 128000 # request cap, only when the request gives none
70
+ metadata: # override what the vendor documented
71
+ contextWindow: 1050000
72
+ cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }
73
+ - id: local-llm
74
+ name: Local llama.cpp
75
+ api: openai-completions # own endpoint: api is required, source is absent
76
+ baseURL: http://127.0.0.1:8080/v1
77
+ models:
78
+ - id: qwen3-coder
79
+ name: Qwen3 Coder
80
+ metadata: # no installed sibling: every fact is declared
81
+ reasoning: true
82
+ input: [text]
83
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
84
+ contextWindow: 262144
85
+ maxTokens: 65536
86
+ - id: zai-all
87
+ name: Z.AI Coding Plan
88
+ source: zai
89
+ auth: { apiKeyRef: ZAI_API_KEY }
90
+ filter: { include: ['glm-*'], exclude: ['glm-4*'] } # '*' is the only wildcard
91
+ ```
92
+
93
+ Rules the resolver enforces, each rejecting the **whole** candidate: `version` is
94
+ exactly `1`; an unknown field anywhere; duplicate provider ids, model ids, or
95
+ aliases; a `source` no installed pi-ai provider ships; `api` beside `source`, or
96
+ absent when `source` is absent; `baseURL` absent, non-HTTP(S), or carrying
97
+ credentials; a `transport` on a route whose source is not `openai-codex`; a
98
+ `fallbackSessionId` on a route whose source is not `opencode-go`; `auth` naming
99
+ both modes, or `apiKeyRef` on `openai-codex`, or a `credentialProvider` that is
100
+ not the `source`; both or neither of `models`/`filter`; an unknown `template`; a
101
+ model with no resolvable template and incomplete `metadata`; a
102
+ `defaultMaxTokens` above the model's `maxTokens`; a `metadata.api` that changes a
103
+ template-backed protocol; an empty catalog whose `default` is not `null`; a
104
+ `default` outside the served selection; a `reasoningEffort` the model does not
105
+ support. Omitting `catalog` is not `providers: []`: the first keeps the plugin's
106
+ legacy additive routes, the second serves nothing.
107
+
108
+ Ownership is exclusive and checked at boot. Disable **every** competing row in
109
+ the whole profile - on `dsh-base` those are `llm-pi-ai`, `llm-deepseek`, and
110
+ `agent-default-model` - and mount provider-extra after them. A surviving adapter,
111
+ provider directory, or default owner logs `CATALOG_OWNER_COLLISION` and fails
112
+ listing, selection, dispatch, and sign-in closed until it is gone.
113
+
114
+ The pinned host reads only `provider` and `model` from `catalog.default`: it
115
+ ignores `reasoningEffort`, so effort is chosen in the session surface. Saving a
116
+ default needs the host's `configEditor`; a host without one rejects with
117
+ `CONFIG_PERSISTENCE_UNAVAILABLE`, and the profile patch stays the place to edit.
118
+
119
+ ## 3. Reasoning efforts
120
+
121
+ pi-ai levels are `off, minimal, low, medium, high, xhigh, max`. In
122
+ `metadata.thinkingLevelMap`:
123
+
124
+ | Value | Meaning |
125
+ | --- | --- |
126
+ | `null` | level is not offered |
127
+ | absent | offered, sent to the vendor under this same name |
128
+ | a string | offered, sent as that vendor value (OpenAI's `none`, codex's `low` for `minimal`) |
129
+
130
+ `xhigh` and `max` are offered **only** when explicitly mapped. `off` means "send no
131
+ reasoning parameter" on the OpenAI and codex adapters. Vendors name the same
132
+ concept differently - map it, never rename the vendor value.
133
+
134
+ ## 4. Costs
135
+
136
+ Per 1M tokens: `{ input, output, cacheRead, cacheWrite }`, plus optional
137
+ `tiers: [{ inputTokensAbove, input, output, cacheRead, cacheWrite }]`. `0` means
138
+ unknown pricing on a credit plan, not free inference. A vendor that charges more
139
+ above an input threshold needs its tier row, otherwise long prompts are
140
+ understated. `metadata.maxTokens` is model capacity and never a request cap;
141
+ `defaultMaxTokens` is the request default and must not exceed that capacity.
142
+
143
+ ## 5. Verify before and after the edit
144
+
145
+ ```sh
146
+ dsh --profile tui list-models # every reachable route, id<TAB>name
147
+ # resolved cost/ctx/efforts, naming the layer each number came from:
148
+ ~/.agents/skills/dsh-tui-update-models/scripts/dump-model-catalog.mjs \
149
+ [--home "$DSH_HOME"] [--profile tui] [--data <pi-ai data dir>] [--json]
150
+ ```
151
+
152
+ The dump marks each value `installed`, `template:<id>`, `route`, or `metadata`, so
153
+ an inherited number is visible instead of trusted, expands a `filter` route
154
+ against the installed data the same way the resolver does, and exits 1 on the
155
+ declarations the resolver refuses - incomplete metadata, a request default above
156
+ capacity, both membership styles, a default the served selection does not
157
+ contain.
158
+
159
+ Then check the vendor's own page for price, context window, max output, and
160
+ accepted effort values - the installed catalog lags vendors, and a template
161
+ clone carries a sibling's numbers.
162
+
163
+ To prove a catalog on a disposable home instead of the live one, provider-extra
164
+ ships a clone-only setup that composes the candidate and disables the competing
165
+ rows for you:
166
+
167
+ ```sh
168
+ cd <dsh-provider-extra checkout> && pnpm catalog:setup \
169
+ --helper ~/.agents/skills/dsh-tui-dogfood/scripts/run-plugin-from-worktree.sh \
170
+ --source-home ~/.dsh --home /tmp/dsh-catalog-check --profile tui \
171
+ --catalog /tmp/catalog-config.json [--allow-row ROW_ID] [--dsh /abs/path/to/dsh/lib/bin.js]
172
+ ```
173
+
174
+ `--catalog` is a private JSON file holding only `{"catalog": {...}}`. Success
175
+ means `composition-verified`, not authenticated runtime; launch only the clone
176
+ command it prints.
177
+
178
+ ## 6. Pitfalls
179
+
180
+ | Symptom | Cause |
181
+ | --- | --- |
182
+ | A new model shows the sibling's price or ctx | it was cloned from a `template`; declare `metadata` |
183
+ | A window that is really a pricing threshold | vendors publish e.g. "above 272K input priced 2x" beside the real window |
184
+ | `off` missing on a reasoning model | `thinkingLevelMap.off` is `null` or absent where the vendor takes `none` |
185
+ | A stealth model with no vendor page | its numbers exist only in the routing catalog (pi.dev model page, models.dev) |
186
+ | Config ignored after an edit | a linked bundle loads `lib/` or `dist/`; run the build in that checkout |
187
+ | ctx gauge and auto-compact trigger early/late | the declared `contextWindow` is wrong, not the gauge |
188
+ | Same model id, two context windows | each route inherits its own source's numbers; declare `metadata` on both |
189
+ | Everything fails closed, logs `CATALOG_OWNER_COLLISION` | a competing adapter, provider directory, or default owner row survived |
190
+ | A new field looks accepted but does nothing | the resolver rejects unknown fields; a stale plugin build silently predates it |
191
+ | `/model` saves a default that reverts | the host has no `configEditor`: `CONFIG_PERSISTENCE_UNAVAILABLE`; edit the patch |
192
+ | Route serves nothing after a `filter` edit | patterns match installed ids only, and `exclude` wins over `include` |
193
+
194
+ Never put a key in a profile or a patch: name it (`apiKeyEnv`, `apiKeyRef`,
195
+ `credentialProvider`) and keep the value in the environment or `$DSH_HOME/.env`.
196
+ Test catalog edits in a cloned home - see the `dsh-tui-dogfood` skill.
197
+
198
+ Full field lists, credentials, and the vendor-verification checklist:
199
+ [references/model-wiring.md](references/model-wiring.md).