@ours.network/fleet 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/LICENSE +98 -0
- package/README.md +196 -0
- package/dist/briefing.d.ts +10 -0
- package/dist/briefing.js +73 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +209 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +79 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +61 -0
- package/dist/exec.d.ts +12 -0
- package/dist/exec.js +14 -0
- package/dist/harness/claude-code.d.ts +8 -0
- package/dist/harness/claude-code.js +116 -0
- package/dist/harness/registry.d.ts +4 -0
- package/dist/harness/registry.js +13 -0
- package/dist/harness/types.d.ts +58 -0
- package/dist/harness/types.js +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +11 -0
- package/dist/ops.d.ts +20 -0
- package/dist/ops.js +87 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +11 -0
- package/dist/runner.d.ts +21 -0
- package/dist/runner.js +97 -0
- package/dist/spawn.d.ts +19 -0
- package/dist/spawn.js +57 -0
- package/dist/supervisor/index.d.ts +7 -0
- package/dist/supervisor/index.js +16 -0
- package/dist/supervisor/launchd.d.ts +4 -0
- package/dist/supervisor/launchd.js +76 -0
- package/dist/supervisor/none.d.ts +8 -0
- package/dist/supervisor/none.js +24 -0
- package/dist/supervisor/systemd.d.ts +5 -0
- package/dist/supervisor/systemd.js +63 -0
- package/dist/supervisor/types.d.ts +17 -0
- package/dist/supervisor/types.js +1 -0
- package/dist/tmux.d.ts +14 -0
- package/dist/tmux.js +50 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +19 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
import { agentDir } from './paths.js';
|
|
6
|
+
import { loadConfig, findRole } from './config.js';
|
|
7
|
+
import { getAdapter } from './harness/registry.js';
|
|
8
|
+
import { Tmux } from './tmux.js';
|
|
9
|
+
import { shq } from './exec.js';
|
|
10
|
+
const defaultDeps = () => ({
|
|
11
|
+
tmux: new Tmux(),
|
|
12
|
+
isAlive: pid => { try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
} },
|
|
19
|
+
sleep: ms => new Promise(r => setTimeout(r, ms)),
|
|
20
|
+
now: () => Date.now(),
|
|
21
|
+
log: line => process.stderr.write(line + '\n'),
|
|
22
|
+
});
|
|
23
|
+
/** Compose the tmux pane shell command: env prefix + argv + exit-status capture. */
|
|
24
|
+
export function buildPaneCommand(launch, roleEnv, exitStatusPath) {
|
|
25
|
+
const env = { PATH: process.env.PATH ?? '', ...launch.env, ...(roleEnv ?? {}) };
|
|
26
|
+
const envPfx = 'env ' + Object.entries(env).map(([k, v]) => `${k}=${shq(v)}`).join(' ');
|
|
27
|
+
const cmd = launch.argv.map(shq).join(' ');
|
|
28
|
+
return `${envPfx} ${cmd}; echo $? > ${shq(exitStatusPath)}`;
|
|
29
|
+
}
|
|
30
|
+
/** Read a temp role's config snapshot written by spawnTemp. */
|
|
31
|
+
export function loadTempRole(name) {
|
|
32
|
+
const p = join(agentDir(name, true), 'role.yaml');
|
|
33
|
+
if (!existsSync(p))
|
|
34
|
+
throw new Error(`temp role '${name}' has no snapshot at ${p}`);
|
|
35
|
+
const role = parse(readFileSync(p, 'utf8'));
|
|
36
|
+
role.__temp = true;
|
|
37
|
+
return role;
|
|
38
|
+
}
|
|
39
|
+
/** One supervised session lifecycle. The supervisor re-invokes us after we return. */
|
|
40
|
+
export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
41
|
+
const deps = { ...defaultDeps(), ...partialDeps };
|
|
42
|
+
const temp = opts.temp === true;
|
|
43
|
+
const role = temp ? loadTempRole(name) : findRole(loadConfig(opts.configPath), name);
|
|
44
|
+
const adapter = getAdapter(role.harness);
|
|
45
|
+
const dir = agentDir(name, temp);
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
const sidFile = join(dir, '.session-id');
|
|
48
|
+
if (!existsSync(sidFile))
|
|
49
|
+
writeFileSync(sidFile, randomUUID() + '\n');
|
|
50
|
+
const sessionId = readFileSync(sidFile, 'utf8').trim();
|
|
51
|
+
const bootedFile = join(dir, '.booted');
|
|
52
|
+
const exitFile = join(dir, '.exit-status');
|
|
53
|
+
const booted = existsSync(bootedFile);
|
|
54
|
+
const mode = booted && adapter.supportsResume ? 'resume' : 'fresh';
|
|
55
|
+
if (mode === 'fresh')
|
|
56
|
+
writeFileSync(bootedFile, '');
|
|
57
|
+
const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
|
|
58
|
+
const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
|
|
59
|
+
const launch = adapter.buildLaunch(role, mode, { sessionId }, prep);
|
|
60
|
+
rmSync(exitFile, { force: true });
|
|
61
|
+
await deps.tmux.kill(name);
|
|
62
|
+
await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile));
|
|
63
|
+
let pid = null;
|
|
64
|
+
for (let i = 0; i < 40 && pid === null; i++) {
|
|
65
|
+
pid = await deps.tmux.panePid(name);
|
|
66
|
+
if (pid === null)
|
|
67
|
+
await deps.sleep(250);
|
|
68
|
+
}
|
|
69
|
+
if (pid === null)
|
|
70
|
+
throw new Error(`[${name}] could not resolve tmux pane pid`);
|
|
71
|
+
deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} mode=${mode}`);
|
|
72
|
+
const start = deps.now();
|
|
73
|
+
while (deps.isAlive(pid))
|
|
74
|
+
await deps.sleep(2000);
|
|
75
|
+
const elapsed = (deps.now() - start) / 1000;
|
|
76
|
+
const code = existsSync(exitFile) ? readFileSync(exitFile, 'utf8').trim() : 'crash';
|
|
77
|
+
const rotate = (why) => {
|
|
78
|
+
writeFileSync(sidFile, randomUUID() + '\n');
|
|
79
|
+
rmSync(bootedFile, { force: true });
|
|
80
|
+
deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
|
|
81
|
+
};
|
|
82
|
+
if (code === '0' && adapter.exitPolicy.cleanExitIsFresh)
|
|
83
|
+
rotate(`clean exit (code 0)`);
|
|
84
|
+
else if (mode === 'resume' && elapsed < adapter.exitPolicy.fastFailSecs)
|
|
85
|
+
rotate(`resume failed fast (${elapsed.toFixed(0)}s, code ${code})`);
|
|
86
|
+
else
|
|
87
|
+
deps.log(`[${name}] exited (code ${code}, ${elapsed.toFixed(0)}s) -> next start RESUMES context`);
|
|
88
|
+
}
|
|
89
|
+
/** Temp-agent entrypoint: run one session, then remove the temp dir. */
|
|
90
|
+
export async function runTemp(name, deps = {}) {
|
|
91
|
+
try {
|
|
92
|
+
await runOnce(name, { temp: true }, deps);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
rmSync(agentDir(name, true), { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/spawn.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type OpsDeps } from './ops.js';
|
|
2
|
+
import type { Tmux } from './tmux.js';
|
|
3
|
+
export interface SpawnOpts {
|
|
4
|
+
name: string;
|
|
5
|
+
temp?: boolean;
|
|
6
|
+
harness?: string;
|
|
7
|
+
mission?: string;
|
|
8
|
+
identity?: string;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
coordinator?: string;
|
|
11
|
+
bioFile?: string;
|
|
12
|
+
personaFile?: string;
|
|
13
|
+
overseeInterval?: string;
|
|
14
|
+
configPath?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
17
|
+
export declare function spawnPermanent(o: SpawnOpts, deps: OpsDeps): Promise<string>;
|
|
18
|
+
/** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
|
|
19
|
+
export declare function spawnTemp(o: SpawnOpts, tmux: Tmux, binPath: string): Promise<string>;
|
package/dist/spawn.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { stringify } from 'yaml';
|
|
4
|
+
import { agentDir, fleetDDir } from './paths.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { applyRole, up } from './ops.js';
|
|
7
|
+
import { shq } from './exec.js';
|
|
8
|
+
function roleFromOpts(o) {
|
|
9
|
+
const r = {};
|
|
10
|
+
if (o.harness)
|
|
11
|
+
r.harness = o.harness;
|
|
12
|
+
if (o.identity)
|
|
13
|
+
r.identity = o.identity;
|
|
14
|
+
if (o.cwd)
|
|
15
|
+
r.cwd = o.cwd;
|
|
16
|
+
if (o.coordinator)
|
|
17
|
+
r.coordinator = o.coordinator;
|
|
18
|
+
if (o.mission)
|
|
19
|
+
r.mission = o.mission;
|
|
20
|
+
if (o.bioFile)
|
|
21
|
+
r.bio = readFileSync(o.bioFile, 'utf8').trim();
|
|
22
|
+
if (o.personaFile)
|
|
23
|
+
r.persona = readFileSync(o.personaFile, 'utf8').trim();
|
|
24
|
+
return r;
|
|
25
|
+
}
|
|
26
|
+
function assertNameFree(o) {
|
|
27
|
+
const cfg = loadConfig(o.configPath);
|
|
28
|
+
if (cfg.roles.some(r => r.name === o.name))
|
|
29
|
+
throw new Error(`role '${o.name}' already exists (${cfg.roles.find(r => r.name === o.name).sourceFile})`);
|
|
30
|
+
if (existsSync(agentDir(o.name)) || existsSync(agentDir(o.name, true)))
|
|
31
|
+
throw new Error(`agent dir for '${o.name}' already exists — pick another name or 'ours-fleet rm ${o.name}'`);
|
|
32
|
+
}
|
|
33
|
+
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
34
|
+
export async function spawnPermanent(o, deps) {
|
|
35
|
+
assertNameFree(o);
|
|
36
|
+
mkdirSync(fleetDDir(), { recursive: true });
|
|
37
|
+
const file = join(fleetDDir(), `${o.name}.yaml`);
|
|
38
|
+
writeFileSync(file, stringify({ roles: { [o.name]: roleFromOpts(o) } }));
|
|
39
|
+
await up(loadConfig(o.configPath), [o.name], deps);
|
|
40
|
+
return file;
|
|
41
|
+
}
|
|
42
|
+
/** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
|
|
43
|
+
export async function spawnTemp(o, tmux, binPath) {
|
|
44
|
+
assertNameFree(o);
|
|
45
|
+
const cfg = loadConfig(o.configPath);
|
|
46
|
+
const role = {
|
|
47
|
+
...roleFromOpts(o),
|
|
48
|
+
name: o.name,
|
|
49
|
+
harness: o.harness ?? cfg.defaults.harness ?? 'claude-code',
|
|
50
|
+
identity: o.identity ?? o.name,
|
|
51
|
+
sourceFile: '(temp)',
|
|
52
|
+
};
|
|
53
|
+
const dir = applyRole(role, { temp: true });
|
|
54
|
+
writeFileSync(join(dir, 'role.yaml'), stringify(role));
|
|
55
|
+
await tmux.newSession(o.name, dir, `${shq(binPath)} _run-temp ${shq(o.name)}`);
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Exec } from '../exec.js';
|
|
2
|
+
import type { SupervisorBackend } from './types.js';
|
|
3
|
+
export type { SupervisorBackend } from './types.js';
|
|
4
|
+
export { makeSystemdBackend, unitFor } from './systemd.js';
|
|
5
|
+
export { makeLaunchdBackend, labelFor } from './launchd.js';
|
|
6
|
+
export { makeNoneBackend } from './none.js';
|
|
7
|
+
export declare function pickBackend(exec?: Exec, platform?: NodeJS.Platform): SupervisorBackend;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { realExec } from '../exec.js';
|
|
2
|
+
import { makeSystemdBackend } from './systemd.js';
|
|
3
|
+
import { makeLaunchdBackend } from './launchd.js';
|
|
4
|
+
import { makeNoneBackend } from './none.js';
|
|
5
|
+
export { makeSystemdBackend, unitFor } from './systemd.js';
|
|
6
|
+
export { makeLaunchdBackend, labelFor } from './launchd.js';
|
|
7
|
+
export { makeNoneBackend } from './none.js';
|
|
8
|
+
export function pickBackend(exec = realExec, platform = process.platform) {
|
|
9
|
+
if (process.env.OURS_FLEET_SUPERVISOR === 'none')
|
|
10
|
+
return makeNoneBackend(exec);
|
|
11
|
+
if (platform === 'darwin')
|
|
12
|
+
return makeLaunchdBackend(exec);
|
|
13
|
+
if (platform === 'linux')
|
|
14
|
+
return makeSystemdBackend(exec);
|
|
15
|
+
throw new Error(`unsupported platform '${platform}' (linux and darwin only)`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { home, logsRoot } from '../paths.js';
|
|
4
|
+
import { realExec } from '../exec.js';
|
|
5
|
+
export const labelFor = (name) => `network.ours.fleet.${name}`;
|
|
6
|
+
const agentsDir = () => join(home(), 'Library', 'LaunchAgents');
|
|
7
|
+
const plistPath = (name) => join(agentsDir(), `${labelFor(name)}.plist`);
|
|
8
|
+
function plist(name, binPath) {
|
|
9
|
+
const log = join(logsRoot(), `${name}.log`);
|
|
10
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
11
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
12
|
+
<plist version="1.0">
|
|
13
|
+
<dict>
|
|
14
|
+
<key>Label</key><string>${labelFor(name)}</string>
|
|
15
|
+
<key>ProgramArguments</key>
|
|
16
|
+
<array><string>${binPath}</string><string>_run</string><string>${name}</string></array>
|
|
17
|
+
<key>KeepAlive</key><true/>
|
|
18
|
+
<key>RunAtLoad</key><true/>
|
|
19
|
+
<key>StandardOutPath</key><string>${log}</string>
|
|
20
|
+
<key>StandardErrorPath</key><string>${log}</string>
|
|
21
|
+
</dict>
|
|
22
|
+
</plist>
|
|
23
|
+
`;
|
|
24
|
+
}
|
|
25
|
+
export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ?? 501) {
|
|
26
|
+
const domain = `gui/${uid}`;
|
|
27
|
+
return {
|
|
28
|
+
id: 'launchd',
|
|
29
|
+
async init() {
|
|
30
|
+
mkdirSync(agentsDir(), { recursive: true });
|
|
31
|
+
mkdirSync(logsRoot(), { recursive: true });
|
|
32
|
+
return [
|
|
33
|
+
`LaunchAgents dir ready: ${agentsDir()}`,
|
|
34
|
+
'note: launchd agents start at login (macOS has no linger equivalent)',
|
|
35
|
+
];
|
|
36
|
+
},
|
|
37
|
+
async install(name, binPath) {
|
|
38
|
+
mkdirSync(agentsDir(), { recursive: true });
|
|
39
|
+
mkdirSync(logsRoot(), { recursive: true });
|
|
40
|
+
writeFileSync(plistPath(name), plist(name, binPath));
|
|
41
|
+
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // best-effort refresh
|
|
42
|
+
const r = await exec('launchctl', ['bootstrap', domain, plistPath(name)]);
|
|
43
|
+
if (r.code !== 0)
|
|
44
|
+
throw new Error(`launchctl bootstrap ${labelFor(name)} failed: ${r.stderr.trim()}`);
|
|
45
|
+
},
|
|
46
|
+
async start(name) {
|
|
47
|
+
const r = await exec('launchctl', ['bootstrap', domain, plistPath(name)]);
|
|
48
|
+
if (r.code !== 0)
|
|
49
|
+
await exec('launchctl', ['kickstart', `${domain}/${labelFor(name)}`]);
|
|
50
|
+
},
|
|
51
|
+
async stop(name) {
|
|
52
|
+
const r = await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]);
|
|
53
|
+
if (r.code !== 0)
|
|
54
|
+
throw new Error(`launchctl bootout ${labelFor(name)} failed: ${r.stderr.trim()}`);
|
|
55
|
+
},
|
|
56
|
+
async restart(name) {
|
|
57
|
+
const r = await exec('launchctl', ['kickstart', '-k', `${domain}/${labelFor(name)}`]);
|
|
58
|
+
if (r.code !== 0)
|
|
59
|
+
throw new Error(`launchctl kickstart ${labelFor(name)} failed: ${r.stderr.trim()}`);
|
|
60
|
+
},
|
|
61
|
+
async status(name) {
|
|
62
|
+
const r = await exec('launchctl', ['print', `${domain}/${labelFor(name)}`]);
|
|
63
|
+
if (r.code !== 0)
|
|
64
|
+
return `not loaded (${labelFor(name)})`;
|
|
65
|
+
return r.stdout.split('\n').slice(0, 12).join('\n');
|
|
66
|
+
},
|
|
67
|
+
async uninstall(name) {
|
|
68
|
+
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]);
|
|
69
|
+
rmSync(plistPath(name), { force: true });
|
|
70
|
+
},
|
|
71
|
+
logsArgs(name, follow) {
|
|
72
|
+
const log = join(logsRoot(), `${name}.log`);
|
|
73
|
+
return { cmd: 'tail', args: follow ? ['-f', log] : ['-n', '200', log] };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Exec } from '../exec.js';
|
|
2
|
+
import type { SupervisorBackend } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* No supervision: sessions are plain tmux, nothing survives a reboot and
|
|
5
|
+
* nothing restarts on crash. Used for temp agents and CI tests
|
|
6
|
+
* (OURS_FLEET_SUPERVISOR=none).
|
|
7
|
+
*/
|
|
8
|
+
export declare function makeNoneBackend(exec?: Exec): SupervisorBackend;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Tmux } from '../tmux.js';
|
|
2
|
+
import { realExec, shq } from '../exec.js';
|
|
3
|
+
/**
|
|
4
|
+
* No supervision: sessions are plain tmux, nothing survives a reboot and
|
|
5
|
+
* nothing restarts on crash. Used for temp agents and CI tests
|
|
6
|
+
* (OURS_FLEET_SUPERVISOR=none).
|
|
7
|
+
*/
|
|
8
|
+
export function makeNoneBackend(exec = realExec) {
|
|
9
|
+
const tmux = new Tmux(exec);
|
|
10
|
+
return {
|
|
11
|
+
id: 'none',
|
|
12
|
+
async init() { return ['no supervisor: sessions are plain tmux (no reboot survival)']; },
|
|
13
|
+
async install(name, binPath) {
|
|
14
|
+
await tmux.kill(name);
|
|
15
|
+
await tmux.newSession(name, process.cwd(), `${shq(binPath)} _run ${shq(name)}`);
|
|
16
|
+
},
|
|
17
|
+
async start(name) { throw new Error(`'${name}' has no unit under the none backend — use install/spawn`); },
|
|
18
|
+
async stop(name) { await tmux.kill(name); },
|
|
19
|
+
async restart(name) { throw new Error(`restart unsupported under the none backend — stop + install '${name}'`); },
|
|
20
|
+
async status(name) { return (await tmux.has(name)) ? `tmux session '${name}' running` : `'${name}' not running`; },
|
|
21
|
+
async uninstall(name) { await tmux.kill(name); },
|
|
22
|
+
logsArgs(name) { return { cmd: 'tmux', args: ['capture-pane', '-t', name, '-p'] }; },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Exec } from '../exec.js';
|
|
2
|
+
import type { SupervisorBackend } from './types.js';
|
|
3
|
+
export declare const UNIT_TEMPLATE = "ours-fleet-agent@.service";
|
|
4
|
+
export declare const unitFor: (name: string) => string;
|
|
5
|
+
export declare function makeSystemdBackend(exec?: Exec): SupervisorBackend;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
4
|
+
import { home } from '../paths.js';
|
|
5
|
+
import { realExec } from '../exec.js';
|
|
6
|
+
export const UNIT_TEMPLATE = 'ours-fleet-agent@.service';
|
|
7
|
+
export const unitFor = (name) => `ours-fleet-agent@${name}.service`;
|
|
8
|
+
export function makeSystemdBackend(exec = realExec) {
|
|
9
|
+
const ctl = (...args) => exec('systemctl', ['--user', ...args]);
|
|
10
|
+
return {
|
|
11
|
+
id: 'systemd',
|
|
12
|
+
async init(binPath) {
|
|
13
|
+
const msgs = [];
|
|
14
|
+
const unitDir = join(home(), '.config', 'systemd', 'user');
|
|
15
|
+
mkdirSync(unitDir, { recursive: true });
|
|
16
|
+
writeFileSync(join(unitDir, UNIT_TEMPLATE), `[Unit]
|
|
17
|
+
Description=ours-fleet agent %i
|
|
18
|
+
After=default.target
|
|
19
|
+
|
|
20
|
+
[Service]
|
|
21
|
+
Type=simple
|
|
22
|
+
ExecStart=${binPath} _run %i
|
|
23
|
+
Restart=always
|
|
24
|
+
RestartSec=2
|
|
25
|
+
TimeoutStopSec=15
|
|
26
|
+
|
|
27
|
+
[Install]
|
|
28
|
+
WantedBy=default.target
|
|
29
|
+
`);
|
|
30
|
+
msgs.push(`installed ${join(unitDir, UNIT_TEMPLATE)}`);
|
|
31
|
+
await ctl('daemon-reload');
|
|
32
|
+
const linger = await exec('loginctl', ['enable-linger', userInfo().username]);
|
|
33
|
+
msgs.push(linger.code === 0
|
|
34
|
+
? 'linger enabled (roles survive logout + reboot)'
|
|
35
|
+
: `warning: could not enable linger (${linger.stderr.trim() || 'permission'}) — run: sudo loginctl enable-linger ${userInfo().username}`);
|
|
36
|
+
return msgs;
|
|
37
|
+
},
|
|
38
|
+
async install(name) {
|
|
39
|
+
const r = await ctl('enable', '--now', unitFor(name));
|
|
40
|
+
if (r.code !== 0)
|
|
41
|
+
throw new Error(`systemctl enable --now ${unitFor(name)} failed: ${r.stderr.trim()}`);
|
|
42
|
+
},
|
|
43
|
+
async start(name) { await ctl('start', unitFor(name)); },
|
|
44
|
+
async stop(name) {
|
|
45
|
+
const r = await ctl('stop', unitFor(name));
|
|
46
|
+
if (r.code !== 0)
|
|
47
|
+
throw new Error(`systemctl stop ${unitFor(name)} failed: ${r.stderr.trim()}`);
|
|
48
|
+
},
|
|
49
|
+
async restart(name) {
|
|
50
|
+
const r = await ctl('restart', unitFor(name));
|
|
51
|
+
if (r.code !== 0)
|
|
52
|
+
throw new Error(`systemctl restart ${unitFor(name)} failed: ${r.stderr.trim()}`);
|
|
53
|
+
},
|
|
54
|
+
async status(name) {
|
|
55
|
+
const r = await ctl('status', unitFor(name), '--no-pager');
|
|
56
|
+
return r.stdout || r.stderr;
|
|
57
|
+
},
|
|
58
|
+
async uninstall(name) { await ctl('disable', '--now', unitFor(name)); },
|
|
59
|
+
logsArgs(name, follow) {
|
|
60
|
+
return { cmd: 'journalctl', args: ['--user', '-u', unitFor(name), ...(follow ? ['-f'] : ['-n', '200'])] };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SupervisorBackend {
|
|
2
|
+
id: 'systemd' | 'launchd' | 'none';
|
|
3
|
+
/** One-time host setup (unit template / dirs / linger). Returns human-readable messages. */
|
|
4
|
+
init(binPath: string): Promise<string[]>;
|
|
5
|
+
/** Ensure the role's unit exists and is enabled + started. */
|
|
6
|
+
install(name: string, binPath: string): Promise<void>;
|
|
7
|
+
start(name: string): Promise<void>;
|
|
8
|
+
stop(name: string): Promise<void>;
|
|
9
|
+
restart(name: string): Promise<void>;
|
|
10
|
+
status(name: string): Promise<string>;
|
|
11
|
+
uninstall(name: string): Promise<void>;
|
|
12
|
+
/** Command the CLI execs (stdio inherited) to show logs. */
|
|
13
|
+
logsArgs(name: string, follow: boolean): {
|
|
14
|
+
cmd: string;
|
|
15
|
+
args: string[];
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/tmux.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Exec } from './exec.js';
|
|
2
|
+
/** Thin tmux wrapper; all session handling in the core goes through this. */
|
|
3
|
+
export declare class Tmux {
|
|
4
|
+
private exec;
|
|
5
|
+
constructor(exec?: Exec);
|
|
6
|
+
has(name: string): Promise<boolean>;
|
|
7
|
+
newSession(name: string, cwd: string, shellCommand: string): Promise<void>;
|
|
8
|
+
kill(name: string): Promise<void>;
|
|
9
|
+
capture(name: string, lines?: number): Promise<string>;
|
|
10
|
+
panePid(name: string): Promise<number | null>;
|
|
11
|
+
list(): Promise<string>;
|
|
12
|
+
sendText(name: string, text: string): Promise<void>;
|
|
13
|
+
sendKey(name: string, key: string): Promise<void>;
|
|
14
|
+
}
|
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { realExec } from './exec.js';
|
|
2
|
+
/** Thin tmux wrapper; all session handling in the core goes through this. */
|
|
3
|
+
export class Tmux {
|
|
4
|
+
exec;
|
|
5
|
+
constructor(exec = realExec) {
|
|
6
|
+
this.exec = exec;
|
|
7
|
+
}
|
|
8
|
+
async has(name) {
|
|
9
|
+
return (await this.exec('tmux', ['has-session', '-t', name])).code === 0;
|
|
10
|
+
}
|
|
11
|
+
async newSession(name, cwd, shellCommand) {
|
|
12
|
+
const r = await this.exec('tmux', ['new-session', '-d', '-s', name, '-c', cwd, shellCommand]);
|
|
13
|
+
if (r.code !== 0)
|
|
14
|
+
throw new Error(`tmux new-session '${name}' failed (${r.code}): ${r.stderr.trim()}`);
|
|
15
|
+
}
|
|
16
|
+
async kill(name) {
|
|
17
|
+
await this.exec('tmux', ['kill-session', '-t', name]); // best-effort
|
|
18
|
+
}
|
|
19
|
+
async capture(name, lines = 40) {
|
|
20
|
+
const r = await this.exec('tmux', ['capture-pane', '-t', name, '-p']);
|
|
21
|
+
if (r.code !== 0)
|
|
22
|
+
throw new Error(`tmux capture-pane '${name}' failed: ${r.stderr.trim()}`);
|
|
23
|
+
const all = r.stdout.replace(/\n+$/, '').split('\n');
|
|
24
|
+
return all.slice(-lines).join('\n');
|
|
25
|
+
}
|
|
26
|
+
async panePid(name) {
|
|
27
|
+
const r = await this.exec('tmux', ['list-panes', '-t', name, '-F', '#{pane_pid}']);
|
|
28
|
+
if (r.code !== 0)
|
|
29
|
+
return null;
|
|
30
|
+
const pid = parseInt(r.stdout.trim().split('\n')[0], 10);
|
|
31
|
+
return Number.isFinite(pid) ? pid : null;
|
|
32
|
+
}
|
|
33
|
+
async list() {
|
|
34
|
+
const r = await this.exec('tmux', ['ls']);
|
|
35
|
+
return r.code === 0 ? r.stdout.trimEnd() : '';
|
|
36
|
+
}
|
|
37
|
+
async sendText(name, text) {
|
|
38
|
+
let r = await this.exec('tmux', ['send-keys', '-t', name, '-l', text]);
|
|
39
|
+
if (r.code !== 0)
|
|
40
|
+
throw new Error(`tmux send-keys '${name}' failed: ${r.stderr.trim()}`);
|
|
41
|
+
r = await this.exec('tmux', ['send-keys', '-t', name, 'Enter']);
|
|
42
|
+
if (r.code !== 0)
|
|
43
|
+
throw new Error(`tmux send-keys Enter '${name}' failed: ${r.stderr.trim()}`);
|
|
44
|
+
}
|
|
45
|
+
async sendKey(name, key) {
|
|
46
|
+
const r = await this.exec('tmux', ['send-keys', '-t', name, key]);
|
|
47
|
+
if (r.code !== 0)
|
|
48
|
+
throw new Error(`tmux send-keys '${name}' failed: ${r.stderr.trim()}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION: string;
|
package/dist/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ours.network/fleet",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
7
|
+
"repository": { "type": "git", "url": "https://github.com/adapt-toolkit/ours-fleet.git" },
|
|
8
|
+
"bin": { "ours-fleet": "dist/cli.js" },
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
11
|
+
"engines": { "node": ">=20" },
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p tsconfig.json",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"prepublishOnly": "npm run build && npm test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": { "commander": "^12.1.0", "yaml": "^2.5.0" },
|
|
18
|
+
"devDependencies": { "@types/node": "^20.14.0", "typescript": "^5.5.0", "vitest": "^2.0.0" }
|
|
19
|
+
}
|