niahere 0.2.17 → 0.2.18

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -12,33 +12,60 @@ export function registerAllChannels(): void {
12
12
  registerChannel(() => createSlackChannel());
13
13
  }
14
14
 
15
- export async function startChannels(): Promise<Channel[]> {
16
- const channels: Channel[] = [];
15
+ export interface StartResult {
16
+ started: Channel[];
17
+ failed: string[];
18
+ }
19
+
20
+ export async function startChannels(): Promise<StartResult> {
21
+ const pending = getFactories()
22
+ .map((factory) => factory())
23
+ .filter((ch): ch is Channel => ch !== null);
17
24
 
18
- for (const factory of getFactories()) {
19
- const channel = factory();
20
- if (!channel) continue;
25
+ if (pending.length === 0) return { started: [], failed: [] };
21
26
 
22
- try {
27
+ const results = await Promise.allSettled(
28
+ pending.map(async (channel) => {
23
29
  await channel.start();
24
- channels.push(channel);
25
- trackStarted(channel);
26
- log.info({ channel: channel.name }, "channel started");
27
- } catch (err) {
28
- log.error({ err, channel: channel.name }, "channel failed to start");
30
+ return channel;
31
+ }),
32
+ );
33
+
34
+ const started: Channel[] = [];
35
+ const failed: string[] = [];
36
+ for (let i = 0; i < results.length; i++) {
37
+ const result = results[i];
38
+ if (result.status === "fulfilled") {
39
+ started.push(result.value);
40
+ trackStarted(result.value);
41
+ log.info({ channel: result.value.name }, "channel started");
42
+ } else {
43
+ failed.push(pending[i].name);
44
+ log.error({ err: result.reason, channel: pending[i].name }, "channel failed to start");
29
45
  }
30
46
  }
31
47
 
32
- return channels;
48
+ if (failed.length > 0) {
49
+ log.warn({ failed }, "some channels failed to start");
50
+ }
51
+
52
+ return { started, failed };
33
53
  }
34
54
 
35
55
  export async function stopChannels(channels: Channel[]): Promise<void> {
36
- for (const channel of channels) {
37
- try {
56
+ const results = await Promise.allSettled(
57
+ channels.map(async (channel) => {
38
58
  await channel.stop();
39
- log.info({ channel: channel.name }, "channel stopped");
40
- } catch (err) {
41
- log.error({ err, channel: channel.name }, "channel failed to stop");
59
+ return channel;
60
+ }),
61
+ );
62
+
63
+ for (let i = 0; i < results.length; i++) {
64
+ const result = results[i];
65
+ if (result.status === "fulfilled") {
66
+ log.info({ channel: result.value.name }, "channel stopped");
67
+ } else {
68
+ log.error({ err: result.reason, channel: channels[i].name }, "channel failed to stop");
42
69
  }
43
70
  }
44
71
  clearStarted();
@@ -94,9 +94,11 @@ class SlackChannel implements Channel {
94
94
  // daemon restarts (otherwise getState falls back to the old room).
95
95
  await Session.create(`placeholder-${room}`, room);
96
96
 
97
+ log.info({ key, room }, "slack: creating chat engine");
97
98
  const engine = await createChatEngine({ room, channel: "slack", resume: false, mcpServers: getMcpServers() });
98
99
  const state: ChatState = { engine, roomIndex: newIdx, lock: Promise.resolve() };
99
100
  chats.set(key, state);
101
+ log.info({ key, room, activeSessions: chats.size }, "slack: engine ready");
100
102
  return state;
101
103
  }
102
104
 
@@ -106,6 +108,8 @@ class SlackChannel implements Channel {
106
108
  fn().catch((err) => log.error({ err, key }, "unhandled error in locked handler"));
107
109
  return;
108
110
  }
111
+ const queued = state.lock !== Promise.resolve();
112
+ if (queued) log.debug({ key }, "slack: message queued behind active lock");
109
113
  state.lock = state.lock.then(fn, fn);
110
114
  }
111
115
 
@@ -345,10 +349,11 @@ class SlackChannel implements Channel {
345
349
 
346
350
  const state = await getState(key);
347
351
 
348
- // Add thinking reaction while processing
349
- await client.reactions.add({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" }).catch(() => {});
350
-
351
352
  withLock(key, async () => {
353
+ // Add thinking reaction inside the lock so cleanup is guaranteed
354
+ await client.reactions.add({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
355
+ .catch((err) => log.debug({ err, channel: msg.channel }, "slack: failed to add thinking reaction"));
356
+
352
357
  try {
353
358
  const { result } = await state.engine.send(text, {
354
359
  onActivity(status) {
@@ -356,8 +361,6 @@ class SlackChannel implements Channel {
356
361
  },
357
362
  }, attachments);
358
363
 
359
- await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" }).catch(() => {});
360
-
361
364
  const reply = result.trim();
362
365
 
363
366
  // [NO_REPLY] or empty = agent chose not to respond (thread judgement)
@@ -378,7 +381,6 @@ class SlackChannel implements Channel {
378
381
 
379
382
  log.info({ channel: msg.channel, key, chars: reply.length }, "slack reply sent");
380
383
  } catch (err) {
381
- await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" }).catch(() => {});
382
384
 
383
385
  const errText = err instanceof Error ? err.message : String(err);
384
386
  log.error({ err, channel: msg.channel }, "slack message processing failed");
@@ -392,6 +394,9 @@ class SlackChannel implements Channel {
392
394
  } else {
393
395
  await say(`[error] ${errText}`);
394
396
  }
397
+ } finally {
398
+ await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
399
+ .catch((err) => log.debug({ err, channel: msg.channel }, "slack: failed to remove thinking reaction"));
395
400
  }
396
401
  });
397
402
  });
@@ -68,9 +68,11 @@ class TelegramChannel implements Channel {
68
68
  const prefix = roomPrefix(chatId);
69
69
  const idx = await Session.getLatestRoomIndex(prefix);
70
70
  const room = roomName(chatId, idx);
71
+ log.info({ chatId, room }, "telegram: creating chat engine");
71
72
  const engine = await createChatEngine({ room, channel: "telegram", resume: true, mcpServers: getMcpServers() });
72
73
  state = { engine, roomIndex: idx, lock: Promise.resolve() };
73
74
  chats.set(chatId, state);
75
+ log.info({ chatId, room, activeSessions: chats.size }, "telegram: engine ready");
74
76
  }
75
77
  return state;
76
78
  }
@@ -100,6 +102,8 @@ class TelegramChannel implements Channel {
100
102
  fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
101
103
  return;
102
104
  }
105
+ const queued = state.lock !== Promise.resolve();
106
+ if (queued) log.debug({ chatId }, "telegram: message queued behind active lock");
103
107
  state.lock = state.lock.then(fn, fn);
104
108
  }
105
109
 
@@ -134,8 +138,6 @@ class TelegramChannel implements Channel {
134
138
  try {
135
139
  const { result } = await state.engine.send(text, {}, attachments);
136
140
 
137
- clearInterval(typingInterval);
138
-
139
141
  const reply = result.trim() || "(no response)";
140
142
  try {
141
143
  await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
@@ -145,11 +147,11 @@ class TelegramChannel implements Channel {
145
147
 
146
148
  log.info({ chatId, chars: result.length }, "telegram reply sent");
147
149
  } catch (err) {
148
- clearInterval(typingInterval);
149
-
150
150
  const errText = err instanceof Error ? err.message : String(err);
151
151
  log.error({ err, chatId }, "telegram message processing failed");
152
152
  await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
153
+ } finally {
154
+ clearInterval(typingInterval);
153
155
  }
154
156
  }
155
157
 
@@ -4,6 +4,7 @@ import { homedir } from "os";
4
4
  import yaml from "js-yaml";
5
5
  import { getNiaHome, getPaths } from "../utils/paths";
6
6
  import { getEnvironmentPrompt, getModePrompt, getChannelPrompt } from "../prompts";
7
+ import { log } from "../utils/log";
7
8
  import type { Mode } from "../types";
8
9
 
9
10
  // niahere project root (resolved from this file's location)
@@ -51,7 +52,12 @@ function scanSkills(): { name: string; description: string }[] {
51
52
  if (!fmMatch) continue;
52
53
 
53
54
  let meta: Record<string, unknown> = {};
54
- try { meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {}; } catch { continue; }
55
+ try {
56
+ meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
57
+ } catch (err) {
58
+ log.warn({ err, skill: entry.name, path: skillFile }, "failed to parse skill metadata, skipping");
59
+ continue;
60
+ }
55
61
  const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
56
62
 
57
63
  if (seen.has(name)) continue;
package/src/chat/repl.ts CHANGED
@@ -112,7 +112,7 @@ async function pickSession(): Promise<string | null> {
112
112
 
113
113
  export type ChatMode = "continue" | "new" | "pick";
114
114
 
115
- export async function startRepl(mode: ChatMode = "continue"): Promise<void> {
115
+ export async function startRepl(mode: ChatMode = "continue", simulateChannel?: string): Promise<void> {
116
116
  try {
117
117
  await runMigrations();
118
118
  } catch (err) {
@@ -142,12 +142,14 @@ export async function startRepl(mode: ChatMode = "continue"): Promise<void> {
142
142
  resume = true;
143
143
  }
144
144
 
145
- const engine = await createChatEngine({ room: "terminal", channel: "terminal", resume, mcpServers: getMcpServers() });
145
+ const channel = simulateChannel || "terminal";
146
+ const engine = await createChatEngine({ room: "terminal", channel, resume, mcpServers: getMcpServers() });
146
147
 
147
148
  // Welcome
148
149
  const isResumed = engine.sessionId && resume;
149
150
  const sessionNote = isResumed ? "resumed" : "new session";
150
- console.log(`\n${DIM}nia chat${RESET} ${DIM}(${sessionNote})${RESET}`);
151
+ const channelNote = simulateChannel ? ` as ${simulateChannel}` : "";
152
+ console.log(`\n${DIM}nia chat${channelNote}${RESET} ${DIM}(${sessionNote})${RESET}`);
151
153
  console.log(`${DIM}type /exit to quit${RESET}\n`);
152
154
 
153
155
  const rl = readline.createInterface({
package/src/cli/index.ts CHANGED
@@ -115,6 +115,12 @@ switch (command) {
115
115
  break;
116
116
  }
117
117
 
118
+ case "health": {
119
+ const { healthCommand } = await import("../commands/health");
120
+ await healthCommand();
121
+ break;
122
+ }
123
+
118
124
  case "restart": {
119
125
  const { isServiceInstalled, restartService } = await import("../commands/service");
120
126
  if (isServiceInstalled()) {
@@ -227,10 +233,23 @@ switch (command) {
227
233
  case "logs": {
228
234
  const { daemonLog } = getPaths();
229
235
  if (!existsSync(daemonLog)) fail("No daemon log found. Is nia running?");
230
- const follow = process.argv[3] === "-f" || process.argv[3] === "--follow";
231
- const args = follow ? ["tail", "-f", daemonLog] : ["tail", "-50", daemonLog];
232
- const proc = Bun.spawn(args, { stdio: ["ignore", "inherit", "inherit"] });
233
- await proc.exited;
236
+ const logArgs = process.argv.slice(3);
237
+ const follow = logArgs.includes("-f") || logArgs.includes("--follow");
238
+ // --channel <name> filters logs by channel/component via grep
239
+ const chIdx = logArgs.indexOf("--channel");
240
+ const channelFilter = chIdx !== -1 && logArgs[chIdx + 1] ? logArgs[chIdx + 1] : null;
241
+
242
+ if (channelFilter) {
243
+ // Pipe through grep to filter by channel name in structured logs
244
+ const tailArgs = follow ? ["tail", "-f", daemonLog] : ["tail", "-200", daemonLog];
245
+ const tail = Bun.spawn(tailArgs, { stdio: ["ignore", "pipe", "inherit"] });
246
+ const grep = Bun.spawn(["grep", "-i", channelFilter], { stdio: [tail.stdout, "inherit", "inherit"] });
247
+ await grep.exited;
248
+ } else {
249
+ const args = follow ? ["tail", "-f", daemonLog] : ["tail", "-50", daemonLog];
250
+ const proc = Bun.spawn(args, { stdio: ["ignore", "inherit", "inherit"] });
251
+ await proc.exited;
252
+ }
234
253
  break;
235
254
  }
236
255
 
@@ -240,13 +259,15 @@ switch (command) {
240
259
  }
241
260
 
242
261
  case "chat": {
243
- const arg = process.argv[3];
244
- const mode = (arg === "--new" || arg === "-n")
262
+ const chatArgs = process.argv.slice(3);
263
+ const mode = (chatArgs.includes("--new") || chatArgs.includes("-n"))
245
264
  ? "new" as const
246
- : (arg === "--resume" || arg === "-r")
265
+ : (chatArgs.includes("--resume") || chatArgs.includes("-r"))
247
266
  ? "pick" as const
248
267
  : "continue" as const;
249
- await startRepl(mode);
268
+ const chIdx = chatArgs.indexOf("--channel");
269
+ const simChannel = chIdx !== -1 && chatArgs[chIdx + 1] ? chatArgs[chIdx + 1] : undefined;
270
+ await startRepl(mode, simChannel);
250
271
  break;
251
272
  }
252
273
 
@@ -392,10 +413,11 @@ switch (command) {
392
413
  console.log(" start / stop — daemon + service control");
393
414
  console.log(" restart — restart daemon");
394
415
  console.log(" status [--json --rooms N --all] — show daemon, jobs, channels");
395
- console.log(" chat interactive chat (auto-continues last session)");
416
+ console.log(" health check daemon, db, channels, config");
417
+ console.log(" chat [--channel ch] — interactive chat (--channel simulates a channel)");
396
418
  console.log(" run <prompt> — one-shot execution");
397
419
  console.log(" history [room] — recent messages");
398
- console.log(" logs [-f] — daemon logs");
420
+ console.log(" logs [-f] [--channel ch] — daemon logs (filter by channel)");
399
421
  console.log(" job <sub> — manage jobs");
400
422
  console.log(" db <sub> — database setup/status/migrate");
401
423
  console.log(" skills — list available skills");
@@ -0,0 +1,12 @@
1
+ import postgres from "postgres";
2
+
3
+ /** Quick DB connectivity check. Returns true if SELECT 1 succeeds. */
4
+ export async function checkDbHealth(url: string): Promise<boolean> {
5
+ const db = postgres(url, { onnotice: () => {}, connect_timeout: 5 });
6
+ try {
7
+ const [row] = await db`SELECT 1 as ok`;
8
+ return row?.ok === 1;
9
+ } finally {
10
+ await db.end();
11
+ }
12
+ }
@@ -0,0 +1,119 @@
1
+ import { existsSync, statSync } from "fs";
2
+ import { join } from "path";
3
+ import { isRunning, readPid } from "../core/daemon";
4
+ import { getConfig, readRawConfig } from "../utils/config";
5
+ import { getPaths } from "../utils/paths";
6
+ import { errMsg } from "../utils/errors";
7
+ import { localTime } from "../utils/time";
8
+
9
+ type Check = { name: string; status: "ok" | "warn" | "fail"; detail: string };
10
+
11
+ function push(checks: Check[], name: string, status: Check["status"], detail: string): void {
12
+ checks.push({ name, status, detail });
13
+ }
14
+
15
+ export async function healthCommand(): Promise<void> {
16
+ const checks: Check[] = [];
17
+ const paths = getPaths();
18
+
19
+ // 1. Daemon
20
+ const pid = readPid();
21
+ if (isRunning()) {
22
+ push(checks, "daemon", "ok", "running (pid: " + pid + ")");
23
+ } else if (pid) {
24
+ push(checks, "daemon", "fail", "stale pid file (pid: " + pid + ", not running)");
25
+ } else {
26
+ push(checks, "daemon", "warn", "not running");
27
+ }
28
+
29
+ // 2. Config
30
+ if (existsSync(paths.config)) {
31
+ const raw = readRawConfig();
32
+ push(checks, "config", "ok", Object.keys(raw).length + " keys loaded");
33
+ } else {
34
+ push(checks, "config", "fail", "missing (" + paths.config + ")");
35
+ }
36
+
37
+ // 3. Database
38
+ try {
39
+ const config = getConfig();
40
+ if (!config.database_url || !config.database_url.startsWith("postgres")) {
41
+ push(checks, "database", "fail", 'invalid url: "' + (config.database_url || "(empty)") + '"');
42
+ } else {
43
+ const { checkDbHealth } = await import("./health-db");
44
+ const ok = await checkDbHealth(config.database_url);
45
+ push(checks, "database", ok ? "ok" : "fail", config.database_url.replace(/\/\/.*@/, "//***@"));
46
+ }
47
+ } catch (err) {
48
+ push(checks, "database", "fail", errMsg(err));
49
+ }
50
+
51
+ // 4. Channels
52
+ const config = getConfig();
53
+ if (!config.channels.enabled) {
54
+ push(checks, "channels", "warn", "disabled");
55
+ } else {
56
+ const chans: string[] = [];
57
+ if (config.channels.telegram.bot_token) chans.push("telegram");
58
+ if (config.channels.slack.bot_token && config.channels.slack.app_token) chans.push("slack");
59
+ if (chans.length > 0) {
60
+ push(checks, "channels", "ok", "configured: " + chans.join(", "));
61
+ } else {
62
+ push(checks, "channels", "warn", "enabled but no tokens configured");
63
+ }
64
+ }
65
+
66
+ // 5. API keys
67
+ const geminiKey = config.gemini_api_key;
68
+ const rawConfig = readRawConfig();
69
+ const openaiKey = typeof rawConfig.openai_api_key === "string" ? rawConfig.openai_api_key : null;
70
+ const apiKeys: string[] = [];
71
+ if (geminiKey) apiKeys.push("gemini");
72
+ if (openaiKey) apiKeys.push("openai");
73
+ push(checks, "api keys", apiKeys.length > 0 ? "ok" : "warn",
74
+ apiKeys.length > 0 ? apiKeys.join(", ") : "none configured");
75
+
76
+ // 6. Persona files
77
+ const personaFiles = ["identity.md", "owner.md", "soul.md"];
78
+ const missing = personaFiles.filter((f) => !existsSync(join(paths.selfDir, f)));
79
+ if (missing.length === 0) {
80
+ push(checks, "persona", "ok", "all files present");
81
+ } else {
82
+ push(checks, "persona", "warn", "missing: " + missing.join(", "));
83
+ }
84
+
85
+ // 7. Daemon log
86
+ if (existsSync(paths.daemonLog)) {
87
+ const stat = statSync(paths.daemonLog);
88
+ const sizeMb = (stat.size / 1024 / 1024).toFixed(1);
89
+ const lastMod = localTime(stat.mtime);
90
+ push(checks, "logs", stat.size > 100 * 1024 * 1024 ? "warn" : "ok",
91
+ sizeMb + " MB, last write: " + lastMod);
92
+ } else {
93
+ push(checks, "logs", "warn", "no log file");
94
+ }
95
+
96
+ // 8. Bun version
97
+ const bunVersion = typeof Bun !== "undefined" ? Bun.version : "unknown";
98
+ push(checks, "bun", "ok", "v" + bunVersion);
99
+
100
+ // Output
101
+ const GREEN = "\x1b[32m";
102
+ const YELLOW = "\x1b[33m";
103
+ const RED = "\x1b[31m";
104
+ const RST = "\x1b[0m";
105
+ const icons: Record<string, string> = {
106
+ ok: GREEN + "\u2713" + RST,
107
+ warn: YELLOW + "!" + RST,
108
+ fail: RED + "\u2717" + RST,
109
+ };
110
+
111
+ console.log();
112
+ for (const c of checks) {
113
+ console.log(" " + icons[c.status] + " " + c.name.padEnd(12) + " " + c.detail);
114
+ }
115
+ console.log();
116
+
117
+ const failCount = checks.filter((c) => c.status === "fail").length;
118
+ if (failCount > 0) process.exit(1);
119
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
2
2
  import { dirname } from "path";
3
3
  import { getPaths } from "../utils/paths";
4
4
  import { getConfig } from "../utils/config";
@@ -46,6 +46,7 @@ export function isRunning(): boolean {
46
46
  process.kill(pid, 0);
47
47
  return true;
48
48
  } catch {
49
+ log.warn({ stalePid: pid }, "removing stale pid file (process not running)");
49
50
  removePid();
50
51
  return false;
51
52
  }
@@ -73,6 +74,7 @@ export function startDaemon(): number {
73
74
  });
74
75
 
75
76
  proc.unref();
77
+ closeSync(logFd); // Child owns the fd now; close parent's copy to prevent leak
76
78
  const pid = proc.pid;
77
79
  writePid(pid);
78
80
  return pid;
@@ -211,7 +213,8 @@ export async function runDaemon(): Promise<void> {
211
213
  let channels: Channel[] = [];
212
214
  const config = getConfig();
213
215
  if (config.channels.enabled) {
214
- channels = await startChannels();
216
+ const result = await startChannels();
217
+ channels = result.started;
215
218
  } else {
216
219
  log.info("channels disabled (channels_enabled: false)");
217
220
  }
@@ -1,11 +1,18 @@
1
1
  import postgres from "postgres";
2
2
  import { getConfig } from "../utils/config";
3
+ import { log } from "../utils/log";
3
4
 
4
5
  let _sql: ReturnType<typeof postgres> | null = null;
5
6
 
6
7
  export function getSql(): ReturnType<typeof postgres> {
7
8
  if (!_sql) {
8
- _sql = postgres(getConfig().database_url, { onnotice: () => {} });
9
+ const url = getConfig().database_url;
10
+ if (!url || !url.startsWith("postgres")) {
11
+ const msg = `Invalid database_url: "${url || "(empty)"}". Expected a postgres:// connection string.`;
12
+ log.error(msg);
13
+ throw new Error(msg);
14
+ }
15
+ _sql = postgres(url, { onnotice: () => {} });
9
16
  }
10
17
  return _sql;
11
18
  }
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { dirname } from "path";
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
3
  import yaml from "js-yaml";
4
4
  import { getPaths } from "./paths";
5
5
  import { log } from "./log";
@@ -179,12 +179,20 @@ function deepMerge(target: Record<string, unknown>, source: Record<string, unkno
179
179
  }
180
180
  }
181
181
 
182
- /** Deep-merge fields into config.yaml and write back. */
182
+ /** Deep-merge fields into config.yaml and write back atomically. */
183
183
  export function updateRawConfig(fields: Record<string, unknown>): void {
184
184
  const { config } = getPaths();
185
185
  const raw = readRawConfig();
186
186
  deepMerge(raw, fields);
187
- mkdirSync(dirname(config), { recursive: true });
188
- writeFileSync(config, yaml.dump(raw, { lineWidth: -1 }));
187
+ const dir = dirname(config);
188
+ mkdirSync(dir, { recursive: true });
189
+ // Back up current config before overwriting
190
+ if (existsSync(config)) {
191
+ copyFileSync(config, join(dir, "config.yaml.bak"));
192
+ }
193
+ // Write to temp file then rename for atomic update (prevents corruption on crash)
194
+ const tmp = join(dir, `.config.yaml.tmp.${process.pid}`);
195
+ writeFileSync(tmp, yaml.dump(raw, { lineWidth: -1 }));
196
+ renameSync(tmp, config);
189
197
  resetConfig();
190
198
  }