@ours.network/fleet 0.10.3 → 0.10.4
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 +66 -0
- package/dist/application/capabilities.d.ts +6 -0
- package/dist/application/capabilities.js +37 -0
- package/dist/application/errors.d.ts +31 -0
- package/dist/application/errors.js +51 -0
- package/dist/application/fleet-query-service.d.ts +31 -0
- package/dist/application/fleet-query-service.js +180 -0
- package/dist/application/log-service.d.ts +28 -0
- package/dist/application/log-service.js +146 -0
- package/dist/application/role-command-service.d.ts +37 -0
- package/dist/application/role-command-service.js +82 -0
- package/dist/application/role-creation-service.d.ts +142 -0
- package/dist/application/role-creation-service.js +374 -0
- package/dist/application/role-repository.d.ts +20 -0
- package/dist/application/role-repository.js +168 -0
- package/dist/application/session-control.d.ts +55 -0
- package/dist/application/session-control.js +115 -0
- package/dist/application/types.d.ts +156 -0
- package/dist/application/types.js +1 -0
- package/dist/cli.js +141 -0
- package/dist/config.d.ts +7 -2
- package/dist/config.js +18 -4
- package/dist/creation.d.ts +11 -0
- package/dist/creation.js +22 -5
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +34 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -1
- package/dist/runner.js +10 -2
- package/dist/session/control.d.ts +4 -2
- package/dist/session/control.js +45 -13
- package/dist/spawn.d.ts +20 -2
- package/dist/spawn.js +94 -24
- package/dist/supervisor/launchd.js +17 -0
- package/dist/supervisor/none.js +17 -0
- package/dist/supervisor/systemd.js +4 -0
- package/dist/supervisor/types.d.ts +6 -0
- package/dist/tmux.d.ts +2 -0
- package/dist/tmux.js +8 -0
- package/dist/web/audit.d.ts +22 -0
- package/dist/web/audit.js +54 -0
- package/dist/web/auth.d.ts +61 -0
- package/dist/web/auth.js +186 -0
- package/dist/web/control.d.ts +14 -0
- package/dist/web/control.js +110 -0
- package/dist/web/device-store.d.ts +27 -0
- package/dist/web/device-store.js +155 -0
- package/dist/web/events.d.ts +15 -0
- package/dist/web/events.js +34 -0
- package/dist/web/lock.d.ts +5 -0
- package/dist/web/lock.js +69 -0
- package/dist/web/runtime.d.ts +12 -0
- package/dist/web/runtime.js +170 -0
- package/dist/web/server.d.ts +35 -0
- package/dist/web/server.js +261 -0
- package/dist/web/service.d.ts +42 -0
- package/dist/web/service.js +180 -0
- package/dist/web/terminal/bridge.d.ts +27 -0
- package/dist/web/terminal/bridge.js +317 -0
- package/dist/web-app/assets/TerminalView-DcImdrI1.js +9 -0
- package/dist/web-app/assets/index-BokQN1Ao.js +9 -0
- package/dist/web-app/assets/index-lAXzaOZM.css +1 -0
- package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
- package/dist/web-app/icons/ours-fleet.svg +4 -0
- package/dist/web-app/index.html +17 -0
- package/dist/web-app/manifest.webmanifest +15 -0
- package/dist/web-app/offline.html +18 -0
- package/dist/web-app/sw.js +51 -0
- package/package.json +26 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { loadConfig, resolveMonitorConfig, resolvePermissions, ROLE_NAME_RE, } from '../config.js';
|
|
5
|
+
import { agentsRoot, tmpRoot } from '../paths.js';
|
|
6
|
+
const MAX_SNAPSHOT_BYTES = 256 * 1024;
|
|
7
|
+
function safeDirs(root) {
|
|
8
|
+
try {
|
|
9
|
+
return readdirSync(root, { withFileTypes: true })
|
|
10
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && ROLE_NAME_RE.test(entry.name))
|
|
11
|
+
.map(entry => entry.name);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function view(role) {
|
|
18
|
+
return {
|
|
19
|
+
name: role.name, harness: role.harness, session: role.session, identity: role.identity,
|
|
20
|
+
mission: role.mission, coordinator: role.coordinator, model: role.model,
|
|
21
|
+
cwd: role.cwd ? redactHome(role.cwd) : undefined,
|
|
22
|
+
permissions: role.permissions,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function redactHome(path) {
|
|
26
|
+
const home = process.env.OURS_FLEET_HOME;
|
|
27
|
+
return home && (path === home || path.startsWith(home + '/')) ? `~${path.slice(home.length)}` : path;
|
|
28
|
+
}
|
|
29
|
+
function snapshot(path, name) {
|
|
30
|
+
try {
|
|
31
|
+
const stat = lstatSync(path);
|
|
32
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_SNAPSHOT_BYTES)
|
|
33
|
+
throw new Error('snapshot is not a bounded regular file');
|
|
34
|
+
const raw = parse(readFileSync(path, 'utf8'));
|
|
35
|
+
if (!raw || typeof raw !== 'object')
|
|
36
|
+
throw new Error('snapshot must be a mapping');
|
|
37
|
+
const session = raw.session === 'acp' || raw.session === 'tmux' ? raw.session : 'tmux';
|
|
38
|
+
return {
|
|
39
|
+
role: {
|
|
40
|
+
...raw, name, harness: typeof raw.harness === 'string' ? raw.harness : 'claude-code',
|
|
41
|
+
session, identity: typeof raw.identity === 'string' ? raw.identity : name,
|
|
42
|
+
permissions: resolvePermissions(undefined, raw.permissions),
|
|
43
|
+
permissionsDeclared: raw.permissions !== undefined,
|
|
44
|
+
monitor: resolveMonitorConfig(undefined, raw.monitor),
|
|
45
|
+
sourceFile: '(temp)',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return {
|
|
51
|
+
problem: {
|
|
52
|
+
code: 'corrupt_temp_snapshot', severity: 'error', source: 'role.yaml',
|
|
53
|
+
detail: `temporary snapshot is unreadable: ${error.message}`,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function boundedMap(values, limit, fn) {
|
|
59
|
+
const results = new Array(values.length);
|
|
60
|
+
let next = 0;
|
|
61
|
+
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, async () => {
|
|
62
|
+
for (;;) {
|
|
63
|
+
const index = next++;
|
|
64
|
+
if (index >= values.length)
|
|
65
|
+
return;
|
|
66
|
+
results[index] = await fn(values[index]);
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
export class RoleRepository {
|
|
72
|
+
options;
|
|
73
|
+
constructor(options = {}) {
|
|
74
|
+
this.options = options;
|
|
75
|
+
}
|
|
76
|
+
async list() {
|
|
77
|
+
const permanentRoot = this.options.permanentRoot ?? agentsRoot();
|
|
78
|
+
const temporaryRoot = this.options.temporaryRoot ?? tmpRoot();
|
|
79
|
+
let configured = [];
|
|
80
|
+
let configProblem;
|
|
81
|
+
try {
|
|
82
|
+
configured = loadConfig(this.options.configPath).roles;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
configProblem = { code: 'config_invalid', severity: 'error', source: 'config', detail: error.message };
|
|
86
|
+
}
|
|
87
|
+
const permanent = new Set(safeDirs(permanentRoot));
|
|
88
|
+
const temporary = new Set(safeDirs(temporaryRoot));
|
|
89
|
+
const configuredByName = new Map(configured.map(role => [role.name, role]));
|
|
90
|
+
const names = [...new Set([...configuredByName.keys(), ...permanent, ...temporary])].sort();
|
|
91
|
+
return boundedMap(names, this.options.concurrency ?? 8, async (name) => {
|
|
92
|
+
const config = configuredByName.get(name);
|
|
93
|
+
const inPermanent = permanent.has(name);
|
|
94
|
+
const inTemporary = temporary.has(name);
|
|
95
|
+
const problems = configProblem ? [configProblem] : [];
|
|
96
|
+
let tempRole;
|
|
97
|
+
let stateHealth = inPermanent || inTemporary ? 'present' : 'missing';
|
|
98
|
+
if (inTemporary) {
|
|
99
|
+
const read = snapshot(join(temporaryRoot, name, 'role.yaml'), name);
|
|
100
|
+
tempRole = read.role;
|
|
101
|
+
if (read.problem) {
|
|
102
|
+
problems.push(read.problem);
|
|
103
|
+
stateHealth = 'corrupt';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (inPermanent && inTemporary)
|
|
107
|
+
problems.push({ code: 'duplicate_state', severity: 'error', detail: 'both permanent and temporary state exist' });
|
|
108
|
+
const intended = config?.session ?? tempRole?.session;
|
|
109
|
+
const detected = await this.detect(name, intended);
|
|
110
|
+
if (intended && detected !== 'none' && detected !== intended && detected !== 'ambiguous')
|
|
111
|
+
problems.push({
|
|
112
|
+
code: 'backend_mismatch', severity: 'warning',
|
|
113
|
+
detail: `configured ${intended}, detected ${detected}`,
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
id: name,
|
|
117
|
+
lifetime: config ? 'permanent' : inTemporary ? 'temporary' : 'orphan',
|
|
118
|
+
configured: Boolean(config),
|
|
119
|
+
config: config ? view(config) : tempRole ? view(tempRole) : undefined,
|
|
120
|
+
stateRef: inTemporary ? { lifetime: 'temporary' } : inPermanent ? { lifetime: 'permanent' } : undefined,
|
|
121
|
+
stateHealth,
|
|
122
|
+
configuredBackend: intended,
|
|
123
|
+
detectedBackend: detected,
|
|
124
|
+
compatibility: { compatible: true },
|
|
125
|
+
problems,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async get(id) {
|
|
130
|
+
if (!ROLE_NAME_RE.test(id))
|
|
131
|
+
return undefined;
|
|
132
|
+
return (await this.list()).find(role => role.id === id);
|
|
133
|
+
}
|
|
134
|
+
stateDir(record) {
|
|
135
|
+
if (!record.stateRef)
|
|
136
|
+
return undefined;
|
|
137
|
+
const root = record.stateRef.lifetime === 'temporary'
|
|
138
|
+
? (this.options.temporaryRoot ?? tmpRoot()) : (this.options.permanentRoot ?? agentsRoot());
|
|
139
|
+
const candidate = join(root, record.id);
|
|
140
|
+
try {
|
|
141
|
+
const canonicalRoot = realpathSync(root);
|
|
142
|
+
const canonical = realpathSync(candidate);
|
|
143
|
+
const rel = relative(canonicalRoot, canonical);
|
|
144
|
+
if (rel.startsWith('..') || rel === '')
|
|
145
|
+
return rel === '' ? undefined : undefined;
|
|
146
|
+
return canonical;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async detect(name, intended) {
|
|
153
|
+
if (!this.options.probeBackend)
|
|
154
|
+
return intended ?? 'none';
|
|
155
|
+
const timeoutMs = this.options.timeoutMs ?? 2_000;
|
|
156
|
+
const result = await Promise.race([
|
|
157
|
+
this.options.probeBackend(name, intended),
|
|
158
|
+
new Promise(resolve => setTimeout(() => resolve({ acp: false, tmux: false }), timeoutMs)),
|
|
159
|
+
]).catch(() => ({ acp: false, tmux: false }));
|
|
160
|
+
if (result.acp && result.tmux)
|
|
161
|
+
return 'ambiguous';
|
|
162
|
+
if (result.acp)
|
|
163
|
+
return 'acp';
|
|
164
|
+
if (result.tmux)
|
|
165
|
+
return 'tmux';
|
|
166
|
+
return 'none';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { controlRequest } from '../session/control.js';
|
|
2
|
+
import type { SessionSnapshot } from '../session/types.js';
|
|
3
|
+
import { Tmux } from '../tmux.js';
|
|
4
|
+
import type { OutputPage, SendReceipt, SessionDescriptor } from './types.js';
|
|
5
|
+
export interface RoleSessionControl {
|
|
6
|
+
describe(): Promise<SessionDescriptor>;
|
|
7
|
+
snapshot(): Promise<SessionSnapshot>;
|
|
8
|
+
recentOutput(request?: {
|
|
9
|
+
since?: number;
|
|
10
|
+
limit?: number;
|
|
11
|
+
}): Promise<OutputPage>;
|
|
12
|
+
sendText(text: string): Promise<SendReceipt>;
|
|
13
|
+
interrupt?(): Promise<{
|
|
14
|
+
accepted: true;
|
|
15
|
+
}>;
|
|
16
|
+
respondPermission?(request: {
|
|
17
|
+
permissionId: string;
|
|
18
|
+
optionId: string;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
accepted: true;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export declare class AcpRoleSessionAdapter implements RoleSessionControl {
|
|
24
|
+
private readonly stateDir;
|
|
25
|
+
private readonly request;
|
|
26
|
+
constructor(stateDir: string, request?: typeof controlRequest);
|
|
27
|
+
describe(): Promise<SessionDescriptor>;
|
|
28
|
+
snapshot(): Promise<SessionSnapshot>;
|
|
29
|
+
recentOutput(request?: {
|
|
30
|
+
since?: number;
|
|
31
|
+
limit?: number;
|
|
32
|
+
}): Promise<OutputPage>;
|
|
33
|
+
sendText(text: string): Promise<SendReceipt>;
|
|
34
|
+
interrupt(): Promise<{
|
|
35
|
+
accepted: true;
|
|
36
|
+
}>;
|
|
37
|
+
respondPermission(request: {
|
|
38
|
+
permissionId: string;
|
|
39
|
+
optionId: string;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
accepted: true;
|
|
42
|
+
}>;
|
|
43
|
+
private call;
|
|
44
|
+
}
|
|
45
|
+
export declare class TmuxRoleSessionAdapter implements RoleSessionControl {
|
|
46
|
+
private readonly roleId;
|
|
47
|
+
private readonly tmux;
|
|
48
|
+
constructor(roleId: string, tmux?: Tmux);
|
|
49
|
+
describe(): Promise<SessionDescriptor>;
|
|
50
|
+
snapshot(): Promise<SessionSnapshot>;
|
|
51
|
+
recentOutput(request?: {
|
|
52
|
+
limit?: number;
|
|
53
|
+
}): Promise<OutputPage>;
|
|
54
|
+
sendText(text: string): Promise<SendReceipt>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { controlRequest } from '../session/control.js';
|
|
3
|
+
import { Tmux } from '../tmux.js';
|
|
4
|
+
import { FleetError, normalizeError } from './errors.js';
|
|
5
|
+
const visibleEvents = (events) => events.filter(event => event.kind !== 'thought');
|
|
6
|
+
export class AcpRoleSessionAdapter {
|
|
7
|
+
stateDir;
|
|
8
|
+
request;
|
|
9
|
+
constructor(stateDir, request = controlRequest) {
|
|
10
|
+
this.stateDir = stateDir;
|
|
11
|
+
this.request = request;
|
|
12
|
+
}
|
|
13
|
+
async describe() {
|
|
14
|
+
const response = await this.call('snapshot');
|
|
15
|
+
const result = response;
|
|
16
|
+
return {
|
|
17
|
+
backend: 'acp', protocolVersion: result.protocolVersion ?? 1,
|
|
18
|
+
features: result.features ?? [], snapshot: result,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async snapshot() {
|
|
22
|
+
return await this.call('snapshot');
|
|
23
|
+
}
|
|
24
|
+
async recentOutput(request = {}) {
|
|
25
|
+
const limit = Math.min(Math.max(request.limit ?? 100, 1), 500);
|
|
26
|
+
const descriptor = await this.describe();
|
|
27
|
+
const result = descriptor.protocolVersion >= 2
|
|
28
|
+
? await this.call('events_since', { since: request.since ?? 0 })
|
|
29
|
+
: await this.call('follow', { since: request.since ?? 0, controller: false });
|
|
30
|
+
const page = result;
|
|
31
|
+
const events = visibleEvents(page.events ?? []).slice(-limit);
|
|
32
|
+
return {
|
|
33
|
+
events, firstSeq: page.firstSeq ?? events[0]?.seq,
|
|
34
|
+
lastSeq: page.lastSeq ?? events.at(-1)?.seq,
|
|
35
|
+
truncated: Boolean(page.truncated) || (page.events?.length ?? 0) > limit,
|
|
36
|
+
nextCursor: events.at(-1) ? String(events.at(-1).seq) : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async sendText(text) {
|
|
40
|
+
if (!text.trim())
|
|
41
|
+
throw new FleetError('invalid_request', 'text is required');
|
|
42
|
+
if (Buffer.byteLength(text) > 32 * 1024)
|
|
43
|
+
throw new FleetError('invalid_request', 'text exceeds 32 KiB');
|
|
44
|
+
const result = await this.call('submit_prompt', { text });
|
|
45
|
+
return {
|
|
46
|
+
accepted: true, promptId: result.promptId, queuedBehind: result.queuedBehind,
|
|
47
|
+
terminalOutcomeKnown: false,
|
|
48
|
+
detail: result.queuedBehind
|
|
49
|
+
? `accepted; ${result.queuedBehind} turn(s) queued ahead`
|
|
50
|
+
: 'accepted; turn may still be running',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async interrupt() {
|
|
54
|
+
await this.call('interrupt');
|
|
55
|
+
return { accepted: true };
|
|
56
|
+
}
|
|
57
|
+
async respondPermission(request) {
|
|
58
|
+
await this.call('respond_permission', request);
|
|
59
|
+
return { accepted: true };
|
|
60
|
+
}
|
|
61
|
+
async call(command, fields = {}) {
|
|
62
|
+
try {
|
|
63
|
+
const response = await this.request(this.stateDir, { command, ...fields }, 5_000);
|
|
64
|
+
if (!response.ok)
|
|
65
|
+
throw new FleetError(response.kind === 'offline' ? 'offline'
|
|
66
|
+
: response.kind === 'control-unavailable' ? 'control_unavailable'
|
|
67
|
+
: response.kind === 'timeout' ? 'timeout'
|
|
68
|
+
: response.kind === 'rejected' ? 'rejected' : 'backend_failure', response.error ?? `${command} failed`, { provesOffline: response.kind === 'offline' });
|
|
69
|
+
return response.result;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
throw normalizeError(error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export class TmuxRoleSessionAdapter {
|
|
77
|
+
roleId;
|
|
78
|
+
tmux;
|
|
79
|
+
constructor(roleId, tmux = new Tmux()) {
|
|
80
|
+
this.roleId = roleId;
|
|
81
|
+
this.tmux = tmux;
|
|
82
|
+
}
|
|
83
|
+
async describe() {
|
|
84
|
+
return { backend: 'tmux', protocolVersion: 1, features: ['text', 'capture'] };
|
|
85
|
+
}
|
|
86
|
+
async snapshot() {
|
|
87
|
+
const alive = await this.tmux.has(this.roleId);
|
|
88
|
+
return { backend: 'tmux', alive, readiness: alive ? 'idle' : 'failed' };
|
|
89
|
+
}
|
|
90
|
+
async recentOutput(request = {}) {
|
|
91
|
+
try {
|
|
92
|
+
const text = await this.tmux.capture(this.roleId, Math.min(request.limit ?? 100, 500));
|
|
93
|
+
return { events: [], text, truncated: false };
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
throw normalizeError(error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async sendText(text) {
|
|
100
|
+
if (!text.trim())
|
|
101
|
+
throw new FleetError('invalid_request', 'text is required');
|
|
102
|
+
if (Buffer.byteLength(text) > 32 * 1024)
|
|
103
|
+
throw new FleetError('invalid_request', 'text exceeds 32 KiB');
|
|
104
|
+
try {
|
|
105
|
+
await this.tmux.sendText(this.roleId, text);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
throw normalizeError(error);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
accepted: true, promptId: randomUUID(), queuedBehind: 0, terminalOutcomeKnown: false,
|
|
112
|
+
detail: 'literal text and Enter sent; terminal outcome is unknown',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { CommonPermissions, SessionBackendId } from '../config.js';
|
|
2
|
+
import type { ExitRecord, SessionEvent, SessionSnapshot } from '../session/types.js';
|
|
3
|
+
export type RoleLifetime = 'permanent' | 'temporary' | 'orphan';
|
|
4
|
+
export type DetectedBackend = SessionBackendId | 'none' | 'ambiguous';
|
|
5
|
+
export interface ResolvedRoleView {
|
|
6
|
+
name: string;
|
|
7
|
+
harness: string;
|
|
8
|
+
session: SessionBackendId;
|
|
9
|
+
identity: string;
|
|
10
|
+
mission?: string;
|
|
11
|
+
coordinator?: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
permissions: CommonPermissions;
|
|
15
|
+
}
|
|
16
|
+
export interface RoleRecord {
|
|
17
|
+
id: string;
|
|
18
|
+
lifetime: RoleLifetime;
|
|
19
|
+
configured: boolean;
|
|
20
|
+
config?: ResolvedRoleView;
|
|
21
|
+
stateRef?: {
|
|
22
|
+
lifetime: 'permanent' | 'temporary';
|
|
23
|
+
};
|
|
24
|
+
stateHealth: 'present' | 'missing' | 'corrupt';
|
|
25
|
+
configuredBackend?: SessionBackendId;
|
|
26
|
+
detectedBackend: DetectedBackend;
|
|
27
|
+
compatibility: {
|
|
28
|
+
compatible: boolean;
|
|
29
|
+
detail?: string;
|
|
30
|
+
};
|
|
31
|
+
problems: Problem[];
|
|
32
|
+
}
|
|
33
|
+
export interface Problem {
|
|
34
|
+
code: string;
|
|
35
|
+
severity: 'info' | 'warning' | 'error';
|
|
36
|
+
detail: string;
|
|
37
|
+
source?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface RoleStatus {
|
|
40
|
+
roleId: string;
|
|
41
|
+
observedAt: string;
|
|
42
|
+
overall: 'ready' | 'busy' | 'attention' | 'offline' | 'unknown';
|
|
43
|
+
supervisor: {
|
|
44
|
+
backend: 'systemd' | 'launchd' | 'none';
|
|
45
|
+
liveness: 'running' | 'stopped' | 'unknown';
|
|
46
|
+
nativeState?: string;
|
|
47
|
+
detail: string;
|
|
48
|
+
};
|
|
49
|
+
session: {
|
|
50
|
+
backend: SessionBackendId | 'unknown';
|
|
51
|
+
reachability: 'online' | 'offline' | 'unavailable' | 'unknown';
|
|
52
|
+
readiness: 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed' | 'unknown';
|
|
53
|
+
evidence: 'authoritative' | 'inferred';
|
|
54
|
+
sessionId?: string;
|
|
55
|
+
lastError?: string;
|
|
56
|
+
pendingPermissionId?: string;
|
|
57
|
+
};
|
|
58
|
+
restart: {
|
|
59
|
+
circuit: 'closed' | 'open';
|
|
60
|
+
consecutiveImmediateFailures: number;
|
|
61
|
+
nextDelayMs: number;
|
|
62
|
+
lastReason?: string;
|
|
63
|
+
openedAt?: string;
|
|
64
|
+
};
|
|
65
|
+
monitor: {
|
|
66
|
+
mode: 'fleet' | 'native' | 'unknown';
|
|
67
|
+
health: 'armed' | 'degraded' | 'failed' | 'unknown';
|
|
68
|
+
updatedAt?: string;
|
|
69
|
+
detail?: string;
|
|
70
|
+
stale: boolean;
|
|
71
|
+
};
|
|
72
|
+
lastExit?: ExitRecord;
|
|
73
|
+
isolation: {
|
|
74
|
+
degraded: boolean;
|
|
75
|
+
detail?: string;
|
|
76
|
+
};
|
|
77
|
+
problems: Problem[];
|
|
78
|
+
}
|
|
79
|
+
export interface RoleCapabilities {
|
|
80
|
+
protocolVersion: number;
|
|
81
|
+
inventory: true;
|
|
82
|
+
status: true;
|
|
83
|
+
output: {
|
|
84
|
+
recent: boolean;
|
|
85
|
+
stream: boolean;
|
|
86
|
+
structured: boolean;
|
|
87
|
+
replayCursor: boolean;
|
|
88
|
+
};
|
|
89
|
+
input: {
|
|
90
|
+
text: boolean;
|
|
91
|
+
rawKeys: boolean;
|
|
92
|
+
interrupt: boolean;
|
|
93
|
+
steering: boolean;
|
|
94
|
+
};
|
|
95
|
+
permissions: {
|
|
96
|
+
observe: boolean;
|
|
97
|
+
respond: boolean;
|
|
98
|
+
};
|
|
99
|
+
terminal: {
|
|
100
|
+
available: boolean;
|
|
101
|
+
reason?: 'wrong_backend' | 'pty_unavailable' | 'offline' | 'incompatible_runner';
|
|
102
|
+
multiViewer: boolean;
|
|
103
|
+
writerLease: boolean;
|
|
104
|
+
};
|
|
105
|
+
lifecycle: {
|
|
106
|
+
start: boolean;
|
|
107
|
+
stop: boolean;
|
|
108
|
+
restartResume: boolean;
|
|
109
|
+
restartFresh: boolean;
|
|
110
|
+
remove: boolean;
|
|
111
|
+
};
|
|
112
|
+
logs: {
|
|
113
|
+
tail: boolean;
|
|
114
|
+
follow: boolean;
|
|
115
|
+
cursor: boolean;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export interface OutputPage {
|
|
119
|
+
events: SessionEvent[];
|
|
120
|
+
text?: string;
|
|
121
|
+
firstSeq?: number;
|
|
122
|
+
lastSeq?: number;
|
|
123
|
+
truncated: boolean;
|
|
124
|
+
nextCursor?: string;
|
|
125
|
+
}
|
|
126
|
+
export interface SendReceipt {
|
|
127
|
+
accepted: boolean;
|
|
128
|
+
promptId: string;
|
|
129
|
+
queuedBehind: number;
|
|
130
|
+
terminalOutcomeKnown: boolean;
|
|
131
|
+
detail: string;
|
|
132
|
+
}
|
|
133
|
+
export interface SessionDescriptor {
|
|
134
|
+
backend: SessionBackendId;
|
|
135
|
+
protocolVersion: number;
|
|
136
|
+
features: string[];
|
|
137
|
+
snapshot?: SessionSnapshot;
|
|
138
|
+
}
|
|
139
|
+
export interface LogRecord {
|
|
140
|
+
at?: string;
|
|
141
|
+
text: string;
|
|
142
|
+
cursor?: string;
|
|
143
|
+
redactionApplied: boolean;
|
|
144
|
+
compacted?: {
|
|
145
|
+
kind: 'monitor_stream_hiccup';
|
|
146
|
+
reason: string;
|
|
147
|
+
count: number;
|
|
148
|
+
firstAt?: string;
|
|
149
|
+
lastAt?: string;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export interface LogPage {
|
|
153
|
+
records: LogRecord[];
|
|
154
|
+
nextCursor?: string;
|
|
155
|
+
truncated: boolean;
|
|
156
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
CHANGED
|
@@ -22,6 +22,9 @@ import { allWarnings, analyzeFleetPermissions, formatNative } from './permission
|
|
|
22
22
|
import { AI_DOCS } from './docs.js';
|
|
23
23
|
import { controlRequest, controlSocketPath, followControl, livenessNote, } from './session/control.js';
|
|
24
24
|
import { SessionControlError } from './session/types.js';
|
|
25
|
+
import { startWebConsole } from './web/runtime.js';
|
|
26
|
+
import { requestWebControl } from './web/control.js';
|
|
27
|
+
import { WebServiceManager } from './web/service.js';
|
|
25
28
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
26
29
|
import './harness/codex.js'; // registers the codex adapter
|
|
27
30
|
// sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
|
|
@@ -47,6 +50,7 @@ const passthrough = (cmd, args) => new Promise(resolve => {
|
|
|
47
50
|
const program = new Command()
|
|
48
51
|
.name('ours-fleet')
|
|
49
52
|
.description('Fleet of persistent, identity-bound AI agents — selectable harness and tmux/ACP sessions.')
|
|
53
|
+
.enablePositionalOptions()
|
|
50
54
|
.version(VERSION);
|
|
51
55
|
const cOpt = (cmd) => cmd.option('-c, --configuration <file>', 'config file (default: ~/fleet.yaml + ~/fleet.d/)');
|
|
52
56
|
const collect = (value, previous) => [...previous, value];
|
|
@@ -508,6 +512,143 @@ program.command('init').description('one-time host setup (units, dirs, linger)')
|
|
|
508
512
|
console.log(m);
|
|
509
513
|
console.log('\nNext: copy examples/fleet.yaml to ~/fleet.yaml, edit, then: ours-fleet up');
|
|
510
514
|
});
|
|
515
|
+
const webCommand = cOpt(program.command('web').description('start or open the secure localhost fleet web console'))
|
|
516
|
+
.enablePositionalOptions()
|
|
517
|
+
.option('--port <port>', 'loopback service port (default: 49271)', value => {
|
|
518
|
+
const port = Number(value);
|
|
519
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
520
|
+
throw new Error('invalid port');
|
|
521
|
+
return port;
|
|
522
|
+
})
|
|
523
|
+
.option('--no-open', 'do not open a browser automatically')
|
|
524
|
+
.action(async (opts) => {
|
|
525
|
+
try {
|
|
526
|
+
const manager = new WebServiceManager();
|
|
527
|
+
for (const line of await manager.install(binPath, opts.port ?? 49_271, opts.configuration))
|
|
528
|
+
process.stdout.write(line + '\n');
|
|
529
|
+
await manager.start();
|
|
530
|
+
if (opts.open !== false) {
|
|
531
|
+
await requestWebControlWhenReady('open');
|
|
532
|
+
process.stdout.write('Trusted-browser pairing opened locally.\n');
|
|
533
|
+
}
|
|
534
|
+
else
|
|
535
|
+
process.stdout.write('Web service started; run `ours-fleet web open` to pair a browser.\n');
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
die(e);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
const webPort = (command) => cOpt(command)
|
|
542
|
+
.option('--port <port>', 'loopback service port (default: 49271)', value => {
|
|
543
|
+
const port = Number(value);
|
|
544
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
545
|
+
throw new Error('invalid port');
|
|
546
|
+
return port;
|
|
547
|
+
});
|
|
548
|
+
const webServe = cOpt(webCommand.command('serve').description('run the web console in the foreground'))
|
|
549
|
+
.option('--port <port>', 'loopback port (default: 49271; 0 chooses a free port)', value => {
|
|
550
|
+
const port = Number(value);
|
|
551
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
552
|
+
throw new Error('invalid port');
|
|
553
|
+
return port;
|
|
554
|
+
});
|
|
555
|
+
webServe
|
|
556
|
+
.option('--no-open', 'do not open a browser automatically')
|
|
557
|
+
.action(async (opts) => {
|
|
558
|
+
try {
|
|
559
|
+
const consoleServer = await startWebConsole({
|
|
560
|
+
configPath: opts.configuration, port: opts.port,
|
|
561
|
+
open: opts.open !== false, binPath,
|
|
562
|
+
log: line => process.stderr.write(line + '\n'),
|
|
563
|
+
});
|
|
564
|
+
process.stdout.write(`ours-fleet web listening on ${consoleServer.address}\n`);
|
|
565
|
+
process.stdout.write(opts.open !== false
|
|
566
|
+
? 'Trusted-browser pairing opened locally.\n'
|
|
567
|
+
: 'Run `ours-fleet web open` on this computer to pair a browser.\n');
|
|
568
|
+
const shutdown = async () => { await consoleServer.close(); process.exit(0); };
|
|
569
|
+
process.once('SIGINT', () => { void shutdown(); });
|
|
570
|
+
process.once('SIGTERM', () => { void shutdown(); });
|
|
571
|
+
}
|
|
572
|
+
catch (e) {
|
|
573
|
+
die(e);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
webPort(webCommand.command('install').description('install or update the owner web service'))
|
|
577
|
+
.action(async (opts) => {
|
|
578
|
+
try {
|
|
579
|
+
for (const line of await new WebServiceManager().install(binPath, opts.port ?? 49_271, opts.configuration))
|
|
580
|
+
process.stdout.write(line + '\n');
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
die(e);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
for (const [name, description] of [
|
|
587
|
+
['start', 'start the installed owner web service'],
|
|
588
|
+
['stop', 'stop the owner web service'],
|
|
589
|
+
['restart', 'restart the owner web service'],
|
|
590
|
+
])
|
|
591
|
+
webCommand.command(name).description(description).action(async () => {
|
|
592
|
+
try {
|
|
593
|
+
await new WebServiceManager()[name]();
|
|
594
|
+
process.stdout.write(`Web service ${{ start: 'started', stop: 'stopped', restart: 'restarted' }[name]}.\n`);
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
die(e);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
webCommand.command('status').description('show native web service status')
|
|
601
|
+
.action(async () => {
|
|
602
|
+
try {
|
|
603
|
+
process.stdout.write(await new WebServiceManager().status() + '\n');
|
|
604
|
+
}
|
|
605
|
+
catch (e) {
|
|
606
|
+
die(e);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
webCommand.command('uninstall').description('stop and uninstall the owner web service')
|
|
610
|
+
.action(async () => {
|
|
611
|
+
try {
|
|
612
|
+
process.stdout.write(await new WebServiceManager().uninstall() + '\n');
|
|
613
|
+
}
|
|
614
|
+
catch (e) {
|
|
615
|
+
die(e);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
webCommand.command('open').description('securely open or re-pair a browser with the running console')
|
|
619
|
+
.action(async () => {
|
|
620
|
+
try {
|
|
621
|
+
await requestWebControl('open');
|
|
622
|
+
process.stdout.write('Trusted-browser pairing opened locally.\n');
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
die(e);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
webCommand.command('revoke-all').description('revoke all trusted browsers and active web sessions')
|
|
629
|
+
.action(async () => {
|
|
630
|
+
try {
|
|
631
|
+
await requestWebControl('revoke-all');
|
|
632
|
+
process.stdout.write('Revoked all trusted browsers and active web sessions.\n');
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
die(e);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
async function requestWebControlWhenReady(command) {
|
|
639
|
+
let last;
|
|
640
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
641
|
+
try {
|
|
642
|
+
await requestWebControl(command, undefined, 500);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
last = error;
|
|
647
|
+
await new Promise(resolve => setTimeout(resolve, 125));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
throw last;
|
|
651
|
+
}
|
|
511
652
|
program.command('_run <name>', { hidden: true }).description('internal: supervisor entrypoint')
|
|
512
653
|
.option('-c, --configuration <file>')
|
|
513
654
|
.action(async (name, opts) => {
|