@yeaft/webchat-agent 1.0.413 → 1.0.414

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/yeaft/init.js CHANGED
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, access
9
9
  import { join, dirname } from 'path';
10
10
  import { homedir } from 'os';
11
11
  import { createHash } from 'crypto';
12
+ import { mutateAgentConfig } from './config-store.js';
12
13
  // NOTE: migrateSessions runs at the end of initYeaftDir(). It collapses
13
14
  // legacy groups/ + chats/ + memory/{group,chat}/ into the unified sessions/
14
15
  // layout AND rewrites pre-rename per-message frontmatter (groupId → sessionId).
@@ -31,9 +32,9 @@ export function isPermissionError(err) {
31
32
  * @param {string} content
32
33
  * @param {string[]} warnings — array to push warning messages into
33
34
  */
34
- function safeWriteFile(filePath, content, warnings) {
35
+ function safeWriteFile(filePath, content, warnings, mode = 0o644) {
35
36
  try {
36
- writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o644 });
37
+ writeFileSync(filePath, content, { encoding: 'utf8', mode });
37
38
  } catch (err) {
38
39
  if (isPermissionError(err)) {
39
40
  warnings.push(`Cannot write ${filePath}: ${err.code}`);
@@ -49,9 +50,9 @@ function safeWriteFile(filePath, content, warnings) {
49
50
  * @param {string[]} warnings — array to push warning messages into
50
51
  * @returns {boolean} — true if directory exists (created or already existed)
51
52
  */
52
- function safeMkdir(dirPath, warnings) {
53
+ function safeMkdir(dirPath, warnings, mode = 0o755) {
53
54
  try {
54
- mkdirSync(dirPath, { recursive: true, mode: 0o755 });
55
+ mkdirSync(dirPath, { recursive: true, mode });
55
56
  return true;
56
57
  } catch (err) {
57
58
  if (isPermissionError(err)) {
@@ -168,7 +169,7 @@ export function initYeaftDir(dir) {
168
169
 
169
170
  // Ensure root exists
170
171
  if (!existsSync(root)) {
171
- if (safeMkdir(root, warnings)) {
172
+ if (safeMkdir(root, warnings, 0o700)) {
172
173
  created.push(root);
173
174
  }
174
175
  }
@@ -194,8 +195,20 @@ export function initYeaftDir(dir) {
194
195
  // config.json — default configuration (user edits this directly)
195
196
  const configJsonPath = join(root, 'config.json');
196
197
  if (!existsSync(configJsonPath)) {
197
- safeWriteFile(configJsonPath, DEFAULT_CONFIG_JSON, warnings);
198
- created.push(configJsonPath);
198
+ let seeded = false;
199
+ try {
200
+ const defaults = JSON.parse(DEFAULT_CONFIG_JSON);
201
+ mutateAgentConfig(root, (current, state) => {
202
+ if (!state.exists) {
203
+ Object.assign(current, defaults);
204
+ seeded = true;
205
+ }
206
+ });
207
+ if (seeded) created.push(configJsonPath);
208
+ } catch (err) {
209
+ if (isPermissionError(err)) warnings.push(`Cannot write ${configJsonPath}: ${err.code}`);
210
+ else throw err;
211
+ }
199
212
  }
200
213
 
201
214
  const memoryPath = join(root, 'memory', 'MEMORY.md');
@@ -12,8 +12,7 @@
12
12
 
13
13
  import { existsSync, readFileSync } from 'fs';
14
14
  import { join } from 'path';
15
- import { normalizePluginConfig } from '../plugins.js';
16
- import { writeAtomic } from '../storage/index.js';
15
+ import { mutateAgentConfig } from '../config-store.js';
17
16
 
18
17
  const CONFIG_FILE = 'config.json';
19
18
  const FLAG_PATH = ['yeaft', 'multiVp', 'enabled'];
@@ -34,20 +33,6 @@ function readConfig(yeaftDir) {
34
33
  * remain tolerant because the flag is optional, but no mutation may replace a
35
34
  * malformed root or a Plugin policy that the runtime must keep fail-closed.
36
35
  */
37
- function readConfigForWrite(yeaftDir) {
38
- const path = join(yeaftDir, CONFIG_FILE);
39
- if (!existsSync(path)) return {};
40
- const config = JSON.parse(readFileSync(path, 'utf8'));
41
- if (!config || typeof config !== 'object' || Array.isArray(config)
42
- || Object.getPrototypeOf(config) !== Object.prototype) {
43
- throw new Error('config.json must contain an object');
44
- }
45
- if (Object.prototype.hasOwnProperty.call(config, 'plugins')) {
46
- normalizePluginConfig(config.plugins);
47
- }
48
- return config;
49
- }
50
-
51
36
  export function isMultiVpEnabled(yeaftDir) {
52
37
  const cfg = readConfig(yeaftDir);
53
38
  let cur = cfg;
@@ -59,24 +44,21 @@ export function isMultiVpEnabled(yeaftDir) {
59
44
  }
60
45
 
61
46
  export function setMultiVpEnabled(yeaftDir, enabled) {
62
- let cfg;
63
- try {
64
- cfg = readConfigForWrite(yeaftDir);
65
- } catch (err) {
66
- return { error: `Failed to read config.json: ${err?.message || err}` };
67
- }
68
- let cur = cfg;
69
- for (let i = 0; i < FLAG_PATH.length - 1; i++) {
70
- const seg = FLAG_PATH[i];
71
- if (!cur[seg] || typeof cur[seg] !== 'object' || Array.isArray(cur[seg])) cur[seg] = {};
72
- cur = cur[seg];
73
- }
74
47
  const nextValue = Boolean(enabled);
75
- cur[FLAG_PATH[FLAG_PATH.length - 1]] = nextValue;
76
48
  try {
77
- writeAtomic(join(yeaftDir, CONFIG_FILE), JSON.stringify(cfg, null, 2));
78
- } catch (err) {
79
- return { error: `Failed to write config.json: ${err?.message || err}` };
49
+ return mutateAgentConfig(yeaftDir, config => {
50
+ let current = config;
51
+ for (let index = 0; index < FLAG_PATH.length - 1; index += 1) {
52
+ const segment = FLAG_PATH[index];
53
+ if (!current[segment] || typeof current[segment] !== 'object' || Array.isArray(current[segment])) {
54
+ current[segment] = {};
55
+ }
56
+ current = current[segment];
57
+ }
58
+ current[FLAG_PATH.at(-1)] = nextValue;
59
+ return { enabled: nextValue };
60
+ });
61
+ } catch (error) {
62
+ return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
80
63
  }
81
- return { enabled: nextValue };
82
64
  }
@@ -9,8 +9,8 @@
9
9
  * the only debris; they are safe to delete on boot (see sweepTmp()).
10
10
  *
11
11
  * Implementation:
12
- * 1. Write bytes to `path.tmp.<pid>.<counter>` via writeFileSync.
13
- * 2. fsync the tmp file (force bytes to disk before rename).
12
+ * 1. Exclusively create `path.tmp.<pid>.<counter>` and write through its fd.
13
+ * 2. fsync the same fd (force bytes to disk before rename).
14
14
  * 3. rename(tmp, path) — POSIX-atomic on same filesystem.
15
15
  * 4. fsync the parent dir (persist the rename itself).
16
16
  *
@@ -31,36 +31,62 @@ import {
31
31
  existsSync,
32
32
  unlinkSync,
33
33
  readdirSync,
34
+ lstatSync,
35
+ constants,
34
36
  } from 'fs';
35
37
  import { dirname, basename, join } from 'path';
36
38
 
37
39
  let tmpCounter = 0;
38
40
 
41
+ export function nextAtomicTmpPathForTest(path) {
42
+ return `${path}.tmp.${process.pid}.${tmpCounter + 1}`;
43
+ }
44
+
45
+ function targetMode(path, requestedMode) {
46
+ try {
47
+ const stat = lstatSync(path);
48
+ if (!stat.isFile()) throw new Error(`Atomic write target is not a regular file: ${path}`);
49
+ const existingMode = stat.mode & 0o777;
50
+ return requestedMode == null ? existingMode : existingMode & requestedMode;
51
+ } catch (error) {
52
+ if (error?.code === 'ENOENT') return requestedMode ?? 0o666;
53
+ throw error;
54
+ }
55
+ }
56
+
39
57
  /**
40
58
  * Atomically write `data` (string | Buffer) to `path`.
41
59
  * Throws on failure; never leaves `path` in a half-written state.
60
+ *
61
+ * When supplied, `mode` is the maximum permission set for the replacement and
62
+ * is still restricted by umask. Existing files preserve any tighter permissions
63
+ * but never retain bits outside that explicit maximum. Omitted mode preserves
64
+ * the historical behavior: existing modes survive, new files default to 0666.
42
65
  */
43
- export function writeAtomic(path, data) {
66
+ export function writeAtomic(path, data, { mode = null } = {}) {
44
67
  const dir = dirname(path);
45
68
  const tmpPath = `${path}.tmp.${process.pid}.${++tmpCounter}`;
46
-
47
- writeFileSync(tmpPath, data);
48
-
49
- // fsync the tmp file so the bytes hit disk before we swap.
69
+ const fileMode = targetMode(path, mode);
70
+ let fd = null;
50
71
  try {
51
- const fd = openSync(tmpPath, 'r+');
52
- try {
53
- fsyncSync(fd);
54
- } finally {
55
- closeSync(fd);
72
+ fd = openSync(
73
+ tmpPath,
74
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
75
+ fileMode,
76
+ );
77
+ writeFileSync(fd, data);
78
+ fsyncSync(fd);
79
+ closeSync(fd);
80
+ fd = null;
81
+ renameSync(tmpPath, path);
82
+ } catch (error) {
83
+ if (fd !== null) {
84
+ try { closeSync(fd); } catch {}
85
+ try { unlinkSync(tmpPath); } catch {}
56
86
  }
57
- } catch {
58
- // Best-effort; some filesystems / platforms don't support fsync on a file
59
- // opened r+. The rename below is still the atomic boundary.
87
+ throw error;
60
88
  }
61
89
 
62
- renameSync(tmpPath, path);
63
-
64
90
  // fsync the parent directory so the rename is durable.
65
91
  // Windows: cannot fsync a directory; skip.
66
92
  if (process.platform !== 'win32') {
@@ -71,18 +71,26 @@ function isSystemdScopeInactive(scope, spawnProcessSync) {
71
71
  }
72
72
  }
73
73
 
74
- function killProcessTree(proc, signalName, platform, spawnProcessSync, systemdScope) {
74
+ function killProcessTree(
75
+ proc,
76
+ signalName,
77
+ platform,
78
+ spawnProcessSync,
79
+ systemdScope,
80
+ commandTimeoutMs = 5000,
81
+ ) {
75
82
  if (!proc.pid) return false;
76
83
  if (platform === 'win32') {
77
84
  try {
78
85
  const result = spawnProcessSync('taskkill', ['/pid', String(proc.pid), '/t', '/f'], {
79
86
  stdio: 'ignore',
80
87
  windowsHide: true,
81
- timeout: 5000,
88
+ timeout: Math.max(1, commandTimeoutMs),
82
89
  });
83
90
  if (!result.error && result.status === 0) return true;
84
91
  } catch {}
85
- try { return proc.kill(signalName) !== false; } catch { return false; }
92
+ try { proc.kill(signalName); } catch {}
93
+ return false;
86
94
  }
87
95
 
88
96
  let signalled = signalSystemdScope(systemdScope, signalName, spawnProcessSync);
@@ -96,12 +104,22 @@ function killProcessTree(proc, signalName, platform, spawnProcessSync, systemdSc
96
104
  return signalled;
97
105
  }
98
106
 
107
+ function processGroupIsInactive(pid) {
108
+ if (!Number.isInteger(pid) || pid <= 0) return true;
109
+ try {
110
+ process.kill(-pid, 0);
111
+ return false;
112
+ } catch (error) {
113
+ return error?.code === 'ESRCH';
114
+ }
115
+ }
116
+
99
117
  /**
100
118
  * Execute a binary directly without a shell and keep captured output bounded.
101
119
  *
102
120
  * @param {string} command
103
121
  * @param {string[]} args
104
- * @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, forceSettleMs?: number, requireExitConfirmation?: boolean, systemdScope?: { unit: string, systemctlPath: string, env?: NodeJS.ProcessEnv } | null, onSettled?: (() => void) | null, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
122
+ * @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, gracefulTerminationDeadline?: number, terminationDeadline?: number, forceSettleMs?: number, treeKillTimeoutMs?: number, requireExitConfirmation?: boolean, requireProcessGroupExit?: boolean, systemdScope?: { unit: string, systemctlPath: string, env?: NodeJS.ProcessEnv } | null, onSettled?: (() => void) | null, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
105
123
  * @returns {Promise<{ code: number, stdout: string, stderr: string, truncated: boolean, timedOut: boolean, terminationError?: string }>}
106
124
  */
107
125
  export function runProcess(command, args, options = {}) {
@@ -123,6 +141,17 @@ export function runProcess(command, args, options = {}) {
123
141
  const forceSettleMs = Number.isFinite(options.forceSettleMs)
124
142
  ? Math.max(1, options.forceSettleMs)
125
143
  : DEFAULT_FORCE_SETTLE_MS;
144
+ const treeKillTimeoutMs = Number.isFinite(options.treeKillTimeoutMs)
145
+ ? Math.max(1, options.treeKillTimeoutMs)
146
+ : 5000;
147
+ const deadlineBudget = (maximum, deadline) => {
148
+ if (!Number.isFinite(deadline)) return maximum;
149
+ return Math.max(0, Math.min(maximum, deadline - Date.now()));
150
+ };
151
+ const terminationBudget = maximum => deadlineBudget(maximum, options.terminationDeadline);
152
+ const treeKillBudget = () => Number.isFinite(options.terminationDeadline)
153
+ ? terminationBudget(treeKillTimeoutMs)
154
+ : treeKillTimeoutMs;
126
155
  let proc;
127
156
  try {
128
157
  proc = spawnProcess(command, args, {
@@ -149,6 +178,8 @@ export function runProcess(command, args, options = {}) {
149
178
  let aborted = false;
150
179
  let stopRequested = false;
151
180
  let forceRequested = false;
181
+ let processTreeKillConfirmed = platform !== 'win32';
182
+ let treeKillFailed = false;
152
183
  let directClosed = false;
153
184
  let directCode = null;
154
185
  let timer = null;
@@ -217,7 +248,14 @@ export function runProcess(command, args, options = {}) {
217
248
  const terminationConfirmed = () => {
218
249
  if (!options.requireExitConfirmation) return directClosed;
219
250
  const scopeInactive = isSystemdScopeInactive(options.systemdScope, spawnProcessSync);
220
- return directClosed && scopeInactive;
251
+ const processTreeInactive = !options.requireProcessGroupExit
252
+ || (platform === 'win32'
253
+ ? processTreeKillConfirmed
254
+ : processGroupIsInactive(proc.pid));
255
+ const treeKillSucceeded = platform !== 'win32'
256
+ || !options.requireProcessGroupExit
257
+ || !treeKillFailed;
258
+ return directClosed && scopeInactive && processTreeInactive && treeKillSucceeded;
221
259
  };
222
260
  const maybeFinishStopped = () => {
223
261
  if (settled || !stopRequested || !terminationConfirmed()) return false;
@@ -227,18 +265,30 @@ export function runProcess(command, args, options = {}) {
227
265
  const startConfirmationPolling = () => {
228
266
  if (!options.requireExitConfirmation || confirmationTimer) return;
229
267
  confirmationTimer = setInterval(maybeFinishStopped, CONFIRMATION_POLL_MS);
268
+ if (!options.requireProcessGroupExit) confirmationTimer.unref?.();
230
269
  };
231
270
  const forceStop = () => {
232
271
  if (settled || forceRequested) return;
233
272
  forceRequested = true;
273
+ const settleBudget = terminationBudget(forceSettleMs);
234
274
  killProcessTree(
235
275
  proc,
236
276
  'SIGKILL',
237
277
  platform,
238
278
  spawnProcessSync,
239
279
  options.systemdScope,
280
+ Math.max(1, treeKillBudget() || 1),
240
281
  );
241
282
  if (maybeFinishStopped()) return;
283
+ if (settleBudget <= 0) {
284
+ finish(
285
+ null,
286
+ options.requireExitConfirmation
287
+ ? new ProcessTerminationError(command, forceSettleMs)
288
+ : null,
289
+ );
290
+ return;
291
+ }
242
292
  forceSettleTimer = setTimeout(() => {
243
293
  if (maybeFinishStopped()) return;
244
294
  finish(
@@ -247,7 +297,7 @@ export function runProcess(command, args, options = {}) {
247
297
  ? new ProcessTerminationError(command, forceSettleMs)
248
298
  : null,
249
299
  );
250
- }, forceSettleMs);
300
+ }, settleBudget);
251
301
  };
252
302
  const stop = () => {
253
303
  if (settled || stopRequested) return;
@@ -256,20 +306,35 @@ export function runProcess(command, args, options = {}) {
256
306
  // taskkill must run while the parent PID still identifies the tree.
257
307
  // It is already forceful, so do not wait for the direct child to exit.
258
308
  forceRequested = true;
259
- killProcessTree(proc, 'SIGKILL', platform, spawnProcessSync, null);
309
+ const settleBudget = terminationBudget(forceSettleMs);
310
+ processTreeKillConfirmed = killProcessTree(
311
+ proc,
312
+ 'SIGKILL',
313
+ platform,
314
+ spawnProcessSync,
315
+ null,
316
+ Math.max(1, treeKillBudget() || 1),
317
+ );
318
+ treeKillFailed = !processTreeKillConfirmed;
319
+ if (maybeFinishStopped()) return;
260
320
  if (!settled) {
261
- forceSettleTimer = setTimeout(() => {
321
+ const finishAfterForce = () => {
322
+ if (maybeFinishStopped()) return;
262
323
  finish(
263
324
  null,
264
325
  options.requireExitConfirmation
265
326
  ? new ProcessTerminationError(command, forceSettleMs)
266
327
  : null,
267
328
  );
268
- }, forceSettleMs);
329
+ };
330
+ const remainingSettleBudget = terminationBudget(forceSettleMs);
331
+ if (remainingSettleBudget <= 0) finishAfterForce();
332
+ else forceSettleTimer = setTimeout(finishAfterForce, remainingSettleBudget);
269
333
  }
270
334
  return;
271
335
  }
272
336
  startConfirmationPolling();
337
+ if (maybeFinishStopped()) return;
273
338
  killProcessTree(
274
339
  proc,
275
340
  'SIGTERM',
@@ -277,8 +342,12 @@ export function runProcess(command, args, options = {}) {
277
342
  spawnProcessSync,
278
343
  options.systemdScope,
279
344
  );
280
- forceTimer = setTimeout(forceStop, killGraceMs);
281
- forceTimer.unref?.();
345
+ const graceBudget = deadlineBudget(killGraceMs, options.gracefulTerminationDeadline);
346
+ if (graceBudget <= 0) forceStop();
347
+ else {
348
+ forceTimer = setTimeout(forceStop, graceBudget);
349
+ if (!options.requireProcessGroupExit) forceTimer.unref?.();
350
+ }
282
351
  };
283
352
  const onAbort = () => {
284
353
  aborted = true;
@@ -321,7 +390,7 @@ export function runProcess(command, args, options = {}) {
321
390
  if (settled) return;
322
391
  if (stopRequested) {
323
392
  directClosed = true;
324
- finishStoppedChild();
393
+ if (platform !== 'win32' || !treeKillFailed || !options.requireProcessGroupExit) finishStoppedChild();
325
394
  return;
326
395
  }
327
396
  settled = true;
@@ -331,8 +400,12 @@ export function runProcess(command, args, options = {}) {
331
400
  onClose = code => {
332
401
  directClosed = true;
333
402
  directCode = code;
403
+ if (!stopRequested && options.requireProcessGroupExit && !terminationConfirmed()) {
404
+ stop();
405
+ return;
406
+ }
334
407
  if (stopRequested) {
335
- finishStoppedChild();
408
+ if (platform !== 'win32' || !treeKillFailed || !options.requireProcessGroupExit) finishStoppedChild();
336
409
  return;
337
410
  }
338
411
  finish(code);