@grantx/fleet-core 0.1.0
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/package.json +22 -0
- package/src/agent-runner.js +291 -0
- package/src/conductor-loop.js +336 -0
- package/src/conductor-runner.js +182 -0
- package/src/config.js +103 -0
- package/src/dispatch-prompts.js +141 -0
- package/src/fleet-utils.js +189 -0
- package/src/index.js +12 -0
- package/src/logger.js +49 -0
- package/src/platform.js +102 -0
- package/src/smart-dispatcher.js +153 -0
- package/src/supabase-client.js +87 -0
package/src/platform.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// platform.js — Cross-platform abstractions for fleet plugin.
|
|
2
|
+
// Replaces all hardcoded /tmp, /opt/homebrew, /Users/clawcob paths.
|
|
3
|
+
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
|
|
8
|
+
export const isWindows = process.platform === 'win32';
|
|
9
|
+
export const isMac = process.platform === 'darwin';
|
|
10
|
+
export const isLinux = process.platform === 'linux';
|
|
11
|
+
|
|
12
|
+
// Lockfile directory: os.tmpdir()/fleet on Windows, /tmp on Unix
|
|
13
|
+
export const lockDir = isWindows
|
|
14
|
+
? path.join(os.tmpdir(), 'fleet')
|
|
15
|
+
: '/tmp';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure the lockfile directory exists.
|
|
19
|
+
*/
|
|
20
|
+
export function ensureLockDir() {
|
|
21
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build environment variables for a spawned agent process.
|
|
26
|
+
* Inherits the user's PATH (no hardcoded /opt/homebrew).
|
|
27
|
+
*/
|
|
28
|
+
export function buildAgentEnv(agentName, agentHome, teamId, extra = {}) {
|
|
29
|
+
const env = {
|
|
30
|
+
HOME: agentHome,
|
|
31
|
+
FLEET_AGENT_ID: agentName,
|
|
32
|
+
FLEET_TEAM_ID: teamId,
|
|
33
|
+
PATH: process.env.PATH,
|
|
34
|
+
CLOUDSDK_CONFIG: process.env.CLOUDSDK_CONFIG || path.join(os.homedir(), '.config', 'gcloud'),
|
|
35
|
+
...extra,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Windows uses USERPROFILE as HOME equivalent
|
|
39
|
+
if (isWindows) {
|
|
40
|
+
env.USERPROFILE = agentHome;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return env;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Set up Claude credentials for an agent.
|
|
48
|
+
* Unix: symlink to user's credentials (auto-refreshes).
|
|
49
|
+
* Windows: copy (symlinks require admin privileges).
|
|
50
|
+
*/
|
|
51
|
+
export function setupCredentials(agentHome) {
|
|
52
|
+
const userCreds = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
53
|
+
const agentClaudeDir = path.join(agentHome, '.claude');
|
|
54
|
+
const agentCreds = path.join(agentClaudeDir, '.credentials.json');
|
|
55
|
+
|
|
56
|
+
if (!fs.existsSync(userCreds)) {
|
|
57
|
+
throw new Error(`Claude credentials not found at ${userCreds}. Run 'claude' first to authenticate.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fs.mkdirSync(agentClaudeDir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
// Remove existing (stale copy or broken symlink)
|
|
63
|
+
try { fs.unlinkSync(agentCreds); } catch { /* doesn't exist */ }
|
|
64
|
+
|
|
65
|
+
if (isWindows) {
|
|
66
|
+
fs.copyFileSync(userCreds, agentCreds);
|
|
67
|
+
} else {
|
|
68
|
+
fs.symlinkSync(userCreds, agentCreds);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Refresh credentials for all agents (Windows only — copies are stale).
|
|
74
|
+
* No-op on Unix (symlinks auto-refresh).
|
|
75
|
+
*/
|
|
76
|
+
export function refreshCredentials(fleetDir, agentNames) {
|
|
77
|
+
if (!isWindows) return;
|
|
78
|
+
|
|
79
|
+
const userCreds = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
80
|
+
if (!fs.existsSync(userCreds)) return;
|
|
81
|
+
|
|
82
|
+
for (const name of agentNames) {
|
|
83
|
+
const agentCreds = path.join(fleetDir, 'agents', name, '.claude', '.credentials.json');
|
|
84
|
+
try {
|
|
85
|
+
fs.copyFileSync(userCreds, agentCreds);
|
|
86
|
+
} catch { /* agent dir may not exist yet */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the fleet working directory (.fleet/) from a project root.
|
|
92
|
+
*/
|
|
93
|
+
export function fleetDir(projectRoot) {
|
|
94
|
+
return path.join(projectRoot, '.fleet');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve an agent's HOME directory.
|
|
99
|
+
*/
|
|
100
|
+
export function agentHome(projectRoot, agentName) {
|
|
101
|
+
return path.join(projectRoot, '.fleet', 'agents', agentName);
|
|
102
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// smart-dispatcher.js — Heuristic fast-path dispatch.
|
|
2
|
+
// Handles the common case (1 pending task + idle worker) in <100ms
|
|
3
|
+
// without invoking conductor AI. Falls through for complex decisions.
|
|
4
|
+
// Keywords loaded from fleet.config.json, not hardcoded.
|
|
5
|
+
|
|
6
|
+
import { log } from './logger.js';
|
|
7
|
+
import { dispatchAgent } from './fleet-utils.js';
|
|
8
|
+
import { getWorkerAgents } from './config.js';
|
|
9
|
+
|
|
10
|
+
export class SmartDispatcher {
|
|
11
|
+
constructor(agentPool, supabase, config) {
|
|
12
|
+
this.agentPool = agentPool;
|
|
13
|
+
this.supabase = supabase;
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.stats = { fastPath: 0, fallthrough: 0 };
|
|
16
|
+
|
|
17
|
+
// Build keyword map from config
|
|
18
|
+
this.workerAgents = getWorkerAgents(config).map(a => a.name);
|
|
19
|
+
this.specialtyKeywords = {};
|
|
20
|
+
for (const agent of getWorkerAgents(config)) {
|
|
21
|
+
if (agent.keywords?.length > 0) {
|
|
22
|
+
this.specialtyKeywords[agent.name] = agent.keywords;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Try to handle events via fast-path. Returns true if handled, false if conductor AI needed.
|
|
29
|
+
*/
|
|
30
|
+
async tryFastPath(events, state) {
|
|
31
|
+
if (!this._isSimpleScenario(events, state)) {
|
|
32
|
+
this.stats.fallthrough++;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const pendingTasks = state.pendingTasks || [];
|
|
37
|
+
if (pendingTasks.length === 0) {
|
|
38
|
+
log.info('smart-dispatch', 'Fast-path: no pending tasks, skipping cycle');
|
|
39
|
+
this.stats.fastPath++;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const dispatchable = await this._filterByDeps(pendingTasks);
|
|
44
|
+
if (dispatchable.length === 0 && pendingTasks.length > 0) {
|
|
45
|
+
log.info('smart-dispatch', `Fast-path: ${pendingTasks.length} pending but all blocked by deps`);
|
|
46
|
+
this.stats.fastPath++;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let dispatched = 0;
|
|
51
|
+
for (const task of dispatchable) {
|
|
52
|
+
const agent = this._pickAgent(task);
|
|
53
|
+
if (!agent) continue;
|
|
54
|
+
|
|
55
|
+
log.info('smart-dispatch', `Fast-path: dispatching ${agent} -> "${task.title}" (${task.id.slice(0, 8)})`);
|
|
56
|
+
await dispatchAgent(
|
|
57
|
+
{ agent, task_id: task.id, reason: 'smart-dispatch: fast-path' },
|
|
58
|
+
this.agentPool, this.supabase, this.config
|
|
59
|
+
);
|
|
60
|
+
dispatched++;
|
|
61
|
+
break; // One per cycle
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (dispatched === 0 && pendingTasks.length > 0) {
|
|
65
|
+
log.info('smart-dispatch', `Fast-path: ${pendingTasks.length} pending but no idle workers`);
|
|
66
|
+
this.stats.fallthrough++;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.stats.fastPath++;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Chain-dispatch: after a task completion, immediately dispatch next pending for same agent.
|
|
76
|
+
*/
|
|
77
|
+
async tryChainDispatch(completedAgent, state) {
|
|
78
|
+
if (this.agentPool.running.has(completedAgent)) return false;
|
|
79
|
+
|
|
80
|
+
const pendingForAgent = (state.pendingTasks || []).filter(t => t.to_agent === completedAgent);
|
|
81
|
+
if (pendingForAgent.length === 0) return false;
|
|
82
|
+
|
|
83
|
+
const dispatchable = await this._filterByDeps(pendingForAgent);
|
|
84
|
+
if (dispatchable.length === 0) return false;
|
|
85
|
+
|
|
86
|
+
const next = dispatchable[0];
|
|
87
|
+
log.info('smart-dispatch', `Chain-dispatch: ${completedAgent} -> "${next.title}"`);
|
|
88
|
+
await dispatchAgent(
|
|
89
|
+
{ agent: completedAgent, task_id: next.id, reason: 'smart-dispatch: chain after completion' },
|
|
90
|
+
this.agentPool, this.supabase, this.config
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
this.stats.fastPath++;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_isSimpleScenario(events, state) {
|
|
98
|
+
const complexTypes = ['task_flagged', 'task_failed', 'agent_health', 'sprint_gate'];
|
|
99
|
+
if (events.some(e => complexTypes.includes(e.type))) return false;
|
|
100
|
+
if (state.blockedTasks?.length > 0) return false;
|
|
101
|
+
if ((state.pendingTasks?.length || 0) > 5) return false;
|
|
102
|
+
if (!events.every(e => e.type === 'dispatch_tick' || e.type === 'task_completed')) return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async _filterByDeps(tasks) {
|
|
107
|
+
const tasksWithDeps = tasks.filter(t => t.depends_on?.length > 0);
|
|
108
|
+
if (tasksWithDeps.length === 0) return tasks;
|
|
109
|
+
|
|
110
|
+
const allDepIds = [...new Set(tasksWithDeps.flatMap(t => t.depends_on))];
|
|
111
|
+
try {
|
|
112
|
+
const depTasks = await this.supabase.getUnscoped(
|
|
113
|
+
`task_queue?id=in.(${allDepIds.join(',')})&select=id,status`
|
|
114
|
+
);
|
|
115
|
+
const depStatus = new Map(depTasks.map(t => [t.id, t.status]));
|
|
116
|
+
|
|
117
|
+
return tasks.filter(task => {
|
|
118
|
+
if (!task.depends_on?.length) return true;
|
|
119
|
+
return task.depends_on.every(depId => depStatus.get(depId) === 'completed');
|
|
120
|
+
});
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log.warn('smart-dispatch', `Dep check failed, allowing all: ${err.message}`);
|
|
123
|
+
return tasks;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_pickAgent(task) {
|
|
128
|
+
// Explicit assignment
|
|
129
|
+
if (task.to_agent && this.workerAgents.includes(task.to_agent)) {
|
|
130
|
+
if (!this.agentPool.running.has(task.to_agent)) return task.to_agent;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Keyword match from config
|
|
135
|
+
const taskText = `${task.title} ${task.description || ''}`.toLowerCase();
|
|
136
|
+
for (const [agent, keywords] of Object.entries(this.specialtyKeywords)) {
|
|
137
|
+
if (keywords.some(kw => taskText.includes(kw)) && !this.agentPool.running.has(agent)) {
|
|
138
|
+
return agent;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// First idle worker
|
|
143
|
+
for (const agent of this.workerAgents) {
|
|
144
|
+
if (!this.agentPool.running.has(agent)) return agent;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getStats() {
|
|
151
|
+
return { ...this.stats };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// supabase-client.js — Team-scoped Supabase REST wrapper.
|
|
2
|
+
// All queries automatically filtered by team_id.
|
|
3
|
+
|
|
4
|
+
import { log } from './logger.js';
|
|
5
|
+
|
|
6
|
+
export class SupabaseClient {
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} url - Supabase project URL
|
|
9
|
+
* @param {string} key - Supabase API key (anon or service_role)
|
|
10
|
+
* @param {string} teamId - Team ID for scoping queries
|
|
11
|
+
*/
|
|
12
|
+
constructor(url, key, teamId) {
|
|
13
|
+
this.url = url.replace(/\/$/, '');
|
|
14
|
+
this.key = key;
|
|
15
|
+
this.teamId = teamId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* GET with team_id filter auto-appended.
|
|
20
|
+
*/
|
|
21
|
+
async get(path) {
|
|
22
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
23
|
+
const scopedPath = `${path}${sep}team_id=eq.${this.teamId}`;
|
|
24
|
+
return this._request('GET', scopedPath);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* GET without team_id filter (for cross-team queries like dep checking).
|
|
29
|
+
*/
|
|
30
|
+
async getUnscoped(path) {
|
|
31
|
+
return this._request('GET', path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* POST with team_id auto-injected into body.
|
|
36
|
+
*/
|
|
37
|
+
async post(path, body) {
|
|
38
|
+
const scopedBody = { ...body, team_id: this.teamId };
|
|
39
|
+
return this._request('POST', path, scopedBody);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* PATCH with team_id filter on path and in body.
|
|
44
|
+
*/
|
|
45
|
+
async patch(path, body) {
|
|
46
|
+
const sep = path.includes('?') ? '&' : '?';
|
|
47
|
+
const scopedPath = `${path}${sep}team_id=eq.${this.teamId}`;
|
|
48
|
+
const scopedBody = { ...body, team_id: this.teamId };
|
|
49
|
+
return this._request('PATCH', scopedPath, scopedBody);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async _request(method, path, body) {
|
|
53
|
+
const opts = {
|
|
54
|
+
method,
|
|
55
|
+
headers: {
|
|
56
|
+
'apikey': this.key,
|
|
57
|
+
'Authorization': `Bearer ${this.key}`,
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
'Prefer': 'return=representation',
|
|
60
|
+
'x-team-id': this.teamId,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (body && (method === 'POST' || method === 'PATCH')) {
|
|
65
|
+
opts.body = JSON.stringify(body);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const url = `${this.url}/rest/v1/${path}`;
|
|
69
|
+
const res = await fetch(url, opts);
|
|
70
|
+
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
const text = await res.text().catch(() => '');
|
|
73
|
+
const msg = `Supabase ${method} ${path}: ${res.status} ${text.slice(0, 200)}`;
|
|
74
|
+
log.error('supabase', msg);
|
|
75
|
+
throw new Error(msg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const text = await res.text();
|
|
79
|
+
if (!text) return method === 'GET' ? [] : {};
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|