@raidiant/notifai 10.1.0 → 10.1.2

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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
package/src/config.ts DELETED
@@ -1,475 +0,0 @@
1
- import {
2
- INTERRUPTION_LEVELS,
3
- REPLY_MAX_WINDOW_SECONDS,
4
- } from '@raidiant/notifai-protocol'
5
- import { createHash } from 'node:crypto'
6
- import { existsSync, readFileSync, realpathSync } from 'node:fs'
7
- import path from 'node:path'
8
- import { parse as parseToml } from 'smol-toml'
9
- import { configHome, stateHome } from './platform.js'
10
- import { isCliSoundRef } from './sound-ref.js'
11
- import { normalizeOrigin } from './url-policy.js'
12
-
13
- /**
14
- * Layered configuration with provenance. Most specific wins:
15
- *
16
- * flag > session > project-local > project > machine-global > default
17
- *
18
- * `session` holds what the user told the agent in conversation ("stop
19
- * notifying me for now"). It lives on disk keyed by harness session id rather
20
- * than in the agent's context, because context is lost to compaction and a
21
- * preference that evaporates mid-session is worse than none.
22
- *
23
- * `project-local` is a personal file under the user's config directory, keyed
24
- * by the Project root so a checkout does not have to gitignore anything. Shared
25
- * `.notifai/config.toml` stays in the tree for committed Project identity.
26
- *
27
- * `config show --explain` surfaces the winning layer per key.
28
- */
29
-
30
- export type ConfigSource =
31
- | 'flag'
32
- | 'env'
33
- | `session:${string}`
34
- | `project-local:${string}`
35
- | `project:${string}`
36
- | `global:${string}`
37
- | 'default'
38
-
39
- export interface ResolvedValue<T> {
40
- value: T
41
- source: ConfigSource
42
- }
43
-
44
- type CliInterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]
45
-
46
- /**
47
- * How much the local log records. Defined here rather than in `logging.ts`
48
- * because it is a configuration vocabulary first — `logging.ts` reads config,
49
- * so the dependency has to point this way.
50
- */
51
- export const LOG_LEVELS = ['off', 'error', 'info', 'debug'] as const
52
- export type LogLevel = (typeof LOG_LEVELS)[number]
53
-
54
- export interface CliConfig {
55
- base_url: ResolvedValue<string>
56
- wait_seconds: ResolvedValue<number>
57
- ttl_seconds: ResolvedValue<number>
58
- collapse_key: ResolvedValue<string | null>
59
- devices: ResolvedValue<string[] | null>
60
- sound: ResolvedValue<string | null>
61
- interruption_level: ResolvedValue<CliInterruptionLevel | null>
62
- /** Project identifier stamped on sends; typically set in .notifai/config.toml. */
63
- project: ResolvedValue<string | null>
64
- /** Whether a registered agent question may reach companion devices at all. */
65
- ask_notifications: ResolvedValue<boolean>
66
- /**
67
- * Terminal-first grace window, measured from the moment the question was
68
- * sent: the agent asks in the terminal and starts a timer, and only once it
69
- * elapses does the question reach companion devices.
70
- *
71
- * A timer, and only a timer. Whether the user happens to be at this keyboard
72
- * decides nothing; the harness-specific owner continues waiting either in
73
- * the held turn or out of band.
74
- */
75
- ask_grace_seconds: ResolvedValue<number>
76
- /**
77
- * How much of what this CLI does is recorded to the local log.
78
- *
79
- * The log exists for agents: a hook runs headless and its most consequential
80
- * act is usually a decision to do nothing, which leaves no other trace. `off`
81
- * disables the sink entirely; `debug` adds config resolution and request
82
- * detail, which is verbose enough to want a reason.
83
- */
84
- log_level: ResolvedValue<LogLevel>
85
- /**
86
- * Bytes the active log file may reach before it is rotated. With
87
- * `log_max_files` this is the whole disk budget — nothing here grows without
88
- * a ceiling, because a log that fills a disk is a bug that outlives its
89
- * usefulness.
90
- */
91
- log_max_bytes: ResolvedValue<number>
92
- /** How long the server accepts an answer and Question Routing owns its return path. */
93
- reply_window_seconds: ResolvedValue<number>
94
- /** Log files kept in total, the active one included. */
95
- log_max_files: ResolvedValue<number>
96
- /**
97
- * Extra origins remote `--image` URLs may use beyond the default public
98
- * HTTPS policy — the self-host and intranet escape hatch. User-owned: the
99
- * repository layer cannot supply it (see `USER_OWNED_CONFIG_KEYS`).
100
- */
101
- media_origins: ResolvedValue<string[]>
102
- /**
103
- * Extra dashboard origins a pairing approval URL may open, for self-hosts
104
- * whose dashboard lives on a different origin than the API. User-owned.
105
- */
106
- approve_origins: ResolvedValue<string[]>
107
- }
108
-
109
- export const CONFIG_KEYS = [
110
- 'wait_seconds',
111
- 'ttl_seconds',
112
- 'collapse_key',
113
- 'devices',
114
- 'sound',
115
- 'interruption_level',
116
- 'project',
117
- 'ask_notifications',
118
- 'ask_grace_seconds',
119
- 'reply_window_seconds',
120
- 'log_level',
121
- 'log_max_bytes',
122
- 'log_max_files',
123
- 'media_origins',
124
- 'approve_origins',
125
- ] as const
126
- export type ConfigKey = (typeof CONFIG_KEYS)[number]
127
-
128
- /**
129
- * Keys only the User may set. The shared `.notifai/config.toml` arrives with
130
- * a clone, so for most keys an unwanted value is at worst a clamped nuisance —
131
- * but these keys widen which network destinations this machine will touch,
132
- * and a hostile repository must not be able to widen them. Resolution skips
133
- * the repository layer for them entirely.
134
- */
135
- export const USER_OWNED_CONFIG_KEYS: readonly ConfigKey[] = ['media_origins', 'approve_origins']
136
-
137
- /** Keys whose TOML value must be a list of normalized http(s) origins. */
138
- export const ORIGIN_LIST_CONFIG_KEYS: readonly ConfigKey[] = ['media_origins', 'approve_origins']
139
-
140
- /** Keys whose TOML value must parse as a boolean; used by `config set`. */
141
- export const BOOLEAN_CONFIG_KEYS: readonly ConfigKey[] = ['ask_notifications']
142
-
143
- /** Keys whose TOML value must parse as an integer within its configured bounds. */
144
- export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
145
- 'wait_seconds',
146
- 'ttl_seconds',
147
- 'ask_grace_seconds',
148
- 'reply_window_seconds',
149
- 'log_max_bytes',
150
- 'log_max_files',
151
- ]
152
-
153
- /**
154
- * Keys whose value must be one of a fixed set.
155
- *
156
- * Config is readable from a repository, so an unrecognised value is untrusted
157
- * input rather than a typo — `coerce` drops it and the layer below wins, which
158
- * is the same discipline the numeric bounds follow.
159
- */
160
- export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
161
- interruption_level: INTERRUPTION_LEVELS,
162
- log_level: LOG_LEVELS,
163
- }
164
-
165
- /** Compiled service origin. Not a user-facing setting; login may override with `--base-url` or `NOTIFAI_BASE_URL`. */
166
- export const DEFAULT_BASE_URL = 'https://api.notifai.sh'
167
-
168
- const DEFAULTS = {
169
- wait_seconds: 10,
170
- ttl_seconds: 86400,
171
- collapse_key: null,
172
- devices: null,
173
- sound: null,
174
- interruption_level: null,
175
- project: null,
176
- // Installing the hooks is the opt-in; these exist to switch the behaviour
177
- // back off for a project or a session without uninstalling anything.
178
- ask_notifications: true,
179
- // Questions reach devices as soon as the turn ends. A terminal-first grace
180
- // window is an opt-in delay on top of that.
181
- ask_grace_seconds: 0,
182
- // A day. The wire contract's own default, and long enough that a question
183
- // asked before someone steps away is still answerable when they come back.
184
- reply_window_seconds: 86_400,
185
- // On by default: the log is the only account of what a headless hook did, and
186
- // a diagnostic nobody switched on before the thing went wrong is not one.
187
- log_level: 'info',
188
- // 2 MB × 3 files caps the logs at roughly 6 MB, which is weeks of ordinary
189
- // agent traffic and small enough that nobody has to think about it.
190
- log_max_bytes: 2_000_000,
191
- log_max_files: 3,
192
- // Empty by default: remote images use public HTTPS and pairing approval
193
- // uses the canonical dashboard, the pairing origin, or loopback. Entries
194
- // here are the User widening that, never a repository.
195
- media_origins: [],
196
- approve_origins: [],
197
- } satisfies Record<ConfigKey, unknown>
198
-
199
- /** The shipped value beneath every user-controlled configuration layer. */
200
- export function configDefaultValue(key: ConfigKey): unknown {
201
- return DEFAULTS[key]
202
- }
203
-
204
- export function globalConfigDir(
205
- env: NodeJS.ProcessEnv = process.env,
206
- platform: NodeJS.Platform = process.platform,
207
- ): string {
208
- return path.join(configHome(env, platform), 'notifai')
209
- }
210
-
211
- export function globalConfigPath(env: NodeJS.ProcessEnv = process.env): string {
212
- return path.join(globalConfigDir(env), 'config.toml')
213
- }
214
-
215
- /**
216
- * Machine-local mutable state: presence markers, pending questions, frozen
217
- * session names, and session config overrides. Separate from the config dir
218
- * because none of it should ever be synced or committed.
219
- */
220
- export function stateDir(
221
- env: NodeJS.ProcessEnv = process.env,
222
- platform: NodeJS.Platform = process.platform,
223
- ): string {
224
- return path.join(stateHome(env, platform), 'notifai')
225
- }
226
-
227
- /** Per-harness-session override file; see the ConfigSource doc comment. */
228
- export function sessionConfigPath(sessionId: string, env: NodeJS.ProcessEnv = process.env): string {
229
- return path.join(stateDir(env), 'sessions', `${sanitizeSessionId(sessionId)}.config.toml`)
230
- }
231
-
232
- /**
233
- * Session ids come from harness JSON, so they reach the filesystem from
234
- * outside. Hash rather than merely sanitising: replacing unsafe characters
235
- * collapsed distinct ids onto one file — `a/b` and `a?b` both became `a_b`, and
236
- * two sessions would then share presence, pending questions and overrides.
237
- * A readable prefix is kept only so the directory is debuggable.
238
- */
239
- export function sanitizeSessionId(sessionId: string): string {
240
- const digest = createHash('sha256').update(sessionId).digest('hex').slice(0, 16)
241
- const readable = sessionId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32)
242
- return readable === '' ? digest : `${readable}-${digest}`
243
- }
244
-
245
- /**
246
- * Pointer from a Project directory to the Agent Session currently working in
247
- * it, written by the UserPromptSubmit hook. Without it `notifai ask` has no way
248
- * to learn its own Agent Session id: an ordinary agent shell command receives no hook
249
- * payload, and no harness exports one into the environment.
250
- */
251
- export function projectSessionPointerPath(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
252
- const digest = createHash('sha256').update(canonicalDir(cwd)).digest('hex').slice(0, 32)
253
- return path.join(stateDir(env), 'projects', `${digest}.json`)
254
- }
255
-
256
- /**
257
- * Resolve symlinks before hashing. The harness reports `cwd` as it knows it
258
- * while a shell reports `process.cwd()` already resolved — on macOS `/tmp` and
259
- * `/private/tmp` are the same directory, so hashing the unresolved paths gave
260
- * two different keys and `notifai ask` could never find the pointer the hook
261
- * had just written.
262
- */
263
- function canonicalDir(dir: string): string {
264
- try {
265
- return realpathSync(path.resolve(dir))
266
- } catch {
267
- return path.resolve(dir)
268
- }
269
- }
270
-
271
- /** Walk up from cwd looking for .notifai/<name> (Project Override). */
272
- function findProjectFile(startDir: string, name: string): string | null {
273
- let dir = path.resolve(startDir)
274
- for (;;) {
275
- const candidate = path.join(dir, '.notifai', name)
276
- if (existsSync(candidate)) return candidate
277
- const parent = path.dirname(dir)
278
- if (parent === dir) return null
279
- dir = parent
280
- }
281
- }
282
-
283
- export function findProjectConfigPath(startDir: string): string | null {
284
- return findProjectFile(startDir, 'config.toml')
285
- }
286
-
287
- /** Walk up from cwd looking for a `.git` file or directory (worktree or checkout). */
288
- function findGitRoot(startDir: string): string | null {
289
- let dir = path.resolve(startDir)
290
- for (;;) {
291
- if (existsSync(path.join(dir, '.git'))) return canonicalDir(dir)
292
- const parent = path.dirname(dir)
293
- if (parent === dir) return null
294
- dir = parent
295
- }
296
- }
297
-
298
- /**
299
- * Stable identity for personal project preferences.
300
- *
301
- * Prefer the directory that owns the shared `.notifai/config.toml`, then the
302
- * git checkout root, then cwd. Subdirectories of one project therefore share
303
- * one personal file, and nothing is written inside the repository.
304
- */
305
- export function personalProjectIdentity(cwd: string): string {
306
- const projectConfig = findProjectConfigPath(cwd)
307
- const root =
308
- projectConfig !== null ? path.dirname(path.dirname(projectConfig)) : (findGitRoot(cwd) ?? cwd)
309
- return createHash('sha256').update(canonicalDir(root)).digest('hex').slice(0, 32)
310
- }
311
-
312
- /** Personal project layer: `$XDG_CONFIG_HOME/notifai/projects/<identity>.toml`. */
313
- export function personalProjectConfigPath(
314
- cwd: string,
315
- env: NodeJS.ProcessEnv = process.env,
316
- ): string {
317
- return path.join(globalConfigDir(env), 'projects', `${personalProjectIdentity(cwd)}.toml`)
318
- }
319
-
320
- /** Existing personal project file, or null when this checkout has none. */
321
- export function findProjectLocalConfigPath(
322
- startDir: string,
323
- env: NodeJS.ProcessEnv = process.env,
324
- ): string | null {
325
- const file = personalProjectConfigPath(startDir, env)
326
- return existsSync(file) ? file : null
327
- }
328
-
329
- /**
330
- * Ranges every layer is clamped to. Config is readable from a repository, so an
331
- * out-of-range value is attacker input, not a typo.
332
- */
333
- const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
334
- wait_seconds: { min: 0, max: 300 },
335
- ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
336
- // Six minutes is the supported terminal-first preference bound. The Stop
337
- // owner carries separate startup headroom, so this never shortens the answer
338
- // window that begins after submission.
339
- ask_grace_seconds: { min: 0, max: 360 },
340
- // The floor is the shortest window the wire contract accepts; the ceiling is
341
- // the point past which a question would outlive the retained content it
342
- // refers to.
343
- reply_window_seconds: { min: 60, max: REPLY_MAX_WINDOW_SECONDS },
344
- // The floor keeps rotation from thrashing (a file smaller than a handful of
345
- // records would rotate on nearly every write); the ceiling is the point past
346
- // which "local diagnostics" has become "a database on your disk".
347
- log_max_bytes: { min: 64_000, max: 100_000_000 },
348
- log_max_files: { min: 1, max: 20 },
349
- }
350
-
351
- /** Drops values a layer must not be trusted to supply, rather than throwing. */
352
- function coerce(key: ConfigKey, value: unknown): unknown | undefined {
353
- const bounds = NUMERIC_BOUNDS[key]
354
- if (bounds !== undefined) {
355
- if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
356
- return Math.min(bounds.max, Math.max(bounds.min, Math.trunc(value)))
357
- }
358
- if (BOOLEAN_CONFIG_KEYS.includes(key)) return typeof value === 'boolean' ? value : undefined
359
- if (ORIGIN_LIST_CONFIG_KEYS.includes(key)) {
360
- // A trust rule is all-or-nothing: one malformed entry drops the whole
361
- // value rather than silently enforcing a subset of what was written.
362
- if (!Array.isArray(value)) return undefined
363
- const origins = value.map((entry) => (typeof entry === 'string' ? normalizeOrigin(entry) : null))
364
- return origins.every((origin): origin is string => origin !== null) ? origins : undefined
365
- }
366
- if (key === 'sound') {
367
- return typeof value === 'string' && isCliSoundRef(value) ? value : undefined
368
- }
369
- const allowed = ENUM_CONFIG_VALUES[key]
370
- if (allowed !== undefined) {
371
- return typeof value === 'string' && allowed.includes(value) ? value : undefined
372
- }
373
- return value
374
- }
375
-
376
- export function configBounds(key: ConfigKey): { min: number; max: number } | undefined {
377
- return NUMERIC_BOUNDS[key]
378
- }
379
-
380
- function readTomlFile(filePath: string): Record<string, unknown> {
381
- try {
382
- return parseToml(readFileSync(filePath, 'utf8')) as Record<string, unknown>
383
- } catch (err) {
384
- throw new Error(`Could not parse ${filePath}: ${String(err)}`)
385
- }
386
- }
387
-
388
- export interface FlagOverrides {
389
- base_url?: string
390
- wait_seconds?: number
391
- ttl_seconds?: number
392
- collapse_key?: string | null
393
- devices?: string[]
394
- sound?: string
395
- interruption_level?: CliInterruptionLevel
396
- }
397
-
398
- /**
399
- * Service origin is not a user setting. Flag and env are pairing/self-host
400
- * overrides for unsigned-in traffic (login). Signed-in traffic always uses
401
- * the origin stored with the Machine Credential.
402
- */
403
- function resolveServiceUrl(
404
- flags: FlagOverrides,
405
- env: NodeJS.ProcessEnv,
406
- ): ResolvedValue<string> {
407
- if (typeof flags.base_url === 'string' && flags.base_url !== '') {
408
- return { value: flags.base_url, source: 'flag' }
409
- }
410
- const fromEnv = env['NOTIFAI_BASE_URL']
411
- if (typeof fromEnv === 'string' && fromEnv !== '') {
412
- return { value: fromEnv, source: 'env' }
413
- }
414
- return { value: DEFAULT_BASE_URL, source: 'default' }
415
- }
416
-
417
- export function loadConfig(options: {
418
- cwd?: string
419
- env?: NodeJS.ProcessEnv
420
- flags?: FlagOverrides
421
- /** Agent Session id; enables the Agent Session override layer when present. */
422
- sessionId?: string | undefined
423
- }): CliConfig {
424
- const env = options.env ?? process.env
425
- const flags = options.flags ?? {}
426
- const globalPath = globalConfigPath(env)
427
- const globalRaw = existsSync(globalPath) ? readTomlFile(globalPath) : {}
428
- const cwd = options.cwd ?? process.cwd()
429
- const projectPath = findProjectConfigPath(cwd)
430
- const projectRaw = projectPath ? readTomlFile(projectPath) : {}
431
- const projectLocalPath = findProjectLocalConfigPath(cwd, env)
432
- const projectLocalRaw = projectLocalPath ? readTomlFile(projectLocalPath) : {}
433
- const sessionId = options.sessionId ?? env['NOTIFAI_SESSION_ID']
434
- const sessionPath = sessionId ? sessionConfigPath(sessionId, env) : null
435
- const sessionRaw = sessionPath && existsSync(sessionPath) ? readTomlFile(sessionPath) : {}
436
-
437
- function resolve<K extends ConfigKey>(key: K): ResolvedValue<never> {
438
- // The shared project file arrives with a clone, so keys that widen
439
- // network trust never read from it — whatever a repository writes there.
440
- const repositoryLayerReadable = !USER_OWNED_CONFIG_KEYS.includes(key)
441
- const layers: [unknown, ConfigSource][] = [
442
- [(flags as Record<string, unknown>)[key], 'flag'],
443
- [sessionPath ? sessionRaw[key] : undefined, `session:${sessionPath ?? ''}`],
444
- [projectLocalPath ? projectLocalRaw[key] : undefined, `project-local:${projectLocalPath ?? ''}`],
445
- [projectPath && repositoryLayerReadable ? projectRaw[key] : undefined, `project:${projectPath ?? ''}`],
446
- [globalRaw[key], `global:${globalPath}`],
447
- ]
448
- for (const [raw, source] of layers) {
449
- if (raw === undefined) continue
450
- const coerced = coerce(key, raw)
451
- if (coerced === undefined) continue
452
- return { value: coerced as never, source }
453
- }
454
- return { value: DEFAULTS[key] as never, source: 'default' }
455
- }
456
-
457
- return {
458
- base_url: resolveServiceUrl(flags, env),
459
- wait_seconds: resolve('wait_seconds'),
460
- ttl_seconds: resolve('ttl_seconds'),
461
- collapse_key: resolve('collapse_key'),
462
- devices: resolve('devices'),
463
- sound: resolve('sound'),
464
- interruption_level: resolve('interruption_level'),
465
- project: resolve('project'),
466
- ask_notifications: resolve('ask_notifications'),
467
- ask_grace_seconds: resolve('ask_grace_seconds'),
468
- reply_window_seconds: resolve('reply_window_seconds'),
469
- log_level: resolve('log_level'),
470
- log_max_bytes: resolve('log_max_bytes'),
471
- log_max_files: resolve('log_max_files'),
472
- media_origins: resolve('media_origins'),
473
- approve_origins: resolve('approve_origins'),
474
- }
475
- }