@hamp10/agentforge 0.2.21 → 0.2.23

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/src/supervisor.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * worker code path is unchanged — only the lifecycle wrapper is new.
11
11
  */
12
12
 
13
- import { spawn } from 'child_process';
13
+ import { spawn, execFileSync } from 'child_process';
14
14
  import { mkdirSync, writeFileSync, readFileSync, unlinkSync, existsSync } from 'fs';
15
15
  import { homedir } from 'os';
16
16
  import path from 'path';
@@ -32,25 +32,80 @@ function removePid(file) {
32
32
  try { unlinkSync(file); } catch {}
33
33
  }
34
34
 
35
+ /**
36
+ * Kill a PID read from a file (if it exists and is still alive).
37
+ * Swallows errors — the process may already be gone.
38
+ */
39
+ function killPidFile(file) {
40
+ try {
41
+ if (!existsSync(file)) return;
42
+ const pid = parseInt(readFileSync(file, 'utf8'));
43
+ if (pid && pid !== process.pid) {
44
+ try { process.kill(pid, 'SIGTERM'); } catch {}
45
+ }
46
+ removePid(file);
47
+ } catch {}
48
+ }
49
+
50
+ function killStaleAgentForgeProcesses() {
51
+ try {
52
+ const thisScript = process.argv[1] || '';
53
+ if (!thisScript) return;
54
+ const keep = new Set([process.pid, process.ppid].filter(Boolean).map(String));
55
+ const output = execFileSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8' });
56
+ for (const rawLine of output.split('\n')) {
57
+ const line = rawLine.trim();
58
+ if (!line) continue;
59
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
60
+ if (!match) continue;
61
+ const [, pid, , command] = match;
62
+ if (keep.has(pid)) continue;
63
+ if (!command.includes(thisScript)) continue;
64
+ if (!/\bstart\b/.test(command)) continue;
65
+ try { process.kill(Number(pid), 'SIGTERM'); } catch {}
66
+ }
67
+ } catch {}
68
+ }
69
+
70
+ /**
71
+ * Check if there is already a supervisor running. Kill it (and its worker)
72
+ * before starting a new one so multiple agentforge start calls never stack up.
73
+ */
74
+ function evictExistingWorker() {
75
+ killPidFile(WORKER_PID_FILE);
76
+ killPidFile(PID_FILE);
77
+ killStaleAgentForgeProcesses();
78
+ }
79
+
35
80
  /**
36
81
  * Run the supervisor in the foreground. Blocks until a clean exit (code 0).
37
82
  * @param {string[]} innerArgv argv to pass to the worker process (must include --no-daemon)
38
83
  */
39
84
  export async function runSupervisor(innerArgv) {
85
+ // Kill any already-running supervisor+worker before taking over.
86
+ // This prevents multiple workers from connecting when the user runs
87
+ // `agentforge start` more than once without stopping first.
88
+ evictExistingWorker();
89
+
40
90
  writePid(PID_FILE, process.pid);
41
91
 
42
- // SIGTERM on supervisor = intentional stop (from agentforge stop command)
43
- process.on('SIGTERM', () => {
44
- console.log('[supervisor] Received SIGTERM shutting down');
92
+ let currentChild = null;
93
+
94
+ const shutdown = (signal) => {
95
+ console.log(`[supervisor] Received ${signal} — shutting down`);
96
+ // Kill the worker child so it doesn't outlive the supervisor (macOS orphan issue)
97
+ if (currentChild && !currentChild.killed) {
98
+ try { currentChild.kill('SIGTERM'); } catch {}
99
+ }
100
+ removePid(WORKER_PID_FILE);
45
101
  removePid(PID_FILE);
46
102
  process.exit(0);
47
- });
103
+ };
104
+
105
+ // SIGTERM on supervisor = intentional stop (from agentforge stop command)
106
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
48
107
  // SIGINT = Ctrl+C in foreground terminal = intentional stop
49
- process.on('SIGINT', () => {
50
- console.log('[supervisor] Received SIGINT — shutting down');
51
- removePid(PID_FILE);
52
- process.exit(0);
53
- });
108
+ process.on('SIGINT', () => shutdown('SIGINT'));
54
109
 
55
110
  let consecutiveCrashes = 0;
56
111
 
@@ -63,6 +118,7 @@ export async function runSupervisor(innerArgv) {
63
118
  env: { ...process.env, AGENTFORGE_SKIP_UPDATE: '1' },
64
119
  });
65
120
 
121
+ currentChild = child;
66
122
  writePid(WORKER_PID_FILE, child.pid);
67
123
 
68
124
  const exitCode = await new Promise(resolve => {
@@ -98,6 +154,9 @@ export async function runSupervisor(innerArgv) {
98
154
  * @param {string[]} supervisorArgv argv for the background supervisor (without --detach)
99
155
  */
100
156
  export function detachSupervisor(supervisorArgv) {
157
+ // Kill any already-running supervisor+worker before starting a new one.
158
+ evictExistingWorker();
159
+
101
160
  const logFile = path.join(CONFIG_DIR, 'worker.log');
102
161
  mkdirSync(CONFIG_DIR, { recursive: true });
103
162
 
@@ -119,17 +178,26 @@ export function detachSupervisor(supervisorArgv) {
119
178
  * Stop a running background supervisor by sending SIGTERM to the PID in the pid file.
120
179
  */
121
180
  export function stopSupervisor() {
122
- if (!existsSync(PID_FILE)) {
123
- console.log('No running supervisor found.');
124
- return;
181
+ let stopped = false;
182
+ // Kill worker first (so it doesn't get orphaned if supervisor dies first)
183
+ if (existsSync(WORKER_PID_FILE)) {
184
+ try {
185
+ const pid = parseInt(readFileSync(WORKER_PID_FILE, 'utf8'));
186
+ if (pid) { process.kill(pid, 'SIGTERM'); stopped = true; }
187
+ } catch {}
188
+ removePid(WORKER_PID_FILE);
125
189
  }
126
- try {
127
- const pid = parseInt(readFileSync(PID_FILE, 'utf8'));
128
- process.kill(pid, 'SIGTERM');
129
- removePid(PID_FILE);
130
- console.log(`Stopped supervisor (PID ${pid})`);
131
- } catch (e) {
132
- console.log('Supervisor not running or already stopped.');
190
+ // Kill supervisor
191
+ if (existsSync(PID_FILE)) {
192
+ try {
193
+ const pid = parseInt(readFileSync(PID_FILE, 'utf8'));
194
+ if (pid) { process.kill(pid, 'SIGTERM'); stopped = true; }
195
+ } catch {}
133
196
  removePid(PID_FILE);
134
197
  }
198
+ if (stopped) {
199
+ console.log('✅ AgentForge worker stopped.');
200
+ } else {
201
+ console.log('No running worker found.');
202
+ }
135
203
  }
@@ -0,0 +1,141 @@
1
+ const PAGE_KIND_RE = /\b(?:pages?|screens?|routes?|views?|listings?)\b/i;
2
+ const PAGE_KIND_WORD_RE = /^(?:listing|listings|page|pages|screen|screens|route|routes|view|views|site|website|app)$/i;
3
+ const ACTION_WORD_RE = /^(?:add|build|change|create|edit|fix|implement|improve|make|redesign|update|work)$/i;
4
+
5
+ const GENERIC_SCOPE_WORDS = new Set([
6
+ 'a', 'an', 'the', 'and', 'or', 'page', 'pages', 'screen', 'screens',
7
+ 'route', 'routes', 'view', 'views', 'listing', 'listings', 'site',
8
+ 'website', 'app', 'readability', 'readable', 'legibility', 'contrast',
9
+ 'quality', 'visual', 'content', 'layout', 'styling', 'polish',
10
+ 'current', 'target', 'targets', 'requested', 'same', 'rest', 'live',
11
+ 'local', 'itself', 'only', 'those', 'these', 'this', 'that', 'two',
12
+ 'both', 'change', 'edit', 'modify', 'touch', 'make', 'improve', 'fix',
13
+ 'update', 'redesign', 'owned', 'source', 'style', 'styles', 'global',
14
+ 'shared', 'reference', 'references', 'broaden', 'work', 'wrong', 'bad',
15
+ 'broken',
16
+ ]);
17
+
18
+ const followedByNamedPageTargets = (text, match) => {
19
+ const after = text.slice(match.index + match[0].length);
20
+ return /^\s+(?:listing\s+)?(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\b/i.test(after);
21
+ };
22
+
23
+ export function scopeSlug(value) {
24
+ return String(value || '')
25
+ .toLowerCase()
26
+ .replace(/^https?:\/\//, '')
27
+ .replace(/[^a-z0-9]+/g, '-')
28
+ .replace(/^-+|-+$/g, '');
29
+ }
30
+
31
+ export function scopeExtractionText(message) {
32
+ const raw = String(message || '');
33
+ const taskMatches = [...raw.matchAll(/\b(?:The task is|Continue with the task):\s*"([^"]{1,4000})"/gi)];
34
+ if (taskMatches.length > 0) {
35
+ const taskText = taskMatches.at(-1)[1];
36
+ const resolvedScopeBlocks = [...raw.matchAll(/\[Resolved (?:task|target) scope[^\]]*\]/gi)]
37
+ .map(match => match[0]);
38
+ return [taskText, ...resolvedScopeBlocks].join('\n');
39
+ }
40
+ return raw;
41
+ }
42
+
43
+ export function hasExplicitScopeRestriction(message) {
44
+ return /\b(do not (?:modify|change|edit|touch) anything else|don't (?:modify|change|edit|touch) anything else|only (?:touch|modify|change|edit|work on)|work only on|nothing else)\b/i.test(scopeExtractionText(message));
45
+ }
46
+
47
+ export function addScopeCandidate(candidates, value) {
48
+ const parts = String(value || '')
49
+ .replace(/\b(?:make|keep|commit|push|verify|test|do\s+not|don't|change|edit|modify|touch|improve|fix|update|redesign)\b[\s\S]*$/i, '')
50
+ .replace(/(.+?)\s+\b(?:the|a|an|this|that|these|those)\b(?=[\s\S]*\b(?:is|are|was|were|looks?|feels?|seems?|needs?|need|should|must|has|have)\b)[\s\S]*$/i, '$1')
51
+ .replace(/\s+\b(?:is|are|was|were|looks?|feels?|seems?|needs?|need|should|must|has|have)\b[\s\S]*$/i, '')
52
+ .split(/\s*(?:,|&|\band\b|\bor\b)\s*/i);
53
+
54
+ for (const part of parts) {
55
+ const cleaned = part
56
+ .replace(/(.+?)\s+\b(?:the|a|an|this|that|these|those)\b[\s\S]*$/i, '$1')
57
+ .replace(/\s+\b(?:is|are|was|were|looks?|feels?|seems?|needs?|need|should|must|has|have)\b[\s\S]*$/i, '')
58
+ .replace(/\b(?:a|an|the|page|pages|screen|screens|route|routes|view|views|listing|listings|site|website|app|readability|readable|legibility|contrast|quality|visual|content|layout|styling|polish|current|target|targets|requested|same|rest|live|local|itself|only|those|these|this|that|two|both|change|edit|modify|touch|make|improve|fix|update|redesign|owned|source|style|styles|global|shared|reference|references|broaden|work|off|wrong|bad|broken)\b/gi, ' ')
59
+ .trim();
60
+ const slug = scopeSlug(cleaned);
61
+ if (slug.length >= 3 && !GENERIC_SCOPE_WORDS.has(slug)) candidates.add(slug);
62
+ }
63
+ }
64
+
65
+ export function extractNamedPageScopeSlugs(message) {
66
+ const text = scopeExtractionText(message);
67
+ if (!PAGE_KIND_RE.test(text)) return [];
68
+
69
+ const candidates = new Set();
70
+ for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
71
+ if (followedByNamedPageTargets(text, match)) continue;
72
+ addScopeCandidate(candidates, match[0]);
73
+ }
74
+
75
+ const beforePage = /\b(?:on|for|of|called|named)\s+(?:the\s+)?([a-z0-9][a-z0-9 _/-]{0,100}?)\s+(?:pages?|screens?|routes?|views?|listings?)\b/gi;
76
+ for (const match of text.matchAll(beforePage)) {
77
+ const after = text.slice(match.index + match[0].length);
78
+ if (/\blistings?\b/i.test(match[1]) && /^\s+(?:for|of)\b/i.test(after)) continue;
79
+ addScopeCandidate(candidates, match[1]);
80
+ }
81
+
82
+ const directBeforePage = /\b([a-z0-9][a-z0-9._/-]{2,80})\s+(?:pages?|screens?|routes?|views?|listings?)\b/gi;
83
+ for (const match of text.matchAll(directBeforePage)) {
84
+ addScopeCandidate(candidates, match[1]);
85
+ }
86
+
87
+ const afterPage = /\b(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\s+(?:the\s+)?([a-z0-9][a-z0-9 ._/-]{0,160}?)(?=$|[?!;]|\.(?:\s|$)|\s+(?:on|at|in|with|without|that|which|where|when|because|so|but)\b)/gi;
88
+ for (const match of text.matchAll(afterPage)) {
89
+ const before = text.slice(Math.max(0, match.index - 120), match.index);
90
+ const precedingToken = before.match(/\b([a-z0-9][a-z0-9._/-]{2,})\s*$/i)?.[1] || '';
91
+ if (precedingToken && !PAGE_KIND_WORD_RE.test(precedingToken) && !ACTION_WORD_RE.test(precedingToken)) {
92
+ continue;
93
+ }
94
+ addScopeCandidate(candidates, match[1]);
95
+ }
96
+
97
+ for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
98
+ const after = text.slice(match.index + match[0].length);
99
+ const pageTarget = after.match(/^\s+(?:listing\s+)?(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\s+(?:the\s+)?([a-z0-9][a-z0-9 ._/-]{0,160}?)(?=$|[?!;]|\.(?:\s|$)|\s+(?:on|at|in|with|without|that|which|where|when|because|so|but)\b)/i);
100
+ if (pageTarget) {
101
+ const targetCandidates = new Set();
102
+ addScopeCandidate(targetCandidates, pageTarget[1]);
103
+ targetCandidates.delete(scopeSlug(match[0]));
104
+ if (targetCandidates.size === 0) continue;
105
+ candidates.delete(scopeSlug(match[0]));
106
+ }
107
+ }
108
+
109
+ return [...candidates];
110
+ }
111
+
112
+ export function extractExplicitScopeSlugs(message) {
113
+ const text = scopeExtractionText(message);
114
+ if (!hasExplicitScopeRestriction(text)) {
115
+ return extractNamedPageScopeSlugs(text);
116
+ }
117
+
118
+ const candidates = new Set();
119
+ for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
120
+ if (followedByNamedPageTargets(text, match)) continue;
121
+ addScopeCandidate(candidates, match[0]);
122
+ }
123
+ for (const slug of extractNamedPageScopeSlugs(text)) {
124
+ candidates.add(slug);
125
+ }
126
+ return [...candidates];
127
+ }
128
+
129
+ export function extractExplicitScope(message) {
130
+ const text = scopeExtractionText(message);
131
+ const slugs = extractExplicitScopeSlugs(text);
132
+ const inferredNamedPageScope = !hasExplicitScopeRestriction(text) && slugs.length > 0;
133
+ const pageOnly =
134
+ slugs.length > 0 &&
135
+ (
136
+ inferredNamedPageScope ||
137
+ /\bonly\s+(?:touch|modify|change|edit|work on)\b[\s\S]{0,120}\b(?:pages?|screens?|routes?|views?)\b/i.test(text) ||
138
+ /\b(?:pages?|screens?|routes?|views?)\b[\s\S]{0,120}\bonly\b/i.test(text)
139
+ );
140
+ return { slugs, pageOnly };
141
+ }