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.
package/README.md CHANGED
@@ -19,8 +19,8 @@ npx agentxchain init
19
19
  ### Happy path: net-new project
20
20
 
21
21
  ```bash
22
- agentxchain init
23
- cd my-project
22
+ npx agentxchain init
23
+ cd my-agentxchain-project # default with init -y, or your chosen folder name
24
24
  agentxchain kickoff
25
25
  ```
26
26
 
@@ -52,8 +52,9 @@ Agents are now required to maintain `TALK.md` as the human-readable handoff log
52
52
  | `doctor` | Validate local setup (tools, trigger flow, accessibility checks) |
53
53
  | `claim` | Human takes control (agents stop claiming) |
54
54
  | `release` | Hand lock back to agents |
55
- | `stop` | Terminate running Claude Code agent sessions |
56
- | `watch` | Optional: TTL safety net + status logging |
55
+ | `stop` | Stop watch daemon, end Claude Code sessions; Cursor/VS Code chats close manually |
56
+ | `branch` | Show/set Cursor branch override for launches |
57
+ | `watch` | Referee loop: validates turns, writes next trigger, and force-releases stale locks |
57
58
  | `config` | View/edit config, add/remove agents, change rules |
58
59
  | `rebind` | Rebuild Cursor workspace/prompt bindings for agents |
59
60
  | `update` | Self-update CLI from npm |
@@ -66,6 +67,7 @@ agentxchain status
66
67
  agentxchain start
67
68
  agentxchain kickoff
68
69
  agentxchain stop
70
+ agentxchain branch
69
71
  agentxchain config
70
72
  agentxchain rebind
71
73
  agentxchain generate
@@ -111,37 +113,22 @@ agentxchain rebind --agent pm # regenerate one agent binding only
111
113
  agentxchain claim --agent pm # guarded claim as agent turn owner
112
114
  agentxchain release --agent pm # guarded release as agent turn owner
113
115
  agentxchain release --force # force-release non-human holder lock
116
+ agentxchain config --set "rules.strict_next_owner true" # TALK-only next owner (no cyclic fallback)
114
117
  ```
115
118
 
116
119
  ## macOS auto-nudge (AppleScript)
117
120
 
118
- If you want the next agent chat to be nudged automatically when turn changes, use the built-in AppleScript helper.
119
-
120
- 1) Keep watcher running in your project:
121
+ **Recommended:** `agentxchain supervise --autonudge` (starts `watch` + auto-nudge together). Requires macOS, `jq`, and Accessibility for Terminal + Cursor.
121
122
 
122
123
  ```bash
123
- agentxchain watch
124
- # or use the combined command:
125
124
  agentxchain supervise --autonudge
125
+ agentxchain supervise --autonudge --send # paste + Enter
126
126
  ```
127
127
 
128
- 2) In another terminal (from `cli/`), start auto-nudge:
129
-
130
- ```bash
131
- bash scripts/run-autonudge.sh --project "/absolute/path/to/your-project"
132
- ```
133
-
134
- By default this is **paste-only** (safe mode): it opens chat and pastes the nudge message, but does not press Enter.
135
-
136
- 3) Enable auto-send once confirmed:
137
-
138
- ```bash
139
- bash scripts/run-autonudge.sh --project "/absolute/path/to/your-project" --send
140
- ```
141
-
142
- Stop it anytime:
128
+ **Advanced (debugging):** from a checkout of `cli/`, run the script alone while `watch` is already running:
143
129
 
144
130
  ```bash
131
+ bash scripts/run-autonudge.sh --project "/absolute/path/to/your-project" [--send]
145
132
  bash scripts/stop-autonudge.sh
146
133
  ```
147
134
 
@@ -166,7 +153,7 @@ Notes:
166
153
  ### VS Code mode
167
154
 
168
155
  1. `agentxchain init` generates `.github/agents/*.agent.md` (VS Code custom agents) and `.github/hooks/` (lifecycle hooks)
169
- 2. VS Code auto-discovers agents in the Chat dropdown
156
+ 2. VS Code discovers custom agents in Chat when using **GitHub Copilot** agents (see Microsoft docs)
170
157
  3. The `Stop` hook acts as referee — hands off to next agent automatically
171
158
 
172
159
  ### Turn ownership
@@ -189,14 +176,12 @@ Agent turns are handoff-driven:
189
176
 
190
177
  ## VS Code extension (optional)
191
178
 
192
- For a richer UI in VS Code:
179
+ The VSIX is not committed to the repo. Build/package from `cli/vscode-extension/` (see that folder’s README or `vsce package`), then:
193
180
 
194
181
  ```bash
195
- code --install-extension cli/vscode-extension/agentxchain-0.1.0.vsix
182
+ code --install-extension /path/to/agentxchain-*.vsix
196
183
  ```
197
184
 
198
- Adds: status bar (lock holder, turn, phase), sidebar dashboard, command palette integration.
199
-
200
185
  ## Publish updates (maintainers)
201
186
 
202
187
  ```bash
@@ -1,9 +1,52 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync } from 'fs';
4
- import { join, dirname } from 'path';
3
+ import { readFileSync, existsSync } from 'fs';
4
+ import { join, dirname, parse as pathParse, resolve } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { Command } from 'commander';
7
+
8
+ // Load .env from AgentXchain project root when available, then cwd as fallback.
9
+ (function loadDotenv() {
10
+ const cwd = process.cwd();
11
+ const projectRoot = findNearestProjectRoot(cwd);
12
+ const envPaths = [];
13
+
14
+ if (projectRoot) {
15
+ envPaths.push(join(projectRoot, '.env'));
16
+ }
17
+ if (!projectRoot || projectRoot !== cwd) {
18
+ envPaths.push(join(cwd, '.env'));
19
+ }
20
+
21
+ for (const envPath of envPaths) {
22
+ if (!existsSync(envPath)) continue;
23
+ try {
24
+ const content = readFileSync(envPath, 'utf8');
25
+ for (const line of content.split(/\r?\n/)) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed || trimmed.startsWith('#')) continue;
28
+ const eqIdx = trimmed.indexOf('=');
29
+ if (eqIdx === -1) continue;
30
+ const key = trimmed.slice(0, eqIdx).trim();
31
+ let val = trimmed.slice(eqIdx + 1).trim();
32
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
33
+ val = val.slice(1, -1);
34
+ }
35
+ if (!process.env[key]) process.env[key] = val;
36
+ }
37
+ } catch {}
38
+ }
39
+ })();
40
+
41
+ function findNearestProjectRoot(startDir) {
42
+ let dir = resolve(startDir);
43
+ const { root: fsRoot } = pathParse(dir);
44
+ while (true) {
45
+ if (existsSync(join(dir, 'agentxchain.json'))) return dir;
46
+ if (dir === fsRoot) return null;
47
+ dir = join(dir, '..');
48
+ }
49
+ }
7
50
  import { initCommand } from '../src/commands/init.js';
8
51
  import { statusCommand } from '../src/commands/status.js';
9
52
  import { startCommand } from '../src/commands/start.js';
@@ -18,6 +61,7 @@ import { superviseCommand } from '../src/commands/supervise.js';
18
61
  import { validateCommand } from '../src/commands/validate.js';
19
62
  import { kickoffCommand } from '../src/commands/kickoff.js';
20
63
  import { rebindCommand } from '../src/commands/rebind.js';
64
+ import { branchCommand } from '../src/commands/branch.js';
21
65
 
22
66
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
67
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -61,7 +105,7 @@ program
61
105
 
62
106
  program
63
107
  .command('stop')
64
- .description('Stop all running agent sessions')
108
+ .description('Stop watch daemon and Claude Code sessions; close Cursor/VS Code chats manually')
65
109
  .action(stopCommand);
66
110
 
67
111
  program
@@ -73,6 +117,13 @@ program
73
117
  .option('-j, --json', 'Output config as JSON')
74
118
  .action(configCommand);
75
119
 
120
+ program
121
+ .command('branch [name]')
122
+ .description('Show or set the Cursor branch used for launches')
123
+ .option('--use-current', 'Set override to the current local git branch')
124
+ .option('--unset', 'Remove override and follow the active git branch automatically')
125
+ .action(branchCommand);
126
+
76
127
  program
77
128
  .command('generate')
78
129
  .description('Regenerate VS Code agent files (.agent.md, hooks) from agentxchain.json')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "CLI for AgentXchain — multi-agent coordination in your IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "dev": "node bin/agentxchain.js",
17
+ "test": "node --test test/*.test.js",
17
18
  "build:macos": "bun build bin/agentxchain.js --compile --target=bun-darwin-arm64 --outfile=dist/agentxchain-macos-arm64",
18
19
  "build:linux": "bun build bin/agentxchain.js --compile --target=bun-linux-x64 --outfile=dist/agentxchain-linux-x64",
19
20
  "publish:npm": "bash scripts/publish-npm.sh"
@@ -43,6 +44,6 @@
43
44
  "ora": "^8.0.0"
44
45
  },
45
46
  "engines": {
46
- "node": ">=18"
47
+ "node": ">=18.17.0 || >=20.5.0"
47
48
  }
48
49
  }
@@ -59,8 +59,31 @@ on nudgeAgent(agentId, turnNum, dispatchKey)
59
59
  set nudgeText to "Hey " & agentId & ", it is your turn now (turn " & turnNum & "). Read lock.json, claim the lock, check state.md + history.jsonl + planning docs, do your work, and release lock."
60
60
  set the clipboard to nudgeText
61
61
 
62
+ tell application "System Events"
63
+ if not (exists process "Cursor") then
64
+ if lastFailedDispatch is not dispatchKey then
65
+ do shell script "osascript -e " & quoted form of ("display notification \"Cursor is not running.\" with title \"AgentXchain\"")
66
+ set lastFailedDispatch to dispatchKey
67
+ end if
68
+ return false
69
+ end if
70
+ end tell
71
+
62
72
  tell application "Cursor" to activate
63
- delay 0.5
73
+ delay 0.6
74
+
75
+ -- Verify Cursor is actually frontmost before sending keystrokes
76
+ tell application "System Events"
77
+ set frontApp to name of first application process whose frontmost is true
78
+ if frontApp is not "Cursor" then
79
+ if lastFailedDispatch is not dispatchKey then
80
+ do shell script "osascript -e " & quoted form of ("display notification \"Cursor lost focus, skipping nudge for " & agentId & ".\" with title \"AgentXchain\"")
81
+ set lastFailedDispatch to dispatchKey
82
+ end if
83
+ return false
84
+ end if
85
+ end tell
86
+
64
87
  set focusedOk to my focusAgentWindow(agentId)
65
88
  if focusedOk is false then
66
89
  if lastFailedDispatch is not dispatchKey then
@@ -69,18 +92,22 @@ on nudgeAgent(agentId, turnNum, dispatchKey)
69
92
  end if
70
93
  return false
71
94
  end if
72
- delay 0.2
95
+ delay 0.3
73
96
 
97
+ -- Re-verify focus before keystrokes
74
98
  tell application "System Events"
75
- if not (exists process "Cursor") then return
99
+ set frontApp to name of first application process whose frontmost is true
100
+ if frontApp is not "Cursor" then
101
+ return false
102
+ end if
76
103
 
77
104
  tell process "Cursor"
78
105
  set frontmost to true
79
106
  keystroke "l" using {command down}
80
- delay 0.2
107
+ delay 0.3
81
108
  keystroke "v" using {command down}
82
109
  if autoSend then
83
- delay 0.15
110
+ delay 0.2
84
111
  key code 36
85
112
  end if
86
113
  end tell
@@ -104,7 +104,7 @@ echo "Mode: $( [[ "${AUTO_SEND}" == "true" ]] && echo "auto-send" || echo "
104
104
  echo "Interval: ${INTERVAL_SECONDS}s"
105
105
  echo ""
106
106
  echo "Requirements:"
107
- echo "- Keep 'agentxchain watch' running in another terminal."
107
+ echo "- Watch must be running (e.g. 'agentxchain supervise --autonudge' starts it in the same supervisor, or run 'agentxchain watch' in another terminal)."
108
108
  echo "- Grant Accessibility permission to Terminal and Cursor."
109
109
  echo ""
110
110
 
@@ -1,8 +1,9 @@
1
1
  import { spawn } from 'child_process';
2
2
  import chalk from 'chalk';
3
3
  import { generateSeedPrompt } from '../lib/seed-prompt.js';
4
- import { writeFileSync } from 'fs';
5
4
  import { join } from 'path';
5
+ import { safeWriteJson } from '../lib/safe-write.js';
6
+ import { filterAgents } from '../lib/filter-agents.js';
6
7
 
7
8
  export async function launchClaudeCodeAgents(config, root, opts) {
8
9
  const agents = filterAgents(config, opts.agent);
@@ -28,22 +29,14 @@ export async function launchClaudeCodeAgents(config, root, opts) {
28
29
  }
29
30
 
30
31
  if (launched.length > 0) {
31
- const sessionFile = JSON.stringify({ launched, started_at: new Date().toISOString(), ide: 'claude-code' }, null, 2);
32
- writeFileSync(join(root, '.agentxchain-session.json'), sessionFile + '\n');
32
+ safeWriteJson(join(root, '.agentxchain-session.json'), {
33
+ launched,
34
+ started_at: new Date().toISOString(),
35
+ ide: 'claude-code'
36
+ });
33
37
  console.log('');
34
38
  console.log(chalk.dim(` Session saved to .agentxchain-session.json`));
35
39
  }
36
40
 
37
41
  return launched;
38
42
  }
39
-
40
- function filterAgents(config, specificId) {
41
- if (specificId) {
42
- if (!config.agents[specificId]) {
43
- console.log(chalk.red(` Agent "${specificId}" not found in agentxchain.json`));
44
- process.exit(1);
45
- }
46
- return { [specificId]: config.agents[specificId] };
47
- }
48
- return config.agents;
49
- }
@@ -4,6 +4,7 @@ import { join } from 'path';
4
4
  import chalk from 'chalk';
5
5
  import inquirer from 'inquirer';
6
6
  import { generatePollingPrompt } from '../lib/seed-prompt-polling.js';
7
+ import { filterAgents } from '../lib/filter-agents.js';
7
8
 
8
9
  export async function launchCursorLocal(config, root, opts) {
9
10
  const agents = filterAgents(config, opts.agent);
@@ -50,13 +51,22 @@ export async function launchCursorLocal(config, root, opts) {
50
51
  console.log(chalk.cyan(` ─── Agent ${i + 1}/${total}: ${chalk.bold(id)} — ${agent.name} ───`));
51
52
  console.log('');
52
53
 
53
- copyToClipboard(prompt);
54
- console.log(chalk.green(` ✓ Prompt copied to clipboard.`));
54
+ const copied = copyToClipboard(prompt);
55
+ if (copied) {
56
+ console.log(chalk.green(' ✓ Prompt copied to clipboard.'));
57
+ } else {
58
+ console.log(chalk.yellow(' ! Clipboard copy failed. Use the saved prompt file manually.'));
59
+ }
55
60
  console.log(chalk.dim(` Saved to: .agentxchain-prompts/${id}.prompt.md`));
56
61
 
57
62
  // Open a separate Cursor window using the symlinked path
58
- openCursorWindow(agentWorkspace);
59
- console.log(chalk.dim(` Cursor window opened for ${id}.`));
63
+ const opened = openCursorWindow(agentWorkspace);
64
+ if (opened) {
65
+ console.log(chalk.dim(` Cursor window opened for ${id}.`));
66
+ } else {
67
+ console.log(chalk.yellow(` Could not open Cursor window automatically for ${id}.`));
68
+ console.log(chalk.dim(` Open manually: cursor --new-window "${agentWorkspace}"`));
69
+ }
60
70
 
61
71
  console.log('');
62
72
  console.log(` ${chalk.bold('In the new Cursor window:')}`);
@@ -108,17 +118,6 @@ export async function launchCursorLocal(config, root, opts) {
108
118
  console.log('');
109
119
  }
110
120
 
111
- function filterAgents(config, specificId) {
112
- if (specificId) {
113
- if (!config.agents[specificId]) {
114
- console.log(chalk.red(` Agent "${specificId}" not found in agentxchain.json`));
115
- process.exit(1);
116
- }
117
- return { [specificId]: config.agents[specificId] };
118
- }
119
- return config.agents;
120
- }
121
-
122
121
  function copyToClipboard(text) {
123
122
  try {
124
123
  if (process.platform === 'darwin') {
@@ -137,10 +136,12 @@ function openCursorWindow(folderPath) {
137
136
  try {
138
137
  if (process.platform === 'darwin') {
139
138
  execSync(`open -na "Cursor" --args "${folderPath}"`, { stdio: 'ignore' });
140
- return;
139
+ return true;
141
140
  }
142
141
  execSync(`cursor --new-window "${folderPath}"`, { stdio: 'ignore' });
142
+ return true;
143
143
  } catch {}
144
+ return false;
144
145
  }
145
146
 
146
147
  function isPmLike(agentId, agentDef) {
@@ -1,7 +1,7 @@
1
- import { writeFileSync } from 'fs';
2
1
  import { join } from 'path';
3
2
  import chalk from 'chalk';
4
3
  import { loadConfig, CONFIG_FILE } from '../lib/config.js';
4
+ import { safeWriteJson } from '../lib/safe-write.js';
5
5
  import { getCurrentBranch } from '../lib/repo.js';
6
6
 
7
7
  export async function branchCommand(name, opts) {
@@ -94,5 +94,5 @@ function setBranchOverride(config, configPath, branch) {
94
94
  }
95
95
 
96
96
  function saveConfig(configPath, config) {
97
- writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
97
+ safeWriteJson(configPath, config);
98
98
  }
@@ -1,8 +1,10 @@
1
- import { writeFileSync, existsSync, readFileSync } from 'fs';
1
+ import { existsSync, readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { loadConfig, loadLock, LOCK_FILE } from '../lib/config.js';
5
- import { resolveNextAgent } from '../lib/next-owner.js';
5
+ import { safeWriteJson } from '../lib/safe-write.js';
6
+ import { resolveExpectedClaimer } from '../lib/next-owner.js';
7
+ import { runConfiguredVerify } from '../lib/verify-command.js';
6
8
 
7
9
  export async function claimCommand(opts) {
8
10
  const result = loadConfig();
@@ -40,7 +42,12 @@ export async function claimCommand(opts) {
40
42
  turn_number: lock.turn_number,
41
43
  claimed_at: new Date().toISOString()
42
44
  };
43
- writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + '\n');
45
+ safeWriteJson(lockPath, newLock);
46
+ const verify = loadLock(root);
47
+ if (verify?.holder !== 'human') {
48
+ console.log(chalk.red(` Claim race: expected holder=human, got ${verify?.holder}. Another process won.`));
49
+ process.exit(1);
50
+ }
44
51
  clearBlockedState(root);
45
52
 
46
53
  console.log('');
@@ -76,6 +83,11 @@ export async function releaseCommand(opts) {
76
83
  }
77
84
 
78
85
  const who = lock.holder;
86
+ const verifyResult = runConfiguredVerify(config, root);
87
+ if (!verifyResult.ok) {
88
+ console.log(chalk.red(` Verification failed: ${verifyResult.command}`));
89
+ process.exit(1);
90
+ }
79
91
  const lockPath = join(root, LOCK_FILE);
80
92
  const newLock = {
81
93
  holder: null,
@@ -83,14 +95,19 @@ export async function releaseCommand(opts) {
83
95
  turn_number: who === 'human' ? lock.turn_number : lock.turn_number + 1,
84
96
  claimed_at: null
85
97
  };
86
- writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + '\n');
98
+ safeWriteJson(lockPath, newLock);
99
+ const verify = loadLock(root);
100
+ if (verify?.holder !== null || verify?.last_released_by !== who || verify?.turn_number !== newLock.turn_number) {
101
+ console.log(chalk.red(' Release race: lock.json changed unexpectedly after release attempt.'));
102
+ process.exit(1);
103
+ }
87
104
  if (who === 'human') {
88
105
  clearBlockedState(root);
89
106
  }
90
107
 
91
108
  console.log('');
92
109
  console.log(chalk.green(` ✓ Lock released by ${chalk.bold(who)} (turn ${newLock.turn_number})`));
93
- console.log(chalk.dim(' The Stop hook will coordinate the next agent turn in VS Code.'));
110
+ console.log(chalk.dim(' Next turn will be coordinated by the VS Code Stop hook or watch/supervise.'));
94
111
  console.log('');
95
112
  }
96
113
 
@@ -108,11 +125,25 @@ function claimAsAgent({ opts, root, config, lock }) {
108
125
  }
109
126
 
110
127
  const expected = pickNextAgent(root, lock, config);
128
+ if (!opts.force && config.rules?.strict_next_owner && (expected === null || expected === undefined)) {
129
+ console.log(chalk.red(' No next owner resolved. Add a valid `Next owner: <agent_id>` line to TALK.md, or set rules.strict_next_owner to false.'));
130
+ process.exit(1);
131
+ }
111
132
  if (!opts.force && expected && expected !== agentId) {
112
133
  console.log(chalk.red(` Out-of-turn claim blocked. Expected: ${expected}, got: ${agentId}.`));
113
134
  process.exit(1);
114
135
  }
115
136
 
137
+ const maxClaims = Number(config.rules?.max_consecutive_claims || 0);
138
+ if (!opts.force && maxClaims > 0 && lock.last_released_by === agentId) {
139
+ const consecutiveTurns = countRecentTurnsByAgent(root, config, agentId);
140
+ if (consecutiveTurns >= maxClaims) {
141
+ console.log(chalk.red(` Consecutive-claim limit reached for "${agentId}" (${consecutiveTurns}/${maxClaims}).`));
142
+ console.log(chalk.dim(' Hand off to another agent or use --force for recovery only.'));
143
+ process.exit(1);
144
+ }
145
+ }
146
+
116
147
  const lockPath = join(root, LOCK_FILE);
117
148
  const next = {
118
149
  holder: agentId,
@@ -120,7 +151,14 @@ function claimAsAgent({ opts, root, config, lock }) {
120
151
  turn_number: lock.turn_number,
121
152
  claimed_at: new Date().toISOString()
122
153
  };
123
- writeFileSync(lockPath, JSON.stringify(next, null, 2) + '\n');
154
+ safeWriteJson(lockPath, next);
155
+
156
+ const verify = loadLock(root);
157
+ if (verify?.holder !== agentId) {
158
+ console.log(chalk.red(` Claim race: expected holder=${agentId}, got ${verify?.holder}. Another process won.`));
159
+ process.exit(1);
160
+ }
161
+
124
162
  console.log(chalk.green(` ✓ Lock claimed by ${agentId} (turn ${next.turn_number})`));
125
163
  }
126
164
 
@@ -135,6 +173,12 @@ function releaseAsAgent({ opts, root, config, lock }) {
135
173
  process.exit(1);
136
174
  }
137
175
 
176
+ const verifyResult = runConfiguredVerify(config, root);
177
+ if (!verifyResult.ok) {
178
+ console.log(chalk.red(` Verification failed: ${verifyResult.command}`));
179
+ process.exit(1);
180
+ }
181
+
138
182
  const lockPath = join(root, LOCK_FILE);
139
183
  const next = {
140
184
  holder: null,
@@ -142,12 +186,21 @@ function releaseAsAgent({ opts, root, config, lock }) {
142
186
  turn_number: lock.turn_number + 1,
143
187
  claimed_at: null
144
188
  };
145
- writeFileSync(lockPath, JSON.stringify(next, null, 2) + '\n');
189
+ safeWriteJson(lockPath, next);
190
+ const verifyRelease = loadLock(root);
191
+ if (
192
+ verifyRelease?.holder !== null ||
193
+ verifyRelease?.last_released_by !== agentId ||
194
+ verifyRelease?.turn_number !== next.turn_number
195
+ ) {
196
+ console.log(chalk.red(' Release race: lock.json changed unexpectedly after release attempt.'));
197
+ process.exit(1);
198
+ }
146
199
  console.log(chalk.green(` ✓ Lock released by ${agentId} (turn ${next.turn_number})`));
147
200
  }
148
201
 
149
202
  function pickNextAgent(root, lock, config) {
150
- return resolveNextAgent(root, config, lock).next;
203
+ return resolveExpectedClaimer(root, config, lock).next;
151
204
  }
152
205
 
153
206
  function clearBlockedState(root) {
@@ -157,7 +210,29 @@ function clearBlockedState(root) {
157
210
  const state = JSON.parse(readFileSync(statePath, 'utf8'));
158
211
  if (state.blocked || state.blocked_on) {
159
212
  const next = { ...state, blocked: false, blocked_on: null };
160
- writeFileSync(statePath, JSON.stringify(next, null, 2) + '\n');
213
+ safeWriteJson(statePath, next);
161
214
  }
162
215
  } catch {}
163
216
  }
217
+
218
+ function countRecentTurnsByAgent(root, config, agentId) {
219
+ const historyPath = join(root, config.history_file || 'history.jsonl');
220
+ if (!existsSync(historyPath)) return 0;
221
+
222
+ try {
223
+ const lines = readFileSync(historyPath, 'utf8')
224
+ .split(/\r?\n/)
225
+ .map(line => line.trim())
226
+ .filter(Boolean);
227
+
228
+ let count = 0;
229
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
230
+ const entry = JSON.parse(lines[i]);
231
+ if (entry?.agent !== agentId) break;
232
+ count += 1;
233
+ }
234
+ return count;
235
+ } catch {
236
+ return 0;
237
+ }
238
+ }
@@ -3,6 +3,7 @@ import { join } from 'path';
3
3
  import chalk from 'chalk';
4
4
  import inquirer from 'inquirer';
5
5
  import { loadConfig, CONFIG_FILE } from '../lib/config.js';
6
+ import { validateConfigSchema } from '../lib/schema.js';
6
7
 
7
8
  export async function configCommand(opts) {
8
9
  const result = loadConfig();
@@ -127,6 +128,12 @@ function setSetting(config, configPath, keyValPair) {
127
128
  const key = parts[0];
128
129
  const rawVal = parts.slice(1).join(' ');
129
130
  const segments = key.split('.');
131
+ const forbiddenKeys = new Set(['__proto__', 'prototype', 'constructor']);
132
+
133
+ if (segments.some(segment => forbiddenKeys.has(segment))) {
134
+ console.log(chalk.red(' Refusing to write reserved object path.'));
135
+ process.exit(1);
136
+ }
130
137
 
131
138
  let target = config;
132
139
  for (let i = 0; i < segments.length - 1; i++) {
@@ -145,6 +152,15 @@ function setSetting(config, configPath, keyValPair) {
145
152
  else if (!isNaN(rawVal) && rawVal !== '') val = Number(rawVal);
146
153
 
147
154
  target[lastKey] = val;
155
+ const validation = validateConfigSchema(config);
156
+ if (!validation.ok) {
157
+ target[lastKey] = oldVal;
158
+ if (oldVal === undefined) {
159
+ delete target[lastKey];
160
+ }
161
+ console.log(chalk.red(` Refusing to save invalid config: ${validation.errors.join(', ')}`));
162
+ process.exit(1);
163
+ }
148
164
  writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
149
165
 
150
166
  console.log('');
@@ -4,6 +4,7 @@ import { join } from 'path';
4
4
  import chalk from 'chalk';
5
5
  import { loadConfig, loadLock } from '../lib/config.js';
6
6
  import { validateProject } from '../lib/validation.js';
7
+ import { getWatchPid } from './watch.js';
7
8
 
8
9
  export async function doctorCommand() {
9
10
  const result = loadConfig();
@@ -86,9 +87,16 @@ function checkPm(config) {
86
87
  }
87
88
 
88
89
  function checkWatchProcess() {
90
+ const result = loadConfig();
91
+ if (result) {
92
+ const pid = getWatchPid(result.root);
93
+ if (pid) {
94
+ return { name: 'watch process', level: 'pass', detail: `watch running (PID: ${pid})` };
95
+ }
96
+ }
89
97
  try {
90
98
  execSync('pgrep -f "agentxchain.*watch" >/dev/null', { stdio: 'ignore' });
91
- return { name: 'watch process', level: 'pass', detail: 'watch appears to be running' };
99
+ return { name: 'watch process', level: 'pass', detail: 'watch appears to be running (no PID file)' };
92
100
  } catch {
93
101
  return { name: 'watch process', level: 'warn', detail: 'watch not running (start with `agentxchain watch` or `agentxchain supervise --autonudge`)' };
94
102
  }