@hamp10/agentforge 0.2.22 → 0.2.24

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/selfUpdate.js CHANGED
@@ -7,6 +7,9 @@
7
7
  */
8
8
 
9
9
  import { execSync, spawn } from 'child_process';
10
+ import { mkdirSync } from 'fs';
11
+ import { homedir } from 'os';
12
+ import path from 'path';
10
13
 
11
14
  function parseVersion(v) {
12
15
  return (v || '0.0.0').split('.').map(Number);
@@ -44,11 +47,11 @@ export async function checkAndUpdate(packageName, currentVersion) {
44
47
  console.log(` Updating ${packageName}...`);
45
48
 
46
49
  try {
47
- execSync(`npm install -g ${packageName}@${latestVersion}`, { stdio: 'inherit' });
50
+ installGlobalPackage(`${packageName}@${latestVersion}`);
48
51
  } catch {
49
- // First attempt failed (often EEXIST on the bin symlink) — retry with --force
52
+ // First attempt failed (often EEXIST on the bin symlink) — retry with --force.
50
53
  try {
51
- execSync(`npm install -g --force ${packageName}@${latestVersion}`, { stdio: 'inherit' });
54
+ installGlobalPackage(`${packageName}@${latestVersion}`, { force: true });
52
55
  } catch {
53
56
  console.warn('⚠️ Auto-update failed — continuing with current version');
54
57
  return;
@@ -69,3 +72,28 @@ export async function checkAndUpdate(packageName, currentVersion) {
69
72
  // Park the parent — child owns the terminal from here
70
73
  await new Promise(() => {});
71
74
  }
75
+
76
+ function installGlobalPackage(pkg, { force = false } = {}) {
77
+ const forceFlag = force ? ' --force' : '';
78
+ try {
79
+ execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit' });
80
+ return;
81
+ } catch (error) {
82
+ if (!looksLikePermissionFailure(error)) throw error;
83
+ }
84
+
85
+ const prefix = path.join(homedir(), '.npm-global');
86
+ mkdirSync(path.join(prefix, 'bin'), { recursive: true });
87
+ mkdirSync(path.join(prefix, 'lib', 'node_modules'), { recursive: true });
88
+ execSync(`npm config set prefix "${prefix}"`, { stdio: 'inherit' });
89
+ const env = {
90
+ ...process.env,
91
+ PATH: `${path.join(prefix, 'bin')}${path.delimiter}${process.env.PATH || ''}`,
92
+ };
93
+ execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit', env });
94
+ }
95
+
96
+ function looksLikePermissionFailure(error) {
97
+ const text = `${error?.message || ''}\n${error?.stderr?.toString?.() || ''}\n${error?.stdout?.toString?.() || ''}`;
98
+ return /EACCES|permission denied|access/i.test(text);
99
+ }
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,176 @@
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
+ const COMMON_DOMAIN_SUFFIXES = new Set([
5
+ 'ai', 'app', 'co', 'com', 'dev', 'gg', 'io', 'net', 'org', 'so', 'xyz',
6
+ ]);
7
+
8
+ const GENERIC_SCOPE_WORDS = new Set([
9
+ 'a', 'an', 'the', 'and', 'or', 'page', 'pages', 'screen', 'screens',
10
+ 'route', 'routes', 'view', 'views', 'listing', 'listings', 'site',
11
+ 'website', 'app', 'readability', 'readable', 'legibility', 'contrast',
12
+ 'quality', 'visual', 'content', 'layout', 'styling', 'polish',
13
+ 'current', 'target', 'targets', 'requested', 'same', 'rest', 'live',
14
+ 'local', 'itself', 'only', 'those', 'these', 'this', 'that', 'two',
15
+ 'both', 'change', 'edit', 'modify', 'touch', 'make', 'improve', 'fix',
16
+ 'update', 'redesign', 'owned', 'source', 'style', 'styles', 'global',
17
+ 'shared', 'reference', 'references', 'broaden', 'work', 'wrong', 'bad',
18
+ 'broken',
19
+ ]);
20
+
21
+ const followedByNamedPageTargets = (text, match) => {
22
+ const after = text.slice(match.index + match[0].length);
23
+ return /^\s+(?:listing\s+)?(?:pages?|screens?|routes?|views?|listings?)\s+(?:for|of|called|named)\b/i.test(after);
24
+ };
25
+
26
+ export function scopeSlug(value) {
27
+ return String(value || '')
28
+ .toLowerCase()
29
+ .replace(/^https?:\/\//, '')
30
+ .replace(/[^a-z0-9]+/g, '-')
31
+ .replace(/^-+|-+$/g, '');
32
+ }
33
+
34
+ export function scopeSlugAliases(value) {
35
+ const slug = scopeSlug(value);
36
+ if (!slug) return [];
37
+
38
+ const aliases = new Set([slug]);
39
+ const parts = slug.split('-').filter(Boolean);
40
+ const suffix = parts.at(-1);
41
+ if (parts.length > 1 && COMMON_DOMAIN_SUFFIXES.has(suffix)) {
42
+ const withoutSuffix = parts.slice(0, -1).join('-');
43
+ if (withoutSuffix.length >= 3 && !GENERIC_SCOPE_WORDS.has(withoutSuffix)) {
44
+ aliases.add(withoutSuffix);
45
+ const compact = withoutSuffix.replace(/-/g, '');
46
+ if (compact.length >= 4 && !GENERIC_SCOPE_WORDS.has(compact)) aliases.add(compact);
47
+ }
48
+ }
49
+
50
+ return [...aliases];
51
+ }
52
+
53
+ export function scopeSlugsMatchingText(value, slugs) {
54
+ const text = String(value || '').toLowerCase();
55
+ return (Array.isArray(slugs) ? slugs : [])
56
+ .filter(slug => scopeSlugAliases(slug).some(alias => alias && text.includes(alias)));
57
+ }
58
+
59
+ export function scopeExtractionText(message) {
60
+ const raw = String(message || '');
61
+ const taskMatches = [...raw.matchAll(/\b(?:The task is|Continue with the task):\s*"([^"]{1,4000})"/gi)];
62
+ if (taskMatches.length > 0) {
63
+ const taskText = taskMatches.at(-1)[1];
64
+ const resolvedScopeBlocks = [...raw.matchAll(/\[Resolved (?:task|target) scope[^\]]*\]/gi)]
65
+ .map(match => match[0]);
66
+ return [taskText, ...resolvedScopeBlocks].join('\n');
67
+ }
68
+ return raw;
69
+ }
70
+
71
+ export function hasExplicitScopeRestriction(message) {
72
+ 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));
73
+ }
74
+
75
+ export function addScopeCandidate(candidates, value) {
76
+ const parts = String(value || '')
77
+ .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, '')
78
+ .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')
79
+ .replace(/\s+\b(?:is|are|was|were|looks?|feels?|seems?|needs?|need|should|must|has|have)\b[\s\S]*$/i, '')
80
+ .split(/\s*(?:,|&|\band\b|\bor\b)\s*/i);
81
+
82
+ for (const part of parts) {
83
+ const cleaned = part
84
+ .replace(/(.+?)\s+\b(?:the|a|an|this|that|these|those)\b[\s\S]*$/i, '$1')
85
+ .replace(/\s+\b(?:is|are|was|were|looks?|feels?|seems?|needs?|need|should|must|has|have)\b[\s\S]*$/i, '')
86
+ .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, ' ')
87
+ .trim();
88
+ const slug = scopeSlug(cleaned);
89
+ if (slug.length >= 3 && !GENERIC_SCOPE_WORDS.has(slug)) candidates.add(slug);
90
+ }
91
+ }
92
+
93
+ export function extractNamedPageScopeSlugs(message) {
94
+ const text = scopeExtractionText(message);
95
+ if (!PAGE_KIND_RE.test(text)) return [];
96
+
97
+ const candidates = new Set();
98
+ for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
99
+ if (followedByNamedPageTargets(text, match)) continue;
100
+ addScopeCandidate(candidates, match[0]);
101
+ }
102
+
103
+ 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;
104
+ for (const match of text.matchAll(beforePage)) {
105
+ const after = text.slice(match.index + match[0].length);
106
+ if (/\blistings?\b/i.test(match[1]) && /^\s+(?:for|of)\b/i.test(after)) continue;
107
+ addScopeCandidate(candidates, match[1]);
108
+ }
109
+
110
+ const directBeforePage = /\b([a-z0-9][a-z0-9._/-]{2,80})\s+(?:pages?|screens?|routes?|views?|listings?)\b/gi;
111
+ for (const match of text.matchAll(directBeforePage)) {
112
+ addScopeCandidate(candidates, match[1]);
113
+ }
114
+
115
+ 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;
116
+ for (const match of text.matchAll(afterPage)) {
117
+ const before = text.slice(Math.max(0, match.index - 120), match.index);
118
+ const precedingToken = before.match(/\b([a-z0-9][a-z0-9._/-]{2,})\s*$/i)?.[1] || '';
119
+ if (precedingToken && !PAGE_KIND_WORD_RE.test(precedingToken) && !ACTION_WORD_RE.test(precedingToken)) {
120
+ continue;
121
+ }
122
+ addScopeCandidate(candidates, match[1]);
123
+ }
124
+
125
+ for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
126
+ const after = text.slice(match.index + match[0].length);
127
+ 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);
128
+ if (pageTarget) {
129
+ const targetCandidates = new Set();
130
+ addScopeCandidate(targetCandidates, pageTarget[1]);
131
+ targetCandidates.delete(scopeSlug(match[0]));
132
+ if (targetCandidates.size === 0) continue;
133
+ candidates.delete(scopeSlug(match[0]));
134
+ }
135
+ }
136
+
137
+ return [...candidates];
138
+ }
139
+
140
+ export function extractExplicitScopeSlugs(message) {
141
+ const text = scopeExtractionText(message);
142
+ if (!hasExplicitScopeRestriction(text)) {
143
+ return extractNamedPageScopeSlugs(text);
144
+ }
145
+
146
+ const candidates = new Set();
147
+ const namedPageScopeSlugs = extractNamedPageScopeSlugs(text);
148
+ const pageContainerSlugs = new Set();
149
+ for (const match of text.matchAll(/\b[a-z0-9]+(?:[.-][a-z0-9]+)+\b/gi)) {
150
+ if (followedByNamedPageTargets(text, match)) {
151
+ const slug = scopeSlug(match[0]);
152
+ if (!namedPageScopeSlugs.includes(slug)) pageContainerSlugs.add(slug);
153
+ continue;
154
+ }
155
+ if (pageContainerSlugs.has(scopeSlug(match[0]))) continue;
156
+ addScopeCandidate(candidates, match[0]);
157
+ }
158
+ for (const slug of namedPageScopeSlugs) {
159
+ candidates.add(slug);
160
+ }
161
+ return [...candidates];
162
+ }
163
+
164
+ export function extractExplicitScope(message) {
165
+ const text = scopeExtractionText(message);
166
+ const slugs = extractExplicitScopeSlugs(text);
167
+ const inferredNamedPageScope = !hasExplicitScopeRestriction(text) && slugs.length > 0;
168
+ const pageOnly =
169
+ slugs.length > 0 &&
170
+ (
171
+ inferredNamedPageScope ||
172
+ /\bonly\s+(?:touch|modify|change|edit|work on)\b[\s\S]{0,120}\b(?:pages?|screens?|routes?|views?)\b/i.test(text) ||
173
+ /\b(?:pages?|screens?|routes?|views?)\b[\s\S]{0,120}\bonly\b/i.test(text)
174
+ );
175
+ return { slugs, pageOnly };
176
+ }