@yeaft/webchat-agent 1.0.412 → 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.
Files changed (53) 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/context.js +1 -0
  21. package/index.js +18 -1
  22. package/llm-config-cli.js +24 -21
  23. package/local-runtime/version.json +1 -1
  24. package/local-runtime/web/app.bundle.js +22 -5
  25. package/local-runtime/web/app.bundle.js.gz +0 -0
  26. package/local-runtime/web/index.html +2 -2
  27. package/local-runtime/web/style.bundle.css +1 -1
  28. package/local-runtime/web/style.bundle.css.gz +0 -0
  29. package/package.json +5 -1
  30. package/service/config.js +23 -2
  31. package/service/index.js +1 -0
  32. package/service/linux.js +3 -2
  33. package/yeaft/config-api.js +138 -192
  34. package/yeaft/config-store.js +192 -0
  35. package/yeaft/config.js +3 -0
  36. package/yeaft/init.js +20 -7
  37. package/yeaft/sessions/feature-flag.js +15 -33
  38. package/yeaft/storage/atomic.js +43 -17
  39. package/yeaft/tools/create-work-item.js +1 -1
  40. package/yeaft/tools/process-runner.js +86 -13
  41. package/yeaft/work-center/bridge.js +3 -2
  42. package/yeaft/work-center/completion-contract.js +6 -0
  43. package/yeaft/work-center/controller.js +2 -1
  44. package/yeaft/work-center/coordinator.js +45 -14
  45. package/yeaft/work-center/durable-model.js +45 -1
  46. package/yeaft/work-center/dynamic-coordination.js +34 -0
  47. package/yeaft/work-center/evidence.js +235 -0
  48. package/yeaft/work-center/mainline-projection.js +4 -1
  49. package/yeaft/work-center/projection.js +45 -4
  50. package/yeaft/work-center/runner.js +82 -7
  51. package/yeaft/work-center/service.js +6 -0
  52. package/yeaft/work-center/store.js +162 -19
  53. package/yeaft/work-center/workflow.js +6 -0
@@ -0,0 +1,192 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ writeFileSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ } from 'node:fs';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { dirname, join } from 'node:path';
13
+ import { hostname } from 'node:os';
14
+ import { normalizePluginConfig } from './plugins.js';
15
+ import { writeAtomic } from './storage/atomic.js';
16
+
17
+ const LOCK_WAIT_MS = 10_000;
18
+ const LOCK_STALE_MS = 5 * 60_000;
19
+ const LOCK_RETRY_MS = 10;
20
+
21
+ function sleepSync(ms) {
22
+ const buffer = new Int32Array(new SharedArrayBuffer(4));
23
+ Atomics.wait(buffer, 0, 0, ms);
24
+ }
25
+
26
+ function ensureOwnerDirectory(path) {
27
+ if (existsSync(path)) {
28
+ const details = lstatSync(path);
29
+ if (!details.isDirectory()) throw new Error('Yeaft data root is not a directory');
30
+ if (process.platform !== 'win32') {
31
+ const currentMode = details.mode & 0o777;
32
+ const restrictedMode = currentMode & 0o700;
33
+ if (currentMode !== restrictedMode) chmodSync(path, restrictedMode);
34
+ }
35
+ return;
36
+ }
37
+ mkdirSync(path, { recursive: true, mode: 0o700 });
38
+ if (process.platform !== 'win32') chmodSync(path, 0o700);
39
+ }
40
+
41
+ function readConfigForWrite(configPath) {
42
+ if (!existsSync(configPath)) return {};
43
+ const json = JSON.parse(readFileSync(configPath, 'utf8'));
44
+ if (!json || typeof json !== 'object' || Array.isArray(json)
45
+ || Object.getPrototypeOf(json) !== Object.prototype) {
46
+ throw new Error('config.json must contain an object');
47
+ }
48
+ if (Object.prototype.hasOwnProperty.call(json, 'plugins')) {
49
+ normalizePluginConfig(json.plugins);
50
+ }
51
+ return json;
52
+ }
53
+
54
+ function processIsAlive(pid) {
55
+ if (!Number.isInteger(pid) || pid <= 0) return false;
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch (error) {
60
+ return error?.code === 'EPERM';
61
+ }
62
+ }
63
+
64
+ function readConfigLockOwner(lockDir) {
65
+ const lockStat = lstatSync(lockDir);
66
+ if (!lockStat.isDirectory()) throw new Error('config.json lock path is not a directory');
67
+ try {
68
+ const owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8'));
69
+ return { owner, lockStat };
70
+ } catch {
71
+ return { owner: null, lockStat };
72
+ }
73
+ }
74
+
75
+ function configLockCanBeTaken(lockDir) {
76
+ const { owner, lockStat } = readConfigLockOwner(lockDir);
77
+ if (owner?.host === hostname()) return !processIsAlive(Number(owner.pid));
78
+ if (owner) return false;
79
+ return Date.now() - lockStat.mtimeMs > LOCK_STALE_MS;
80
+ }
81
+
82
+ function lockIsOwned(lockDir, token) {
83
+ try {
84
+ const owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8'));
85
+ return owner?.token === token;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function removeConfigLockIfOwned(lockDir, token) {
92
+ if (!lockIsOwned(lockDir, token)) return false;
93
+ const claimed = `${lockDir}.release-${token}`;
94
+ try {
95
+ renameSync(lockDir, claimed);
96
+ } catch (error) {
97
+ if (error?.code === 'ENOENT') return false;
98
+ throw error;
99
+ }
100
+ if (!lockIsOwned(claimed, token)) {
101
+ try { renameSync(claimed, lockDir); } catch {}
102
+ return false;
103
+ }
104
+ rmSync(claimed, { recursive: true, force: true });
105
+ return true;
106
+ }
107
+
108
+ function lockOwnerIdentity(owner) {
109
+ if (!owner) return null;
110
+ if (typeof owner.token === 'string' && owner.token) return `token:${owner.token}`;
111
+ return `legacy:${owner.host || ''}:${Number(owner.pid) || 0}:${Number(owner.startedAt) || 0}`;
112
+ }
113
+
114
+ function takeConfigLock(lockDir) {
115
+ const observed = readConfigLockOwner(lockDir).owner;
116
+ if (!configLockCanBeTaken(lockDir)) return false;
117
+ const observedIdentity = lockOwnerIdentity(observed);
118
+ const claimed = `${lockDir}.stale-${randomUUID()}`;
119
+ try {
120
+ renameSync(lockDir, claimed);
121
+ } catch (error) {
122
+ if (error?.code === 'ENOENT') return true;
123
+ return false;
124
+ }
125
+ const claimedOwner = readConfigLockOwner(claimed).owner;
126
+ const ownerChanged = lockOwnerIdentity(claimedOwner) !== observedIdentity;
127
+ const ownerRevived = claimedOwner?.host === hostname()
128
+ && processIsAlive(Number(claimedOwner.pid));
129
+ if (ownerChanged || ownerRevived) {
130
+ try { renameSync(claimed, lockDir); } catch {}
131
+ return false;
132
+ }
133
+ rmSync(claimed, { recursive: true, force: true });
134
+ return true;
135
+ }
136
+
137
+ function acquireConfigLock(root, { waitMs = LOCK_WAIT_MS } = {}) {
138
+ ensureOwnerDirectory(root);
139
+ const lockDir = join(root, '.config.json.lock');
140
+ const deadline = Date.now() + waitMs;
141
+ for (;;) {
142
+ const token = randomUUID();
143
+ try {
144
+ mkdirSync(lockDir, { mode: 0o700 });
145
+ writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({
146
+ pid: process.pid,
147
+ host: hostname(),
148
+ token,
149
+ startedAt: Date.now(),
150
+ }), { flag: 'wx', mode: 0o600 });
151
+ return () => removeConfigLockIfOwned(lockDir, token);
152
+ } catch (error) {
153
+ if (error?.code !== 'EEXIST') throw error;
154
+ try {
155
+ if (takeConfigLock(lockDir)) continue;
156
+ } catch (inspectionError) {
157
+ if (inspectionError?.code === 'ENOENT') continue;
158
+ throw inspectionError;
159
+ }
160
+ if (Date.now() >= deadline) throw new Error('config.json is busy');
161
+ sleepSync(Math.min(LOCK_RETRY_MS, Math.max(1, deadline - Date.now())));
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Mutate one Agent-owned config.json under a cross-process lock.
168
+ * The callback runs after the file is re-read and validated inside the lock.
169
+ */
170
+ export function mutateAgentConfig(root, mutate, options = {}) {
171
+ if (!root) throw new Error('Yeaft data root required');
172
+ if (typeof mutate !== 'function') throw new Error('config mutator required');
173
+ const release = acquireConfigLock(root, options);
174
+ const configPath = join(root, 'config.json');
175
+ try {
176
+ const exists = existsSync(configPath);
177
+ const current = readConfigForWrite(configPath);
178
+ const result = mutate(current, { exists, configPath });
179
+ writeAtomic(configPath, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o600 });
180
+ return result;
181
+ } finally {
182
+ release();
183
+ }
184
+ }
185
+
186
+ export function mutateAgentConfigPath(configPath, mutate, options = {}) {
187
+ return mutateAgentConfig(dirname(configPath), mutate, options);
188
+ }
189
+
190
+ export function readAgentConfigForWrite(configPath) {
191
+ return readConfigForWrite(configPath);
192
+ }
package/yeaft/config.js CHANGED
@@ -27,6 +27,7 @@ import { getModelEffortOptions, getThinkingCapability, modelSupportsEffort, reso
27
27
  import { inferProtocolFromModelId } from './llm/router.js';
28
28
  import { normalizeKnownProviderForRuntime } from './llm/known-providers.js';
29
29
  import { createDenyAllPluginConfig, normalizePluginConfig } from './plugins.js';
30
+ import { normaliseBrowserRuntimeSection } from '../browser-runtime/config.js';
30
31
  import { readWorkspaceFile } from './workspace-file.js';
31
32
 
32
33
  /** Default configuration values. */
@@ -368,6 +369,7 @@ function loadLegacyConfig(dir, overrides) {
368
369
  // task-318: legacy path never had the `yeaft` section — defaults.
369
370
  yeaft: normaliseYeaftSection(null),
370
371
  telemetry: normaliseTelemetrySection(null),
372
+ browserRuntime: normaliseBrowserRuntimeSection(null),
371
373
  plugins: {},
372
374
  providers: null,
373
375
  primaryModel: null,
@@ -524,6 +526,7 @@ export function loadConfig(overrides = {}) {
524
526
  // don't pollute the flat config namespace used by chat code.
525
527
  yeaft: normaliseYeaftSection(jsonConfig.yeaft),
526
528
  telemetry: normaliseTelemetrySection(jsonConfig.telemetry),
529
+ browserRuntime: normaliseBrowserRuntimeSection(jsonConfig.browserRuntime),
527
530
 
528
531
  // Agent-level tools / skills / MCP server allowlists. Missing fields mean
529
532
  // all currently discovered capabilities remain enabled. A persisted schema
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') {
@@ -74,7 +74,7 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
74
74
  // a different dispatch policy into it.
75
75
  origin: {
76
76
  sessionId,
77
- messageId: ctx.inboundEnvelope?.msgId || null,
77
+ messageId: ctx.inboundEnvelope?.msg?.id || null,
78
78
  createdBy: ctx.currentVpId || 'assistant',
79
79
  },
80
80
  linkedSessionIds: [sessionId],
@@ -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);
@@ -27,7 +27,8 @@ const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_req
27
27
  // client-supplied value and only emits files resolved from owned upload ids.
28
28
  const BROWSER_FILE_FIELDS = Object.freeze({
29
29
  create: [
30
- 'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'files', 'start',
30
+ 'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'deliveryTarget',
31
+ 'reuseMemory', 'files', 'start',
31
32
  ],
32
33
  post_work_item_message: [
33
34
  'id', 'clientMessageId', 'text', 'target', 'revision', 'planRevision', 'ledgerRevision',
@@ -225,7 +226,7 @@ export async function handleWorkCenterRequest(msg) {
225
226
  const payload = Object.hasOwn(BROWSER_FILE_FIELDS, op)
226
227
  ? browserFilePayload(op, msg.payload)
227
228
  : (BROWSER_ACTION_DEBUG_OPS.has(op) ? browserFilePayload(op, msg.payload) : (msg.payload || {}));
228
- data = await workCenter.handle(op, payload);
229
+ data = await workCenter.handle(op, payload, { userOriginated: true });
229
230
  }
230
231
  if (BROWSER_DETAIL_OPS.has(op) && data?.accepted !== true) {
231
232
  data = workCenter.projectBrowserDetail(data);