agentxchain 0.4.1 → 0.4.2
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 +7 -0
- package/bin/agentxchain.js +2 -1
- package/package.json +1 -1
- package/src/commands/claim.js +27 -3
- package/src/commands/config.js +5 -0
- package/src/commands/start.js +14 -1
- package/src/commands/stop.js +13 -6
- package/src/commands/watch.js +46 -5
- package/src/lib/repo.js +23 -12
package/README.md
CHANGED
|
@@ -52,6 +52,13 @@ agentxchain branch --use-current # pin to whatever branch you're on now
|
|
|
52
52
|
agentxchain branch --unset # remove pin; follow active git branch
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### Additional command flags
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
agentxchain watch --daemon # run watch in background
|
|
59
|
+
agentxchain release --force # force-release non-human holder lock
|
|
60
|
+
```
|
|
61
|
+
|
|
55
62
|
## Key features
|
|
56
63
|
|
|
57
64
|
- **Claim-based coordination** — no fixed turn order; agents self-organize
|
package/bin/agentxchain.js
CHANGED
|
@@ -16,7 +16,7 @@ const program = new Command();
|
|
|
16
16
|
program
|
|
17
17
|
.name('agentxchain')
|
|
18
18
|
.description('Multi-agent coordination in your IDE')
|
|
19
|
-
.version('0.4.
|
|
19
|
+
.version('0.4.1');
|
|
20
20
|
|
|
21
21
|
program
|
|
22
22
|
.command('init')
|
|
@@ -74,6 +74,7 @@ program
|
|
|
74
74
|
program
|
|
75
75
|
.command('release')
|
|
76
76
|
.description('Release the lock (hand back to agents)')
|
|
77
|
+
.option('--force', 'Force release even if a non-human holder has the lock')
|
|
77
78
|
.action(releaseCommand);
|
|
78
79
|
|
|
79
80
|
program
|
package/package.json
CHANGED
package/src/commands/claim.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function claimCommand(opts) {
|
|
|
71
71
|
console.log('');
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
export async function releaseCommand() {
|
|
74
|
+
export async function releaseCommand(opts) {
|
|
75
75
|
const result = loadConfig();
|
|
76
76
|
if (!result) { console.log(chalk.red(' No agentxchain.json found.')); process.exit(1); }
|
|
77
77
|
|
|
@@ -84,7 +84,18 @@ export async function releaseCommand() {
|
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
if (lock.holder !== 'human' && !opts.force) {
|
|
88
|
+
const name = config.agents[lock.holder]?.name || lock.holder;
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(chalk.red(` Lock is held by ${chalk.bold(lock.holder)} (${name}), not human.`));
|
|
91
|
+
console.log(chalk.dim(' Refusing to release another holder without explicit override.'));
|
|
92
|
+
console.log(chalk.dim(' Use `agentxchain release --force` if you really want to break this lock.'));
|
|
93
|
+
console.log('');
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
const who = lock.holder;
|
|
98
|
+
const priorLastReleasedBy = lock.last_released_by;
|
|
88
99
|
const lockPath = join(root, LOCK_FILE);
|
|
89
100
|
const newLock = {
|
|
90
101
|
holder: null,
|
|
@@ -112,8 +123,12 @@ export async function releaseCommand() {
|
|
|
112
123
|
}
|
|
113
124
|
|
|
114
125
|
if (session?.ide === 'cursor' && apiKey) {
|
|
115
|
-
const
|
|
116
|
-
|
|
126
|
+
const next = pickNextAgent(priorLastReleasedBy, config);
|
|
127
|
+
if (!next) {
|
|
128
|
+
console.log(chalk.dim(' No agents configured to wake.'));
|
|
129
|
+
console.log('');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
117
132
|
const cloudAgent = session.launched.find(a => a.id === next);
|
|
118
133
|
|
|
119
134
|
if (cloudAgent) {
|
|
@@ -133,3 +148,12 @@ export async function releaseCommand() {
|
|
|
133
148
|
|
|
134
149
|
console.log('');
|
|
135
150
|
}
|
|
151
|
+
|
|
152
|
+
function pickNextAgent(lastReleasedBy, config) {
|
|
153
|
+
const agentIds = Object.keys(config.agents || {});
|
|
154
|
+
if (agentIds.length === 0) return null;
|
|
155
|
+
if (!lastReleasedBy || !agentIds.includes(lastReleasedBy)) return agentIds[0];
|
|
156
|
+
|
|
157
|
+
const idx = agentIds.indexOf(lastReleasedBy);
|
|
158
|
+
return agentIds[(idx + 1) % agentIds.length];
|
|
159
|
+
}
|
package/src/commands/config.js
CHANGED
|
@@ -102,6 +102,11 @@ function removeAgent(config, configPath, id) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
const name = config.agents[id].name;
|
|
105
|
+
if (Object.keys(config.agents).length <= 1) {
|
|
106
|
+
console.log(chalk.red(' Cannot remove the last agent.'));
|
|
107
|
+
console.log(chalk.dim(' Add another agent first, then remove this one if needed.'));
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
105
110
|
delete config.agents[id];
|
|
106
111
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
107
112
|
|
package/src/commands/start.js
CHANGED
|
@@ -11,9 +11,22 @@ export async function startCommand(opts) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const { root, config } = result;
|
|
14
|
-
const
|
|
14
|
+
const agentIds = Object.keys(config.agents || {});
|
|
15
|
+
const agentCount = agentIds.length;
|
|
15
16
|
const ide = opts.ide;
|
|
16
17
|
|
|
18
|
+
if (agentCount === 0) {
|
|
19
|
+
console.log(chalk.red(' No agents configured in agentxchain.json.'));
|
|
20
|
+
console.log(chalk.dim(' Add an agent with: agentxchain config --add-agent'));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (opts.agent && !config.agents?.[opts.agent]) {
|
|
25
|
+
console.log(chalk.red(` Agent "${opts.agent}" not found in agentxchain.json.`));
|
|
26
|
+
console.log(chalk.dim(` Available: ${agentIds.join(', ')}`));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
console.log('');
|
|
18
31
|
console.log(chalk.bold(` Launching ${agentCount} agents via ${ide}`));
|
|
19
32
|
console.log(chalk.dim(` Project: ${config.project}`));
|
package/src/commands/stop.js
CHANGED
|
@@ -23,6 +23,7 @@ export async function stopCommand() {
|
|
|
23
23
|
console.log('');
|
|
24
24
|
console.log(chalk.bold(` Stopping ${session.launched.length} agents (${session.ide})`));
|
|
25
25
|
console.log('');
|
|
26
|
+
let allStopped = true;
|
|
26
27
|
|
|
27
28
|
if (session.ide === 'cursor') {
|
|
28
29
|
const apiKey = getCursorApiKey(root);
|
|
@@ -40,9 +41,11 @@ export async function stopCommand() {
|
|
|
40
41
|
console.log(chalk.green(` ✓ Deleted ${chalk.bold(agent.id)} (${agent.cloudId})`));
|
|
41
42
|
} else {
|
|
42
43
|
console.log(chalk.yellow(` ⚠ Could not delete ${agent.id} — may already be gone`));
|
|
44
|
+
allStopped = false;
|
|
43
45
|
}
|
|
44
46
|
} catch (err) {
|
|
45
47
|
console.log(chalk.red(` ✗ ${agent.id}: ${err.message}`));
|
|
48
|
+
allStopped = false;
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
} else if (session.ide === 'claude-code') {
|
|
@@ -56,18 +59,22 @@ export async function stopCommand() {
|
|
|
56
59
|
console.log(chalk.dim(` ${agent.id} (PID: ${agent.pid}) — already stopped`));
|
|
57
60
|
} else {
|
|
58
61
|
console.log(chalk.red(` ✗ ${agent.id}: ${err.message}`));
|
|
62
|
+
allStopped = false;
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
// Remove session file
|
|
66
|
-
const sessionPath = join(root, SESSION_FILE);
|
|
67
|
-
if (existsSync(sessionPath)) unlinkSync(sessionPath);
|
|
68
|
-
|
|
69
69
|
console.log('');
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const sessionPath = join(root, SESSION_FILE);
|
|
71
|
+
if (allStopped) {
|
|
72
|
+
if (existsSync(sessionPath)) unlinkSync(sessionPath);
|
|
73
|
+
console.log(chalk.dim(' Session file removed.'));
|
|
74
|
+
console.log(chalk.green(' All agents stopped.'));
|
|
75
|
+
} else {
|
|
76
|
+
console.log(chalk.yellow(' Some agents could not be stopped.'));
|
|
77
|
+
console.log(chalk.dim(' Session file was kept so you can retry `agentxchain stop`.'));
|
|
78
|
+
}
|
|
72
79
|
console.log('');
|
|
73
80
|
}
|
package/src/commands/watch.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
3
5
|
import chalk from 'chalk';
|
|
4
6
|
import { loadConfig, loadLock, LOCK_FILE } from '../lib/config.js';
|
|
5
7
|
import { notifyHuman as sendNotification } from '../lib/notify.js';
|
|
@@ -7,6 +9,11 @@ import { sendFollowup, getAgentStatus, stopAgent, loadSession } from '../adapter
|
|
|
7
9
|
import { getCursorApiKey, printCursorApiKeyRequired } from '../lib/cursor-api-key.js';
|
|
8
10
|
|
|
9
11
|
export async function watchCommand(opts) {
|
|
12
|
+
if (opts.daemon && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
|
|
13
|
+
startWatchDaemon();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
const result = loadConfig();
|
|
11
18
|
if (!result) {
|
|
12
19
|
console.log(chalk.red(' No agentxchain.json found. Run `agentxchain init` first.'));
|
|
@@ -17,6 +24,11 @@ export async function watchCommand(opts) {
|
|
|
17
24
|
const interval = config.rules?.watch_interval_ms || 5000;
|
|
18
25
|
const ttlMinutes = config.rules?.ttl_minutes || 10;
|
|
19
26
|
const agentIds = Object.keys(config.agents);
|
|
27
|
+
if (agentIds.length === 0) {
|
|
28
|
+
console.log(chalk.red(' No agents configured in agentxchain.json.'));
|
|
29
|
+
console.log(chalk.dim(' Add an agent with: agentxchain config --add-agent'));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
20
32
|
const apiKey = getCursorApiKey(root);
|
|
21
33
|
const session = loadSession(root);
|
|
22
34
|
const hasCursorSession = session?.ide === 'cursor' && session?.launched?.length > 0;
|
|
@@ -108,12 +120,19 @@ export async function watchCommand(opts) {
|
|
|
108
120
|
if (stateKey !== lastState) {
|
|
109
121
|
const next = pickNextAgent(lock, config);
|
|
110
122
|
log('free', `Lock free (released by ${lock.last_released_by || 'none'}). Waking ${chalk.bold(next)}.`);
|
|
111
|
-
|
|
123
|
+
let wakeSucceeded = false;
|
|
112
124
|
|
|
113
125
|
if (hasCursorSession && apiKey) {
|
|
114
|
-
await wakeCursorAgent(apiKey, session, next, lock, config);
|
|
126
|
+
wakeSucceeded = await wakeCursorAgent(apiKey, session, next, lock, config, root);
|
|
115
127
|
} else {
|
|
116
128
|
writeTrigger(root, next, lock, config);
|
|
129
|
+
wakeSucceeded = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (wakeSucceeded) {
|
|
133
|
+
lastState = stateKey;
|
|
134
|
+
} else {
|
|
135
|
+
log('warn', `Wake for ${next} failed. Will retry next poll.`);
|
|
117
136
|
}
|
|
118
137
|
}
|
|
119
138
|
} catch (err) {
|
|
@@ -132,11 +151,12 @@ export async function watchCommand(opts) {
|
|
|
132
151
|
});
|
|
133
152
|
}
|
|
134
153
|
|
|
135
|
-
async function wakeCursorAgent(apiKey, session, agentId, lock, config) {
|
|
154
|
+
async function wakeCursorAgent(apiKey, session, agentId, lock, config, root) {
|
|
136
155
|
const cloudAgent = session.launched.find(a => a.id === agentId);
|
|
137
156
|
if (!cloudAgent) {
|
|
138
157
|
log('warn', `No Cursor cloud agent found for "${agentId}". Using trigger file.`);
|
|
139
|
-
|
|
158
|
+
writeTrigger(root, agentId, lock, config);
|
|
159
|
+
return true;
|
|
140
160
|
}
|
|
141
161
|
|
|
142
162
|
const name = config.agents[agentId]?.name || agentId;
|
|
@@ -158,13 +178,16 @@ This must be the last thing you write. The watch process will wake the next agen
|
|
|
158
178
|
try {
|
|
159
179
|
await sendFollowup(apiKey, cloudAgent.cloudId, wakeMessage);
|
|
160
180
|
log('wake', `Sent followup to ${chalk.bold(agentId)} (${cloudAgent.cloudId})`);
|
|
181
|
+
return true;
|
|
161
182
|
} catch (err) {
|
|
162
183
|
log('error', `Failed to wake ${agentId}: ${err.message}`);
|
|
184
|
+
return false;
|
|
163
185
|
}
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
function pickNextAgent(lock, config) {
|
|
167
189
|
const agentIds = Object.keys(config.agents);
|
|
190
|
+
if (agentIds.length === 0) return null;
|
|
168
191
|
const lastAgent = lock.last_released_by;
|
|
169
192
|
|
|
170
193
|
if (!lastAgent || !agentIds.includes(lastAgent)) return agentIds[0];
|
|
@@ -191,6 +214,7 @@ function forceRelease(root, lock, staleAgent, config) {
|
|
|
191
214
|
}
|
|
192
215
|
|
|
193
216
|
function writeTrigger(root, agentId, lock, config) {
|
|
217
|
+
if (!agentId) return;
|
|
194
218
|
const triggerPath = join(root, '.agentxchain-trigger.json');
|
|
195
219
|
writeFileSync(triggerPath, JSON.stringify({
|
|
196
220
|
agent: agentId,
|
|
@@ -214,3 +238,20 @@ function log(type, msg) {
|
|
|
214
238
|
};
|
|
215
239
|
console.log(` ${chalk.dim(time)} ${tags[type] || chalk.dim(type)} ${msg}`);
|
|
216
240
|
}
|
|
241
|
+
|
|
242
|
+
function startWatchDaemon() {
|
|
243
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
244
|
+
const cliBin = join(currentDir, '../../bin/agentxchain.js');
|
|
245
|
+
const child = spawn(process.execPath, [cliBin, 'watch'], {
|
|
246
|
+
cwd: process.cwd(),
|
|
247
|
+
detached: true,
|
|
248
|
+
stdio: 'ignore',
|
|
249
|
+
env: { ...process.env, AGENTXCHAIN_WATCH_DAEMON: '1' }
|
|
250
|
+
});
|
|
251
|
+
child.unref();
|
|
252
|
+
|
|
253
|
+
console.log('');
|
|
254
|
+
console.log(chalk.green(` ✓ Watch started in daemon mode (PID: ${child.pid})`));
|
|
255
|
+
console.log(chalk.dim(' Use `agentxchain stop` or kill the PID to stop it.'));
|
|
256
|
+
console.log('');
|
|
257
|
+
}
|
package/src/lib/repo.js
CHANGED
|
@@ -2,7 +2,11 @@ import { execSync } from 'child_process';
|
|
|
2
2
|
|
|
3
3
|
export async function getRepoUrl(root) {
|
|
4
4
|
try {
|
|
5
|
-
const raw = execSync('git remote get-url origin', {
|
|
5
|
+
const raw = execSync('git remote get-url origin', {
|
|
6
|
+
cwd: root,
|
|
7
|
+
encoding: 'utf8',
|
|
8
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
9
|
+
}).trim();
|
|
6
10
|
|
|
7
11
|
// Convert SSH to HTTPS if needed
|
|
8
12
|
// git@github.com:user/repo.git -> https://github.com/user/repo
|
|
@@ -11,18 +15,17 @@ export async function getRepoUrl(root) {
|
|
|
11
15
|
return `https://github.com/${path}`;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
// Strip embedded credentials/tokens from HTTPS URLs.
|
|
19
|
+
// https://x-access-token:TOKEN@github.com/org/repo.git -> https://github.com/org/repo
|
|
20
|
+
// https://user:pass@github.com/org/repo.git -> https://github.com/org/repo
|
|
21
|
+
const credentialStripped = raw.replace(/^https?:\/\/[^/@]+@github\.com\//, 'https://github.com/');
|
|
18
22
|
|
|
19
|
-
//
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
return cleaned.replace(/\.git$/, '');
|
|
23
|
+
// Already HTTPS — strip .git suffix
|
|
24
|
+
if (credentialStripped.includes('github.com')) {
|
|
25
|
+
return credentialStripped.replace(/\.git$/, '');
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
return
|
|
28
|
+
return credentialStripped;
|
|
26
29
|
} catch {
|
|
27
30
|
return null;
|
|
28
31
|
}
|
|
@@ -30,12 +33,20 @@ export async function getRepoUrl(root) {
|
|
|
30
33
|
|
|
31
34
|
export function getCurrentBranch(root) {
|
|
32
35
|
try {
|
|
33
|
-
const current = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
36
|
+
const current = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
37
|
+
cwd: root,
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
40
|
+
}).trim();
|
|
34
41
|
if (current && current !== 'HEAD') return current;
|
|
35
42
|
} catch {}
|
|
36
43
|
|
|
37
44
|
try {
|
|
38
|
-
const remoteHead = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
|
|
45
|
+
const remoteHead = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
|
|
46
|
+
cwd: root,
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
49
|
+
}).trim();
|
|
39
50
|
if (remoteHead.includes('/')) return remoteHead.split('/').pop();
|
|
40
51
|
} catch {
|
|
41
52
|
return 'main';
|