@linxiraos/pi-utils 1.0.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 (244) hide show
  1. package/CHANGELOG.md +449 -0
  2. package/README.md +36 -0
  3. package/dist/types/abortable.d.ts +32 -0
  4. package/dist/types/acp/connection.d.ts +118 -0
  5. package/dist/types/acp/protocol.d.ts +526 -0
  6. package/dist/types/acp/schema.d.ts +41 -0
  7. package/dist/types/acp/stream.d.ts +8 -0
  8. package/dist/types/acp/transport.d.ts +81 -0
  9. package/dist/types/acp.d.ts +6 -0
  10. package/dist/types/async.d.ts +20 -0
  11. package/dist/types/binary.d.ts +17 -0
  12. package/dist/types/browsers.d.ts +68 -0
  13. package/dist/types/chalk.d.ts +125 -0
  14. package/dist/types/cli.d.ts +134 -0
  15. package/dist/types/color.d.ts +102 -0
  16. package/dist/types/dates.d.ts +7 -0
  17. package/dist/types/dirs.d.ts +282 -0
  18. package/dist/types/docx/converter.d.ts +46 -0
  19. package/dist/types/docx/xml.d.ts +26 -0
  20. package/dist/types/docx/zip.d.ts +6 -0
  21. package/dist/types/docx.d.ts +11 -0
  22. package/dist/types/dom/core.d.ts +431 -0
  23. package/dist/types/dom/parser.d.ts +7 -0
  24. package/dist/types/dom/selector.d.ts +5 -0
  25. package/dist/types/dom.d.ts +5 -0
  26. package/dist/types/env.d.ts +124 -0
  27. package/dist/types/fetch-retry.d.ts +96 -0
  28. package/dist/types/file-lock.d.ts +23 -0
  29. package/dist/types/format.d.ts +37 -0
  30. package/dist/types/frontmatter.d.ts +46 -0
  31. package/dist/types/fs-error.d.ts +31 -0
  32. package/dist/types/glob.d.ts +28 -0
  33. package/dist/types/headers.d.ts +34 -0
  34. package/dist/types/index.d.ts +39 -0
  35. package/dist/types/json-parse.d.ts +74 -0
  36. package/dist/types/json.d.ts +14 -0
  37. package/dist/types/logger/rotating-file.d.ts +18 -0
  38. package/dist/types/logger.d.ts +96 -0
  39. package/dist/types/loop-phase.d.ts +10 -0
  40. package/dist/types/lru.d.ts +46 -0
  41. package/dist/types/marked/core.d.ts +445 -0
  42. package/dist/types/marked.d.ts +2 -0
  43. package/dist/types/mermaid-ascii.d.ts +11 -0
  44. package/dist/types/mime.d.ts +29 -0
  45. package/dist/types/module-timer.d.ts +1 -0
  46. package/dist/types/path-tree.d.ts +76 -0
  47. package/dist/types/path.d.ts +2 -0
  48. package/dist/types/peek-file.d.ts +29 -0
  49. package/dist/types/postmortem.d.ts +104 -0
  50. package/dist/types/process-name.d.ts +7 -0
  51. package/dist/types/procmgr.d.ts +74 -0
  52. package/dist/types/prompt.d.ts +18 -0
  53. package/dist/types/ptree.d.ts +110 -0
  54. package/dist/types/readability/readability.d.ts +9 -0
  55. package/dist/types/readability/readerable.d.ts +10 -0
  56. package/dist/types/readability/types.d.ts +70 -0
  57. package/dist/types/readability.d.ts +4 -0
  58. package/dist/types/ring.d.ts +93 -0
  59. package/dist/types/runtime-install.d.ts +78 -0
  60. package/dist/types/sanitize-text.d.ts +29 -0
  61. package/dist/types/snowflake.d.ts +25 -0
  62. package/dist/types/stderr-guard.d.ts +22 -0
  63. package/dist/types/stream.d.ts +67 -0
  64. package/dist/types/tab-spacing.d.ts +24 -0
  65. package/dist/types/temp.d.ts +17 -0
  66. package/dist/types/template.d.ts +62 -0
  67. package/dist/types/timing-buffer.d.ts +22 -0
  68. package/dist/types/tls-fetch.d.ts +37 -0
  69. package/dist/types/turndown/gfm.d.ts +11 -0
  70. package/dist/types/turndown/html.d.ts +5 -0
  71. package/dist/types/turndown/service.d.ts +21 -0
  72. package/dist/types/turndown/types.d.ts +70 -0
  73. package/dist/types/turndown.d.ts +4 -0
  74. package/dist/types/type-guards.d.ts +5 -0
  75. package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
  76. package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
  77. package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
  78. package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
  79. package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
  80. package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
  81. package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
  82. package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
  83. package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
  84. package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
  85. package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
  86. package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
  87. package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
  88. package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
  89. package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
  90. package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
  91. package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
  92. package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
  93. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
  94. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
  95. package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
  96. package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
  97. package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
  98. package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
  99. package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
  100. package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
  101. package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
  102. package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
  103. package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
  104. package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
  105. package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
  106. package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
  107. package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
  108. package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
  109. package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
  110. package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
  111. package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
  112. package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
  113. package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
  114. package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
  115. package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
  116. package/dist/types/version.d.ts +18 -0
  117. package/dist/types/vterm/buffer.d.ts +99 -0
  118. package/dist/types/vterm/terminal.d.ts +44 -0
  119. package/dist/types/vterm.d.ts +8 -0
  120. package/dist/types/which.d.ts +37 -0
  121. package/dist/types/worker-host.d.ts +51 -0
  122. package/dist/types/xml.d.ts +31 -0
  123. package/package.json +59 -0
  124. package/src/abortable.ts +109 -0
  125. package/src/acp/connection.ts +344 -0
  126. package/src/acp/protocol.ts +466 -0
  127. package/src/acp/schema.ts +160 -0
  128. package/src/acp/stream.ts +82 -0
  129. package/src/acp/transport.ts +213 -0
  130. package/src/acp.ts +6 -0
  131. package/src/async.ts +91 -0
  132. package/src/binary.ts +50 -0
  133. package/src/browsers.ts +501 -0
  134. package/src/chalk.ts +312 -0
  135. package/src/cli.ts +529 -0
  136. package/src/color.ts +302 -0
  137. package/src/dates.ts +194 -0
  138. package/src/dirs.ts +1026 -0
  139. package/src/docx/converter.ts +681 -0
  140. package/src/docx/xml.ts +166 -0
  141. package/src/docx/zip.ts +87 -0
  142. package/src/docx.ts +20 -0
  143. package/src/dom/core.ts +1254 -0
  144. package/src/dom/parser.ts +370 -0
  145. package/src/dom/selector.ts +290 -0
  146. package/src/dom.ts +33 -0
  147. package/src/env.ts +400 -0
  148. package/src/fetch-retry.ts +379 -0
  149. package/src/file-lock.ts +69 -0
  150. package/src/format.ts +113 -0
  151. package/src/frontmatter.ts +205 -0
  152. package/src/fs-error.ts +56 -0
  153. package/src/glob.ts +189 -0
  154. package/src/headers.ts +167 -0
  155. package/src/index.ts +59 -0
  156. package/src/json-parse.ts +821 -0
  157. package/src/json.ts +23 -0
  158. package/src/logger/rotating-file.ts +149 -0
  159. package/src/logger.ts +798 -0
  160. package/src/loop-phase.ts +49 -0
  161. package/src/lru.ts +185 -0
  162. package/src/marked/core.ts +1576 -0
  163. package/src/marked.ts +2 -0
  164. package/src/mermaid-ascii.ts +31 -0
  165. package/src/mime.ts +159 -0
  166. package/src/module-timer.ts +148 -0
  167. package/src/path-tree.ts +147 -0
  168. package/src/path.ts +28 -0
  169. package/src/peek-file.ts +188 -0
  170. package/src/postmortem.ts +433 -0
  171. package/src/process-name.ts +57 -0
  172. package/src/procmgr.ts +247 -0
  173. package/src/prompt.ts +545 -0
  174. package/src/ptree.ts +412 -0
  175. package/src/readability/readability.ts +533 -0
  176. package/src/readability/readerable.ts +51 -0
  177. package/src/readability/types.ts +72 -0
  178. package/src/readability.ts +11 -0
  179. package/src/ring.ts +169 -0
  180. package/src/runtime-install.ts +393 -0
  181. package/src/sanitize-text.ts +96 -0
  182. package/src/snowflake.ts +121 -0
  183. package/src/stderr-guard.ts +155 -0
  184. package/src/stream.ts +454 -0
  185. package/src/tab-spacing.ts +336 -0
  186. package/src/temp.ts +134 -0
  187. package/src/template.ts +586 -0
  188. package/src/timing-buffer.ts +47 -0
  189. package/src/tls-fetch.ts +178 -0
  190. package/src/turndown/gfm.ts +106 -0
  191. package/src/turndown/html.ts +257 -0
  192. package/src/turndown/service.ts +334 -0
  193. package/src/turndown/types.ts +81 -0
  194. package/src/turndown.ts +5 -0
  195. package/src/type-guards.ts +17 -0
  196. package/src/vendor/mermaid-ascii/NOTICE +33 -0
  197. package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
  198. package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
  199. package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
  200. package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
  201. package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
  202. package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
  203. package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
  204. package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
  205. package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
  206. package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
  207. package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
  208. package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +277 -0
  209. package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
  210. package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
  211. package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
  212. package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
  213. package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
  214. package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
  215. package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
  216. package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
  217. package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
  218. package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
  219. package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
  220. package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
  221. package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
  222. package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
  223. package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
  224. package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
  225. package/src/vendor/mermaid-ascii/class/types.ts +121 -0
  226. package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
  227. package/src/vendor/mermaid-ascii/er/types.ts +91 -0
  228. package/src/vendor/mermaid-ascii/index.ts +14 -0
  229. package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
  230. package/src/vendor/mermaid-ascii/parser.ts +645 -0
  231. package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
  232. package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
  233. package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
  234. package/src/vendor/mermaid-ascii/types.ts +164 -0
  235. package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
  236. package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
  237. package/src/vendor/mermaid-ascii/xychart/types.ts +150 -0
  238. package/src/version.ts +99 -0
  239. package/src/vterm/buffer.ts +218 -0
  240. package/src/vterm/terminal.ts +773 -0
  241. package/src/vterm.ts +8 -0
  242. package/src/which.ts +234 -0
  243. package/src/worker-host.ts +100 -0
  244. package/src/xml.ts +313 -0
package/src/dirs.ts ADDED
@@ -0,0 +1,1026 @@
1
+ /**
2
+ * Centralized path helpers for omp config directories.
3
+ *
4
+ * Uses PI_CONFIG_DIR (default ".omp") for the config root and
5
+ * PI_CODING_AGENT_DIR to override the agent directory.
6
+ *
7
+ * On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
8
+ * variables are set, paths are redirected to XDG-compliant locations under
9
+ * $XDG_*_HOME/omp/. This requires running `omp config migrate` first to
10
+ * move data to the new locations. No filesystem existence checks are performed
11
+ * — if the env var is set, omp trusts that the migration has been done.
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { engines, version } from "../package.json" with { type: "json" };
18
+
19
+ /** App name (e.g. "zeta") */
20
+ export const APP_NAME: string = "zeta";
21
+
22
+ /** Config directory name (e.g. ".zeta") */
23
+ export const CONFIG_DIR_NAME: string = ".zeta";
24
+
25
+ /** Ordered main settings filenames: canonical write target first, legacy-compatible YAML fallback second. */
26
+ export const MAIN_CONFIG_FILENAMES = ["config.yml", "config.yaml"] as const;
27
+
28
+ /** Version (e.g. "1.0.0") */
29
+ export const VERSION: string = version;
30
+
31
+ /** Minimum Bun version */
32
+ export const MIN_BUN_VERSION: string = engines.bun.replace(/[^0-9.]/g, "");
33
+
34
+ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
35
+ const PROFILE_ENV_KEYS = ["OMP_PROFILE", "PI_PROFILE"] as const;
36
+
37
+ /**
38
+ * Names Windows treats as reserved device aliases. Matches the basename
39
+ * itself as well as any `BASENAME.<anything>` form, because Windows reserves
40
+ * `CON.foo`/`PRN.txt`/etc. too — using them as a profile name would let
41
+ * `setProfile` accept the input only for directory creation to fail later
42
+ * with a confusing `ENOENT`/`EINVAL`. Case-insensitive: NTFS treats `CON`
43
+ * and `con` identically.
44
+ */
45
+ const WINDOWS_RESERVED_BASENAME_RE = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i;
46
+
47
+ /**
48
+ * Normalize and validate a profile name. Returns `undefined` for the implicit
49
+ * default (empty string, whitespace, or the explicit "default" sentinel) and
50
+ * throws for syntactically invalid or platform-reserved names.
51
+ *
52
+ * Exported so consumers of `@linxiraos/pi-utils/dirs` (CLI bootstrap, tests,
53
+ * downstream tools) can validate user input without re-deriving the rules.
54
+ */
55
+ export function normalizeProfileName(profile: string | undefined): string | undefined {
56
+ const normalized = profile?.trim();
57
+ if (!normalized || normalized === "default") return undefined;
58
+ if (
59
+ normalized === "." ||
60
+ normalized === ".." ||
61
+ normalized.endsWith(".") ||
62
+ !PROFILE_NAME_RE.test(normalized) ||
63
+ WINDOWS_RESERVED_BASENAME_RE.test(normalized)
64
+ ) {
65
+ throw new Error(
66
+ `Invalid OMP profile "${profile}". Profile names must match ${PROFILE_NAME_RE.source}, ` +
67
+ `cannot be "." or "..", cannot end with ".", and cannot be a Windows reserved device name ` +
68
+ `(CON, PRN, AUX, NUL, COM0-9, LPT0-9, or any of those with an extension).`,
69
+ );
70
+ }
71
+ return normalized;
72
+ }
73
+
74
+ /**
75
+ * Resolve the active profile from the two profile env vars. `OMP_PROFILE` is the
76
+ * canonical variable and takes precedence; `PI_PROFILE` is the legacy
77
+ * compatibility fallback, consulted only when `OMP_PROFILE` is undefined. An
78
+ * explicitly-empty `OMP_PROFILE` therefore selects the default profile rather
79
+ * than silently inheriting `PI_PROFILE`. Delegates validation/normalization to
80
+ * {@link normalizeProfileName} (which throws on a syntactically invalid value).
81
+ */
82
+ export function resolveProfileEnv(omp: string | undefined, pi: string | undefined): string | undefined {
83
+ return normalizeProfileName(omp !== undefined ? omp : pi);
84
+ }
85
+
86
+ function getProfileFromEnv(): string | undefined {
87
+ return resolveProfileEnv(process.env.OMP_PROFILE, process.env.PI_PROFILE);
88
+ }
89
+
90
+ /**
91
+ * Module-load profile resolution. Unlike {@link getProfileFromEnv}, an invalid
92
+ * OMP_PROFILE/PI_PROFILE value does NOT throw here — a bad env var must not
93
+ * crash a bare `import` of this module with an uncaught stack trace before the
94
+ * CLI's error handling is in scope. The default profile is used instead; the
95
+ * CLI re-validates the env (see `runCli` in coding-agent/src/cli.ts) so the
96
+ * user still gets a clean "Invalid OMP profile" message.
97
+ */
98
+ function readProfileFromEnvSafe(): string | undefined {
99
+ try {
100
+ return getProfileFromEnv();
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ function getBaseConfigRoot(): string {
107
+ return path.join(os.homedir(), getConfigDirName());
108
+ }
109
+
110
+ function getProfileConfigRoot(profile: string | undefined): string {
111
+ const root = getBaseConfigRoot();
112
+ return profile ? path.join(root, "profiles", profile) : root;
113
+ }
114
+
115
+ function readPiProfileFromEnvSafe(): string | undefined {
116
+ try {
117
+ return normalizeProfileName(process.env.PI_PROFILE);
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ function getProfileAgentDir(profile: string): string {
124
+ return path.join(getProfileConfigRoot(profile), "agent");
125
+ }
126
+
127
+ function isProfileDerivedAgentDir(profile: string | undefined, agentDirEnv: string | undefined): boolean {
128
+ return profile !== undefined && agentDirEnv === getProfileAgentDir(profile);
129
+ }
130
+ // =============================================================================
131
+ // Project directory
132
+ // =============================================================================
133
+
134
+ /**
135
+ * On macOS, strip /private prefix only when both paths resolve to the same location.
136
+ * This preserves aliases like /private/tmp -> /tmp without rewriting unrelated paths.
137
+ */
138
+ function standardizeMacOSPath(p: string): string {
139
+ if (process.platform !== "darwin" || !p.startsWith("/private/")) return p;
140
+ const stripped = p.slice("/private".length);
141
+ try {
142
+ if (fs.realpathSync(p) === fs.realpathSync(stripped)) {
143
+ return stripped;
144
+ }
145
+ } catch {}
146
+ return p;
147
+ }
148
+
149
+ export function resolveEquivalentPath(inputPath: string): string {
150
+ const resolvedPath = path.resolve(inputPath);
151
+ try {
152
+ return fs.realpathSync(resolvedPath);
153
+ } catch {
154
+ return resolvedPath;
155
+ }
156
+ }
157
+
158
+ export function normalizePathForComparison(inputPath: string): string {
159
+ const resolvedPath = resolveEquivalentPath(inputPath);
160
+ return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
161
+ }
162
+
163
+ export function pathIsWithin(root: string, candidate: string): boolean {
164
+ const normalizedRoot = normalizePathForComparison(root);
165
+ const normalizedCandidate = normalizePathForComparison(candidate);
166
+ const relative = path.relative(normalizedRoot, normalizedCandidate);
167
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
168
+ }
169
+
170
+ export function relativePathWithinRoot(root: string, candidate: string): string | null {
171
+ if (!pathIsWithin(root, candidate)) return null;
172
+ const normalizedRoot = normalizePathForComparison(root);
173
+ const normalizedCandidate = normalizePathForComparison(candidate);
174
+ const relative = path.relative(normalizedRoot, normalizedCandidate);
175
+ return relative || null;
176
+ }
177
+
178
+ let projectDir = standardizeMacOSPath(process.cwd());
179
+
180
+ /** Get the project directory. */
181
+ export function getProjectDir(): string {
182
+ return projectDir;
183
+ }
184
+
185
+ /** Set the project directory. */
186
+ export function setProjectDir(dir: string): void {
187
+ projectDir = standardizeMacOSPath(path.resolve(dir));
188
+ process.chdir(projectDir);
189
+ }
190
+
191
+ /**
192
+ * Whether `dir` resolves to an existing directory. Any stat failure — a deleted
193
+ * path (ENOENT), permission error, or a non-directory — returns `false`, so
194
+ * callers can decide whether a directory is safe to `chdir` into or adopt as a
195
+ * working directory before {@link setProjectDir} throws on it.
196
+ */
197
+ export async function directoryExists(dir: string): Promise<boolean> {
198
+ try {
199
+ return (await fs.promises.stat(dir)).isDirectory();
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ /** Get the config directory name relative to home (e.g. ".zeta" or PI_CONFIG_DIR override). Falls back to ".omp" for backward compatibility. */
206
+ export function getConfigDirName(): string {
207
+ if (process.env.PI_CONFIG_DIR) return process.env.PI_CONFIG_DIR;
208
+ // Prefer .zeta; fall back to .omp for existing installs
209
+ const home = os.homedir();
210
+ try {
211
+ if (fs.existsSync(path.join(home, ".zeta"))) return ".zeta";
212
+ } catch {}
213
+ try {
214
+ if (fs.existsSync(path.join(home, ".omp"))) return ".omp";
215
+ } catch {}
216
+ return ".zeta"; // default for new installs with no existing .omp dir
217
+ }
218
+
219
+ /** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
220
+ export function getConfigAgentDirName(): string {
221
+ const profile = getActiveProfile();
222
+ return profile ? path.join(getConfigDirName(), "profiles", profile, "agent") : `${getConfigDirName()}/agent`;
223
+ }
224
+
225
+ // =============================================================================
226
+ // DirResolver — cached, XDG-aware path resolution
227
+ // =============================================================================
228
+
229
+ type XdgCategory = "data" | "state" | "cache";
230
+
231
+ /**
232
+ * Resolves and caches all omp directory paths. On Linux, when XDG environment
233
+ * variables are set, paths are redirected under $XDG_*_HOME/omp/. A new
234
+ * instance is created whenever the agent directory changes, which naturally
235
+ * invalidates all cached paths.
236
+ */
237
+ class DirResolver {
238
+ readonly configRoot: string;
239
+ readonly agentDir: string;
240
+
241
+ // Per-category base dirs. Without XDG, all three equal configRoot / agentDir.
242
+ // With XDG on Linux, they point to $XDG_*_HOME/omp/.
243
+ readonly #rootDirs: Record<XdgCategory, string>;
244
+ readonly #agentDirs: Record<XdgCategory, string>;
245
+
246
+ readonly #rootCache = new Map<string, string>();
247
+ readonly #agentCache = new Map<string, string>();
248
+
249
+ constructor(options: { agentDirOverride?: string; profile?: string } = {}) {
250
+ const profile = normalizeProfileName(options.profile);
251
+ this.configRoot = getProfileConfigRoot(profile);
252
+
253
+ const defaultAgent = path.join(this.configRoot, "agent");
254
+ const agentDirOverride = profile ? undefined : options.agentDirOverride;
255
+ this.agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgent;
256
+ const isDefault = this.agentDir === defaultAgent;
257
+
258
+ // XDG is a Linux convention. On supported platforms, default profile state
259
+ // resolves under $XDG_*_HOME/omp once `omp config init-xdg` has migrated
260
+ // the user's data. Named profiles follow a stricter rule: the XDG choice
261
+ // is keyed on the profile-specific XDG path, never the base app root.
262
+ //
263
+ // Why: if we consulted the base app root for named profiles too, the same
264
+ // profile could resolve to `~/.omp/profiles/<name>` on first activation
265
+ // (when no $XDG_*_HOME/omp exists yet) and then silently move to
266
+ // `$XDG_*_HOME/omp/profiles/<name>` the moment the base appeared, orphaning
267
+ // the earlier state. Pinning on the profile path means a profile's location
268
+ // is decided at first activation and stays put until the user explicitly
269
+ // migrates it (e.g. by mkdir'ing the XDG profile dir).
270
+ let xdgData: string | undefined;
271
+ let xdgState: string | undefined;
272
+ let xdgCache: string | undefined;
273
+ if ((process.platform === "linux" || process.platform === "darwin") && isDefault) {
274
+ const resolveIf = (envVar: string) => {
275
+ const value = process.env[envVar];
276
+ if (!value) return undefined;
277
+ try {
278
+ const appRoot = path.join(value, APP_NAME);
279
+ if (profile) {
280
+ const profilePath = path.join(appRoot, "profiles", profile);
281
+ if (fs.existsSync(profilePath)) {
282
+ return profilePath;
283
+ }
284
+ return undefined;
285
+ }
286
+ if (fs.existsSync(appRoot)) {
287
+ return appRoot;
288
+ }
289
+ } catch {}
290
+ return undefined;
291
+ };
292
+ xdgData = resolveIf("XDG_DATA_HOME");
293
+ xdgState = resolveIf("XDG_STATE_HOME");
294
+ xdgCache = resolveIf("XDG_CACHE_HOME");
295
+ }
296
+
297
+ this.#rootDirs = {
298
+ data: xdgData ?? this.configRoot,
299
+ state: xdgState ?? this.configRoot,
300
+ cache: xdgCache ?? this.configRoot,
301
+ };
302
+ // XDG flattens the agent/ prefix: ~/.omp/agent/sessions → $XDG_DATA_HOME/omp/sessions
303
+ this.#agentDirs = {
304
+ data: xdgData ?? this.agentDir,
305
+ state: xdgState ?? this.agentDir,
306
+ cache: xdgCache ?? this.agentDir,
307
+ };
308
+ }
309
+
310
+ /** Config-root subdirectory, with optional XDG override. */
311
+ rootSubdir(subdir: string, xdg?: XdgCategory): string {
312
+ const cached = this.#rootCache.get(subdir);
313
+ if (cached) return cached;
314
+ const base = xdg ? this.#rootDirs[xdg] : this.configRoot;
315
+ const result = path.join(base, subdir);
316
+ this.#rootCache.set(subdir, result);
317
+ return result;
318
+ }
319
+
320
+ /** Agent subdirectory, with optional XDG override. */
321
+ agentSubdir(userAgentDir: string | undefined, subdir: string, xdg?: XdgCategory): string {
322
+ if (!userAgentDir || userAgentDir === this.agentDir) {
323
+ const cached = this.#agentCache.get(subdir);
324
+ if (cached) return cached;
325
+ const base = xdg ? this.#agentDirs[xdg] : this.agentDir;
326
+ const result = path.join(base, subdir);
327
+ this.#agentCache.set(subdir, result);
328
+ return result;
329
+ }
330
+ return path.join(userAgentDir, subdir);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Decide which `PI_CODING_AGENT_DIR` value to capture as the pre-profile
336
+ * baseline. A value equal to a profile's derived agent dir is profile-derived
337
+ * (propagated by a parent's `setProfile`), so it must NOT be snapshotted as the
338
+ * default-mode baseline — otherwise default mode would resolve to the profile's
339
+ * agent dir. The profile source can be the active profile or a lower-priority
340
+ * `PI_PROFILE` that was bypassed because `OMP_PROFILE` explicitly selected the
341
+ * default profile. Returns `undefined` in those cases so reset falls back to the
342
+ * standard `~/.omp/agent`.
343
+ */
344
+ function resolvePreProfileAgentDir(
345
+ profile: string | undefined,
346
+ agentDirEnv: string | undefined,
347
+ profileAgentDirSource: string | undefined = profile,
348
+ ): string | undefined {
349
+ return isProfileDerivedAgentDir(profile ?? profileAgentDirSource, agentDirEnv) ? undefined : agentDirEnv;
350
+ }
351
+
352
+ let activeProfile = readProfileFromEnvSafe();
353
+
354
+ /**
355
+ * Resolve the agent-dir override for the current `activeProfile` from the live
356
+ * environment. A named profile derives its own agent dir (no override); default
357
+ * mode honors a non-profile `PI_CODING_AGENT_DIR` (see
358
+ * {@link resolvePreProfileAgentDir}). Shared by the module-load resolver and
359
+ * {@link refreshDirsFromEnv} so both apply identical logic.
360
+ */
361
+ function resolveActiveAgentDirOverride(): string | undefined {
362
+ return activeProfile
363
+ ? undefined
364
+ : resolvePreProfileAgentDir(undefined, process.env.PI_CODING_AGENT_DIR, readPiProfileFromEnvSafe());
365
+ }
366
+
367
+ let dirs = new DirResolver({
368
+ agentDirOverride: resolveActiveAgentDirOverride(),
369
+ profile: activeProfile,
370
+ });
371
+ /**
372
+ * Snapshot of `PI_CODING_AGENT_DIR` from before the first named-profile
373
+ * activation. Reset paths restore this value (or its absence) instead of
374
+ * unconditionally deleting the env var. Without the snapshot, a process started
375
+ * with `PI_CODING_AGENT_DIR=/custom` then `setProfile("work")` then
376
+ * `setProfile(undefined)` would silently lose `/custom` and fall back to
377
+ * `~/.omp/agent`. Captured at module load — ignoring a profile-derived value
378
+ * inherited from a parent's `setProfile` (see {@link resolvePreProfileAgentDir})
379
+ * — and refreshed on `setAgentDir`, since that call is the user explicitly
380
+ * redefining the baseline.
381
+ */
382
+ let preProfileAgentDirEnv: string | undefined = resolvePreProfileAgentDir(
383
+ activeProfile,
384
+ process.env.PI_CODING_AGENT_DIR,
385
+ activeProfile ?? readPiProfileFromEnvSafe(),
386
+ );
387
+ // Anchor home for the resolver. Captured at module load to stay stable across
388
+ // test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
389
+ // production callers (`home === RESOLVER_HOME`) hit the XDG-aware resolver while
390
+ // tests passing a temp HOME short-circuit to a deterministic path.
391
+ const RESOLVER_HOME = os.homedir();
392
+
393
+ /**
394
+ * Rebuild the dirs resolver from the current environment, reusing the profile
395
+ * resolved at module load. Directory-affecting keys (XDG_*_HOME and, in default
396
+ * mode, `PI_CODING_AGENT_DIR`) loaded from a profile/agent `.env` only reach
397
+ * `process.env` *after* this module froze the resolver at import time, so
398
+ * `env.ts` calls this once after applying its `.env` files. The agent `.env`
399
+ * location derives from the profile name + home before this runs, so the
400
+ * rebuild re-reads only the directory vars, never the profile selection. The
401
+ * `preProfileAgentDirEnv` snapshot is intentionally left untouched.
402
+ */
403
+ export function refreshDirsFromEnv(): void {
404
+ dirs = new DirResolver({
405
+ agentDirOverride: resolveActiveAgentDirOverride(),
406
+ profile: activeProfile,
407
+ });
408
+ }
409
+
410
+ // =============================================================================
411
+ // Root directories
412
+ // =============================================================================
413
+
414
+ /** Get the config root directory (~/.omp). */
415
+ export function getConfigRootDir(): string {
416
+ return dirs.configRoot;
417
+ }
418
+
419
+ /** Set the coding agent directory. Creates a fresh resolver, invalidating all cached paths. */
420
+ export function setAgentDir(dir: string): void {
421
+ activeProfile = undefined;
422
+ dirs = new DirResolver({ agentDirOverride: dir });
423
+ process.env.PI_CODING_AGENT_DIR = dir;
424
+ preProfileAgentDirEnv = dir;
425
+ for (const key of PROFILE_ENV_KEYS) {
426
+ delete process.env[key];
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Test-only: reset the pre-profile `PI_CODING_AGENT_DIR` snapshot to whatever
432
+ * the current environment looks like. Cross-suite test pollution can otherwise
433
+ * leak a stale snapshot through `setAgentDir` and corrupt `setProfile(undefined)`
434
+ * restore semantics. Production code MUST NOT call this — the snapshot's
435
+ * lifecycle is owned by `setAgentDir` / `setProfile` and a runtime caller has
436
+ * no business clearing it.
437
+ */
438
+ export function __resetProfileSnapshotForTests(): void {
439
+ preProfileAgentDirEnv = resolvePreProfileAgentDir(
440
+ activeProfile,
441
+ process.env.PI_CODING_AGENT_DIR,
442
+ activeProfile ?? readPiProfileFromEnvSafe(),
443
+ );
444
+ }
445
+
446
+ /**
447
+ * Test-only: rebuild profile + directory state from the current process env.
448
+ * Production code keeps the module-load profile stable; tests that mutate
449
+ * `setAgentDir`/`setProfile` need an exact restore point after they put env vars
450
+ * back.
451
+ */
452
+ export function __resetDirsFromEnvForTests(): void {
453
+ activeProfile = readProfileFromEnvSafe();
454
+ __resetProfileSnapshotForTests();
455
+ refreshDirsFromEnv();
456
+ }
457
+
458
+ /** Activate a named profile. Passing undefined or "default" returns to the default profile. */
459
+ export function setProfile(profile: string | undefined): void {
460
+ const next = normalizeProfileName(profile);
461
+ if (next && !activeProfile) {
462
+ // First activation of a named profile in this process: snapshot the
463
+ // current PI_CODING_AGENT_DIR so a later reset can restore the user's
464
+ // explicit override. Subsequent profile switches keep the original
465
+ // snapshot — the "pre-profile" baseline is the state before profiles
466
+ // entered the picture, not the state between two activations.
467
+ preProfileAgentDirEnv = resolvePreProfileAgentDir(
468
+ undefined,
469
+ process.env.PI_CODING_AGENT_DIR,
470
+ readPiProfileFromEnvSafe(),
471
+ );
472
+ }
473
+ activeProfile = next;
474
+ if (activeProfile) {
475
+ dirs = new DirResolver({ profile: activeProfile });
476
+ process.env.OMP_PROFILE = activeProfile;
477
+ process.env.PI_PROFILE = activeProfile;
478
+ process.env.PI_CODING_AGENT_DIR = dirs.agentDir;
479
+ } else {
480
+ for (const key of PROFILE_ENV_KEYS) {
481
+ delete process.env[key];
482
+ }
483
+ if (preProfileAgentDirEnv === undefined) {
484
+ delete process.env.PI_CODING_AGENT_DIR;
485
+ } else {
486
+ process.env.PI_CODING_AGENT_DIR = preProfileAgentDirEnv;
487
+ }
488
+ dirs = new DirResolver({ agentDirOverride: preProfileAgentDirEnv });
489
+ }
490
+ }
491
+
492
+ /** Get the active named profile. Undefined means the default profile. */
493
+ export function getActiveProfile(): string | undefined {
494
+ return activeProfile;
495
+ }
496
+
497
+ /** Resolve the config root that backs a profile without activating it. */
498
+ export function getProfileRootDir(profile: string | undefined): string {
499
+ return getProfileConfigRoot(normalizeProfileName(profile));
500
+ }
501
+ /** Get the agent config directory (~/.omp/agent). */
502
+ export function getAgentDir(): string {
503
+ return dirs.agentDir;
504
+ }
505
+
506
+ /** Get the project-local config directory (.zeta). Falls back to .omp for backward compatibility. */
507
+ export function getProjectAgentDir(cwd: string = getProjectDir()): string {
508
+ const zetaDir = path.join(cwd, ".zeta");
509
+ try {
510
+ if (fs.existsSync(zetaDir)) return zetaDir;
511
+ } catch {}
512
+ const ompDir = path.join(cwd, ".omp");
513
+ try {
514
+ if (fs.existsSync(ompDir)) return ompDir;
515
+ } catch {}
516
+ return zetaDir; // default for new projects
517
+ }
518
+
519
+ // =============================================================================
520
+ // Config-root subdirectories (~/.omp/*)
521
+ // =============================================================================
522
+
523
+ /** Get the reports directory (~/.omp/reports). */
524
+ export function getReportsDir(): string {
525
+ return dirs.rootSubdir("reports", "state");
526
+ }
527
+
528
+ /** Get the logs directory (~/.omp/logs). */
529
+ export function getLogsDir(): string {
530
+ return dirs.rootSubdir("logs", "state");
531
+ }
532
+
533
+ /** Get this process's dated log path (~/.omp/logs/omp.YYYY-MM-DD.PID.log). */
534
+ export function getLogPath(date = new Date(), pid = process.pid): string {
535
+ return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.${pid}.log`);
536
+ }
537
+
538
+ /**
539
+ * Get the plugins directory (~/.omp/plugins or its XDG equivalent).
540
+ *
541
+ * No-arg form (production callers) goes through the XDG-aware DirResolver so
542
+ * reads and writes always agree. The optional `home` parameter is for test
543
+ * isolation: when it differs from `os.homedir()` it short-circuits the resolver
544
+ * and returns `<home>/<configDir>/plugins` so tests with a temp HOME get a
545
+ * deterministic path. Passing `os.homedir()` explicitly is identical to the
546
+ * no-arg form — XDG semantics are preserved.
547
+ */
548
+ export function getPluginsDir(home?: string): string {
549
+ if (home !== undefined && home !== RESOLVER_HOME) {
550
+ return path.join(home, CONFIG_DIR_NAME, "plugins");
551
+ }
552
+ return dirs.rootSubdir("plugins", "data");
553
+ }
554
+
555
+ /** Where npm installs packages (~/.omp/plugins/node_modules). */
556
+ export function getPluginsNodeModules(home?: string): string {
557
+ return path.join(getPluginsDir(home), "node_modules");
558
+ }
559
+
560
+ /** Plugin manifest (~/.omp/plugins/package.json). */
561
+ export function getPluginsPackageJson(home?: string): string {
562
+ return path.join(getPluginsDir(home), "package.json");
563
+ }
564
+
565
+ /** Plugin lock file (~/.omp/plugins/omp-plugins.lock.json). */
566
+ export function getPluginsLockfile(home?: string): string {
567
+ return path.join(getPluginsDir(home), "omp-plugins.lock.json");
568
+ }
569
+
570
+ /** Get the remote mount directory (~/.omp/remote). */
571
+ export function getRemoteDir(): string {
572
+ return dirs.rootSubdir("remote", "data");
573
+ }
574
+
575
+ /**
576
+ * Expand a leading `~` and require an absolute result. Returns `undefined` for
577
+ * empty/whitespace input or a path that is still relative after expansion.
578
+ *
579
+ * A worktree base is process-global and consumed by both creation
580
+ * (PR checkout, task isolation) and cleanup (`omp worktree`). A relative value
581
+ * would resolve against whatever cwd happened to launch `omp`, so checkout and
582
+ * cleanup could disagree — we refuse it rather than silently bind it to cwd.
583
+ */
584
+ function resolveWorktreeBase(value: string | undefined): string | undefined {
585
+ const trimmed = value?.trim();
586
+ if (!trimmed) return undefined;
587
+ let p = trimmed;
588
+ if (p === "~") p = os.homedir();
589
+ else if (p.startsWith("~/") || p.startsWith("~\\")) p = os.homedir() + p.slice(1);
590
+ return path.isAbsolute(p) ? path.normalize(p) : undefined;
591
+ }
592
+
593
+ let worktreesDirOverride: string | undefined;
594
+
595
+ /**
596
+ * Relocate the base directory for agent-managed worktrees (PR checkouts, task
597
+ * isolation, and `omp worktree` cleanup all read the same base). Driven by the
598
+ * `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
599
+ * fall back to `OMP_WORKTREE_DIR` or the `~/.omp/wt` default.
600
+ *
601
+ * `~` is expanded and a relative path is rejected (see {@link resolveWorktreeBase}).
602
+ * Returns the absolute path that took effect, or `undefined` if the input was
603
+ * cleared or rejected — callers can warn on a non-empty input that returns
604
+ * `undefined`.
605
+ */
606
+ export function setWorktreesDir(dir: string | undefined): string | undefined {
607
+ worktreesDirOverride = resolveWorktreeBase(dir);
608
+ return worktreesDirOverride;
609
+ }
610
+
611
+ /**
612
+ * Get the agent-managed worktrees directory. Resolution order: the
613
+ * `OMP_WORKTREE_DIR` env var, then the {@link setWorktreesDir} override (the
614
+ * `worktree.base` setting), then the `~/.omp/wt` default. The env var and the
615
+ * override are both `~`-expanded and must be absolute; a relative value is
616
+ * ignored and resolution falls through.
617
+ */
618
+ export function getWorktreesDir(): string {
619
+ return resolveWorktreeBase(process.env.OMP_WORKTREE_DIR) ?? worktreesDirOverride ?? dirs.rootSubdir("wt", "data");
620
+ }
621
+
622
+ /** Get the SSH control socket directory (~/.omp/ssh-control). */
623
+ export function getSshControlDir(): string {
624
+ return dirs.rootSubdir("ssh-control", "state");
625
+ }
626
+
627
+ /** Get the remote host info directory (~/.omp/remote-host). */
628
+ export function getRemoteHostDir(): string {
629
+ return dirs.rootSubdir("remote-host", "data");
630
+ }
631
+
632
+ /** Get the managed Python venv directory (~/.omp/python-env). */
633
+ export function getPythonEnvDir(): string {
634
+ return dirs.rootSubdir("python-env", "data");
635
+ }
636
+
637
+ /** Get the shared Python gateway state directory (~/.omp/agent/python-gateway; XDG default: $XDG_STATE_HOME/omp/python-gateway). */
638
+ export function getPythonGatewayDir(): string {
639
+ return dirs.agentSubdir(undefined, "python-gateway", "state");
640
+ }
641
+
642
+ /** Get the puppeteer sandbox directory (~/.omp/puppeteer). */
643
+ export function getPuppeteerDir(): string {
644
+ return dirs.rootSubdir("puppeteer", "cache");
645
+ }
646
+
647
+ /** Get the browser relay extension install directory (~/.omp/browser-relay). */
648
+ export function getBrowserRelayDir(): string {
649
+ return dirs.rootSubdir("browser-relay", "data");
650
+ }
651
+
652
+ /** Get DOCS_RS cache directory () */
653
+ export function getDocsRsCacheDir(): string {
654
+ return dirs.rootSubdir("webcache", "cache");
655
+ }
656
+
657
+ /** Get the auto-QA grievances SQLite database path (~/.omp/autoqa.db; XDG: $XDG_DATA_HOME/omp/autoqa.db). */
658
+ export function getAutoQaDbPath(): string {
659
+ return dirs.rootSubdir("autoqa.db", "data");
660
+ }
661
+ /**
662
+ * Stable 7-character hex digest of an absolute filesystem path.
663
+ *
664
+ * Used to pack the project identity into a single short fs-safe segment
665
+ * (e.g. PR-checkout and task-isolation worktree dirs under `~/.omp/wt/`).
666
+ * Bun.hash is non-cryptographic — collision space is ~2^28, which is fine
667
+ * for naming a handful of repos on a single machine. Same input on the
668
+ * same Bun runtime yields the same output.
669
+ */
670
+ export function hashPath(absPath: string): string {
671
+ return Bun.hash(path.resolve(absPath)).toString(16).padStart(16, "0").slice(-7);
672
+ }
673
+
674
+ /** Get the path to a single worktree directory (~/.omp/wt/<segment>). */
675
+ export function getWorktreeDir(segment: string): string {
676
+ return path.join(getWorktreesDir(), segment);
677
+ }
678
+
679
+ /** Get the GPU cache path (~/.omp/gpu_cache.json). */
680
+ export function getGpuCachePath(): string {
681
+ return dirs.rootSubdir("gpu_cache.json", "cache");
682
+ }
683
+
684
+ /**
685
+ * Get the GitHub view cache database path (~/.omp/cache/github-cache.db).
686
+ * Honors the `OMP_GITHUB_CACHE_DB` env var when set so tests can isolate the
687
+ * cache file without touching the rest of the config root.
688
+ */
689
+ export function getGithubCacheDbPath(): string {
690
+ const override = process.env.OMP_GITHUB_CACHE_DB;
691
+ if (override) return override;
692
+ return dirs.rootSubdir(path.join("cache", "github-cache.db"), "cache");
693
+ }
694
+
695
+ /**
696
+ * Get the encrypted auth-broker snapshot cache path (~/.omp/cache/auth-broker-snapshot.enc).
697
+ * Honors the `OMP_AUTH_BROKER_SNAPSHOT_CACHE` env var when set so tests and
698
+ * operators can isolate or relocate the cache file.
699
+ */
700
+ export function getAuthBrokerSnapshotCachePath(): string {
701
+ const override = process.env.OMP_AUTH_BROKER_SNAPSHOT_CACHE;
702
+ if (override) return override;
703
+ return dirs.rootSubdir(path.join("cache", "auth-broker-snapshot.enc"), "cache");
704
+ }
705
+
706
+ /** Get the local FastEmbed model cache directory (~/.omp/cache/fastembed). */
707
+ export function getFastembedCacheDir(): string {
708
+ return dirs.rootSubdir(path.join("cache", "fastembed"), "cache");
709
+ }
710
+
711
+ /** Get the on-demand fastembed runtime install root (~/.omp/cache/fastembed-runtime). */
712
+ export function getFastembedRuntimeDir(): string {
713
+ return dirs.rootSubdir(path.join("cache", "fastembed-runtime"), "cache");
714
+ }
715
+
716
+ /** Get the natives directory (~/.omp/natives). */
717
+ export function getNativesDir(): string {
718
+ return dirs.rootSubdir("natives", "cache");
719
+ }
720
+
721
+ /** Get the stats database path (~/.omp/stats.db). */
722
+ export function getStatsDbPath(): string {
723
+ return dirs.rootSubdir("stats.db", "data");
724
+ }
725
+
726
+ /** Get the autoresearch state directory (~/.omp/autoresearch). */
727
+ export function getAutoresearchDir(): string {
728
+ return dirs.rootSubdir("autoresearch", "state");
729
+ }
730
+
731
+ /** Get the per-project autoresearch state directory (~/.omp/autoresearch/<encoded-project>). */
732
+ export function getAutoresearchProjectDir(encodedProject: string): string {
733
+ return path.join(getAutoresearchDir(), encodedProject);
734
+ }
735
+
736
+ /** Get the per-project autoresearch SQLite database path (~/.omp/autoresearch/<encoded-project>.db). */
737
+ export function getAutoresearchDbPath(encodedProject: string): string {
738
+ return path.join(getAutoresearchDir(), `${encodedProject}.db`);
739
+ }
740
+
741
+ /** Get the per-run artifact directory (~/.omp/autoresearch/<encoded-project>/runs/<runId>). */
742
+ export function getAutoresearchRunDir(encodedProject: string, runId: number): string {
743
+ return path.join(getAutoresearchProjectDir(encodedProject), "runs", String(runId).padStart(4, "0"));
744
+ }
745
+
746
+ /** Get the security-analysis state directory (~/.omp/security). */
747
+ export function getSecurityDir(): string {
748
+ return dirs.rootSubdir("security", "state");
749
+ }
750
+
751
+ /** Get one project's security-analysis state directory (~/.omp/security/<project-key>). */
752
+ export function getSecurityProjectDir(projectKey: string): string {
753
+ return path.join(getSecurityDir(), projectKey);
754
+ }
755
+
756
+ // =============================================================================
757
+ // Agent subdirectories (~/.omp/agent/*)
758
+ // =============================================================================
759
+
760
+ /** Get the path to agent.db (SQLite database for settings and auth storage). */
761
+ export function getAgentDbPath(agentDir?: string): string {
762
+ return dirs.agentSubdir(agentDir, "agent.db", "data");
763
+ }
764
+
765
+ /** Get the last-seen-changelog-version marker file (~/.omp/agent/last-changelog-version). */
766
+ export function getLastChangelogVersionPath(agentDir?: string): string {
767
+ return dirs.agentSubdir(agentDir, "last-changelog-version", "state");
768
+ }
769
+
770
+ /** Get the path to history.db (SQLite database for session history). */
771
+ export function getHistoryDbPath(agentDir?: string): string {
772
+ return dirs.agentSubdir(agentDir, "history.db", "data");
773
+ }
774
+
775
+ /** Get the path to models.db (model cache database). */
776
+ export function getModelDbPath(agentDir?: string): string {
777
+ return dirs.agentSubdir(agentDir, "models.db", "data");
778
+ }
779
+
780
+ /** Get the tiny title model cache directory (~/.omp/agent/cache/tiny-models). */
781
+ export function getTinyModelsCacheDir(agentDir?: string): string {
782
+ return dirs.agentSubdir(agentDir, path.join("cache", "tiny-models"), "cache");
783
+ }
784
+
785
+ /** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
786
+ export function getDocumentConversionCacheDir(agentDir?: string): string {
787
+ return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
788
+ }
789
+
790
+ /** Get the sessions directory (~/.omp/agent/sessions). */
791
+ export function getSessionsDir(agentDir?: string): string {
792
+ return dirs.agentSubdir(agentDir, "sessions", "data");
793
+ }
794
+
795
+ /** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
796
+ export function getBlobsDir(agentDir?: string): string {
797
+ return dirs.agentSubdir(agentDir, "blobs", "data");
798
+ }
799
+
800
+ /** Get the custom themes directory (~/.omp/agent/themes). */
801
+ export function getCustomThemesDir(agentDir?: string): string {
802
+ return dirs.agentSubdir(agentDir, "themes");
803
+ }
804
+
805
+ /** Get the tools directory (~/.omp/agent/tools). */
806
+ export function getToolsDir(agentDir?: string): string {
807
+ return dirs.agentSubdir(agentDir, "tools");
808
+ }
809
+
810
+ /** Get the slash commands directory (~/.omp/agent/commands). */
811
+ export function getCommandsDir(agentDir?: string): string {
812
+ return dirs.agentSubdir(agentDir, "commands");
813
+ }
814
+
815
+ /** Get the prompts directory (~/.omp/agent/prompts). */
816
+ export function getPromptsDir(agentDir?: string): string {
817
+ return dirs.agentSubdir(agentDir, "prompts");
818
+ }
819
+
820
+ /** Get the user-level Python modules directory (~/.omp/agent/modules). */
821
+ export function getAgentModulesDir(agentDir?: string): string {
822
+ return dirs.agentSubdir(agentDir, "modules");
823
+ }
824
+
825
+ /** Get the memories directory (~/.omp/agent/memories). */
826
+ export function getMemoriesDir(agentDir?: string): string {
827
+ return dirs.agentSubdir(agentDir, "memories", "state");
828
+ }
829
+
830
+ /** Get the terminal sessions directory (~/.omp/agent/terminal-sessions). */
831
+ export function getTerminalSessionsDir(agentDir?: string): string {
832
+ return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
833
+ }
834
+
835
+ /** Get the crash log path (~/.omp/agent/omp-crash.log). */
836
+ export function getCrashLogPath(agentDir?: string): string {
837
+ return dirs.agentSubdir(agentDir, "omp-crash.log", "state");
838
+ }
839
+
840
+ /** Get the debug log path (~/.omp/agent/omp-debug.log). */
841
+ export function getDebugLogPath(agentDir?: string): string {
842
+ return dirs.agentSubdir(agentDir, `${APP_NAME}-debug.log`, "state");
843
+ }
844
+
845
+ /**
846
+ * Best-effort one-time copy of a legacy config-root file to its redirected XDG
847
+ * location. Existing installs that enable XDG after the file was created keep
848
+ * their data (e.g. a placeholder key whose loss would break deobfuscation of
849
+ * persisted transcripts). The legacy file is left in place for older omp
850
+ * versions sharing the profile.
851
+ */
852
+ function adoptLegacyFile(legacyPath: string, targetPath: string): void {
853
+ if (targetPath === legacyPath) return;
854
+ try {
855
+ if (fs.existsSync(targetPath) || !fs.existsSync(legacyPath)) return;
856
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
857
+ fs.copyFileSync(legacyPath, targetPath, fs.constants.COPYFILE_EXCL);
858
+ } catch {
859
+ // Opportunistic: a copy race or unwritable XDG dir falls back to a fresh
860
+ // file at the new path — the pre-adoption behavior.
861
+ }
862
+ }
863
+
864
+ /** Get the secret placeholder key path (~/.omp/agent/secret-placeholder.key; XDG default: $XDG_STATE_HOME/omp/secret-placeholder.key). Adopts a legacy key on first XDG resolution. */
865
+ export function getSecretPlaceholderKeyPath(): string {
866
+ const keyPath = dirs.agentSubdir(undefined, "secret-placeholder.key", "state");
867
+ adoptLegacyFile(path.join(dirs.agentDir, "secret-placeholder.key"), keyPath);
868
+ return keyPath;
869
+ }
870
+
871
+ /** Get the daemon runtime directory for a project (~/.omp/run/daemons/<hash>; XDG default: $XDG_STATE_HOME/omp/run/daemons/<hash>). */
872
+ export function getDaemonRuntimeDir(projectDir: string): string {
873
+ const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
874
+ return dirs.rootSubdir(path.join("run", "daemons", key), "state");
875
+ }
876
+
877
+ /** Get a profile-independent runtime directory for a machine-global daemon service. */
878
+ export function getGlobalDaemonRuntimeDir(service: string): string {
879
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(service)) {
880
+ throw new Error(`Invalid global daemon service name: ${JSON.stringify(service)}`);
881
+ }
882
+ return path.join(getBaseConfigRoot(), "run", "daemons", "global", service);
883
+ }
884
+
885
+ /** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
886
+ export function getProviderInFlightRoot(): string {
887
+ return dirs.rootSubdir(path.join("run", "provider-inflight"), "state");
888
+ }
889
+
890
+ /** Get the marketplaces registry path (~/.omp/marketplaces.json; XDG default: $XDG_DATA_HOME/omp/marketplaces.json). Adopts a legacy registry on first XDG resolution. */
891
+ export function getMarketplacesRegistryPath(): string {
892
+ const registryPath = dirs.rootSubdir("marketplaces.json", "data");
893
+ adoptLegacyFile(path.join(dirs.configRoot, "marketplaces.json"), registryPath);
894
+ return registryPath;
895
+ }
896
+
897
+ // =============================================================================
898
+ // Project subdirectories (.omp/*)
899
+ // =============================================================================
900
+
901
+ /** Get the project-level Python modules directory (.omp/modules). */
902
+ export function getProjectModulesDir(cwd: string = getProjectDir()): string {
903
+ return path.join(getProjectAgentDir(cwd), "modules");
904
+ }
905
+
906
+ /** Get the project-level prompts directory (.omp/prompts). */
907
+ export function getProjectPromptsDir(cwd: string = getProjectDir()): string {
908
+ return path.join(getProjectAgentDir(cwd), "prompts");
909
+ }
910
+
911
+ /** Get the project-level plugin overrides path (.omp/plugin-overrides.json). */
912
+ export function getProjectPluginOverridesPath(cwd: string = getProjectDir()): string {
913
+ return path.join(getProjectAgentDir(cwd), "plugin-overrides.json");
914
+ }
915
+
916
+ // =============================================================================
917
+ // MCP config paths
918
+ // =============================================================================
919
+
920
+ /** Get the primary MCP config file path (first candidate). */
921
+ export function getMCPConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string {
922
+ if (scope === "user") {
923
+ return path.join(getAgentDir(), "mcp.json");
924
+ }
925
+ return path.join(getProjectAgentDir(cwd), "mcp.json");
926
+ }
927
+
928
+ /** Get the SSH config file path. */
929
+ export function getSSHConfigPath(scope: "user" | "project", cwd: string = getProjectDir()): string {
930
+ if (scope === "user") {
931
+ return path.join(getAgentDir(), "ssh.json");
932
+ }
933
+ return path.join(getProjectAgentDir(cwd), "ssh.json");
934
+ }
935
+
936
+ // =============================================================================
937
+ // Project tracking
938
+ // =============================================================================
939
+
940
+ /** Get the project-level tracking directory (<project>/.zeta/tracking). */
941
+ export function getProjectTrackingDir(cwd: string = getProjectDir()): string {
942
+ return path.join(getProjectAgentDir(cwd), "tracking");
943
+ }
944
+
945
+ /** Get the global tracking index path (~/.zeta/agent/tracking-index.json). */
946
+ export function getTrackingIndexPath(agentDir?: string): string {
947
+ return path.join(agentDir ?? getAgentDir(), "tracking-index.json");
948
+ }
949
+
950
+ // =============================================================================
951
+ // Install identity
952
+ // =============================================================================
953
+
954
+ let cachedInstallId: string | null = null;
955
+
956
+ const INSTALL_ID_FILE = "install-id";
957
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
958
+
959
+ /**
960
+ * Persistent per-install UUID stored at `~/.omp/install-id`.
961
+ *
962
+ * Generated lazily on first call and persisted with `O_CREAT|O_EXCL` so
963
+ * concurrent first-call races don't clobber each other (loser re-reads the
964
+ * winner's id). Survives independently of agent state: deleting
965
+ * `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
966
+ * pushes (and similar telemetry) keys on this id.
967
+ *
968
+ * Anchored to the base config root (`~/.omp/install-id`) regardless of the
969
+ * active profile: install identity is per-install, not per-profile, so every
970
+ * profile shares one id and the global cache stays correct no matter the
971
+ * profile / `getInstallId` call order.
972
+ */
973
+ export function getInstallId(): string {
974
+ if (cachedInstallId) return cachedInstallId;
975
+ const filePath = path.join(getBaseConfigRoot(), INSTALL_ID_FILE);
976
+
977
+ let observedInvalid = false;
978
+ try {
979
+ const existing = fs.readFileSync(filePath, "utf8").trim();
980
+ if (UUID_RE.test(existing)) {
981
+ cachedInstallId = existing;
982
+ return existing;
983
+ }
984
+ // File present but unparseable — fall through and overwrite below.
985
+ observedInvalid = existing.length > 0;
986
+ } catch {}
987
+
988
+ const next = crypto.randomUUID();
989
+ try {
990
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
991
+ // If we already saw garbage in the file, unlink first so O_EXCL doesn't
992
+ // trip on it. Ignored if the unlink races against another writer.
993
+ if (observedInvalid) {
994
+ try {
995
+ fs.unlinkSync(filePath);
996
+ } catch {}
997
+ }
998
+ const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
999
+ try {
1000
+ fs.writeSync(fd, `${next}\n`);
1001
+ } finally {
1002
+ fs.closeSync(fd);
1003
+ }
1004
+ } catch (err) {
1005
+ // Lost the create race — re-read whatever the winner wrote.
1006
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
1007
+ try {
1008
+ const existing = fs.readFileSync(filePath, "utf8").trim();
1009
+ if (UUID_RE.test(existing)) {
1010
+ cachedInstallId = existing;
1011
+ return existing;
1012
+ }
1013
+ } catch {}
1014
+ }
1015
+ // Any other failure: keep the generated id in-memory so the rest of
1016
+ // this process has a stable value; future processes will retry.
1017
+ }
1018
+
1019
+ cachedInstallId = next;
1020
+ return next;
1021
+ }
1022
+
1023
+ /** Test-only: clear cached install id. Never call from production code. */
1024
+ export function __resetInstallIdCacheForTests(): void {
1025
+ cachedInstallId = null;
1026
+ }