@aiwayds/dsh-opentui 0.4.2 → 0.5.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.
@@ -58,6 +58,29 @@ const check = (name, ok, hint = '') => {
58
58
  console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ' ← ' + hint}`)
59
59
  }
60
60
 
61
+ // Wait for the current turn to END before driving the next phase. Fixed
62
+ // sleeps raced provider latency: a slow counting turn (~5 tok/s that day)
63
+ // ran past the budget and every later keystroke landed mid-turn, where the
64
+ // TUI has no command/picker surface — 5 phantom FAILs with zero markers in
65
+ // the stream. Detection: the live think/tool dock keeps re-painting into
66
+ // the stream tail while a turn runs (its timer ticks every second); after
67
+ // the turn ends only the powerline clock repaints, so a tail window free
68
+ // of 💭/thinking/⚙ means idle. The powerline clock keeps raw length
69
+ // growing forever, so whole-stream length quiescence never fires.
70
+ const waitTurnEnd = async (minMs, maxMs) => {
71
+ await sleep(minMs)
72
+ const t0 = Date.now()
73
+ let idleStreak = 0
74
+ while (Date.now() - t0 < maxMs) {
75
+ await sleep(2000)
76
+ const tail = out.slice(-3000)
77
+ const busy = tail.includes('💭') || tail.includes('thinking') || tail.includes('⚙')
78
+ idleStreak = busy ? 0 : idleStreak + 1
79
+ if (idleStreak >= 3) return true // 6s of clock-only tail = turn over
80
+ }
81
+ return false
82
+ }
83
+
61
84
  try {
62
85
  await sleep(9000) // boot + welcome + powerline
63
86
 
@@ -65,13 +88,13 @@ try {
65
88
  await send('用 bash 工具运行 date 命令,然后告诉我今天是几月几号。只回答一句话。')
66
89
  await sleep(6000) // mid-turn: TPS segment + thinking + tool active
67
90
  const midTurn = out.length
68
- await sleep(22000)
91
+ await waitTurnEnd(10000, 120000)
69
92
 
70
93
  // --- turn 2: todos + steering
71
94
  await send('先用 todo 工具记录两个任务:测试多行、收尾,然后从 1 数到 20,每个数字单独一行。')
72
95
  await sleep(5000)
73
96
  await send('改一下:只要数到 10 就行。')
74
- await sleep(55000)
97
+ await waitTurnEnd(30000, 300000)
75
98
 
76
99
  // --- /theme picker opens, esc closes
77
100
  await send('/theme')
@@ -91,7 +114,7 @@ try {
91
114
 
92
115
  // --- subagent turn: dispatch a child, expect panel rows (tree rail)
93
116
  await send('派一个子代理去运行 ls 命令看看当前目录有什么,然后把结果告诉我。')
94
- await sleep(55000)
117
+ await waitTurnEnd(30000, 300000)
95
118
 
96
119
  // --- /history fork at the newest turn boundary + context probe
97
120
  await send('/history')
@@ -99,7 +122,7 @@ try {
99
122
  child.stdin.write('\r') // pick the highlighted (newest) turn
100
123
  await sleep(6000)
101
124
  await send('刚才我最后请你做的是什么?一句话回答。')
102
- await sleep(45000)
125
+ await waitTurnEnd(30000, 240000)
103
126
 
104
127
  child.stdin.end()
105
128
  await Promise.race([sleep(4000), new Promise((r) => child.once('exit', r))])
@@ -0,0 +1,80 @@
1
+ // Idle-exit reaper (ADR-0005 decision 5, entry-side companion of
2
+ // lifecycle.ts): the lifecycle's idle-exit timer is unref()ed — a
3
+ // refcount-0 manager must stay free to exit — so a cleanly quitting TUI
4
+ // cannot deliver "the host exits after idleExitSec of idleness" with an
5
+ // in-process timer. The quitting client spawns this script DETACHED
6
+ // instead (spawnIdleReaper in src/client-entry.tsx):
7
+ //
8
+ // bun idle-reaper.mjs <pid> <port> <lockfilePath> <graceSec>
9
+ //
10
+ // After the grace window it shuts the host down — unless:
11
+ // - the lockfile now describes a DIFFERENT pid (a fresh boot replaced the
12
+ // host): never touch what we do not own;
13
+ // - a client re-attached inside the grace window (established TCP
14
+ // connections to the host port — the adoption path keeps the host alive
15
+ // under its new owner; that owner's own quit spawns the next reaper).
16
+ // The probe is `lsof -sTCP:ESTABLISHED`; when the tool is unavailable the
17
+ // reaper falls back to the plain lifecycle semantics (idle exit).
18
+ import { readFileSync, rmSync } from 'node:fs'
19
+ import { spawnSync } from 'node:child_process'
20
+
21
+ const [pidArg, portArg, lockPath, graceArg] = process.argv.slice(2)
22
+ const pid = Number(pidArg)
23
+ const port = Number(portArg)
24
+ const graceSec = Number(graceArg ?? 60)
25
+
26
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
27
+
28
+ const pidAlive = (target) => {
29
+ try {
30
+ process.kill(target, 0)
31
+ return true
32
+ } catch {
33
+ return false
34
+ }
35
+ }
36
+
37
+ /** Established TCP connections involving the host port (0 = none, -1 =
38
+ * the probe tool is unavailable). lsof exits 1 with no output when the
39
+ * filter matches nothing — that IS the zero case. */
40
+ function establishedTo(targetPort) {
41
+ const res = spawnSync('lsof', ['-nP', `-iTCP:${targetPort}`, '-sTCP:ESTABLISHED'], { encoding: 'utf8' })
42
+ if (res.error) return -1
43
+ if (res.status === 1) return 0
44
+ const lines = String(res.stdout ?? '').split('\n')
45
+ return Math.max(0, lines.slice(1).filter((l) => l.trim().length > 0).length)
46
+ }
47
+
48
+ function readLock() {
49
+ try {
50
+ const raw = JSON.parse(readFileSync(lockPath, 'utf8'))
51
+ return typeof raw?.pid === 'number' ? raw : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ await sleep(graceSec * 1000)
58
+
59
+ const lock = readLock()
60
+ if (!lock || lock.pid !== pid) process.exit(0) // replaced by a fresh boot — not ours
61
+
62
+ const established = establishedTo(port)
63
+ if (established > 0) process.exit(0) // a client re-attached — the host stays under its new owner
64
+
65
+ try {
66
+ process.kill(pid, 'SIGTERM')
67
+ } catch {}
68
+ await sleep(2000)
69
+ if (pidAlive(pid)) {
70
+ try {
71
+ process.kill(pid, 'SIGKILL')
72
+ } catch {}
73
+ }
74
+ // Clear the lockfile only if it still describes THIS host (never clobber
75
+ // a newer host's lock — lifecycle parity).
76
+ try {
77
+ const now = readLock()
78
+ if (now && now.pid === pid && now.port === port) rmSync(lockPath, { force: true })
79
+ } catch {}
80
+ process.exit(0)
@@ -22,6 +22,7 @@ import { execFileSync, spawnSync } from 'node:child_process'
22
22
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
23
23
  import { homedir } from 'node:os'
24
24
  import { dirname, join } from 'node:path'
25
+ import { ensureSolidPatch, pinSolidJsExports } from './solid-js-pin.mjs'
25
26
 
26
27
  const args = process.argv.slice(2)
27
28
  const flag = (name) => {
@@ -59,11 +60,15 @@ pkg.dsh = { profile: { bundles: [...bundles] } }
59
60
  pkg.dependencies = { ...(pkg.dependencies || {}), '@aiwayds/dsh-opentui': depSpec }
60
61
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2))
61
62
 
62
- // --- cordis.yml (empty root) + workspace ---
63
+ // --- cordis.yml (empty root) + workspace + solid-js patch ---
63
64
  const cordisYml = join(profileDir, 'cordis.yml')
64
65
  if (!existsSync(cordisYml)) writeFileSync(cordisYml, '# dsh profile root — an empty entry list. The tree is composed as patches.\n[]\n')
65
- const wsYml = join(profileDir, 'pnpm-workspace.yaml')
66
- if (!existsSync(wsYml)) writeFileSync(wsYml, 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\n')
66
+ // pnpm-workspace.yaml (created with the hoisted layout when missing) + the
67
+ // solid-js@1.9.12 patchedDependencies entry. This MUST run before the
68
+ // `pnpm install` steps below: with the patch registered, every install
69
+ // itself produces the client-build-pinned solid-js instead of wiping it.
70
+ const { workspaceNote } = ensureSolidPatch(profileDir)
71
+ console.log(`[dsh-opentui] ${workspaceNote}`)
67
72
 
68
73
  // --- cordis.patch.yml (provider route + model pin + our insert) ---
69
74
  const patchPath = join(profileDir, 'cordis.patch.yml')
@@ -121,26 +126,12 @@ const pnpm = (() => {
121
126
  run(pnpm[0], [...pnpm.slice(1), 'install'])
122
127
  run(pnpm[0], [...pnpm.slice(1), 'install'])
123
128
 
124
- // --- pin solid-js to its CLIENT build ---
125
- // solid-js's "node" export condition resolves to dist/server.js (SSR): the
126
- // first frame renders and then the screen goes dead — no reactive updates.
127
- // Under bun dev the @opentui/solid preload remaps this; under the host's
128
- // node runtime the fix is rewriting the installed package's exports.
129
+ // --- pin solid-js to its CLIENT build (belt-and-braces; the primary fix is
130
+ // the patchedDependencies patch registered above) ---
129
131
  {
130
- const solidPkg = join(profileDir, 'node_modules', 'solid-js', 'package.json')
131
- try {
132
- const pkgJson = JSON.parse(readFileSync(solidPkg, 'utf8'))
133
- pkgJson.exports = {
134
- '.': { types: './types/index.d.ts', import: './dist/solid.js', require: './dist/solid.cjs' },
135
- './dist/solid.js': './dist/solid.js',
136
- './dist/solid.cjs': './dist/solid.cjs',
137
- './package.json': './package.json',
138
- }
139
- writeFileSync(solidPkg, JSON.stringify(pkgJson, null, 2))
140
- console.log('[dsh-opentui] solid-js pinned to client build (dist/solid.js)')
141
- } catch {
142
- console.warn('[dsh-opentui] could not pin solid-js — TUI updates may be dead')
143
- }
132
+ const state = pinSolidJsExports(profileDir)
133
+ if (state === 'missing') console.warn('[dsh-opentui] could not pin solid-js — TUI updates may be dead')
134
+ else console.log(`[dsh-opentui] solid-js ${state === 'pinned' ? 'pinned to' : 'already pinned to'} client build (dist/solid.js)`)
144
135
  }
145
136
 
146
137
  // --- closure link for the plugin's dsh-llm import (hoisted layout: the
@@ -2,9 +2,11 @@
2
2
  /**
3
3
  * Dev/publish closure linker for @aiwayds/dsh-opentui.
4
4
  *
5
- * The plugin imports `@deepseek-ai/dsh-llm` (createUserMessage). Those
6
- * packages are not consumable from the public npm registry, so they resolve
7
- * from the global dsh install's own closure:
5
+ * The plugin imports `@deepseek-ai/dsh-llm` (createUserMessage); the
6
+ * client-mode transport also imports `@deepseek-ai/dsh-client-connection`
7
+ * (ConnectionGenerationSource / installConnection). Those packages are not
8
+ * consumable from the public npm registry, so they resolve from the global
9
+ * dsh install's own closure:
8
10
  * $(realpath $(which dsh))/node_modules/@deepseek-ai
9
11
  * via a plain symlink. ALL @deepseek-ai/* must resolve to that single
10
12
  * closure instance (one @deepseek-ai/cordis in the type/runtime graph).
@@ -44,6 +46,9 @@ function findDshClosure() {
44
46
  }
45
47
  }
46
48
 
49
+ /** Closure packages the shell imports but npm cannot provide. */
50
+ const CLOSURE_PACKAGES = ['dsh-llm', 'dsh-client-connection']
51
+
47
52
  const closure = findDshClosure()
48
53
  if (!closure) {
49
54
  if (process.env.DSH_OPENTUI_DEBUG) console.warn('[dsh-opentui] no global dsh closure found; skipping link')
@@ -56,13 +61,16 @@ const targets = [join(pkgRoot, 'node_modules', '@deepseek-ai'), join(dirname(pkg
56
61
  for (const scopeDir of targets) {
57
62
  if (!scopeDir.includes('node_modules')) continue
58
63
  mkdirSync(scopeDir, { recursive: true })
59
- const link = join(scopeDir, 'dsh-llm')
60
- try {
61
- rmSync(link, { force: true })
62
- symlinkSync(join(closure, 'dsh-llm'), link, 'dir')
63
- } catch {
64
- /* never fail an install over a link */
64
+ for (const name of CLOSURE_PACKAGES) {
65
+ if (!existsSync(join(closure, name))) continue // older closure — skip, never throw
66
+ const link = join(scopeDir, name)
67
+ try {
68
+ rmSync(link, { force: true })
69
+ symlinkSync(join(closure, name), link, 'dir')
70
+ } catch {
71
+ /* never fail an install over a link */
72
+ }
65
73
  }
66
74
  }
67
- if (process.env.DSH_OPENTUI_DEBUG) console.log(`[dsh-opentui] linked dsh-llm from ${closure}`)
75
+ if (process.env.DSH_OPENTUI_DEBUG) console.log(`[dsh-opentui] linked ${CLOSURE_PACKAGES.join(', ')} from ${closure}`)
68
76
  process.exit(0)
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ // `npm pack` runs from this package dir, but LICENSE / NOTICE / README.md /
3
+ // CHANGELOG.md live at the repo ROOT — the files-array entries pointed at
4
+ // paths that do not exist here, so every published tarball (0.1.0-0.4.3)
5
+ // silently shipped without them. prepublishOnly copies the four root files
6
+ // in; they are gitignored and never committed (single source of truth).
7
+ import { copyFileSync } from 'node:fs'
8
+ import { dirname, join, resolve } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url))
12
+ const pkgDir = resolve(here, '..')
13
+ const root = resolve(pkgDir, '../..')
14
+ for (const f of ['LICENSE', 'NOTICE', 'README.md', 'CHANGELOG.md']) {
15
+ copyFileSync(join(root, f), join(pkgDir, f))
16
+ }
17
+ console.log('pack-meta: copied LICENSE NOTICE README.md CHANGELOG.md from repo root')
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ // provision-opentui-profile.mjs — idempotent bootstrap of the fixed `opentui`
3
+ // host profile (ADR-0005 decisions 3 and 5).
4
+ //
5
+ // What it does:
6
+ // 1. resolves $DSH_HOME (env or ~/.dsh)
7
+ // 2. if $DSH_HOME/profiles/opentui already exists → keeps it (idempotent,
8
+ // never recreates); otherwise recreates it from the shipped `web`
9
+ // template via `dsh opentui --from-default-profile web --dump-config`
10
+ // (--dump-config makes dsh compose-and-exit, so the template copy
11
+ // happens WITHOUT booting the profile; verified against the shipped CLI
12
+ // whose help documents "dsh rescue --from-default-profile web — recreate
13
+ // rescue from the shipped web template")
14
+ // 3. writes/merges the model route into the profile's `cordis.patch.yml`
15
+ // — the shipped web template ships NO provider route, so without this
16
+ // the profile cannot complete a real model turn (provider auth fails).
17
+ // Flags: --provider <id> --model <id> --reasoning-effort <id>
18
+ // (defaults zai-coding-cn / glm-5.3-flash / high — the same defaults as
19
+ // scripts/install-profile.mjs). Same patch-body shape as
20
+ // install-profile.mjs: an `llm-pi-ai` provider entry (apiKeyEnv derived
21
+ // the dsh way — uppercased, non-alnum → underscore, + _API_KEY) plus an
22
+ // `agent-default-model` row.
23
+ // 4. prints what it did and the plugin-install next step:
24
+ // `dsh plugin --profile opentui add <pkg>`
25
+ //
26
+ // Route idempotency contract (follows install-profile.mjs): per managed
27
+ // entry (`llm-pi-ai`, `agent-default-model`), an entry already present in the
28
+ // patch layer is left untouched — a re-run never duplicates entries and never
29
+ // clobbers a model/provider the user edited. Missing entries are appended
30
+ // (never rewritten) so the template's own patch entries survive; the file is
31
+ // backed up (*.bak-<ts>) before the first modification.
32
+ //
33
+ // Hard guard: this script only ever touches the ONE profile named `opentui`.
34
+ // It never touches any other profile — in particular the pre-existing
35
+ // `dsh-opentui` dev profile (which install-profile.mjs owns).
36
+ //
37
+ // Requires a global `dsh` on PATH (the real host CLI). The first host boot
38
+ // resolves the profile's bundles (@deepseek-ai/dsh-base + dsh-web-app) from
39
+ // the installed dsh closure; no explicit pnpm install is needed. The provider
40
+ // key is read by the host from the apiKeyEnv environment variable (this
41
+ // script only names it in the patch; it never handles key values).
42
+
43
+ import { spawnSync } from 'node:child_process'
44
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
45
+ import { homedir } from 'node:os'
46
+ import { join } from 'node:path'
47
+
48
+ const PROFILE = 'opentui'
49
+ const TEMPLATE = 'web'
50
+
51
+ const args = process.argv.slice(2)
52
+ const flag = (name) => {
53
+ const i = args.indexOf(`--${name}`)
54
+ return i >= 0 ? args[i + 1] : undefined
55
+ }
56
+
57
+ const provider = flag('provider') || 'zai-coding-cn'
58
+ const model = flag('model') || 'glm-5.3-flash'
59
+ const reasoningEffort = flag('reasoning-effort') || 'high'
60
+ // dsh convention: deriveKeyRef — uppercased, non-alnum → underscore, +_API_KEY
61
+ const apiKeyEnv = `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
62
+
63
+ function fail(message, detail = '') {
64
+ console.error(`provision-opentui-profile: FAIL — ${message}`)
65
+ if (detail) console.error(detail.trimEnd())
66
+ process.exit(1)
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // 1. Resolve the dsh home.
71
+ // ---------------------------------------------------------------------------
72
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
73
+ const profileDir = join(dshHome, 'profiles', PROFILE)
74
+ console.log(`provision-opentui-profile: dsh home ${dshHome}`)
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // 2. The profile: keep an existing one; otherwise recreate from the shipped
78
+ // web template, without booting.
79
+ // ---------------------------------------------------------------------------
80
+ if (!existsSync(profileDir)) {
81
+ const which = spawnSync('which', ['dsh'], { encoding: 'utf8' })
82
+ if (which.status !== 0 || !which.stdout.trim()) {
83
+ fail('no global `dsh` on PATH — install the dsh host CLI first')
84
+ }
85
+
86
+ console.log(`provision-opentui-profile: creating ${profileDir} from the shipped '${TEMPLATE}' template`)
87
+ const run = spawnSync(
88
+ 'dsh',
89
+ [PROFILE, '--from-default-profile', TEMPLATE, '--dump-config'],
90
+ {
91
+ // DSH_HOME is explicit so the profile lands in the resolved home even
92
+ // when the caller's env was missing or divergent.
93
+ env: { ...process.env, DSH_HOME: dshHome },
94
+ encoding: 'utf8',
95
+ maxBuffer: 64 * 1024 * 1024,
96
+ },
97
+ )
98
+ if (run.error) fail(`failed to run dsh: ${run.error.message}`)
99
+ if (run.status !== 0) {
100
+ fail(
101
+ `\`dsh ${PROFILE} --from-default-profile ${TEMPLATE} --dump-config\` exited ${run.status}`,
102
+ [run.stderr, run.stdout].filter(Boolean).join('\n').slice(-2000),
103
+ )
104
+ }
105
+ if (!existsSync(profileDir)) {
106
+ fail(
107
+ `dsh exited 0 but ${profileDir} was not created — unexpected CLI behavior; refusing to continue`,
108
+ run.stdout.slice(-2000),
109
+ )
110
+ }
111
+ } else {
112
+ console.log(`provision-opentui-profile: ${profileDir} already provisioned — leaving it untouched`)
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // 3. Model route: write/merge the provider route into cordis.patch.yml.
117
+ // Per-entry idempotent append (see the contract in the header).
118
+ // ---------------------------------------------------------------------------
119
+ const patchPath = join(profileDir, 'cordis.patch.yml')
120
+
121
+ const providerEntry = `- id: llm-pi-ai
122
+ name: "@deepseek-ai/dsh-llm-pi-ai"
123
+ config:
124
+ providers:
125
+ ${provider}:
126
+ apiKeyEnv: ${apiKeyEnv}
127
+ models:
128
+ - id: ${model}
129
+ name: ${model}
130
+ input:
131
+ - text
132
+ `
133
+ const modelEntry = `- id: agent-default-model
134
+ name: "@deepseek-ai/dsh-agent-default-model"
135
+ config:
136
+ provider: ${provider}
137
+ model: ${model}
138
+ reasoningEffort: ${reasoningEffort}
139
+ `
140
+
141
+ // Top-level entries only: the file is a YAML array of `- id:` entries, and
142
+ // nested plugin ids are indented — the ^ anchor keeps the check off them.
143
+ const hasEntry = (text, id) => new RegExp(`^- id: ${id}\\s*$`, 'm').test(text)
144
+
145
+ // True when the patch layer carries NO top-level entries at all (missing
146
+ // file, empty, comments only, or a bare `[]` flow document — the shipped web
147
+ // template writes exactly that). Such a file has nothing to merge into: it is
148
+ // replaced rather than appended to, because appending after the `[]` document
149
+ // terminator would produce an invalid two-document YAML stream (the host's
150
+ // single-document parser rejects it at boot).
151
+ const isEmptyListDoc = (text) => {
152
+ const body = text
153
+ .split('\n')
154
+ .filter((line) => { const t = line.trim(); return t !== '' && !t.startsWith('#') })
155
+ .join('\n')
156
+ .trim()
157
+ return body === '' || body === '[]'
158
+ }
159
+
160
+ let existing = ''
161
+ try {
162
+ existing = readFileSync(patchPath, 'utf8')
163
+ } catch {
164
+ /* no patch layer yet — created below */
165
+ }
166
+
167
+ const missing = [
168
+ ['llm-pi-ai', providerEntry],
169
+ ['agent-default-model', modelEntry],
170
+ ].filter(([id, body]) => !hasEntry(existing, id))
171
+
172
+ if (missing.length === 0) {
173
+ console.log(
174
+ 'provision-opentui-profile: model route already in cordis.patch.yml (llm-pi-ai + agent-default-model) — leaving it untouched',
175
+ )
176
+ } else {
177
+ const routeBody = missing.map(([, body]) => body).join('')
178
+ const banner =
179
+ `# model route (managed by provision-opentui-profile.mjs — provider ${provider}, ` +
180
+ `key read by the host from ${apiKeyEnv}):\n`
181
+ if (existing && !isEmptyListDoc(existing)) {
182
+ // Append after the existing entries; back up before the first
183
+ // modification, like install-profile.mjs.
184
+ const backupPath = `${patchPath}.bak-${Date.now()}`
185
+ renameSync(patchPath, backupPath)
186
+ console.log(`provision-opentui-profile: backed up existing patch to ${backupPath}`)
187
+ writeFileSync(patchPath, `${existing.replace(/\n*$/, '\n\n')}${banner}${routeBody}`)
188
+ console.log(
189
+ `provision-opentui-profile: appended route entries [${missing.map(([id]) => id).join(', ')}] to cordis.patch.yml`,
190
+ )
191
+ } else {
192
+ // Missing file or an entry-less document (`[]`): write the list fresh.
193
+ if (existing) {
194
+ const backupPath = `${patchPath}.bak-${Date.now()}`
195
+ renameSync(patchPath, backupPath)
196
+ console.log(`provision-opentui-profile: backed up existing patch to ${backupPath}`)
197
+ }
198
+ mkdirSync(profileDir, { recursive: true })
199
+ writeFileSync(patchPath, `${banner}${routeBody}`)
200
+ console.log(
201
+ `provision-opentui-profile: wrote route entries [${missing.map(([id]) => id).join(', ')}] to cordis.patch.yml`,
202
+ )
203
+ }
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // 4. Report.
208
+ // ---------------------------------------------------------------------------
209
+ console.log(`provision-opentui-profile: model pinned to ${provider}/${model} (effort ${reasoningEffort}, key env ${apiKeyEnv})`)
210
+ console.log('provision-opentui-profile: PASS')
211
+ console.log(`the '${PROFILE}' profile serves the host Remote protocol (web-app template)`)
212
+ console.log(`install plugins with: dsh plugin --profile ${PROFILE} add <package>`)