agentxchain 0.8.7 → 0.8.8

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.
@@ -52,7 +52,12 @@ export async function initCommand(opts) {
52
52
  project = 'My AgentXchain project';
53
53
  agents = DEFAULT_AGENTS;
54
54
  folderName = slugify(project);
55
- rules = { max_consecutive_claims: 2, require_message: true, compress_after_words: 5000 };
55
+ rules = {
56
+ max_consecutive_claims: 2,
57
+ require_message: true,
58
+ compress_after_words: 5000,
59
+ strict_next_owner: false
60
+ };
56
61
  } else {
57
62
  const templates = loadTemplates();
58
63
 
@@ -86,7 +91,12 @@ export async function initCommand(opts) {
86
91
  project = projectName;
87
92
  } else if (template === 'default') {
88
93
  agents = DEFAULT_AGENTS;
89
- rules = { max_consecutive_claims: 2, require_message: true, compress_after_words: 5000 };
94
+ rules = {
95
+ max_consecutive_claims: 2,
96
+ require_message: true,
97
+ compress_after_words: 5000,
98
+ strict_next_owner: false
99
+ };
90
100
  const { projectName } = await inquirer.prompt([{
91
101
  type: 'input',
92
102
  name: 'projectName',
@@ -103,7 +113,12 @@ export async function initCommand(opts) {
103
113
  }]);
104
114
  project = projectName;
105
115
  agents = {};
106
- rules = { max_consecutive_claims: 2, require_message: true, compress_after_words: 5000 };
116
+ rules = {
117
+ max_consecutive_claims: 2,
118
+ require_message: true,
119
+ compress_after_words: 5000,
120
+ strict_next_owner: false
121
+ };
107
122
 
108
123
  const { count } = await inquirer.prompt([{
109
124
  type: 'number',
@@ -192,6 +207,7 @@ export async function initCommand(opts) {
192
207
  compress_after_words: rules.compress_after_words || 5000,
193
208
  ttl_minutes: rules.ttl_minutes || 10,
194
209
  watch_interval_ms: rules.watch_interval_ms || 5000,
210
+ strict_next_owner: rules.strict_next_owner === true,
195
211
  ...(rules.verify_command ? { verify_command: rules.verify_command } : {})
196
212
  }
197
213
  };
@@ -209,7 +225,7 @@ export async function initCommand(opts) {
209
225
  writeFileSync(join(dir, 'TALK.md'), `# ${project} — Team Talk File\n\nCanonical human-readable handoff log for all agents.\n\n## How to write entries\n\nUse this exact structure:\n\n## Turn N — <agent_id> (<role>)\n- Status:\n- Decision:\n- Action:\n- Risks/Questions:\n- Next owner:\n\n---\n\n`);
210
226
  writeFileSync(join(dir, 'HUMAN_TASKS.md'), '# Human Tasks\n\n(Agents append tasks here when they need human action.)\n');
211
227
  const gitignorePath = join(dir, '.gitignore');
212
- const requiredIgnores = ['.env', '.agentxchain-trigger.json', '.agentxchain-prompts/', '.agentxchain-workspaces/'];
228
+ const requiredIgnores = ['.env', '.agentxchain-trigger.json', '.agentxchain-prompts/', '.agentxchain-workspaces/', '.agentxchain-watch.pid', '.agentxchain-autonudge.state'];
213
229
  if (!existsSync(gitignorePath)) {
214
230
  writeFileSync(gitignorePath, requiredIgnores.join('\n') + '\n');
215
231
  } else {
@@ -230,10 +246,13 @@ export async function initCommand(opts) {
230
246
 
231
247
  writeFileSync(join(dir, '.planning', 'REQUIREMENTS.md'), `# Requirements — ${project}\n\n## v1 (MVP)\n\n(PM fills this: numbered list of requirements. Each requirement has one-sentence acceptance criteria.)\n\n| # | Requirement | Acceptance criteria | Phase | Status |\n|---|-------------|-------------------|-------|--------|\n| 1 | | | | Pending |\n\n## v2 (Future)\n\n(Out of scope for MVP. Captured here so they don't creep in.)\n\n## Out of scope\n\n(Explicitly not building.)\n`);
232
248
 
233
- writeFileSync(join(dir, '.planning', 'ROADMAP.md'), `# Roadmap — ${project}\n\n## Phases\n\n| Phase | Description | Status | Requirements |\n|-------|-------------|--------|-------------|\n| 1 | Discovery + setup | In progress | — |\n\n(PM updates this as phases are planned and completed.)\n`);
249
+ writeFileSync(join(dir, '.planning', 'ROADMAP.md'), `# Roadmap — ${project}\n\n## Waves\n\n| Wave | Goal | Status |\n|------|------|--------|\n| Wave 1 | Discovery, planning, and phase setup | In progress |\n\n## Phases\n\n| Phase | Description | Status | Requirements |\n|-------|-------------|--------|-------------|\n| 1 | Discovery + setup | In progress | — |\n\n(PM updates this as phases are planned and completed.)\n`);
234
250
  writeFileSync(join(dir, '.planning', 'PM_SIGNOFF.md'), `# PM Signoff — ${project}\n\nApproved: NO\n\n## Discovery Checklist\n- [ ] Target user defined\n- [ ] Core pain point defined\n- [ ] Core workflow defined\n- [ ] MVP scope defined\n- [ ] Out-of-scope list defined\n- [ ] Success metric defined\n\n## Notes for team\n(PM and human add final kickoff notes here.)\n`);
235
251
 
236
252
  // QA structure
253
+ mkdirSync(join(dir, '.planning', 'phases', 'phase-1'), { recursive: true });
254
+ writeFileSync(join(dir, '.planning', 'phases', 'phase-1', 'PLAN.md'), `# Phase 1 Plan — ${project}\n\n## Goal\n\nAlign scope, requirements, and initial implementation plan.\n\n## Deliverables\n\n- PM signoff\n- Initial requirements and roadmap\n- First implementation slice\n`);
255
+ writeFileSync(join(dir, '.planning', 'phases', 'phase-1', 'TESTS.md'), `# Phase 1 Tests — ${project}\n\n## Planned checks\n\n- Kickoff validation passes\n- Requirements have acceptance criteria\n- First implementation slice has an executable verification path\n`);
237
256
  writeFileSync(join(dir, '.planning', 'qa', 'TEST-COVERAGE.md'), `# Test Coverage — ${project}\n\n## Coverage Map\n\n| Feature / Area | Unit tests | Integration tests | E2E tests | Manual QA | UX audit | Status |\n|---------------|-----------|------------------|----------|----------|---------|--------|\n| (QA fills this as testing progresses) | | | | | | |\n\n## Coverage gaps\n\n(Areas with no tests or insufficient coverage.)\n`);
238
257
 
239
258
  writeFileSync(join(dir, '.planning', 'qa', 'REGRESSION-LOG.md'), `# Regression Log — ${project}\n\nBugs that were found and fixed. Each entry has a regression test to prevent recurrence.\n\n| Bug ID | Description | Found turn | Fixed turn | Regression test | Status |\n|--------|-------------|-----------|-----------|----------------|--------|\n| (QA adds entries as bugs are found and fixed) | | | | | |\n`);
@@ -2,8 +2,10 @@ import { readFileSync, existsSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { loadConfig } from '../lib/config.js';
5
+ import { getWatchPid } from './watch.js';
5
6
 
6
7
  const SESSION_FILE = '.agentxchain-session.json';
8
+ const WATCH_PID_FILE = '.agentxchain-watch.pid';
7
9
 
8
10
  export async function stopCommand() {
9
11
  const result = loadConfig();
@@ -11,47 +13,77 @@ export async function stopCommand() {
11
13
 
12
14
  const { root } = result;
13
15
  const sessionPath = join(root, SESSION_FILE);
16
+ const watchPidPath = join(root, WATCH_PID_FILE);
17
+ const watchPid = getWatchPid(root);
18
+ let didStopAnything = false;
14
19
 
15
- if (!existsSync(sessionPath)) {
16
- console.log(chalk.yellow(' No active session found.'));
17
- console.log(chalk.dim(' If agents are running in VS Code / Cursor, close their chat sessions manually.'));
18
- return;
20
+ if (watchPid) {
21
+ try {
22
+ process.kill(watchPid, 'SIGTERM');
23
+ didStopAnything = true;
24
+ console.log('');
25
+ console.log(chalk.green(` ✓ Stopped watch process (PID: ${watchPid})`));
26
+ console.log('');
27
+ } catch (err) {
28
+ if (err.code === 'ESRCH') {
29
+ if (existsSync(watchPidPath)) {
30
+ try { unlinkSync(watchPidPath); } catch {}
31
+ }
32
+ } else {
33
+ console.log(chalk.red(` ✗ Could not stop watch process (PID: ${watchPid}): ${err.message}`));
34
+ }
35
+ }
36
+ } else if (existsSync(watchPidPath)) {
37
+ // Stale PID file from an unexpected shutdown.
38
+ try {
39
+ unlinkSync(watchPidPath);
40
+ console.log(chalk.dim(' Removed stale watch PID file.'));
41
+ } catch {}
19
42
  }
20
43
 
21
- let session;
22
- try {
23
- session = JSON.parse(readFileSync(sessionPath, 'utf8'));
24
- } catch {
25
- console.log(chalk.yellow(' Could not read session file.'));
26
- return;
27
- }
44
+ if (existsSync(sessionPath)) {
45
+ let session;
46
+ try {
47
+ session = JSON.parse(readFileSync(sessionPath, 'utf8'));
48
+ } catch {
49
+ console.log(chalk.yellow(' Could not read session file.'));
50
+ return;
51
+ }
52
+
53
+ console.log('');
54
+ console.log(chalk.bold(` Stopping ${session.launched?.length || 0} agents (${session.ide || 'unknown'})`));
55
+ console.log('');
28
56
 
29
- console.log('');
30
- console.log(chalk.bold(` Stopping ${session.launched?.length || 0} agents (${session.ide || 'unknown'})`));
31
- console.log('');
32
-
33
- if (session.ide === 'claude-code') {
34
- for (const agent of (session.launched || [])) {
35
- if (agent.pid) {
36
- try {
37
- process.kill(agent.pid, 'SIGTERM');
38
- console.log(chalk.green(` ✓ Sent SIGTERM to ${agent.id} (PID: ${agent.pid})`));
39
- } catch (err) {
40
- if (err.code === 'ESRCH') {
41
- console.log(chalk.dim(` ${agent.id} (PID: ${agent.pid}) — already stopped`));
42
- } else {
43
- console.log(chalk.red(` ✗ ${agent.id}: ${err.message}`));
57
+ if (session.ide === 'claude-code') {
58
+ for (const agent of (session.launched || [])) {
59
+ if (agent.pid) {
60
+ try {
61
+ process.kill(agent.pid, 'SIGTERM');
62
+ didStopAnything = true;
63
+ console.log(chalk.green(` ✓ Sent SIGTERM to ${agent.id} (PID: ${agent.pid})`));
64
+ } catch (err) {
65
+ if (err.code === 'ESRCH') {
66
+ console.log(chalk.dim(` ${agent.id} (PID: ${agent.pid}) — already stopped`));
67
+ } else {
68
+ console.log(chalk.red(` ✗ ${agent.id}: ${err.message}`));
69
+ }
44
70
  }
45
71
  }
46
72
  }
73
+ } else {
74
+ console.log(chalk.dim(' For VS Code / Cursor agents, close the chat sessions manually.'));
47
75
  }
48
- } else {
49
- console.log(chalk.dim(' For VS Code / Cursor agents, close the chat sessions manually.'));
76
+
77
+ unlinkSync(sessionPath);
78
+ console.log('');
79
+ console.log(chalk.dim(' Session file removed.'));
80
+ console.log(chalk.green(' Done.'));
81
+ console.log('');
82
+ return;
50
83
  }
51
84
 
52
- unlinkSync(sessionPath);
53
- console.log('');
54
- console.log(chalk.dim(' Session file removed.'));
55
- console.log(chalk.green(' Done.'));
56
- console.log('');
85
+ if (!didStopAnything) {
86
+ console.log(chalk.yellow(' No active session found.'));
87
+ console.log(chalk.dim(' If agents are running in VS Code / Cursor, close their chat sessions manually.'));
88
+ }
57
89
  }
@@ -4,6 +4,19 @@ import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
5
  import chalk from 'chalk';
6
6
 
7
+ function printGlobalInstallFallbacks() {
8
+ console.log(chalk.dim(' If global install failed with permission errors (EACCES):'));
9
+ console.log(` ${chalk.bold('sudo npm install -g agentxchain@latest')} ${chalk.dim('(macOS/Linux)')}`);
10
+ console.log(` ${chalk.bold('npm config get prefix')} ${chalk.dim('— fix ownership of that directory, or use a user prefix:')}`);
11
+ console.log(` ${chalk.bold('mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global')}`);
12
+ console.log(` ${chalk.dim('(add ~/.npm-global/bin to PATH)')}`);
13
+ console.log(chalk.dim(' Or run without global install:'));
14
+ console.log(` ${chalk.bold('npx agentxchain@latest <command>')}`);
15
+ console.log('');
16
+ console.log(chalk.dim(' Node: use 18.17+ or 20.5+ to avoid engine warnings from dependencies.'));
17
+ console.log('');
18
+ }
19
+
7
20
  export async function updateCommand() {
8
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
22
  const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8'));
@@ -24,18 +37,26 @@ export async function updateCommand() {
24
37
 
25
38
  console.log(` ${chalk.dim('Latest version:')} ${chalk.cyan(latest)}`);
26
39
  console.log('');
27
- console.log(` Updating...`);
40
+ console.log(' Updating...');
28
41
 
29
- execSync('npm install -g agentxchain@latest', { stdio: 'inherit' });
42
+ try {
43
+ execSync('npm install -g agentxchain@latest', { stdio: 'inherit' });
44
+ } catch {
45
+ console.log('');
46
+ console.log(chalk.yellow(' Global install failed. Common fixes:'));
47
+ printGlobalInstallFallbacks();
48
+ return;
49
+ }
30
50
 
31
51
  console.log('');
32
52
  console.log(chalk.green(` ✓ Updated to ${latest}`));
33
53
  console.log('');
34
54
  } catch (err) {
35
55
  console.log('');
36
- console.log(chalk.yellow(' Could not auto-update. Run manually:'));
56
+ console.log(chalk.yellow(' Could not check or install the latest version.'));
37
57
  console.log(` ${chalk.bold('npm install -g agentxchain@latest')}`);
38
58
  console.log('');
59
+ printGlobalInstallFallbacks();
39
60
  console.log(chalk.dim(` Error: ${err.message}`));
40
61
  console.log('');
41
62
  }
@@ -1,12 +1,15 @@
1
- import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'fs';
1
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import { fileURLToPath } from 'url';
5
5
  import chalk from 'chalk';
6
6
  import { loadConfig, loadLock, LOCK_FILE } from '../lib/config.js';
7
+ import { safeWriteJson } from '../lib/safe-write.js';
7
8
  import { notifyHuman as sendNotification } from '../lib/notify.js';
8
9
  import { validateProject } from '../lib/validation.js';
9
- import { resolveNextAgent } from '../lib/next-owner.js';
10
+ import { resolveNextAgent, resolveExpectedClaimer } from '../lib/next-owner.js';
11
+
12
+ const PID_FILE = '.agentxchain-watch.pid';
10
13
 
11
14
  export async function watchCommand(opts) {
12
15
  if (opts.daemon && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
@@ -37,12 +40,15 @@ export async function watchCommand(opts) {
37
40
  console.log(chalk.dim(` Poll: ${interval}ms | TTL: ${ttlMinutes}min`));
38
41
  console.log(chalk.dim(` Mode: Local file watcher + trigger file`));
39
42
  console.log('');
43
+ writePidFile(root);
44
+
40
45
  console.log(chalk.cyan(' Watching lock.json... (Ctrl+C to stop)'));
41
46
  console.log(chalk.dim(' Note: In VS Code/Cursor, the Stop hook coordinates turns automatically.'));
42
47
  console.log(chalk.dim(' This watch process is a fallback for non-IDE environments.'));
43
48
  console.log('');
44
49
 
45
50
  let lastState = null;
51
+ let lastClaimedState = null;
46
52
 
47
53
  const tick = async () => {
48
54
  try {
@@ -51,17 +57,6 @@ export async function watchCommand(opts) {
51
57
 
52
58
  const stateKey = `${lock.holder}:${lock.turn_number}`;
53
59
 
54
- if (lock.holder && lock.holder !== 'human') {
55
- const expected = pickNextAgent(root, lock, config);
56
- if (!isValidClaimer(root, lock, config)) {
57
- log('warn', `Illegal claim detected: holder=${lock.holder}, expected=${expected}. Handing lock to HUMAN.`);
58
- blockOnIllegalClaim(root, lock, config, expected);
59
- sendNotification(`Illegal claim detected (${lock.holder}). Human intervention required.`);
60
- lastState = null;
61
- return;
62
- }
63
- }
64
-
65
60
  if (lock.holder && lock.holder !== 'human' && lock.claimed_at) {
66
61
  const elapsed = Date.now() - new Date(lock.claimed_at).getTime();
67
62
  const ttlMs = ttlMinutes * 60 * 1000;
@@ -86,11 +81,27 @@ export async function watchCommand(opts) {
86
81
  }
87
82
 
88
83
  if (lock.holder) {
84
+ // Validate claim ownership only once per new claimed state.
85
+ // With handoff-driven routing, TALK.md may change during an active turn.
86
+ // Re-validating every tick can produce false "illegal claim" alerts.
87
+ if (stateKey !== lastClaimedState && lock.holder !== 'human') {
88
+ const expected = pickNextAgent(root, lock, config);
89
+ if (!isValidClaimer(root, lock, config)) {
90
+ log('warn', `Illegal claim detected: holder=${lock.holder}, expected=${expected ?? 'none'}. Handing lock to HUMAN.`);
91
+ blockOnIllegalClaim(root, lock, config, expected);
92
+ sendNotification(`Illegal claim detected (${lock.holder}). Human intervention required.`);
93
+ lastState = null;
94
+ lastClaimedState = null;
95
+ return;
96
+ }
97
+ }
98
+
89
99
  if (stateKey !== lastState) {
90
100
  const name = config.agents[lock.holder]?.name || lock.holder;
91
101
  log('claimed', `${lock.holder} (${name}) working... (turn ${lock.turn_number})`);
92
102
  lastState = stateKey;
93
103
  }
104
+ lastClaimedState = stateKey;
94
105
  return;
95
106
  }
96
107
 
@@ -109,7 +120,15 @@ export async function watchCommand(opts) {
109
120
  }
110
121
  }
111
122
 
112
- const next = pickNextAgent(root, lock, config);
123
+ const resolved = resolveNextAgent(root, config, lock);
124
+ const next = resolved.next;
125
+ if (!next) {
126
+ log('warn', `No next owner (${resolved.source}). strict_next_owner requires TALK.md handoff. Handing lock to HUMAN.`);
127
+ blockOnMissingNext(root, lock, config, resolved.source);
128
+ sendNotification('No next owner in TALK.md. Human action required.');
129
+ lastState = null;
130
+ return;
131
+ }
113
132
  log('free', `Lock free (released by ${lock.last_released_by || 'none'}). Next: ${chalk.bold(next)}.`);
114
133
  writeTrigger(root, next, lock, config);
115
134
  lastState = stateKey;
@@ -122,12 +141,16 @@ export async function watchCommand(opts) {
122
141
  await tick();
123
142
  const timer = setInterval(tick, interval);
124
143
 
125
- process.on('SIGINT', () => {
144
+ const cleanup = () => {
126
145
  clearInterval(timer);
146
+ removePidFile(root);
127
147
  console.log('');
128
148
  log('stop', 'Watch stopped.');
129
149
  process.exit(0);
130
- });
150
+ };
151
+
152
+ process.on('SIGINT', cleanup);
153
+ process.on('SIGTERM', cleanup);
131
154
  }
132
155
 
133
156
  function pickNextAgent(root, lock, config) {
@@ -137,7 +160,7 @@ function pickNextAgent(root, lock, config) {
137
160
  function isValidClaimer(root, lock, config) {
138
161
  if (!lock.holder || lock.holder === 'human') return true;
139
162
  if (!config.agents?.[lock.holder]) return false;
140
- const expected = pickNextAgent(root, lock, config);
163
+ const expected = resolveExpectedClaimer(root, config, lock).next;
141
164
  return lock.holder === expected;
142
165
  }
143
166
 
@@ -146,10 +169,10 @@ function forceRelease(root, lock, staleAgent, config) {
146
169
  const newLock = {
147
170
  holder: null,
148
171
  last_released_by: `system:ttl:${staleAgent}`,
149
- turn_number: lock.turn_number,
172
+ turn_number: lock.turn_number + 1,
150
173
  claimed_at: null
151
174
  };
152
- writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + '\n');
175
+ safeWriteJson(lockPath, newLock);
153
176
 
154
177
  const logFile = config.log || 'log.md';
155
178
  const logPath = join(root, logFile);
@@ -161,12 +184,12 @@ function forceRelease(root, lock, staleAgent, config) {
161
184
  function writeTrigger(root, agentId, lock, config) {
162
185
  if (!agentId) return;
163
186
  const triggerPath = join(root, '.agentxchain-trigger.json');
164
- writeFileSync(triggerPath, JSON.stringify({
187
+ safeWriteJson(triggerPath, {
165
188
  agent: agentId,
166
189
  turn_number: lock.turn_number,
167
190
  triggered_at: new Date().toISOString(),
168
191
  project: config.project
169
- }, null, 2) + '\n');
192
+ });
170
193
  }
171
194
 
172
195
  function blockOnValidation(root, lock, config, validation) {
@@ -177,7 +200,7 @@ function blockOnValidation(root, lock, config, validation) {
177
200
  turn_number: lock.turn_number,
178
201
  claimed_at: new Date().toISOString()
179
202
  };
180
- writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + '\n');
203
+ safeWriteJson(lockPath, newLock);
181
204
 
182
205
  const statePath = join(root, 'state.json');
183
206
  if (existsSync(statePath)) {
@@ -189,7 +212,7 @@ function blockOnValidation(root, lock, config, validation) {
189
212
  blocked: true,
190
213
  blocked_on: `validation: ${message}`
191
214
  };
192
- writeFileSync(statePath, JSON.stringify(nextState, null, 2) + '\n');
215
+ safeWriteJson(statePath, nextState);
193
216
  } catch {}
194
217
  }
195
218
 
@@ -204,6 +227,39 @@ function blockOnValidation(root, lock, config, validation) {
204
227
  }
205
228
  }
206
229
 
230
+ function blockOnMissingNext(root, lock, config, source) {
231
+ const lockPath = join(root, LOCK_FILE);
232
+ const newLock = {
233
+ holder: 'human',
234
+ last_released_by: lock.last_released_by,
235
+ turn_number: lock.turn_number,
236
+ claimed_at: new Date().toISOString()
237
+ };
238
+ safeWriteJson(lockPath, newLock);
239
+
240
+ const statePath = join(root, 'state.json');
241
+ if (existsSync(statePath)) {
242
+ try {
243
+ const current = JSON.parse(readFileSync(statePath, 'utf8'));
244
+ const nextState = {
245
+ ...current,
246
+ blocked: true,
247
+ blocked_on: `missing-next-owner: ${source}`
248
+ };
249
+ safeWriteJson(statePath, nextState);
250
+ } catch {}
251
+ }
252
+
253
+ const logFile = config.log || 'log.md';
254
+ const logPath = join(root, logFile);
255
+ if (existsSync(logPath)) {
256
+ appendFileSync(
257
+ logPath,
258
+ `\n---\n\n### [system] (Watch) | Turn ${lock.turn_number}\n\n**Status:** Could not resolve next agent (${source}).\n\n**Action:** Add \`Next owner: <agent_id>\` to ${config.talk_file || 'TALK.md'}, or set \`rules.strict_next_owner\` to false for cyclic fallback.\n\n`
259
+ );
260
+ }
261
+ }
262
+
207
263
  function blockOnIllegalClaim(root, lock, config, expected) {
208
264
  const lockPath = join(root, LOCK_FILE);
209
265
  const newLock = {
@@ -212,7 +268,7 @@ function blockOnIllegalClaim(root, lock, config, expected) {
212
268
  turn_number: lock.turn_number,
213
269
  claimed_at: new Date().toISOString()
214
270
  };
215
- writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + '\n');
271
+ safeWriteJson(lockPath, newLock);
216
272
 
217
273
  const statePath = join(root, 'state.json');
218
274
  if (existsSync(statePath)) {
@@ -223,7 +279,7 @@ function blockOnIllegalClaim(root, lock, config, expected) {
223
279
  blocked: true,
224
280
  blocked_on: `illegal-claim: expected ${expected}, got ${lock.holder}`
225
281
  };
226
- writeFileSync(statePath, JSON.stringify(nextState, null, 2) + '\n');
282
+ safeWriteJson(statePath, nextState);
227
283
  } catch {}
228
284
  }
229
285
 
@@ -262,8 +318,39 @@ function startWatchDaemon() {
262
318
  });
263
319
  child.unref();
264
320
 
321
+ const result = loadConfig();
322
+ if (result) {
323
+ writeFileSync(join(result.root, PID_FILE), String(child.pid));
324
+ }
325
+
265
326
  console.log('');
266
327
  console.log(chalk.green(` ✓ Watch started in daemon mode (PID: ${child.pid})`));
267
328
  console.log(chalk.dim(' Use `agentxchain stop` or kill the PID to stop it.'));
268
329
  console.log('');
269
330
  }
331
+
332
+ function writePidFile(root) {
333
+ try {
334
+ writeFileSync(join(root, PID_FILE), String(process.pid));
335
+ } catch {}
336
+ }
337
+
338
+ function removePidFile(root) {
339
+ try {
340
+ const pidPath = join(root, PID_FILE);
341
+ if (existsSync(pidPath)) unlinkSync(pidPath);
342
+ } catch {}
343
+ }
344
+
345
+ export function getWatchPid(root) {
346
+ try {
347
+ const pidPath = join(root, PID_FILE);
348
+ if (!existsSync(pidPath)) return null;
349
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
350
+ if (!Number.isFinite(pid)) return null;
351
+ process.kill(pid, 0);
352
+ return pid;
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
package/src/lib/config.js CHANGED
@@ -1,36 +1,71 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
- import { join } from 'path';
2
+ import { join, parse as pathParse, resolve } from 'path';
3
+ import { safeParseJson, validateConfigSchema, validateLockSchema, validateStateSchema } from './schema.js';
3
4
 
4
5
  const CONFIG_FILE = 'agentxchain.json';
5
6
  const LOCK_FILE = 'lock.json';
6
7
  const STATE_FILE = 'state.json';
7
8
 
8
9
  export function findProjectRoot(startDir = process.cwd()) {
9
- let dir = startDir;
10
- while (dir !== '/') {
10
+ let dir = resolve(startDir);
11
+ const { root: fsRoot } = pathParse(dir);
12
+ while (true) {
11
13
  if (existsSync(join(dir, CONFIG_FILE))) return dir;
14
+ if (dir === fsRoot) return null;
12
15
  dir = join(dir, '..');
13
16
  }
14
- return null;
15
17
  }
16
18
 
17
19
  export function loadConfig(dir = process.cwd()) {
18
20
  const root = findProjectRoot(dir);
19
21
  if (!root) return null;
20
- const raw = readFileSync(join(root, CONFIG_FILE), 'utf8');
21
- return { root, config: JSON.parse(raw) };
22
+ const filePath = join(root, CONFIG_FILE);
23
+ let raw;
24
+ try {
25
+ raw = readFileSync(filePath, 'utf8');
26
+ } catch {
27
+ return null;
28
+ }
29
+ const result = safeParseJson(raw, validateConfigSchema);
30
+ if (!result.ok) {
31
+ console.error(` Warning: agentxchain.json has issues: ${result.errors.join(', ')}`);
32
+ return null;
33
+ }
34
+ return { root, config: result.data };
22
35
  }
23
36
 
24
37
  export function loadLock(root) {
25
- const path = join(root, LOCK_FILE);
26
- if (!existsSync(path)) return null;
27
- return JSON.parse(readFileSync(path, 'utf8'));
38
+ const filePath = join(root, LOCK_FILE);
39
+ if (!existsSync(filePath)) return null;
40
+ let raw;
41
+ try {
42
+ raw = readFileSync(filePath, 'utf8');
43
+ } catch {
44
+ return null;
45
+ }
46
+ const result = safeParseJson(raw, validateLockSchema);
47
+ if (!result.ok) {
48
+ console.error(` Warning: lock.json has issues: ${result.errors.join(', ')}`);
49
+ return null;
50
+ }
51
+ return result.data;
28
52
  }
29
53
 
30
54
  export function loadState(root) {
31
- const path = join(root, STATE_FILE);
32
- if (!existsSync(path)) return null;
33
- return JSON.parse(readFileSync(path, 'utf8'));
55
+ const filePath = join(root, STATE_FILE);
56
+ if (!existsSync(filePath)) return null;
57
+ let raw;
58
+ try {
59
+ raw = readFileSync(filePath, 'utf8');
60
+ } catch {
61
+ return null;
62
+ }
63
+ const result = safeParseJson(raw, validateStateSchema);
64
+ if (!result.ok) {
65
+ console.error(` Warning: state.json has issues: ${result.errors.join(', ')}`);
66
+ return null;
67
+ }
68
+ return result.data;
34
69
  }
35
70
 
36
71
  export { CONFIG_FILE, LOCK_FILE, STATE_FILE };
@@ -0,0 +1,12 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function filterAgents(config, specificId) {
4
+ if (specificId) {
5
+ if (!config.agents[specificId]) {
6
+ console.log(chalk.red(` Agent "${specificId}" not found in agentxchain.json`));
7
+ process.exit(1);
8
+ }
9
+ return { [specificId]: config.agents[specificId] };
10
+ }
11
+ return config.agents;
12
+ }