@oh-my-pi/pi-coding-agent 15.12.4 → 15.13.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 (291) hide show
  1. package/CHANGELOG.md +304 -6
  2. package/dist/cli.js +1015 -881
  3. package/dist/types/async/job-manager.d.ts +15 -0
  4. package/dist/types/autolearn/controller.d.ts +25 -0
  5. package/dist/types/autolearn/managed-skills.d.ts +45 -0
  6. package/dist/types/autoresearch/state.d.ts +1 -1
  7. package/dist/types/autoresearch/types.d.ts +1 -1
  8. package/dist/types/cli/args.d.ts +19 -1
  9. package/dist/types/cli/session-picker.d.ts +1 -1
  10. package/dist/types/cli/setup-cli.d.ts +1 -1
  11. package/dist/types/cli/setup-model-picker.d.ts +14 -0
  12. package/dist/types/collab/protocol.d.ts +1 -1
  13. package/dist/types/commands/say.d.ts +24 -0
  14. package/dist/types/config/keybindings.d.ts +3 -3
  15. package/dist/types/config/model-registry.d.ts +10 -0
  16. package/dist/types/config/models-config-schema.d.ts +12 -0
  17. package/dist/types/config/models-config.d.ts +8 -2
  18. package/dist/types/config/settings-schema.d.ts +261 -58
  19. package/dist/types/export/html/index.d.ts +2 -1
  20. package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +47 -1
  23. package/dist/types/extensibility/hooks/index.d.ts +2 -1
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
  25. package/dist/types/extensibility/plugins/loader.d.ts +11 -0
  26. package/dist/types/extensibility/shared-events.d.ts +1 -1
  27. package/dist/types/extensibility/skills.d.ts +10 -0
  28. package/dist/types/goals/guided-setup.d.ts +18 -0
  29. package/dist/types/goals/state.d.ts +1 -1
  30. package/dist/types/hindsight/transcript.d.ts +1 -1
  31. package/dist/types/index.d.ts +5 -0
  32. package/dist/types/internal-urls/local-protocol.d.ts +4 -2
  33. package/dist/types/main.d.ts +4 -3
  34. package/dist/types/mcp/startup-events.d.ts +11 -0
  35. package/dist/types/memories/index.d.ts +7 -0
  36. package/dist/types/memory-backend/local-backend.d.ts +4 -3
  37. package/dist/types/mnemopi/config.d.ts +4 -4
  38. package/dist/types/modes/components/agent-hub.d.ts +6 -0
  39. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  40. package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +39 -1
  42. package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
  43. package/dist/types/modes/components/session-selector.d.ts +1 -1
  44. package/dist/types/modes/components/tool-execution.d.ts +26 -16
  45. package/dist/types/modes/components/transcript-container.d.ts +23 -2
  46. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  47. package/dist/types/modes/components/usage-row.d.ts +3 -0
  48. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  49. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  50. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  51. package/dist/types/modes/gradient-highlight.d.ts +9 -4
  52. package/dist/types/modes/image-references.d.ts +6 -0
  53. package/dist/types/modes/interactive-mode.d.ts +27 -3
  54. package/dist/types/modes/magic-keywords.d.ts +13 -1
  55. package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
  56. package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
  57. package/dist/types/modes/runtime-init.d.ts +4 -0
  58. package/dist/types/modes/theme/theme.d.ts +13 -2
  59. package/dist/types/modes/types.d.ts +8 -2
  60. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  61. package/dist/types/registry/agent-registry.d.ts +17 -0
  62. package/dist/types/secrets/obfuscator.d.ts +1 -1
  63. package/dist/types/session/agent-session.d.ts +14 -2
  64. package/dist/types/session/indexed-session-storage.d.ts +3 -4
  65. package/dist/types/session/session-context.d.ts +39 -0
  66. package/dist/types/session/session-entries.d.ts +159 -0
  67. package/dist/types/session/session-listing.d.ts +69 -0
  68. package/dist/types/session/session-loader.d.ts +16 -0
  69. package/dist/types/session/session-manager.d.ts +82 -474
  70. package/dist/types/session/session-migrations.d.ts +12 -0
  71. package/dist/types/session/session-paths.d.ts +25 -0
  72. package/dist/types/session/session-persistence.d.ts +8 -0
  73. package/dist/types/session/session-storage.d.ts +11 -12
  74. package/dist/types/session/snapcompact-inline.d.ts +12 -1
  75. package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
  76. package/dist/types/session/tool-choice-queue.d.ts +6 -6
  77. package/dist/types/stt/asr-client.d.ts +90 -0
  78. package/dist/types/stt/asr-protocol.d.ts +97 -0
  79. package/dist/types/stt/asr-worker.d.ts +2 -0
  80. package/dist/types/stt/downloader.d.ts +38 -0
  81. package/dist/types/stt/endpointer.d.ts +59 -0
  82. package/dist/types/stt/index.d.ts +5 -1
  83. package/dist/types/stt/models.d.ts +120 -0
  84. package/dist/types/stt/recorder.d.ts +17 -0
  85. package/dist/types/stt/stt-controller.d.ts +6 -0
  86. package/dist/types/stt/transcriber.d.ts +5 -7
  87. package/dist/types/stt/wav.d.ts +29 -0
  88. package/dist/types/system-prompt.d.ts +4 -0
  89. package/dist/types/task/executor.d.ts +2 -0
  90. package/dist/types/task/index.d.ts +9 -1
  91. package/dist/types/task/types.d.ts +36 -0
  92. package/dist/types/tools/bash.d.ts +2 -2
  93. package/dist/types/tools/eval-render.d.ts +1 -1
  94. package/dist/types/tools/index.d.ts +11 -1
  95. package/dist/types/tools/irc.d.ts +1 -0
  96. package/dist/types/tools/learn.d.ts +51 -0
  97. package/dist/types/tools/manage-skill.d.ts +40 -0
  98. package/dist/types/tools/plan-mode-guard.d.ts +10 -0
  99. package/dist/types/tools/renderers.d.ts +7 -11
  100. package/dist/types/tools/ssh.d.ts +1 -1
  101. package/dist/types/tools/todo.d.ts +1 -1
  102. package/dist/types/tools/tts.d.ts +25 -0
  103. package/dist/types/tools/write.d.ts +1 -1
  104. package/dist/types/tts/downloader.d.ts +20 -0
  105. package/dist/types/tts/index.d.ts +8 -0
  106. package/dist/types/tts/models.d.ts +82 -0
  107. package/dist/types/tts/player.d.ts +32 -0
  108. package/dist/types/tts/runtime.d.ts +6 -0
  109. package/dist/types/tts/streaming-player.d.ts +41 -0
  110. package/dist/types/tts/tts-client.d.ts +93 -0
  111. package/dist/types/tts/tts-protocol.d.ts +95 -0
  112. package/dist/types/tts/tts-worker.d.ts +2 -0
  113. package/dist/types/tts/vocalizer.d.ts +41 -0
  114. package/dist/types/tts/wav.d.ts +8 -0
  115. package/dist/types/utils/tool-choice.d.ts +8 -0
  116. package/dist/types/utils/tools-manager.d.ts +2 -1
  117. package/dist/types/utils/tools-manager.test.d.ts +1 -0
  118. package/dist/types/web/scrapers/github.d.ts +1 -1
  119. package/package.json +15 -14
  120. package/src/async/job-manager.ts +49 -0
  121. package/src/autolearn/controller.ts +139 -0
  122. package/src/autolearn/managed-skills.ts +257 -0
  123. package/src/autoresearch/state.ts +1 -1
  124. package/src/autoresearch/types.ts +1 -1
  125. package/src/cli/args.ts +56 -2
  126. package/src/cli/session-picker.ts +2 -1
  127. package/src/cli/setup-cli.ts +148 -47
  128. package/src/cli/setup-model-picker.ts +43 -0
  129. package/src/cli-commands.ts +1 -0
  130. package/src/cli.ts +45 -13
  131. package/src/collab/host.ts +1 -1
  132. package/src/collab/protocol.ts +1 -1
  133. package/src/commands/say.ts +102 -0
  134. package/src/commands/setup.ts +1 -1
  135. package/src/commit/agentic/tools/analyze-file.ts +3 -0
  136. package/src/config/keybindings.ts +2 -2
  137. package/src/config/model-discovery.ts +11 -5
  138. package/src/config/model-registry.ts +64 -9
  139. package/src/config/models-config-schema.ts +4 -1
  140. package/src/config/models-config.ts +2 -1
  141. package/src/config/settings-schema.ts +248 -32
  142. package/src/config/settings.ts +10 -0
  143. package/src/discovery/builtin.ts +23 -1
  144. package/src/discovery/claude-plugins.ts +44 -5
  145. package/src/discovery/helpers.ts +41 -1
  146. package/src/eval/__tests__/budget-bridge.test.ts +1 -1
  147. package/src/eval/js/shared/prelude.txt +69 -17
  148. package/src/export/html/index.ts +3 -6
  149. package/src/extensibility/extensions/model-api.ts +41 -0
  150. package/src/extensibility/extensions/runner.ts +4 -0
  151. package/src/extensibility/extensions/types.ts +52 -1
  152. package/src/extensibility/extensions/wrapper.ts +41 -5
  153. package/src/extensibility/hooks/index.ts +2 -1
  154. package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
  155. package/src/extensibility/plugins/loader.ts +30 -19
  156. package/src/extensibility/plugins/manager.ts +221 -90
  157. package/src/extensibility/shared-events.ts +1 -1
  158. package/src/extensibility/skills.ts +96 -15
  159. package/src/goals/guided-setup.ts +133 -0
  160. package/src/goals/state.ts +1 -1
  161. package/src/hindsight/transcript.ts +1 -1
  162. package/src/index.ts +5 -0
  163. package/src/internal-urls/docs-index.generated.ts +10 -10
  164. package/src/internal-urls/history-protocol.ts +1 -1
  165. package/src/internal-urls/local-protocol.ts +29 -7
  166. package/src/main.ts +27 -7
  167. package/src/mcp/startup-events.ts +21 -0
  168. package/src/mcp/transports/stdio.ts +2 -1
  169. package/src/memories/index.ts +146 -11
  170. package/src/memory-backend/local-backend.ts +11 -5
  171. package/src/mnemopi/backend.ts +1 -0
  172. package/src/mnemopi/config.ts +26 -10
  173. package/src/modes/acp/acp-agent.ts +3 -5
  174. package/src/modes/components/agent-hub.ts +49 -4
  175. package/src/modes/components/assistant-message.ts +4 -37
  176. package/src/modes/components/compaction-summary-message.ts +125 -26
  177. package/src/modes/components/custom-editor.test.ts +96 -0
  178. package/src/modes/components/custom-editor.ts +164 -8
  179. package/src/modes/components/session-selector.ts +1 -1
  180. package/src/modes/components/settings-defs.ts +7 -0
  181. package/src/modes/components/tool-execution.ts +82 -43
  182. package/src/modes/components/transcript-container.ts +70 -1
  183. package/src/modes/components/tree-selector.ts +1 -1
  184. package/src/modes/components/usage-row.ts +18 -0
  185. package/src/modes/components/user-message.ts +4 -2
  186. package/src/modes/controllers/command-controller.ts +14 -4
  187. package/src/modes/controllers/event-controller.ts +78 -11
  188. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  189. package/src/modes/controllers/input-controller.ts +258 -27
  190. package/src/modes/controllers/selector-controller.ts +12 -2
  191. package/src/modes/gradient-highlight.ts +21 -9
  192. package/src/modes/image-references.ts +20 -0
  193. package/src/modes/interactive-mode.ts +286 -40
  194. package/src/modes/magic-keywords.ts +27 -5
  195. package/src/modes/rpc/rpc-mode.ts +146 -14
  196. package/src/modes/rpc/rpc-subagents.ts +2 -2
  197. package/src/modes/rpc/rpc-types.ts +8 -2
  198. package/src/modes/runtime-init.ts +28 -3
  199. package/src/modes/theme/theme.ts +98 -50
  200. package/src/modes/types.ts +6 -2
  201. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  202. package/src/modes/utils/ui-helpers.ts +34 -6
  203. package/src/priority.json +5 -1
  204. package/src/prompts/agents/task.md +1 -0
  205. package/src/prompts/goals/guided-goal-interview.md +8 -0
  206. package/src/prompts/goals/guided-goal-system.md +12 -0
  207. package/src/prompts/memories/read-path.md +6 -0
  208. package/src/prompts/system/autolearn-guidance-learn.md +1 -0
  209. package/src/prompts/system/autolearn-guidance.md +7 -0
  210. package/src/prompts/system/autolearn-nudge.md +3 -0
  211. package/src/prompts/system/eager-task.md +7 -0
  212. package/src/prompts/system/eager-todo.md +11 -6
  213. package/src/prompts/system/subagent-system-prompt.md +4 -0
  214. package/src/prompts/system/system-prompt.md +10 -5
  215. package/src/prompts/system/title-marker-instruction.md +1 -0
  216. package/src/prompts/system/title-system-marker.md +16 -0
  217. package/src/prompts/tools/job.md +1 -0
  218. package/src/prompts/tools/learn.md +7 -0
  219. package/src/prompts/tools/manage-skill.md +9 -0
  220. package/src/prompts/tools/task.md +3 -0
  221. package/src/registry/agent-registry.ts +30 -0
  222. package/src/sdk.ts +88 -24
  223. package/src/secrets/obfuscator.ts +1 -1
  224. package/src/session/agent-session.ts +209 -87
  225. package/src/session/history-storage.ts +2 -2
  226. package/src/session/indexed-session-storage.ts +7 -17
  227. package/src/session/session-context.ts +352 -0
  228. package/src/session/session-entries.ts +194 -0
  229. package/src/session/session-listing.ts +588 -0
  230. package/src/session/session-loader.ts +106 -0
  231. package/src/session/session-manager.ts +933 -3145
  232. package/src/session/session-migrations.ts +78 -0
  233. package/src/session/session-paths.ts +193 -0
  234. package/src/session/session-persistence.ts +131 -0
  235. package/src/session/session-storage.ts +91 -50
  236. package/src/session/snapcompact-inline.ts +21 -1
  237. package/src/session/snapcompact-savings-journal.ts +113 -0
  238. package/src/session/tool-choice-queue.ts +23 -11
  239. package/src/slash-commands/builtin-registry.ts +25 -3
  240. package/src/stt/asr-client.ts +520 -0
  241. package/src/stt/asr-protocol.ts +65 -0
  242. package/src/stt/asr-worker.ts +790 -0
  243. package/src/stt/downloader.ts +107 -47
  244. package/src/stt/endpointer.ts +259 -0
  245. package/src/stt/index.ts +5 -1
  246. package/src/stt/models.ts +150 -0
  247. package/src/stt/recorder.ts +247 -60
  248. package/src/stt/stt-controller.ts +201 -22
  249. package/src/stt/transcriber.ts +37 -68
  250. package/src/stt/wav.ts +173 -0
  251. package/src/system-prompt.ts +8 -0
  252. package/src/task/agents.ts +1 -2
  253. package/src/task/executor.ts +49 -15
  254. package/src/task/index.ts +60 -6
  255. package/src/task/render.ts +83 -8
  256. package/src/task/types.ts +53 -0
  257. package/src/tools/ask.ts +8 -0
  258. package/src/tools/bash.ts +4 -3
  259. package/src/tools/eval-render.ts +4 -3
  260. package/src/tools/index.ts +40 -4
  261. package/src/tools/irc.ts +10 -2
  262. package/src/tools/job.ts +14 -2
  263. package/src/tools/learn.ts +144 -0
  264. package/src/tools/manage-skill.ts +104 -0
  265. package/src/tools/plan-mode-guard.ts +53 -19
  266. package/src/tools/renderers.ts +7 -11
  267. package/src/tools/ssh.ts +4 -3
  268. package/src/tools/todo.ts +1 -1
  269. package/src/tools/tts.ts +203 -92
  270. package/src/tools/write.ts +18 -2
  271. package/src/tts/downloader.ts +64 -0
  272. package/src/tts/index.ts +8 -0
  273. package/src/tts/models.ts +137 -0
  274. package/src/tts/player.ts +137 -0
  275. package/src/tts/runtime.ts +21 -0
  276. package/src/tts/streaming-player.ts +266 -0
  277. package/src/tts/tts-client.ts +647 -0
  278. package/src/tts/tts-protocol.ts +60 -0
  279. package/src/tts/tts-worker.ts +497 -0
  280. package/src/tts/vocalizer.ts +162 -0
  281. package/src/tts/wav.ts +58 -0
  282. package/src/utils/title-generator.ts +48 -5
  283. package/src/utils/tool-choice.ts +16 -0
  284. package/src/utils/tools-manager.test.ts +25 -0
  285. package/src/utils/tools-manager.ts +19 -1
  286. package/src/web/scrapers/github.ts +96 -0
  287. package/src/web/search/index.ts +13 -0
  288. package/src/web/search/providers/searxng.ts +13 -1
  289. package/dist/types/stt/setup.d.ts +0 -18
  290. package/src/stt/setup.ts +0 -52
  291. package/src/stt/transcribe.py +0 -70
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Managed-skills primitives for the experimental auto-learn feature.
3
+ *
4
+ * Managed skills are auto-generated/enhanced `SKILL.md` files kept in an
5
+ * isolated directory (`~/.omp/agent/managed-skills`) separate from
6
+ * user-authored skills (`~/.omp/agent/skills`). They are discovered and
7
+ * surfaced like normal skills, but every write here is confined to
8
+ * `getManagedSkillsDir()` — auto-management can never touch authored skills.
9
+ */
10
+ import { constants as fsConstants, type Stats } from "node:fs";
11
+ import * as fs from "node:fs/promises";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { isEnoent } from "@oh-my-pi/pi-utils";
15
+ import { YAML } from "bun";
16
+ import { SOURCE_PATHS } from "../discovery/helpers";
17
+
18
+ /** Provider id stamped on discovered managed skills (distinguishes them from authored). */
19
+ export const MANAGED_SKILLS_PROVIDER_ID = "omp-managed";
20
+
21
+ /** Hard cap on a managed SKILL.md body to keep generated skills bounded. */
22
+ export const MAX_MANAGED_SKILL_BYTES = 64_000;
23
+
24
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
25
+
26
+ /** Resolve the isolated managed-skills directory (`~/.omp/agent/managed-skills`). */
27
+ export function getManagedSkillsDir(home: string = os.homedir()): string {
28
+ return path.join(home, SOURCE_PATHS.native.userAgent, "managed-skills");
29
+ }
30
+
31
+ /**
32
+ * Validate + normalize a managed-skill name. Throws on anything outside the
33
+ * strict allowlist so a bad name can never escape `getManagedSkillsDir()`
34
+ * (blocks `..`, slashes, empty, and uppercase).
35
+ */
36
+ export function sanitizeSkillName(raw: string): string {
37
+ const name = raw.trim().toLowerCase();
38
+ if (!SKILL_NAME_PATTERN.test(name)) {
39
+ throw new Error(
40
+ `Invalid skill name "${raw}". Use lowercase letters, digits, and hyphens (1-64 chars, starting with a letter or digit).`,
41
+ );
42
+ }
43
+ return name;
44
+ }
45
+
46
+ /**
47
+ * Whether `name` is a safe managed-skill name (the exact post-sanitize shape).
48
+ * Used to validate names read from disk at discovery time — a managed
49
+ * `SKILL.md` whose `frontmatter.name` was not produced by `sanitizeSkillName`
50
+ * (e.g. hand-placed) must not render unescaped into the system prompt.
51
+ */
52
+ export function isValidManagedSkillName(name: string): boolean {
53
+ return SKILL_NAME_PATTERN.test(name);
54
+ }
55
+
56
+ /**
57
+ * Neutralize a machine-generated managed-skill description so it cannot break
58
+ * out of the system prompt's `<skills>` listing. Managed descriptions are
59
+ * generated from prior task content and persist across sessions, so this is a
60
+ * trust boundary: strip control/format chars, angle brackets (`<system-directive>`
61
+ * / `</skills>`), and Markdown fence delimiters (backticks, `~~~`), then collapse
62
+ * to a single line. Applied on BOTH write and read so existing files are safe too.
63
+ */
64
+ export function sanitizeManagedDescription(raw: string): string {
65
+ return raw
66
+ .replace(/[\p{Cc}\p{Cf}]/gu, " ")
67
+ .replace(/[<>`]/g, "")
68
+ .replace(/~{2,}/g, "~")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ }
72
+
73
+ /**
74
+ * Serialize the minimal `name`/`description` frontmatter block via the repo's
75
+ * YAML helper (round-trips through `parseFrontmatter`).
76
+ */
77
+ export function toSkillFrontmatter(name: string, description: string): string {
78
+ const frontmatter = YAML.stringify(
79
+ { name, description: sanitizeManagedDescription(description) },
80
+ null,
81
+ 2,
82
+ ).trimEnd();
83
+ return `---\n${frontmatter}\n---\n`;
84
+ }
85
+
86
+ export interface WriteManagedSkillInput {
87
+ action: "create" | "update";
88
+ name: string;
89
+ description: string;
90
+ body: string;
91
+ }
92
+
93
+ /**
94
+ * Serialize create/update/delete on the same skill name. Both tools are
95
+ * non-exclusive, so a parallel tool batch in one turn can run two mutations on
96
+ * the same skill at once (e.g. an update observing the file mid-delete). This
97
+ * per-name promise chain runs same-skill mutations in submission order while
98
+ * different names still proceed in parallel. In-process only; cross-process
99
+ * races are out of scope.
100
+ */
101
+ const skillMutationChains = new Map<string, Promise<unknown>>();
102
+ function serializeSkillMutation<T>(name: string, op: () => Promise<T>): Promise<T> {
103
+ const prev = skillMutationChains.get(name) ?? Promise.resolve();
104
+ const run = prev.then(op, op);
105
+ const guarded = run.catch(() => {});
106
+ skillMutationChains.set(name, guarded);
107
+ void guarded.finally(() => {
108
+ if (skillMutationChains.get(name) === guarded) skillMutationChains.delete(name);
109
+ });
110
+ return run;
111
+ }
112
+
113
+ /**
114
+ * Reject when the managed-skills root itself is a symlink. lstat on a child
115
+ * follows intermediate components, so a symlinked root would let an otherwise
116
+ * valid name write/delete outside the isolated directory (e.g. onto authored
117
+ * skills). Checked before composing any child path.
118
+ */
119
+ async function assertManagedRootSafe(): Promise<void> {
120
+ const rootStat = await fs.lstat(getManagedSkillsDir()).catch(err => {
121
+ if (isEnoent(err)) return null;
122
+ throw err;
123
+ });
124
+ if (rootStat?.isSymbolicLink()) {
125
+ throw new Error("The managed-skills root is a symlink; refusing to operate outside the managed directory.");
126
+ }
127
+ }
128
+
129
+ const UPDATE_FILE_OPEN_FLAGS = fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW;
130
+
131
+ function assertManagedSkillFileSafeForUpdate(name: string, fileStat: Stats): void {
132
+ if (!fileStat.isFile()) {
133
+ throw new Error(`Managed skill "${name}" SKILL.md is not a regular file; refusing to overwrite it.`);
134
+ }
135
+ if (fileStat.nlink > 1) {
136
+ throw new Error(
137
+ `Managed skill "${name}" SKILL.md has ${fileStat.nlink} hard links; refusing to overwrite a file that may be user-authored elsewhere.`,
138
+ );
139
+ }
140
+ }
141
+
142
+ async function openManagedSkillFileForUpdate(name: string, file: string) {
143
+ try {
144
+ return await fs.open(file, UPDATE_FILE_OPEN_FLAGS);
145
+ } catch (err) {
146
+ if ((err as { code?: string }).code === "ELOOP") {
147
+ throw new Error(`Managed skill "${name}" SKILL.md is a symlink; refusing to overwrite it.`);
148
+ }
149
+ throw err;
150
+ }
151
+ }
152
+
153
+ /** Create or update a managed `SKILL.md`. Returns the resolved file path. */
154
+ export async function writeManagedSkill(input: WriteManagedSkillInput): Promise<{ path: string }> {
155
+ const name = sanitizeSkillName(input.name);
156
+ const description = sanitizeManagedDescription(input.description);
157
+ const body = input.body.trim();
158
+ // Reject empty content: an all-whitespace/control description sanitizes to ""
159
+ // and the `requireDescription` discovery scan then silently drops the skill,
160
+ // so the tool would report success for a skill that never appears.
161
+ if (!description) {
162
+ throw new Error(`Managed skill "${name}" needs a non-empty description.`);
163
+ }
164
+ if (!body) {
165
+ throw new Error(`Managed skill "${name}" needs a non-empty body.`);
166
+ }
167
+ const content = `${toSkillFrontmatter(name, description)}\n${body}\n`;
168
+ // Cap the UTF-8 byte size of the FINAL file (body + description + frontmatter),
169
+ // not the UTF-16 code-unit length of the body alone.
170
+ const bytes = Buffer.byteLength(content, "utf8");
171
+ if (bytes > MAX_MANAGED_SKILL_BYTES) {
172
+ throw new Error(
173
+ `Managed skill is ${bytes} bytes; the limit is ${MAX_MANAGED_SKILL_BYTES}. Trim the body or description.`,
174
+ );
175
+ }
176
+ return serializeSkillMutation(name, async () => {
177
+ await assertManagedRootSafe();
178
+ const dir = path.join(getManagedSkillsDir(), name);
179
+ const file = path.join(dir, "SKILL.md");
180
+ // Reject a symlinked skill directory: an intermediate symlink would let the
181
+ // write escape the isolated managed root. lstat does not follow the final
182
+ // component, so a symlinked `dir` is caught here.
183
+ const dirStat = await fs.lstat(dir).catch(err => {
184
+ if (isEnoent(err)) return null;
185
+ throw err;
186
+ });
187
+ if (dirStat?.isSymbolicLink()) {
188
+ throw new Error(
189
+ `Managed skill "${name}" resolves through a symlink; refusing to write outside the managed directory.`,
190
+ );
191
+ }
192
+ if (input.action === "create") {
193
+ await fs.mkdir(dir, { recursive: true });
194
+ // O_CREAT|O_EXCL ("wx"): atomic create that fails if the file already
195
+ // exists (closing the check-then-write race) and refuses a symlinked SKILL.md.
196
+ try {
197
+ await fs.writeFile(file, content, { flag: "wx" });
198
+ } catch (err) {
199
+ if ((err as { code?: string }).code === "EEXIST") {
200
+ throw new Error(`Managed skill "${name}" already exists. Use action "update" to change it.`);
201
+ }
202
+ throw err;
203
+ }
204
+ return { path: file };
205
+ }
206
+ // update: the file must already exist, be a plain managed file, and must
207
+ // not share an inode with a user-authored file via hard link. Open the
208
+ // checked file handle before truncating so a path swap after lstat cannot
209
+ // redirect the write into a symlink or newly hard-linked target.
210
+ const fileStat = await fs.lstat(file).catch(err => {
211
+ if (isEnoent(err)) return null;
212
+ throw err;
213
+ });
214
+ if (fileStat === null) {
215
+ throw new Error(`Managed skill "${name}" does not exist. Use action "create" to add it.`);
216
+ }
217
+ if (fileStat.isSymbolicLink()) {
218
+ throw new Error(`Managed skill "${name}" SKILL.md is a symlink; refusing to overwrite it.`);
219
+ }
220
+ assertManagedSkillFileSafeForUpdate(name, fileStat);
221
+ const handle = await openManagedSkillFileForUpdate(name, file);
222
+ try {
223
+ const openStat = await handle.stat();
224
+ assertManagedSkillFileSafeForUpdate(name, openStat);
225
+ await handle.truncate(0);
226
+ await handle.writeFile(content);
227
+ } finally {
228
+ await handle.close();
229
+ }
230
+ return { path: file };
231
+ });
232
+ }
233
+
234
+ /** Delete a managed skill directory. Throws when it does not exist. */
235
+ export async function deleteManagedSkill(name: string): Promise<void> {
236
+ const safe = sanitizeSkillName(name);
237
+ await serializeSkillMutation(safe, async () => {
238
+ await assertManagedRootSafe();
239
+ const dir = path.join(getManagedSkillsDir(), safe);
240
+ // Refuse to follow a symlinked skill directory (rm would delete the target).
241
+ const dirStat = await fs.lstat(dir).catch(err => {
242
+ if (isEnoent(err)) return null;
243
+ throw err;
244
+ });
245
+ if (dirStat?.isSymbolicLink()) {
246
+ throw new Error(`Managed skill "${safe}" is a symlink; refusing to delete outside the managed directory.`);
247
+ }
248
+ try {
249
+ await fs.rm(dir, { recursive: true });
250
+ } catch (err) {
251
+ if (isEnoent(err)) {
252
+ throw new Error(`Managed skill "${safe}" does not exist.`);
253
+ }
254
+ throw err;
255
+ }
256
+ });
257
+ }
@@ -1,4 +1,4 @@
1
- import type { SessionEntry } from "../session/session-manager";
1
+ import type { SessionEntry } from "../session/session-entries";
2
2
  import { inferMetricUnitFromName, isBetter } from "./helpers";
3
3
  import type { RunRow, SessionRow } from "./storage";
4
4
  import type {
@@ -1,6 +1,6 @@
1
1
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import type { ExtensionAPI, ExtensionContext } from "../extensibility/extensions";
3
- import type { SessionEntry } from "../session/session-manager";
3
+ import type { SessionEntry } from "../session/session-entries";
4
4
  import type { TruncationResult } from "../session/streaming-output";
5
5
 
6
6
  export type MetricDirection = "lower" | "higher";
package/src/cli/args.ts CHANGED
@@ -53,8 +53,19 @@ export interface Args {
53
53
  approvalMode?: "always-ask" | "write" | "yolo";
54
54
  messages: string[];
55
55
  fileArgs: string[];
56
- /** Unknown flags (potentially extension flags) - map of flag name to value */
56
+ /** Extension-registered flags this parse recognized name to value. */
57
57
  unknownFlags: Map<string, boolean | string>;
58
+ /**
59
+ * `--`/`-` prefixed tokens this parse could not match against any built-in
60
+ * or {@link extensionFlags} entry. The startup parse runs *before*
61
+ * extensions load, so it always lists every extension-registered flag here;
62
+ * the post-extension reparse in {@link applyExtensionFlags} clears those
63
+ * once the real flag set is known. Anything still present after that
64
+ * reparse is a genuine typo or stale flag and {@link reportUnrecognizedFlags}
65
+ * surfaces it as a hard error so the agent does not silently start a
66
+ * session with the misparsed positionals as a prompt (issue #2459).
67
+ */
68
+ unrecognizedFlags: string[];
58
69
  }
59
70
 
60
71
  export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
@@ -67,12 +78,23 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
67
78
  messages: [],
68
79
  fileArgs: [],
69
80
  unknownFlags: new Map(),
81
+ unrecognizedFlags: [],
70
82
  };
71
83
 
84
+ let sawSeparator = false;
72
85
  for (let i = 0; i < args.length; i++) {
73
86
  let arg = args[i];
74
87
  const flagIndex = i;
75
88
 
89
+ // POSIX positional separator: once `--` lands, every remaining token is
90
+ // a positional regardless of shape. Without this, a flag-looking message
91
+ // (`omp -p -- --explain-this`) would be re-validated by the loop below
92
+ // and rejected by the unknown-flag guard (#2461 review).
93
+ if (sawSeparator) {
94
+ result.messages.push(arg);
95
+ continue;
96
+ }
97
+
76
98
  // Support --flag=value syntax (e.g. --tools=ask,read). The value is
77
99
  // spliced in as the next token so value-consuming flags pick it up via
78
100
  // `args[++i]`; a non-consuming flag (e.g. a boolean) leaves it behind and
@@ -229,8 +251,22 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
229
251
  result.skills = args[++i].split(",").map(s => s.trim());
230
252
  } else if (arg.startsWith("@")) {
231
253
  result.fileArgs.push(arg.slice(1)); // Remove @ prefix
232
- } else if (!arg.startsWith("-")) {
254
+ } else if (!arg.startsWith("-") || arg === "-") {
255
+ // Plain positional or lone `-` (stdin marker) — pass through as a
256
+ // message rather than flagging it.
233
257
  result.messages.push(arg);
258
+ } else if (arg === "--") {
259
+ // POSIX positional separator: drop the token and switch the loop
260
+ // into "everything from here is a positional" mode. The guard at
261
+ // the top of the loop body handles the remaining tokens.
262
+ sawSeparator = true;
263
+ } else {
264
+ // Flag-shaped (`-x`, `--name`) but unrecognized at this parse. Record
265
+ // it so the post-extension reparse can decide whether to surface it
266
+ // as a hard error. `--flag=value` already split `value` into the next
267
+ // slot; the standard "drop unconsumed equals value" guard below
268
+ // removes it so it does not leak into messages (issue #2459).
269
+ result.unrecognizedFlags.push(arg);
234
270
  }
235
271
  // Drop an unconsumed `--flag=value` value (e.g. a boolean flag): when no
236
272
  // branch advanced past the spliced token, remove it so it does not fall
@@ -243,6 +279,24 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
243
279
  return result;
244
280
  }
245
281
 
282
+ /**
283
+ * Emit a stderr error listing the unrecognized flags and return `true` when
284
+ * there were any. Caller is expected to exit with a non-zero status. Splitting
285
+ * the print from the exit keeps the helper unit-testable without forking a
286
+ * process (issue #2459).
287
+ */
288
+ export function reportUnrecognizedFlags(
289
+ args: Pick<Args, "unrecognizedFlags">,
290
+ write: (text: string) => void = text => process.stderr.write(text),
291
+ ): boolean {
292
+ if (args.unrecognizedFlags.length === 0) return false;
293
+ const flags = args.unrecognizedFlags;
294
+ const plural = flags.length === 1 ? "" : "s";
295
+ write(`${chalk.red(`Error: unknown flag${plural}: ${flags.join(", ")}`)}\n`);
296
+ write(`Run \`${APP_NAME} --help\` for available flags.\n`);
297
+ return true;
298
+ }
299
+
246
300
  export function getExtraHelpText(): string {
247
301
  return `${chalk.bold("Environment Variables:")}
248
302
  ${chalk.dim("# Core Providers")}
@@ -2,7 +2,8 @@ import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
2
2
  import { logger } from "@oh-my-pi/pi-utils";
3
3
  import { SessionSelectorComponent } from "../modes/components/session-selector";
4
4
  import { HistoryStorage } from "../session/history-storage";
5
- import { type SessionInfo, SessionManager } from "../session/session-manager";
5
+ import type { SessionInfo } from "../session/session-listing";
6
+ import { SessionManager } from "../session/session-manager";
6
7
  import { FileSessionStorage } from "../session/session-storage";
7
8
 
8
9
  /**
@@ -4,12 +4,18 @@
4
4
  * Handles `omp setup` for onboarding and `omp setup <component>` for optional dependencies.
5
5
  */
6
6
  import * as path from "node:path";
7
- import { $which, APP_NAME, getPythonEnvDir } from "@oh-my-pi/pi-utils";
7
+ import { $which, APP_NAME, getProjectDir, getPythonEnvDir } from "@oh-my-pi/pi-utils";
8
8
  import { $ } from "bun";
9
9
  import chalk from "chalk";
10
+ import { Settings, settings } from "../config/settings";
10
11
  import { theme } from "../modes/theme/theme";
12
+ import { downloadSttModel, isSttModelCached } from "../stt/downloader";
13
+ import { isSttModelKey, STT_MODEL_OPTIONS } from "../stt/models";
14
+ import { detectRecorder, ensureRecorder } from "../stt/recorder";
15
+ import { downloadTtsModel, isTtsLocalModelKey, isTtsModelCached, TTS_LOCAL_MODEL_OPTIONS } from "../tts";
16
+ import { selectSetupModel } from "./setup-model-picker";
11
17
 
12
- export type SetupComponent = "python" | "stt";
18
+ export type SetupComponent = "python" | "speech";
13
19
 
14
20
  export interface SetupCommandArgs {
15
21
  component: SetupComponent;
@@ -19,7 +25,7 @@ export interface SetupCommandArgs {
19
25
  };
20
26
  }
21
27
 
22
- const VALID_COMPONENTS: SetupComponent[] = ["python", "stt"];
28
+ const VALID_COMPONENTS: SetupComponent[] = ["python", "speech"];
23
29
 
24
30
  const MANAGED_PYTHON_ENV = getPythonEnvDir();
25
31
 
@@ -114,8 +120,8 @@ export async function runSetupCommand(cmd: SetupCommandArgs): Promise<void> {
114
120
  case "python":
115
121
  await handlePythonSetup(cmd.flags);
116
122
  break;
117
- case "stt":
118
- await handleSttSetup(cmd.flags);
123
+ case "speech":
124
+ await handleSpeechSetup(cmd.flags);
119
125
  break;
120
126
  }
121
127
  }
@@ -149,58 +155,153 @@ async function handlePythonSetup(flags: { json?: boolean; check?: boolean }): Pr
149
155
  process.exit(1);
150
156
  }
151
157
 
152
- async function handleSttSetup(flags: { json?: boolean; check?: boolean }): Promise<void> {
153
- const { checkDependencies, formatDependencyStatus } = await import("../stt/setup");
154
- const status = await checkDependencies();
158
+ /**
159
+ * One installable speech dependency. `isReady`/`status` are read-only probes;
160
+ * `pick` (optional) lets an interactive user choose + persist a model; `ensure`
161
+ * performs the download, streaming a normalized progress event.
162
+ */
163
+ interface SpeechComponent {
164
+ name: string;
165
+ isReady(): Promise<boolean>;
166
+ status(): Promise<string>;
167
+ pick?(): Promise<boolean>;
168
+ ensure(onProgress: (progress: { stage: string; percent?: number }) => void): Promise<void>;
169
+ }
155
170
 
156
- if (flags.json) {
157
- console.log(JSON.stringify(status, null, 2));
158
- if (!status.recorder.available || !status.python.available || !status.whisper.available) process.exit(1);
159
- return;
160
- }
171
+ function buildSpeechComponents(): SpeechComponent[] {
172
+ return [
173
+ {
174
+ name: "Recorder",
175
+ isReady: async () => detectRecorder() !== null,
176
+ status: async () => {
177
+ const recorder = detectRecorder();
178
+ return recorder ? `${recorder.tool} (${recorder.bin})` : "none — ffmpeg will be downloaded";
179
+ },
180
+ ensure: async onProgress => {
181
+ await ensureRecorder(onProgress);
182
+ },
183
+ },
184
+ {
185
+ name: "Speech-to-Text model",
186
+ isReady: () => isSttModelCached(settings.get("stt.modelName")),
187
+ status: async () => {
188
+ const key = settings.get("stt.modelName");
189
+ return (await isSttModelCached(key)) ? key : `${key} — not downloaded`;
190
+ },
191
+ pick: async () => {
192
+ const chosen = await selectSetupModel(
193
+ "Speech-to-Text model",
194
+ [...STT_MODEL_OPTIONS],
195
+ settings.get("stt.modelName"),
196
+ );
197
+ if (chosen === null) return false;
198
+ if (isSttModelKey(chosen)) {
199
+ settings.set("stt.modelName", chosen);
200
+ await settings.flush();
201
+ }
202
+ return true;
203
+ },
204
+ ensure: onProgress =>
205
+ downloadSttModel(settings.get("stt.modelName"), progress =>
206
+ onProgress({ stage: `Downloading ${progress.label} model`, percent: progress.percent }),
207
+ ),
208
+ },
209
+ {
210
+ name: "Text-to-Speech model",
211
+ isReady: () => isTtsModelCached(settings.get("tts.localModel")),
212
+ status: async () => {
213
+ const key = settings.get("tts.localModel");
214
+ return (await isTtsModelCached(key)) ? key : `${key} — model/runtime not installed`;
215
+ },
216
+ pick: async () => {
217
+ const chosen = await selectSetupModel(
218
+ "Text-to-Speech model",
219
+ [...TTS_LOCAL_MODEL_OPTIONS],
220
+ settings.get("tts.localModel"),
221
+ );
222
+ if (chosen === null) return false;
223
+ if (isTtsLocalModelKey(chosen)) {
224
+ settings.set("tts.localModel", chosen);
225
+ await settings.flush();
226
+ }
227
+ return true;
228
+ },
229
+ ensure: async onProgress => {
230
+ const ok = await downloadTtsModel(settings.get("tts.localModel"), progress =>
231
+ onProgress({ stage: progress.stage, percent: progress.percent }),
232
+ );
233
+ if (!ok) throw new Error("Failed to download the local text-to-speech model.");
234
+ },
235
+ },
236
+ ];
237
+ }
161
238
 
162
- console.log(formatDependencyStatus(status));
239
+ /**
240
+ * Unified `omp setup speech` flow. Drives every {@link SpeechComponent} through
241
+ * one path: report (`--json`/`--check`) or install (interactive pick + ensure
242
+ * with single-line progress; non-TTY skips pickers and installs configured
243
+ * values).
244
+ */
245
+ async function handleSpeechSetup(flags: { json?: boolean; check?: boolean }): Promise<void> {
246
+ await Settings.init({ cwd: getProjectDir() });
247
+ const components = buildSpeechComponents();
163
248
 
164
- if (status.recorder.available && status.python.available && status.whisper.available) {
165
- console.log(chalk.green(`\n${theme.status.success} Speech-to-text is ready`));
249
+ if (flags.json) {
250
+ const report: Record<string, { ready: boolean; status: string }> = {};
251
+ let allReady = true;
252
+ for (const component of components) {
253
+ const ready = await component.isReady();
254
+ if (!ready) allReady = false;
255
+ report[component.name] = { ready, status: await component.status() };
256
+ }
257
+ console.log(JSON.stringify(report, null, 2));
258
+ if (!allReady) process.exit(1);
166
259
  return;
167
260
  }
168
261
 
169
262
  if (flags.check) {
170
- process.exit(1);
171
- }
172
-
173
- if (!status.python.available) {
174
- console.error(chalk.red(`\n${theme.status.error} Python not found`));
175
- console.error(chalk.dim("Install Python 3.8+ and ensure it's in your PATH"));
176
- process.exit(1);
177
- }
178
-
179
- if (!status.recorder.available) {
180
- console.error(chalk.yellow(`\n${theme.status.warning} No recording tool found`));
181
- console.error(chalk.dim(status.recorder.installHint));
263
+ console.log(chalk.bold("Speech dependencies:"));
264
+ let allReady = true;
265
+ for (const component of components) {
266
+ const ready = await component.isReady();
267
+ if (!ready) allReady = false;
268
+ const mark = ready ? chalk.green("[ok]") : chalk.yellow("[missing]");
269
+ console.log(` ${mark} ${component.name}: ${await component.status()}`);
270
+ }
271
+ if (!allReady) process.exit(1);
272
+ return;
182
273
  }
183
274
 
184
- if (!status.whisper.available) {
185
- console.log(chalk.dim(`\nInstalling openai-whisper...`));
186
- const { resolvePython } = await import("../stt/transcriber");
187
- const pythonCmd = resolvePython()!;
188
- const result = await $`${pythonCmd} -m pip install -q openai-whisper`.nothrow();
189
- if (result.exitCode !== 0) {
190
- console.error(chalk.red(`\n${theme.status.error} Failed to install openai-whisper`));
191
- console.error(chalk.dim("Try manually: pip install openai-whisper"));
275
+ const interactive = Boolean(process.stdout.isTTY);
276
+ for (const component of components) {
277
+ if (interactive && component.pick) {
278
+ await component.pick();
279
+ }
280
+ if (await component.isReady()) {
281
+ console.log(chalk.green(`${theme.status.success} ${component.name} ready`));
282
+ continue;
283
+ }
284
+ console.log(chalk.dim(`Preparing ${component.name}...`));
285
+ try {
286
+ await component.ensure(progress => {
287
+ const percent = typeof progress.percent === "number" ? ` (${progress.percent}%)` : "";
288
+ process.stdout.write(`\r${chalk.dim(`${progress.stage}${percent}`)}\x1b[K`);
289
+ });
290
+ process.stdout.write("\n");
291
+ } catch (err) {
292
+ process.stdout.write("\n");
293
+ const msg = err instanceof Error ? err.message : `Failed to set up ${component.name}`;
294
+ console.error(chalk.red(`${theme.status.error} ${msg}`));
192
295
  process.exit(1);
193
296
  }
194
297
  }
195
298
 
196
- const recheck = await checkDependencies();
197
- if (recheck.recorder.available && recheck.python.available && recheck.whisper.available) {
198
- console.log(chalk.green(`\n${theme.status.success} Speech-to-text is ready`));
199
- } else {
200
- console.error(chalk.red(`\n${theme.status.error} Setup incomplete`));
201
- console.log(formatDependencyStatus(recheck));
202
- process.exit(1);
203
- }
299
+ console.log(chalk.green(`\n${theme.status.success} Speech is ready`));
300
+ console.log(
301
+ chalk.dim(
302
+ "Enable speech-to-text via stt.enabled, then hold Space to talk (or bind app.stt.toggle); enable the speech-generation tool via speechgen.enabled; speak replies aloud via speech.enabled.",
303
+ ),
304
+ );
204
305
  }
205
306
 
206
307
  /**
@@ -215,7 +316,7 @@ ${chalk.bold("Usage:")}
215
316
 
216
317
  ${chalk.bold("Components:")}
217
318
  python Verify a Python 3 interpreter is reachable for code execution
218
- stt Install speech-to-text dependencies (openai-whisper, recording tools)
319
+ speech Pick + download the speech-to-text and text-to-speech models and an audio recorder
219
320
 
220
321
  ${chalk.bold("Options:")}
221
322
  -c, --check Check if dependencies are installed without installing
@@ -224,8 +325,8 @@ ${chalk.bold("Options:")}
224
325
  ${chalk.bold("Examples:")}
225
326
  ${APP_NAME} setup Run the onboarding wizard
226
327
  ${APP_NAME} setup python Check Python execution dependencies
227
- ${APP_NAME} setup stt Install speech-to-text dependencies
228
- ${APP_NAME} setup stt --check Check if STT dependencies are available
328
+ ${APP_NAME} setup speech Set up speech (pick STT + TTS models, install a recorder)
329
+ ${APP_NAME} setup speech --check Check if speech dependencies are available
229
330
  ${APP_NAME} setup python --check Check if Python execution is available
230
331
  `);
231
332
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Standalone TUI model picker used by `omp setup speech`.
3
+ *
4
+ * Mirrors {@link ./session-picker.ts} for the standalone-TUI lifecycle: spin up
5
+ * a one-shot {@link TUI} over a {@link SelectList}, resolve on select/cancel, and
6
+ * tear the UI down. The standalone TUI auto-renders on input, so no manual
7
+ * render wiring is needed beyond `addChild`/`setFocus`/`start`.
8
+ */
9
+ import { ProcessTerminal, type SelectItem, SelectList, TUI } from "@oh-my-pi/pi-tui";
10
+ import { getSelectListTheme } from "../modes/theme/theme";
11
+
12
+ /**
13
+ * Show a single-column model picker and resolve with the chosen item's value,
14
+ * or `null` if the user cancelled. `currentValue` pre-selects the matching row.
15
+ */
16
+ export async function selectSetupModel(
17
+ title: string,
18
+ items: SelectItem[],
19
+ currentValue: string,
20
+ ): Promise<string | null> {
21
+ const { promise, resolve } = Promise.withResolvers<string | null>();
22
+ const ui = new TUI(new ProcessTerminal());
23
+ let resolved = false;
24
+
25
+ const finish = (value: string | null): void => {
26
+ if (resolved) return;
27
+ resolved = true;
28
+ ui.stop();
29
+ resolve(value);
30
+ };
31
+
32
+ const list = new SelectList(items, Math.min(items.length, 10), getSelectListTheme());
33
+ const currentIndex = items.findIndex(item => item.value === currentValue);
34
+ if (currentIndex >= 0) list.setSelectedIndex(currentIndex);
35
+ list.onSelect = item => finish(item.value);
36
+ list.onCancel = () => finish(null);
37
+
38
+ process.stdout.write(`${title}\n`);
39
+ ui.addChild(list);
40
+ ui.setFocus(list);
41
+ ui.start();
42
+ return promise;
43
+ }