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 +14 -29
- package/bin/agentxchain.js +54 -3
- package/package.json +3 -2
- package/scripts/agentxchain-autonudge.applescript +32 -5
- package/scripts/run-autonudge.sh +1 -1
- package/src/adapters/claude-code.js +7 -14
- package/src/adapters/cursor-local.js +17 -16
- package/src/commands/branch.js +2 -2
- package/src/commands/claim.js +84 -9
- package/src/commands/config.js +16 -0
- package/src/commands/doctor.js +9 -1
- package/src/commands/init.js +24 -5
- package/src/commands/stop.js +65 -33
- package/src/commands/update.js +24 -3
- package/src/commands/watch.js +112 -25
- package/src/lib/config.js +47 -12
- package/src/lib/filter-agents.js +12 -0
- package/src/lib/generate-vscode.js +158 -68
- package/src/lib/next-owner.js +61 -6
- package/src/lib/notify.js +14 -12
- package/src/lib/prompt-core.js +108 -0
- package/src/lib/safe-write.js +44 -0
- package/src/lib/schema.js +68 -0
- package/src/lib/seed-prompt-polling.js +15 -73
- package/src/lib/seed-prompt.js +17 -63
- package/src/lib/validation.js +30 -19
- package/src/lib/verify-command.js +72 -0
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` |
|
|
56
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
package/bin/agentxchain.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
95
|
+
delay 0.3
|
|
73
96
|
|
|
97
|
+
-- Re-verify focus before keystrokes
|
|
74
98
|
tell application "System Events"
|
|
75
|
-
|
|
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.
|
|
107
|
+
delay 0.3
|
|
81
108
|
keystroke "v" using {command down}
|
|
82
109
|
if autoSend then
|
|
83
|
-
delay 0.
|
|
110
|
+
delay 0.2
|
|
84
111
|
key code 36
|
|
85
112
|
end if
|
|
86
113
|
end tell
|
package/scripts/run-autonudge.sh
CHANGED
|
@@ -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 "-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/commands/branch.js
CHANGED
|
@@ -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
|
-
|
|
97
|
+
safeWriteJson(configPath, config);
|
|
98
98
|
}
|
package/src/commands/claim.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/config.js
CHANGED
|
@@ -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('');
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
}
|