neoagent 3.0.0 → 3.0.1-beta.1

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.
@@ -1,9 +1,13 @@
1
+ 'use strict';
2
+
1
3
  const fs = require('fs');
2
4
  const path = require('path');
3
5
  const db = require('../../db/database');
4
6
  const { AGENT_DATA_DIR } = require('../../../runtime/paths');
5
7
 
6
8
  const SKILLS_DIR = path.join(AGENT_DATA_DIR, 'skills');
9
+ const USER_SKILLS_DIR = path.join(SKILLS_DIR, 'users');
10
+ const LEGACY_SKILL_USER_ID = 0;
7
11
 
8
12
  function shellEscape(value) {
9
13
  const text = String(value ?? '');
@@ -30,13 +34,56 @@ function clampText(value, maxChars) {
30
34
  }
31
35
 
32
36
  function isValidUserId(userId) {
37
+ return normalizeRuntimeUserId(userId) !== null;
38
+ }
39
+
40
+ function normalizeRuntimeUserId(userId) {
33
41
  if (typeof userId === 'number') {
34
- return Number.isInteger(userId) && userId > 0;
42
+ return Number.isInteger(userId) && userId > 0 ? userId : null;
35
43
  }
36
44
  if (typeof userId === 'string') {
37
- return userId.trim() !== '';
45
+ const trimmed = userId.trim();
46
+ if (!trimmed) return null;
47
+ const parsed = Number.parseInt(trimmed, 10);
48
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ function normalizeStoredUserId(userId) {
54
+ if (typeof userId === 'number' && Number.isInteger(userId)) {
55
+ return userId;
56
+ }
57
+ if (typeof userId === 'string') {
58
+ const trimmed = userId.trim();
59
+ if (!trimmed) return LEGACY_SKILL_USER_ID;
60
+ const parsed = Number.parseInt(trimmed, 10);
61
+ return Number.isInteger(parsed) ? parsed : LEGACY_SKILL_USER_ID;
62
+ }
63
+ return LEGACY_SKILL_USER_ID;
64
+ }
65
+
66
+ function normalizeSkillName(name) {
67
+ return String(name || '')
68
+ .trim()
69
+ .replace(/[^a-z0-9-]/gi, '-')
70
+ .replace(/-+/g, '-')
71
+ .replace(/^-|-$/g, '')
72
+ .toLowerCase();
73
+ }
74
+
75
+ function buildSkillKey(ownerType, ownerId, name) {
76
+ return `${ownerType}:${ownerId ?? 'global'}:${name}`;
77
+ }
78
+
79
+ function parseMetadataJson(value) {
80
+ if (!value || typeof value !== 'string') return {};
81
+ try {
82
+ const parsed = JSON.parse(value);
83
+ return parsed && typeof parsed === 'object' ? parsed : {};
84
+ } catch {
85
+ return {};
38
86
  }
39
- return false;
40
87
  }
41
88
 
42
89
  class SkillRunner {
@@ -47,47 +94,67 @@ class SkillRunner {
47
94
 
48
95
  async loadSkills() {
49
96
  this.skills.clear();
50
- if (!fs.existsSync(SKILLS_DIR)) return;
97
+ const dbSkills = db.prepare('SELECT * FROM skills').all();
98
+ const dbSkillPaths = new Set(
99
+ dbSkills
100
+ .map((skill) => path.resolve(String(skill.file_path || '')))
101
+ .filter(Boolean),
102
+ );
103
+ const loadedPaths = new Set();
51
104
 
52
105
  const loadDir = (dir) => {
53
106
  const entries = fs.readdirSync(dir, { withFileTypes: true });
54
107
  for (const entry of entries) {
55
108
  const fullPath = path.join(dir, entry.name);
109
+ if (path.resolve(fullPath) === path.resolve(USER_SKILLS_DIR)) {
110
+ continue;
111
+ }
56
112
  if (entry.isDirectory()) {
57
113
  const skillFile = path.join(fullPath, 'SKILL.md');
58
- if (fs.existsSync(skillFile)) {
59
- this.loadSkillFile(skillFile);
114
+ if (fs.existsSync(skillFile) && !dbSkillPaths.has(path.resolve(skillFile))) {
115
+ this.loadSkillFile(skillFile, { ownerType: 'global' });
116
+ loadedPaths.add(path.resolve(skillFile));
60
117
  }
61
118
  loadDir(fullPath);
62
- } else if (entry.name.endsWith('.md')) {
63
- this.loadSkillFile(fullPath);
119
+ } else if (
120
+ entry.name.endsWith('.md')
121
+ && !loadedPaths.has(path.resolve(fullPath))
122
+ && !dbSkillPaths.has(path.resolve(fullPath))
123
+ ) {
124
+ this.loadSkillFile(fullPath, { ownerType: 'global' });
125
+ loadedPaths.add(path.resolve(fullPath));
64
126
  }
65
127
  }
66
128
  };
67
129
 
68
- loadDir(SKILLS_DIR);
69
-
70
- const dbSkills = db.prepare('SELECT * FROM skills WHERE enabled = 1').all();
130
+ if (fs.existsSync(SKILLS_DIR)) {
131
+ loadDir(SKILLS_DIR);
132
+ }
71
133
  for (const skill of dbSkills) {
72
134
  if (fs.existsSync(skill.file_path)) {
73
- this.loadSkillFile(skill.file_path);
135
+ this.loadSkillFile(skill.file_path, {
136
+ ownerType: normalizeStoredUserId(skill.user_id) > 0 ? 'user' : 'legacy',
137
+ userId: normalizeStoredUserId(skill.user_id),
138
+ enabled: skill.enabled !== 0,
139
+ metadata: parseMetadataJson(skill.metadata),
140
+ });
74
141
  }
75
142
  }
76
143
  }
77
144
 
78
- loadSkillFile(filePath) {
145
+ loadSkillFile(filePath, options = {}) {
79
146
  try {
80
147
  const content = fs.readFileSync(filePath, 'utf-8');
81
- const skill = this.parseSkillMd(content, filePath);
148
+ const skill = this.parseSkillMd(content, filePath, options);
82
149
  if (skill) {
83
- this.skills.set(skill.name, skill);
150
+ this.skills.set(skill.key, skill);
84
151
  }
85
152
  } catch (err) {
86
153
  console.error(`Failed to load skill from ${filePath}:`, err.message);
87
154
  }
88
155
  }
89
156
 
90
- parseSkillMd(content, filePath) {
157
+ parseSkillMd(content, filePath, options = {}) {
91
158
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)/);
92
159
  if (!frontmatterMatch) return null;
93
160
 
@@ -109,15 +176,32 @@ class SkillRunner {
109
176
  }
110
177
  }
111
178
 
112
- if (!metadata.name) return null;
179
+ const name = normalizeSkillName(metadata.name);
180
+ if (!name) return null;
181
+
182
+ const ownerType = options.ownerType || 'global';
183
+ const userId = ownerType === 'user' || ownerType === 'legacy'
184
+ ? normalizeStoredUserId(options.userId)
185
+ : null;
186
+ const mergedMetadata = {
187
+ ...metadata,
188
+ ...(options.metadata && typeof options.metadata === 'object' ? options.metadata : {}),
189
+ };
190
+ if (typeof options.enabled === 'boolean') {
191
+ mergedMetadata.enabled = options.enabled;
192
+ }
113
193
 
114
194
  return {
115
- name: metadata.name,
195
+ key: buildSkillKey(ownerType, userId, name),
196
+ name,
116
197
  description: metadata.description || '',
117
- metadata,
198
+ metadata: mergedMetadata,
118
199
  instructions: body.trim(),
119
200
  filePath,
120
- dir: path.dirname(filePath)
201
+ dir: path.dirname(filePath),
202
+ ownerType,
203
+ userId,
204
+ readOnly: ownerType !== 'user',
121
205
  };
122
206
  }
123
207
 
@@ -125,7 +209,7 @@ class SkillRunner {
125
209
  const maxTotalChars = options.maxTotalChars || 9000;
126
210
  const maxDescriptionChars = options.maxDescriptionChars || 220;
127
211
  const maxTriggerChars = options.maxTriggerChars || 120;
128
- const skills = Array.from(this.skills.values())
212
+ const skills = this.getAll(options.userId)
129
213
  .filter((skill) => skill.metadata.enabled !== false)
130
214
  .sort((a, b) => {
131
215
  const categoryCompare = String(a.metadata?.category || 'general')
@@ -166,9 +250,9 @@ class SkillRunner {
166
250
  return `\n${lines.join('\n')}`;
167
251
  }
168
252
 
169
- getToolDefinitions() {
253
+ getToolDefinitions(options = {}) {
170
254
  const tools = [];
171
- for (const skill of this.skills.values()) {
255
+ for (const skill of this.getAll(options.userId)) {
172
256
  if (skill.metadata.enabled !== false && skill.metadata.tool) {
173
257
  tools.push({
174
258
  name: skill.name,
@@ -181,7 +265,7 @@ class SkillRunner {
181
265
  }
182
266
 
183
267
  async executeTool(toolName, args, context = {}) {
184
- const skill = this.skills.get(toolName);
268
+ const skill = this.getSkill(toolName, context.userId);
185
269
  if (!skill) return null;
186
270
  const metricContext = {
187
271
  userId: context.userId,
@@ -272,28 +356,70 @@ class SkillRunner {
272
356
  );
273
357
  }
274
358
 
275
- createSkill(name, description, instructions, metadata = {}) {
276
- const safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
277
- const skillDir = path.join(SKILLS_DIR, safeName);
359
+ createSkill(userId, name, description, instructions, metadata = {}) {
360
+ const ownerId = normalizeRuntimeUserId(userId);
361
+ if (ownerId === null) {
362
+ return { error: 'Missing or invalid userId' };
363
+ }
364
+ const safeName = normalizeSkillName(name);
365
+ if (!safeName) {
366
+ return { error: 'Skill name is required' };
367
+ }
368
+ if (this._findOwnedSkill(safeName, ownerId)) {
369
+ return { error: `Skill '${safeName}' already exists` };
370
+ }
371
+
372
+ const metaToWrite = metadata && typeof metadata === 'object' ? metadata : {};
373
+ const skillDir = path.join(USER_SKILLS_DIR, String(ownerId), safeName);
278
374
  if (!fs.existsSync(skillDir)) fs.mkdirSync(skillDir, { recursive: true });
279
375
 
280
- const frontmatter = this._buildFrontmatter(safeName, description, metadata);
376
+ const frontmatter = this._buildFrontmatter(safeName, description, metaToWrite);
281
377
  const filePath = path.join(skillDir, 'SKILL.md');
282
378
  fs.writeFileSync(filePath, frontmatter + `\n\n${instructions}`);
283
379
 
284
- db.prepare(`
285
- INSERT OR REPLACE INTO skills (name, description, file_path, metadata, enabled, auto_created, updated_at)
286
- VALUES (?, ?, ?, ?, ?, 1, datetime('now'))
287
- `).run(safeName, description, filePath, JSON.stringify(metadata), metadata.enabled === false ? 0 : 1);
380
+ try {
381
+ db.prepare(`
382
+ INSERT INTO skills (user_id, name, description, file_path, metadata, enabled, auto_created, updated_at)
383
+ VALUES (?, ?, ?, ?, ?, ?, 1, datetime('now'))
384
+ `).run(
385
+ ownerId,
386
+ safeName,
387
+ description,
388
+ filePath,
389
+ JSON.stringify(metaToWrite),
390
+ metaToWrite.enabled === false ? 0 : 1,
391
+ );
392
+ } catch (err) {
393
+ try {
394
+ fs.unlinkSync(filePath);
395
+ } catch {}
396
+ try {
397
+ const remaining = fs.readdirSync(skillDir);
398
+ if (remaining.length === 0) fs.rmdirSync(skillDir);
399
+ } catch {}
400
+ if (String(err?.message || '').includes('UNIQUE')) {
401
+ return { error: `Skill '${safeName}' already exists` };
402
+ }
403
+ throw err;
404
+ }
288
405
 
289
- this.loadSkillFile(filePath);
406
+ this.loadSkillFile(filePath, {
407
+ ownerType: 'user',
408
+ userId: ownerId,
409
+ enabled: metaToWrite.enabled !== false,
410
+ metadata: metaToWrite,
411
+ });
290
412
 
291
413
  return { success: true, name: safeName, path: filePath };
292
414
  }
293
415
 
294
- updateSkill(name, { description, instructions, metadata } = {}) {
295
- const skill = this.skills.get(name);
416
+ updateSkill(userId, name, { description, instructions, metadata } = {}) {
417
+ const ownerId = normalizeRuntimeUserId(userId);
418
+ const skill = this.getSkill(name, ownerId);
296
419
  if (!skill) return { error: `Skill '${name}' not found` };
420
+ if (!this._canEditSkill(skill, ownerId)) {
421
+ return { error: `Skill '${skill.name}' is read-only`, code: 'forbidden' };
422
+ }
297
423
 
298
424
  const newDesc = description !== undefined ? description : skill.description;
299
425
  const newInstructions = instructions !== undefined ? instructions : skill.instructions;
@@ -308,29 +434,57 @@ class SkillRunner {
308
434
  metaToWrite = existing;
309
435
  }
310
436
 
311
- const frontmatter = this._buildFrontmatter(name, newDesc, metaToWrite);
437
+ const frontmatter = this._buildFrontmatter(skill.name, newDesc, metaToWrite);
312
438
  fs.writeFileSync(skill.filePath, frontmatter + `\n\n${newInstructions}`);
313
- db.prepare('UPDATE skills SET description = ?, metadata = ?, enabled = ?, updated_at = datetime(\'now\') WHERE name = ?')
314
- .run(newDesc, JSON.stringify(metaToWrite || {}), metaToWrite?.enabled === false ? 0 : 1, name);
315
- this.loadSkillFile(skill.filePath);
316
-
317
- return { success: true, name, path: skill.filePath };
439
+ db.prepare(
440
+ `UPDATE skills
441
+ SET description = ?, metadata = ?, enabled = ?, updated_at = datetime('now')
442
+ WHERE user_id = ? AND name = ?`
443
+ ).run(
444
+ newDesc,
445
+ JSON.stringify(metaToWrite || {}),
446
+ metaToWrite?.enabled === false ? 0 : 1,
447
+ ownerId,
448
+ skill.name,
449
+ );
450
+ this.loadSkillFile(skill.filePath, {
451
+ ownerType: 'user',
452
+ userId: ownerId,
453
+ enabled: metaToWrite?.enabled !== false,
454
+ metadata: metaToWrite,
455
+ });
456
+
457
+ return { success: true, name: skill.name, path: skill.filePath };
318
458
  }
319
459
 
320
- getSkill(name) {
321
- return this.skills.get(name) || null;
460
+ getSkill(name, userId = null) {
461
+ const skillName = normalizeSkillName(name);
462
+ if (!skillName) return null;
463
+ const ownerId = normalizeRuntimeUserId(userId);
464
+ if (ownerId !== null) {
465
+ const owned = this._findOwnedSkill(skillName, ownerId);
466
+ if (owned) return owned;
467
+ }
468
+ return this._findVisibleSkill(skillName, ownerId);
322
469
  }
323
470
 
324
- setSkillEnabled(name, enabled) {
325
- const skill = this.skills.get(name);
471
+ setSkillEnabled(userId, name, enabled) {
472
+ const skill = this.getSkill(name, userId);
326
473
  if (!skill) return { error: `Skill '${name}' not found` };
474
+ if (!this._canEditSkill(skill, normalizeRuntimeUserId(userId))) {
475
+ return { error: `Skill '${skill.name}' is read-only`, code: 'forbidden' };
476
+ }
327
477
  const metadata = { ...skill.metadata, enabled: !!enabled };
328
- return this.updateSkill(name, { metadata });
478
+ return this.updateSkill(userId, skill.name, { metadata });
329
479
  }
330
480
 
331
- deleteSkill(name) {
332
- const skill = this.skills.get(name);
481
+ deleteSkill(userId, name) {
482
+ const ownerId = normalizeRuntimeUserId(userId);
483
+ const skill = this.getSkill(name, ownerId);
333
484
  if (!skill) return { error: `Skill '${name}' not found` };
485
+ if (!this._canEditSkill(skill, ownerId)) {
486
+ return { error: `Skill '${skill.name}' is read-only`, code: 'forbidden' };
487
+ }
334
488
 
335
489
  try {
336
490
  fs.unlinkSync(skill.filePath);
@@ -341,10 +495,10 @@ class SkillRunner {
341
495
  }
342
496
  } catch (e) { /* ignore */ }
343
497
 
344
- db.prepare('DELETE FROM skills WHERE name = ?').run(name);
345
- this.skills.delete(name);
498
+ db.prepare('DELETE FROM skills WHERE user_id = ? AND name = ?').run(ownerId, skill.name);
499
+ this.skills.delete(skill.key);
346
500
 
347
- return { success: true, deleted: name };
501
+ return { success: true, deleted: skill.name };
348
502
  }
349
503
 
350
504
  _buildFrontmatter(name, description, metadata = {}) {
@@ -361,15 +515,68 @@ class SkillRunner {
361
515
  return fm;
362
516
  }
363
517
 
364
- getAll() {
365
- return Array.from(this.skills.values()).map(s => ({
518
+ getAll(userId = null) {
519
+ return this._getVisibleSkills(userId).map((s) => ({
366
520
  name: s.name,
367
521
  description: s.description,
368
522
  metadata: s.metadata,
369
523
  filePath: s.filePath,
370
- enabled: s.metadata.enabled !== false
524
+ enabled: s.metadata.enabled !== false,
525
+ userId: s.userId,
526
+ ownerType: s.ownerType,
527
+ readOnly: s.readOnly,
371
528
  }));
372
529
  }
530
+
531
+ findSkillByWorkflowSignature(userId, workflowSignature) {
532
+ const ownerId = normalizeRuntimeUserId(userId);
533
+ if (ownerId === null || !workflowSignature) return null;
534
+ return Array.from(this.skills.values()).find(
535
+ (skill) => skill.ownerType === 'user'
536
+ && skill.userId === ownerId
537
+ && skill.metadata?.workflow_signature === workflowSignature,
538
+ ) || null;
539
+ }
540
+
541
+ _findOwnedSkill(name, userId) {
542
+ const ownerId = normalizeRuntimeUserId(userId);
543
+ if (ownerId === null) return null;
544
+ return this.skills.get(buildSkillKey('user', ownerId, normalizeSkillName(name))) || null;
545
+ }
546
+
547
+ _findVisibleSkill(name, userId) {
548
+ const skillName = normalizeSkillName(name);
549
+ return this._getVisibleSkills(userId).find((skill) => skill.name === skillName) || null;
550
+ }
551
+
552
+ _getVisibleSkills(userId = null) {
553
+ const ownerId = normalizeRuntimeUserId(userId);
554
+ const visible = Array.from(this.skills.values()).filter((skill) => {
555
+ if (ownerId === null) return true;
556
+ if (skill.ownerType === 'user') return skill.userId === ownerId;
557
+ return true;
558
+ });
559
+ if (ownerId === null) return visible;
560
+ const ownedNames = new Set(
561
+ visible
562
+ .filter((skill) => skill.ownerType === 'user' && skill.userId === ownerId)
563
+ .map((skill) => skill.name),
564
+ );
565
+ return visible.filter((skill) => {
566
+ if (skill.ownerType === 'user') return skill.userId === ownerId;
567
+ return !ownedNames.has(skill.name);
568
+ });
569
+ }
570
+
571
+ _canEditSkill(skill, userId) {
572
+ const ownerId = normalizeRuntimeUserId(userId);
573
+ return skill?.ownerType === 'user' && skill.userId === ownerId;
574
+ }
373
575
  }
374
576
 
375
- module.exports = { SkillRunner };
577
+ module.exports = {
578
+ SkillRunner,
579
+ LEGACY_SKILL_USER_ID,
580
+ USER_SKILLS_DIR,
581
+ normalizeSkillName,
582
+ };
@@ -2588,25 +2588,25 @@ async function executeTool(toolName, args, context, engine) {
2588
2588
  const { SkillRunner } = require('./toolRunner');
2589
2589
  const sharedRunner = sk();
2590
2590
  if (sharedRunner) {
2591
- const result = sharedRunner.createSkill(args.name, args.description, args.instructions, args.metadata);
2591
+ const result = sharedRunner.createSkill(userId, args.name, args.description, args.instructions, args.metadata);
2592
2592
  return result;
2593
2593
  }
2594
2594
  const runner = new SkillRunner();
2595
2595
  await runner.loadSkills();
2596
- return runner.createSkill(args.name, args.description, args.instructions, args.metadata);
2596
+ return runner.createSkill(userId, args.name, args.description, args.instructions, args.metadata);
2597
2597
  }
2598
2598
 
2599
2599
  case 'list_skills': {
2600
2600
  const skillRunner = sk();
2601
2601
  if (!skillRunner) return { error: 'Skill runner not available' };
2602
- const all = skillRunner.getAll();
2602
+ const all = skillRunner.getAll(userId);
2603
2603
  return { skills: all, count: all.length };
2604
2604
  }
2605
2605
 
2606
2606
  case 'update_skill': {
2607
2607
  const skillRunner = sk();
2608
2608
  if (!skillRunner) return { error: 'Skill runner not available' };
2609
- return skillRunner.updateSkill(args.name, {
2609
+ return skillRunner.updateSkill(userId, args.name, {
2610
2610
  description: args.description,
2611
2611
  instructions: args.instructions,
2612
2612
  metadata: args.metadata
@@ -2616,7 +2616,7 @@ async function executeTool(toolName, args, context, engine) {
2616
2616
  case 'delete_skill': {
2617
2617
  const skillRunner = sk();
2618
2618
  if (!skillRunner) return { error: 'Skill runner not available' };
2619
- return skillRunner.deleteSkill(args.name);
2619
+ return skillRunner.deleteSkill(userId, args.name);
2620
2620
  }
2621
2621
 
2622
2622
  case 'think': {
@@ -5,6 +5,60 @@ const { randomUUID } = require('crypto');
5
5
  const path = require('path');
6
6
 
7
7
  const WORKER_SCRIPT = path.resolve(__dirname, 'shell_worker.js');
8
+ const DEFAULT_WORKER_ENV_KEYS = Object.freeze([
9
+ 'HOME',
10
+ 'PATH',
11
+ 'SHELL',
12
+ 'TMPDIR',
13
+ 'TMP',
14
+ 'TEMP',
15
+ 'TERM',
16
+ 'LANG',
17
+ 'LC_ALL',
18
+ 'LC_CTYPE',
19
+ 'USER',
20
+ 'LOGNAME',
21
+ 'COLORTERM',
22
+ 'SystemRoot',
23
+ 'ComSpec',
24
+ 'PATHEXT',
25
+ 'WINDIR',
26
+ ]);
27
+
28
+ function normalizeExtraEnvKeys(extraEnvKeys = process.env.NEOAGENT_SHELL_WORKER_ENV_ALLOWLIST || '') {
29
+ if (Array.isArray(extraEnvKeys)) {
30
+ return extraEnvKeys.map((key) => String(key || '').trim()).filter(Boolean);
31
+ }
32
+ return String(extraEnvKeys || '')
33
+ .split(',')
34
+ .map((key) => key.trim())
35
+ .filter(Boolean);
36
+ }
37
+
38
+ function buildWorkerEnv(baseEnv = process.env, extraEnvKeys) {
39
+ const allowedKeys = new Set([
40
+ ...DEFAULT_WORKER_ENV_KEYS,
41
+ ...normalizeExtraEnvKeys(extraEnvKeys),
42
+ ]);
43
+ const workerEnv = {};
44
+ for (const key of allowedKeys) {
45
+ if (Object.prototype.hasOwnProperty.call(baseEnv, key) && baseEnv[key] != null) {
46
+ workerEnv[key] = String(baseEnv[key]);
47
+ }
48
+ }
49
+ return workerEnv;
50
+ }
51
+
52
+ function createShutdownResult(message, killed = false) {
53
+ return {
54
+ exitCode: -1,
55
+ stdout: '',
56
+ stderr: message,
57
+ killed,
58
+ timedOut: false,
59
+ durationMs: 0,
60
+ };
61
+ }
8
62
 
9
63
  class ShellWorkerPool {
10
64
  /**
@@ -21,6 +75,7 @@ class ShellWorkerPool {
21
75
  this._queue = [];
22
76
  /** @type {Map<string, Function>} requestId → resolve */
23
77
  this._pending = new Map();
78
+ this._shuttingDown = false;
24
79
 
25
80
  for (let i = 0; i < this._size; i++) {
26
81
  this._spawnWorker();
@@ -31,7 +86,7 @@ class ShellWorkerPool {
31
86
  const proc = fork(this._workerScript, [], {
32
87
  detached: false,
33
88
  stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
34
- env: { ...process.env },
89
+ env: buildWorkerEnv(process.env),
35
90
  });
36
91
 
37
92
  const workerEntry = { proc, busy: false, pendingRequestId: null };
@@ -52,7 +107,6 @@ class ShellWorkerPool {
52
107
  });
53
108
 
54
109
  proc.on('exit', (code) => {
55
- console.warn(`[ShellWorkerPool] Worker exited (code=${code}), respawning`);
56
110
  const idx = this._workers.indexOf(workerEntry);
57
111
  if (idx !== -1) this._workers.splice(idx, 1);
58
112
 
@@ -61,10 +115,14 @@ class ShellWorkerPool {
61
115
  const resolve = this._pending.get(workerEntry.pendingRequestId);
62
116
  if (resolve) {
63
117
  this._pending.delete(workerEntry.pendingRequestId);
64
- resolve({ exitCode: -1, stdout: '', stderr: 'Worker process crashed', killed: true, timedOut: false });
118
+ const message = this._shuttingDown ? 'Server shutting down' : 'Worker process crashed';
119
+ resolve(createShutdownResult(message, true));
65
120
  }
66
121
  }
67
122
 
123
+ if (this._shuttingDown) return;
124
+
125
+ console.warn(`[ShellWorkerPool] Worker exited (code=${code}), respawning`);
68
126
  this._spawnWorker();
69
127
  });
70
128
 
@@ -113,13 +171,37 @@ class ShellWorkerPool {
113
171
 
114
172
  /** Gracefully terminate all workers. */
115
173
  shutdown() {
174
+ if (this._shuttingDown) return;
175
+ this._shuttingDown = true;
176
+
177
+ const shutdownResult = createShutdownResult('Server shutting down', true);
178
+ for (const job of this._queue) {
179
+ job.resolve(shutdownResult);
180
+ }
181
+ this._queue = [];
182
+
183
+ for (const w of this._workers) {
184
+ if (w.pendingRequestId) {
185
+ const resolve = this._pending.get(w.pendingRequestId);
186
+ if (resolve) {
187
+ this._pending.delete(w.pendingRequestId);
188
+ resolve(shutdownResult);
189
+ }
190
+ w.pendingRequestId = null;
191
+ w.busy = false;
192
+ }
193
+ }
194
+
116
195
  for (const w of this._workers) {
117
196
  try { w.proc.kill(); } catch {}
118
197
  }
119
198
  this._workers = [];
120
- this._queue = [];
121
199
  this._pending.clear();
122
200
  }
123
201
  }
124
202
 
125
- module.exports = { ShellWorkerPool };
203
+ module.exports = {
204
+ ShellWorkerPool,
205
+ DEFAULT_WORKER_ENV_KEYS,
206
+ buildWorkerEnv,
207
+ };
@@ -571,6 +571,22 @@ async function startServices(app, io) {
571
571
  async function stopServices(app) {
572
572
  const tasks = [];
573
573
  console.log('[Services] Stopping services');
574
+ if (app.locals.approvalGateService && typeof app.locals.approvalGateService.shutdown === 'function') {
575
+ try {
576
+ app.locals.approvalGateService.shutdown();
577
+ logServiceReady('Pending approvals expired');
578
+ } catch (err) {
579
+ console.error('[ApprovalGate] Shutdown error:', getErrorMessage(err));
580
+ }
581
+ }
582
+ if (app.locals.agentEngine && typeof app.locals.agentEngine.interruptAllActiveRuns === 'function') {
583
+ try {
584
+ app.locals.agentEngine.interruptAllActiveRuns();
585
+ logServiceReady('Active runs marked interrupted');
586
+ } catch (err) {
587
+ console.error('[AgentEngine] Interrupt error:', getErrorMessage(err));
588
+ }
589
+ }
574
590
  if (app.locals.memoryManager) {
575
591
  app.locals.memoryManager.stopEmbeddingIndexBackfill();
576
592
  }