@yeaft/webchat-agent 1.0.413 → 1.0.415

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 (58) hide show
  1. package/browser-runtime/browser-install.js +497 -0
  2. package/browser-runtime/cli.js +88 -0
  3. package/browser-runtime/config.js +116 -0
  4. package/browser-runtime/errors.js +8 -0
  5. package/browser-runtime/extension/manifest.json +18 -0
  6. package/browser-runtime/extension/offscreen.html +5 -0
  7. package/browser-runtime/extension/offscreen.js +101 -0
  8. package/browser-runtime/extension/popup.html +5 -0
  9. package/browser-runtime/extension/popup.js +1 -0
  10. package/browser-runtime/extension/service-worker.js +48 -0
  11. package/browser-runtime/extension.js +45 -0
  12. package/browser-runtime/index.js +5 -0
  13. package/browser-runtime/probe.js +427 -0
  14. package/browser-runtime/protocol.js +71 -0
  15. package/browser-runtime/service.js +132 -0
  16. package/browser-runtime/windows-version-job.ps1 +233 -0
  17. package/browser-runtime/windows-version-worker.js +75 -0
  18. package/browser-runtime/windows-version.js +85 -0
  19. package/cli.js +24 -7
  20. package/connection/index.js +12 -0
  21. package/context.js +1 -0
  22. package/index.js +19 -2
  23. package/llm-config-cli.js +24 -21
  24. package/local-runtime/server/client-protocol.js +14 -0
  25. package/local-runtime/server/context.js +3 -2
  26. package/local-runtime/server/handlers/agent-file-terminal.js +185 -115
  27. package/local-runtime/server/handlers/agent-output.js +3 -0
  28. package/local-runtime/server/handlers/client-misc.js +21 -4
  29. package/local-runtime/server/handlers/client-workbench.js +222 -41
  30. package/local-runtime/server/workbench-correlation.js +184 -0
  31. package/local-runtime/server/workbench-route.js +180 -0
  32. package/local-runtime/server/ws-agent.js +4 -0
  33. package/local-runtime/server/ws-client.js +25 -3
  34. package/local-runtime/version.json +1 -1
  35. package/local-runtime/web/app.bundle.js +191 -135
  36. package/local-runtime/web/app.bundle.js.gz +0 -0
  37. package/local-runtime/web/index.html +2 -2
  38. package/local-runtime/web/style.bundle.css +1 -1
  39. package/local-runtime/web/style.bundle.css.gz +0 -0
  40. package/package.json +5 -1
  41. package/service/config.js +23 -2
  42. package/service/index.js +1 -0
  43. package/service/linux.js +3 -2
  44. package/terminal.js +167 -30
  45. package/workbench/file-ops.js +21 -20
  46. package/workbench/file-search.js +4 -3
  47. package/workbench/git-ops.js +23 -22
  48. package/workbench/request-routing.js +16 -0
  49. package/yeaft/cli.js +57 -1
  50. package/yeaft/config-api.js +138 -192
  51. package/yeaft/config-store.js +192 -0
  52. package/yeaft/config.js +3 -0
  53. package/yeaft/init.js +20 -7
  54. package/yeaft/sessions/feature-flag.js +15 -33
  55. package/yeaft/sessions/session-manifest.js +114 -10
  56. package/yeaft/stdio-protocol.js +57 -0
  57. package/yeaft/storage/atomic.js +43 -17
  58. package/yeaft/tools/process-runner.js +86 -13
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
  }
@@ -15,18 +15,25 @@
15
15
  import {
16
16
  cpSync,
17
17
  existsSync,
18
+ linkSync,
18
19
  mkdirSync,
19
20
  readFileSync,
20
21
  readdirSync,
21
22
  rmSync,
22
23
  statSync,
24
+ unlinkSync,
23
25
  writeFileSync,
24
26
  } from 'fs';
27
+ import { randomUUID } from 'node:crypto';
25
28
  import { join } from 'path';
26
29
  import { loadSessionMeta } from './session-store.js';
30
+ import { writeAtomic } from '../storage/atomic.js';
27
31
 
28
32
  export const SESSIONS_MANIFEST_FILE = 'sessions-manifest.json';
29
33
  const MANIFEST_VERSION = 1;
34
+ const MANIFEST_LOCK_FILE = 'sessions-manifest.lock';
35
+ const MANIFEST_LOCK_WAIT_MS = 5_000;
36
+ const lockWaitArray = new Int32Array(new SharedArrayBuffer(4));
30
37
 
31
38
  export function sessionManifestPath(yeaftDir) {
32
39
  return join(yeaftDir, SESSIONS_MANIFEST_FILE);
@@ -62,6 +69,10 @@ export function loadSessionsManifest(yeaftDir) {
62
69
  }
63
70
 
64
71
  export function writeSessionsManifest(yeaftDir, sessions) {
72
+ return withManifestLock(yeaftDir, () => writeSessionsManifestUnlocked(yeaftDir, sessions));
73
+ }
74
+
75
+ function writeSessionsManifestUnlocked(yeaftDir, sessions) {
65
76
  if (!yeaftDir) throw new Error('yeaftDir required');
66
77
  mkdirSync(yeaftDir, { recursive: true });
67
78
  const manifest = {
@@ -69,7 +80,7 @@ export function writeSessionsManifest(yeaftDir, sessions) {
69
80
  generatedAt: new Date().toISOString(),
70
81
  sessions: dedupeSessions(sessions).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || ''))),
71
82
  };
72
- writeFileSync(sessionManifestPath(yeaftDir), `${JSON.stringify(manifest, null, 2)}\n`);
83
+ writeAtomic(sessionManifestPath(yeaftDir), `${JSON.stringify(manifest, null, 2)}\n`);
73
84
  return manifest;
74
85
  }
75
86
 
@@ -171,21 +182,114 @@ export function ensureSessionsManifest(yeaftDir, options) {
171
182
  }
172
183
  }
173
184
 
174
- const manifest = writeSessionsManifest(yeaftDir, buildManifestFromLocalSessions(yeaftDir, root));
175
- return { created: true, migrated, skipped, migratedIds, skippedIds, manifest };
185
+ return withManifestLock(yeaftDir, () => {
186
+ const current = loadSessionsManifest(yeaftDir);
187
+ const manifest = writeSessionsManifestUnlocked(yeaftDir, [
188
+ ...(current?.sessions || []),
189
+ ...buildManifestFromLocalSessions(yeaftDir, root),
190
+ ]);
191
+ return {
192
+ created: !current,
193
+ migrated,
194
+ skipped,
195
+ migratedIds,
196
+ skippedIds,
197
+ manifest,
198
+ };
199
+ });
176
200
  }
177
201
 
178
202
  export function addOrUpdateManifestSession(yeaftDir, meta, dir) {
179
- const current = loadSessionsManifest(yeaftDir) || { sessions: [] };
180
- const rows = current.sessions.filter(row => row.id !== meta.id);
181
- rows.push(manifestRowFromMeta(meta, dir));
182
- return writeSessionsManifest(yeaftDir, rows);
203
+ return withManifestLock(yeaftDir, () => {
204
+ const current = loadSessionsManifest(yeaftDir);
205
+ const recovered = current?.sessions
206
+ || buildManifestFromLocalSessions(yeaftDir, join(yeaftDir, 'sessions'));
207
+ const rows = recovered.filter(row => row.id !== meta.id);
208
+ rows.push(manifestRowFromMeta(meta, dir));
209
+ return writeSessionsManifestUnlocked(yeaftDir, rows);
210
+ });
183
211
  }
184
212
 
185
213
  export function removeManifestSession(yeaftDir, sessionId) {
186
- const current = loadSessionsManifest(yeaftDir);
187
- if (!current) return null;
188
- return writeSessionsManifest(yeaftDir, current.sessions.filter(row => row.id !== sessionId));
214
+ return withManifestLock(yeaftDir, () => {
215
+ const current = loadSessionsManifest(yeaftDir);
216
+ if (!current) return null;
217
+ return writeSessionsManifestUnlocked(yeaftDir, current.sessions.filter(row => row.id !== sessionId));
218
+ });
219
+ }
220
+
221
+ export function withSessionManifestLock(yeaftDir, operation) {
222
+ if (!yeaftDir) throw new Error('yeaftDir required');
223
+ mkdirSync(yeaftDir, { recursive: true });
224
+ const lockFile = join(yeaftDir, MANIFEST_LOCK_FILE);
225
+ const deadline = Date.now() + MANIFEST_LOCK_WAIT_MS;
226
+ const token = randomUUID();
227
+ const ownerFile = `${lockFile}.owner.${process.pid}.${token}`;
228
+ const owner = { pid: process.pid, token, ownerFile };
229
+ writeFileSync(ownerFile, `${JSON.stringify(owner)}\n`, { flag: 'wx' });
230
+ for (;;) {
231
+ try {
232
+ linkSync(ownerFile, lockFile);
233
+ break;
234
+ } catch (error) {
235
+ if (error?.code !== 'EEXIST') {
236
+ try { unlinkSync(ownerFile); } catch {}
237
+ throw error;
238
+ }
239
+ reapDeadManifestLock(lockFile);
240
+ if (Date.now() >= deadline) {
241
+ try { unlinkSync(ownerFile); } catch {}
242
+ throw new Error('Timed out waiting for Session manifest lock');
243
+ }
244
+ Atomics.wait(lockWaitArray, 0, 0, 25);
245
+ }
246
+ }
247
+ try {
248
+ return operation();
249
+ } finally {
250
+ try {
251
+ const current = JSON.parse(readFileSync(lockFile, 'utf8'));
252
+ if (current.token === owner.token) unlinkSync(lockFile);
253
+ } catch {
254
+ // A missing lock means another process already recovered this owner.
255
+ }
256
+ try { unlinkSync(ownerFile); } catch {}
257
+ }
258
+ }
259
+
260
+ function withManifestLock(yeaftDir, operation) {
261
+ return withSessionManifestLock(yeaftDir, operation);
262
+ }
263
+
264
+ function reapDeadManifestLock(lockFile) {
265
+ let observed;
266
+ try {
267
+ observed = JSON.parse(readFileSync(lockFile, 'utf8'));
268
+ } catch {
269
+ return;
270
+ }
271
+ if (!Number.isInteger(observed.pid) || typeof observed.token !== 'string') return;
272
+ try {
273
+ process.kill(observed.pid, 0);
274
+ return;
275
+ } catch (error) {
276
+ if (error?.code === 'EPERM') return;
277
+ }
278
+ const claim = `${lockFile}.reap.${process.pid}.${randomUUID()}`;
279
+ try {
280
+ linkSync(lockFile, claim);
281
+ const claimed = JSON.parse(readFileSync(claim, 'utf8'));
282
+ if (claimed.token !== observed.token) return;
283
+ unlinkSync(lockFile);
284
+ const expectedOwnerFile = `${lockFile}.owner.${observed.pid}.${observed.token}`;
285
+ if (observed.ownerFile === expectedOwnerFile) {
286
+ try { unlinkSync(expectedOwnerFile); } catch {}
287
+ }
288
+ } catch {
289
+ // Another process either recovered or replaced the observed lock.
290
+ } finally {
291
+ try { unlinkSync(claim); } catch {}
292
+ }
189
293
  }
190
294
 
191
295
  function manifestRowFromMeta(meta, dir) {
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { isReservedVpId, validateVpId } from './sessions/ids.js';
3
4
 
4
5
  const TERMINAL_STOP_REASONS = new Set([
5
6
  'end_turn', 'max_tokens', 'stop_sequence', 'aborted', 'error', 'tool_handoff', 'plan_recorded',
@@ -78,6 +79,62 @@ export function normalizeStreamRoutingIntent(message) {
78
79
  });
79
80
  }
80
81
 
82
+ /**
83
+ * Read the opt-in formal Session seed supplied by an integration's first
84
+ * stream-json prompt. Existing Sessions keep their persisted roster; this only
85
+ * establishes a canonical roster for a previously unknown session id.
86
+ */
87
+ export function normalizeStreamSessionBootstrap(message) {
88
+ if (!message || typeof message !== 'object') return null;
89
+ const hasRoster = Object.hasOwn(message, 'roster');
90
+ const hasVps = Object.hasOwn(message, 'vps');
91
+ const hasDefault = Object.hasOwn(message, 'defaultVpId');
92
+ if (!hasRoster && !hasVps && !hasDefault) return null;
93
+ if (!hasRoster && !hasVps) {
94
+ throw new Error('stream-json defaultVpId requires roster or vps');
95
+ }
96
+
97
+ const readRoster = (key) => {
98
+ const value = message[key];
99
+ if (!Array.isArray(value)) throw new Error(`stream-json ${key} must be an array`);
100
+ return value.slice();
101
+ };
102
+ const roster = hasRoster ? readRoster('roster') : readRoster('vps');
103
+ if (hasRoster && hasVps) {
104
+ const vps = readRoster('vps');
105
+ if (vps.length !== roster.length || vps.some((vpId, index) => vpId !== roster[index])) {
106
+ throw new Error('stream-json roster and vps must contain the same VP ids in the same order');
107
+ }
108
+ }
109
+
110
+ const seen = new Set();
111
+ for (const vpId of roster) {
112
+ const verdict = validateVpId(vpId);
113
+ if (!verdict.ok || isReservedVpId(vpId)) {
114
+ throw new Error(`stream-json roster contains invalid VP id ${JSON.stringify(vpId)} (${verdict.reason || 'reserved'})`);
115
+ }
116
+ if (seen.has(vpId)) throw new Error(`stream-json roster contains duplicate VP id ${vpId}`);
117
+ seen.add(vpId);
118
+ }
119
+
120
+ let defaultVpId = roster[0] || null;
121
+ if (hasDefault && message.defaultVpId != null) {
122
+ defaultVpId = message.defaultVpId;
123
+ const verdict = validateVpId(defaultVpId);
124
+ if (!verdict.ok || isReservedVpId(defaultVpId)) {
125
+ throw new Error(`stream-json defaultVpId is invalid (${verdict.reason || 'reserved'})`);
126
+ }
127
+ if (!seen.has(defaultVpId)) {
128
+ throw new Error(`stream-json defaultVpId ${defaultVpId} is not in roster`);
129
+ }
130
+ }
131
+
132
+ return Object.freeze({
133
+ roster: Object.freeze(roster),
134
+ defaultVpId,
135
+ });
136
+ }
137
+
81
138
  export function createJsonlWriter(output = process.stdout) {
82
139
  return event => { output.write(`${JSON.stringify(event)}\n`); };
83
140
  }
@@ -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);