@ours.network/fleet 0.9.3 → 0.10.0-nightly.1
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 +36 -11
- package/dist/cli.js +146 -13
- package/dist/config.d.ts +24 -0
- package/dist/config.js +79 -1
- package/dist/doctor.js +35 -6
- package/dist/harness/claude-code.js +30 -1
- package/dist/harness/codex.js +39 -2
- package/dist/harness/types.d.ts +12 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/monitor.d.ts +35 -5
- package/dist/monitor.js +187 -33
- package/dist/runner.js +73 -18
- package/dist/session/acp.d.ts +49 -0
- package/dist/session/acp.js +280 -0
- package/dist/session/control.d.ts +41 -0
- package/dist/session/control.js +218 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +20 -0
- package/dist/session/tmux.js +46 -0
- package/dist/session/types.d.ts +47 -0
- package/dist/session/types.js +1 -0
- package/dist/spawn.d.ts +5 -0
- package/dist/spawn.js +24 -1
- package/package.json +3 -2
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createConnection, createServer } from 'node:net';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const MAX_LINE_BYTES = 64 * 1024;
|
|
6
|
+
export const controlSocketPath = (stateDir) => join(stateDir, '.control.sock');
|
|
7
|
+
export const controlTokenPath = (stateDir) => join(stateDir, '.control-token');
|
|
8
|
+
function sameToken(actual, supplied) {
|
|
9
|
+
const a = Buffer.from(actual);
|
|
10
|
+
const b = Buffer.from(supplied);
|
|
11
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
12
|
+
}
|
|
13
|
+
/** Private, versioned JSONL control plane for CLI and future console frontends. */
|
|
14
|
+
export class RoleControlServer {
|
|
15
|
+
session;
|
|
16
|
+
log;
|
|
17
|
+
server;
|
|
18
|
+
socketPath;
|
|
19
|
+
token;
|
|
20
|
+
sockets = new Set();
|
|
21
|
+
constructor(stateDir, session, log) {
|
|
22
|
+
this.session = session;
|
|
23
|
+
this.log = log;
|
|
24
|
+
this.socketPath = controlSocketPath(stateDir);
|
|
25
|
+
const tokenPath = controlTokenPath(stateDir);
|
|
26
|
+
this.token = existsSync(tokenPath)
|
|
27
|
+
? readFileSync(tokenPath, 'utf8').trim()
|
|
28
|
+
: randomBytes(32).toString('hex');
|
|
29
|
+
writeFileSync(tokenPath, this.token + '\n', { mode: 0o600 });
|
|
30
|
+
chmodSync(tokenPath, 0o600);
|
|
31
|
+
rmSync(this.socketPath, { force: true });
|
|
32
|
+
this.server = createServer(socket => this.accept(socket));
|
|
33
|
+
}
|
|
34
|
+
async start() {
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
this.server.once('error', reject);
|
|
37
|
+
this.server.listen(this.socketPath, () => {
|
|
38
|
+
this.server.off('error', reject);
|
|
39
|
+
try {
|
|
40
|
+
chmodSync(this.socketPath, 0o600);
|
|
41
|
+
}
|
|
42
|
+
catch { /* platform dependent */ }
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async close() {
|
|
48
|
+
for (const socket of this.sockets)
|
|
49
|
+
socket.destroy();
|
|
50
|
+
await new Promise(resolve => this.server.close(() => resolve()));
|
|
51
|
+
rmSync(this.socketPath, { force: true });
|
|
52
|
+
}
|
|
53
|
+
accept(socket) {
|
|
54
|
+
this.sockets.add(socket);
|
|
55
|
+
socket.setEncoding('utf8');
|
|
56
|
+
let buffer = '';
|
|
57
|
+
let unsubscribe;
|
|
58
|
+
socket.on('data', chunk => {
|
|
59
|
+
buffer += chunk;
|
|
60
|
+
if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) {
|
|
61
|
+
socket.destroy(new Error('control request too large'));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (;;) {
|
|
65
|
+
const newline = buffer.indexOf('\n');
|
|
66
|
+
if (newline < 0)
|
|
67
|
+
break;
|
|
68
|
+
const line = buffer.slice(0, newline);
|
|
69
|
+
buffer = buffer.slice(newline + 1);
|
|
70
|
+
if (!line.trim())
|
|
71
|
+
continue;
|
|
72
|
+
void this.handle(line, socket).then(stop => {
|
|
73
|
+
if (stop) {
|
|
74
|
+
if (unsubscribe) {
|
|
75
|
+
unsubscribe();
|
|
76
|
+
this.session.setControllerAttached(false);
|
|
77
|
+
}
|
|
78
|
+
unsubscribe = stop;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
socket.on('close', () => {
|
|
84
|
+
this.sockets.delete(socket);
|
|
85
|
+
if (unsubscribe) {
|
|
86
|
+
unsubscribe();
|
|
87
|
+
this.session.setControllerAttached(false);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
socket.on('error', error => this.log(`control socket: ${error.message}`));
|
|
91
|
+
}
|
|
92
|
+
async handle(line, socket) {
|
|
93
|
+
let request;
|
|
94
|
+
try {
|
|
95
|
+
request = JSON.parse(line);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
this.write(socket, { version: 1, id: '?', ok: false, error: 'invalid JSON' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (request.version !== 1 || typeof request.id !== 'string'
|
|
102
|
+
|| !sameToken(this.token, request.token ?? '')) {
|
|
103
|
+
this.write(socket, { version: 1, id: request.id ?? '?', ok: false, error: 'unauthorized' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
switch (request.command) {
|
|
108
|
+
case 'status':
|
|
109
|
+
case 'snapshot':
|
|
110
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result: this.session.snapshot() });
|
|
111
|
+
return;
|
|
112
|
+
case 'submit_prompt': {
|
|
113
|
+
if (!request.text?.trim())
|
|
114
|
+
throw new Error('text is required');
|
|
115
|
+
const result = await this.session.submitPrompt(request.text);
|
|
116
|
+
this.write(socket, { version: 1, id: request.id, ok: result.accepted, result,
|
|
117
|
+
error: result.accepted ? undefined : result.detail });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
case 'respond_permission': {
|
|
121
|
+
if (!request.permissionId || !request.optionId)
|
|
122
|
+
throw new Error('permissionId and optionId are required');
|
|
123
|
+
const accepted = this.session.respondPermission(request.permissionId, request.optionId);
|
|
124
|
+
this.write(socket, {
|
|
125
|
+
version: 1, id: request.id, ok: accepted,
|
|
126
|
+
result: { accepted }, error: accepted ? undefined : 'stale or invalid permission response',
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
case 'interrupt':
|
|
131
|
+
await this.session.interrupt();
|
|
132
|
+
this.write(socket, { version: 1, id: request.id, ok: true });
|
|
133
|
+
return;
|
|
134
|
+
case 'follow': {
|
|
135
|
+
const since = Number.isFinite(request.since) ? Number(request.since) : 0;
|
|
136
|
+
this.write(socket, {
|
|
137
|
+
version: 1, id: request.id, ok: true,
|
|
138
|
+
result: { events: this.session.eventsSince(since), snapshot: this.session.snapshot() },
|
|
139
|
+
});
|
|
140
|
+
this.session.setControllerAttached(true);
|
|
141
|
+
return this.session.subscribe(event => {
|
|
142
|
+
if (!socket.destroyed)
|
|
143
|
+
socket.write(JSON.stringify({ version: 1, event }) + '\n');
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
this.write(socket, {
|
|
150
|
+
version: 1,
|
|
151
|
+
id: request.id,
|
|
152
|
+
ok: false,
|
|
153
|
+
error: error?.message ?? String(error),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
write(socket, response) {
|
|
158
|
+
if (!socket.destroyed)
|
|
159
|
+
socket.write(JSON.stringify(response) + '\n');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
|
|
163
|
+
const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
|
|
164
|
+
const id = randomUUID();
|
|
165
|
+
const socket = createConnection(controlSocketPath(stateDir));
|
|
166
|
+
socket.setEncoding('utf8');
|
|
167
|
+
const response = await new Promise((resolve, reject) => {
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
socket.destroy();
|
|
170
|
+
reject(new Error('role control request timed out'));
|
|
171
|
+
}, timeoutMs);
|
|
172
|
+
let buffer = '';
|
|
173
|
+
socket.once('error', error => { clearTimeout(timer); reject(error); });
|
|
174
|
+
socket.on('data', chunk => {
|
|
175
|
+
buffer += chunk;
|
|
176
|
+
const newline = buffer.indexOf('\n');
|
|
177
|
+
if (newline < 0)
|
|
178
|
+
return;
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
try {
|
|
181
|
+
resolve(JSON.parse(buffer.slice(0, newline)));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
reject(error);
|
|
185
|
+
}
|
|
186
|
+
socket.end();
|
|
187
|
+
});
|
|
188
|
+
socket.once('connect', () => socket.write(JSON.stringify({
|
|
189
|
+
version: 1, id, token, ...request,
|
|
190
|
+
}) + '\n'));
|
|
191
|
+
});
|
|
192
|
+
return response;
|
|
193
|
+
}
|
|
194
|
+
export async function followControl(stateDir, onMessage) {
|
|
195
|
+
const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
|
|
196
|
+
const socket = createConnection(controlSocketPath(stateDir));
|
|
197
|
+
socket.setEncoding('utf8');
|
|
198
|
+
let buffer = '';
|
|
199
|
+
socket.on('data', chunk => {
|
|
200
|
+
buffer += chunk;
|
|
201
|
+
for (;;) {
|
|
202
|
+
const newline = buffer.indexOf('\n');
|
|
203
|
+
if (newline < 0)
|
|
204
|
+
break;
|
|
205
|
+
const line = buffer.slice(0, newline);
|
|
206
|
+
buffer = buffer.slice(newline + 1);
|
|
207
|
+
if (line.trim())
|
|
208
|
+
onMessage(JSON.parse(line));
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
await new Promise((resolve, reject) => {
|
|
212
|
+
socket.once('connect', resolve);
|
|
213
|
+
socket.once('error', reject);
|
|
214
|
+
});
|
|
215
|
+
const send = (request) => socket.write(JSON.stringify({ version: 1, id: randomUUID(), token, ...request }) + '\n');
|
|
216
|
+
send({ command: 'follow', since: 0 });
|
|
217
|
+
return { socket, send };
|
|
218
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SessionEvent, SessionEventKind } from './types.js';
|
|
2
|
+
/** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
|
|
3
|
+
export declare class SessionEvents {
|
|
4
|
+
private readonly path;
|
|
5
|
+
private seq;
|
|
6
|
+
private readonly events;
|
|
7
|
+
private readonly listeners;
|
|
8
|
+
constructor(path: string);
|
|
9
|
+
emit(kind: SessionEventKind, fields?: Omit<SessionEvent, 'version' | 'seq' | 'at' | 'kind'>): SessionEvent;
|
|
10
|
+
since(seq: number): SessionEvent[];
|
|
11
|
+
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
12
|
+
private restore;
|
|
13
|
+
private rotateIfNeeded;
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
const MAX_EVENTS = 1_000;
|
|
3
|
+
const MAX_EVENT_FILE_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
/** Bounded, typed event stream shared by CLI frontends and future ACP/Toad facades. */
|
|
5
|
+
export class SessionEvents {
|
|
6
|
+
path;
|
|
7
|
+
seq = 0;
|
|
8
|
+
events = [];
|
|
9
|
+
listeners = new Set();
|
|
10
|
+
constructor(path) {
|
|
11
|
+
this.path = path;
|
|
12
|
+
this.restore();
|
|
13
|
+
}
|
|
14
|
+
emit(kind, fields = {}) {
|
|
15
|
+
const event = {
|
|
16
|
+
version: 1,
|
|
17
|
+
seq: ++this.seq,
|
|
18
|
+
at: new Date().toISOString(),
|
|
19
|
+
kind,
|
|
20
|
+
...fields,
|
|
21
|
+
};
|
|
22
|
+
this.events.push(event);
|
|
23
|
+
if (this.events.length > MAX_EVENTS)
|
|
24
|
+
this.events.shift();
|
|
25
|
+
try {
|
|
26
|
+
this.rotateIfNeeded();
|
|
27
|
+
appendFileSync(this.path, JSON.stringify(event) + '\n', { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
catch { /* diagnostics must never terminate a role */ }
|
|
30
|
+
for (const listener of this.listeners)
|
|
31
|
+
listener(event);
|
|
32
|
+
return event;
|
|
33
|
+
}
|
|
34
|
+
since(seq) {
|
|
35
|
+
return this.events.filter(event => event.seq > seq);
|
|
36
|
+
}
|
|
37
|
+
subscribe(listener) {
|
|
38
|
+
this.listeners.add(listener);
|
|
39
|
+
return () => this.listeners.delete(listener);
|
|
40
|
+
}
|
|
41
|
+
restore() {
|
|
42
|
+
try {
|
|
43
|
+
if (!existsSync(this.path))
|
|
44
|
+
return;
|
|
45
|
+
const lines = readFileSync(this.path, 'utf8').trim().split('\n').slice(-MAX_EVENTS);
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const event = JSON.parse(line);
|
|
48
|
+
if (event.version === 1 && typeof event.seq === 'number') {
|
|
49
|
+
this.events.push(event);
|
|
50
|
+
this.seq = Math.max(this.seq, event.seq);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* begin a fresh projection if the diagnostic file is corrupt */ }
|
|
55
|
+
}
|
|
56
|
+
rotateIfNeeded() {
|
|
57
|
+
if (!existsSync(this.path) || statSync(this.path).size < MAX_EVENT_FILE_BYTES)
|
|
58
|
+
return;
|
|
59
|
+
const rotated = this.path + '.1';
|
|
60
|
+
try {
|
|
61
|
+
renameSync(this.path, rotated);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
writeFileSync(this.path, '', { mode: 0o600 });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Tmux } from '../tmux.js';
|
|
2
|
+
import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
|
|
3
|
+
/** SessionHandle adapter for the existing tmux transport. */
|
|
4
|
+
export declare class TmuxSession implements SessionHandle {
|
|
5
|
+
private readonly name;
|
|
6
|
+
readonly pid: number;
|
|
7
|
+
private readonly tmux;
|
|
8
|
+
private readonly processAlive;
|
|
9
|
+
readonly backend: "tmux";
|
|
10
|
+
constructor(name: string, pid: number, tmux: Tmux, processAlive: (pid: number) => boolean);
|
|
11
|
+
isAlive(): boolean;
|
|
12
|
+
snapshot(): SessionSnapshot;
|
|
13
|
+
submitPrompt(text: string): Promise<TurnResult>;
|
|
14
|
+
interrupt(): Promise<void>;
|
|
15
|
+
respondPermission(): boolean;
|
|
16
|
+
eventsSince(): SessionEvent[];
|
|
17
|
+
subscribe(): () => void;
|
|
18
|
+
setControllerAttached(): void;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** SessionHandle adapter for the existing tmux transport. */
|
|
2
|
+
export class TmuxSession {
|
|
3
|
+
name;
|
|
4
|
+
pid;
|
|
5
|
+
tmux;
|
|
6
|
+
processAlive;
|
|
7
|
+
backend = 'tmux';
|
|
8
|
+
constructor(name, pid, tmux, processAlive) {
|
|
9
|
+
this.name = name;
|
|
10
|
+
this.pid = pid;
|
|
11
|
+
this.tmux = tmux;
|
|
12
|
+
this.processAlive = processAlive;
|
|
13
|
+
}
|
|
14
|
+
isAlive() {
|
|
15
|
+
return this.processAlive(this.pid);
|
|
16
|
+
}
|
|
17
|
+
snapshot() {
|
|
18
|
+
return {
|
|
19
|
+
backend: 'tmux',
|
|
20
|
+
alive: this.isAlive(),
|
|
21
|
+
readiness: this.isAlive() ? 'idle' : 'failed',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async submitPrompt(text) {
|
|
25
|
+
if (!this.isAlive())
|
|
26
|
+
return { accepted: false, outcome: 'failed', detail: 'tmux pane is offline' };
|
|
27
|
+
await this.tmux.sendText(this.name, text);
|
|
28
|
+
return { accepted: true, outcome: 'inconclusive' };
|
|
29
|
+
}
|
|
30
|
+
async interrupt() {
|
|
31
|
+
await this.tmux.sendKey(this.name, 'C-c');
|
|
32
|
+
}
|
|
33
|
+
respondPermission() {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
eventsSince() {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
subscribe() {
|
|
40
|
+
return () => { };
|
|
41
|
+
}
|
|
42
|
+
setControllerAttached() { }
|
|
43
|
+
async close() {
|
|
44
|
+
await this.tmux.kill(this.name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { SessionBackendId } from '../config.js';
|
|
2
|
+
export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
|
|
3
|
+
export interface TurnResult {
|
|
4
|
+
accepted: boolean;
|
|
5
|
+
outcome: 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
|
|
6
|
+
detail?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SessionSnapshot {
|
|
9
|
+
backend: SessionBackendId;
|
|
10
|
+
alive: boolean;
|
|
11
|
+
readiness: SessionReadiness;
|
|
12
|
+
sessionId?: string;
|
|
13
|
+
lastError?: string;
|
|
14
|
+
pendingPermissionId?: string;
|
|
15
|
+
}
|
|
16
|
+
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'turn_stop' | 'error';
|
|
17
|
+
export interface SessionEvent {
|
|
18
|
+
version: 1;
|
|
19
|
+
seq: number;
|
|
20
|
+
at: string;
|
|
21
|
+
kind: SessionEventKind;
|
|
22
|
+
turnId?: string;
|
|
23
|
+
toolCallId?: string;
|
|
24
|
+
permissionId?: string;
|
|
25
|
+
text?: string;
|
|
26
|
+
title?: string;
|
|
27
|
+
status?: string;
|
|
28
|
+
stopReason?: string;
|
|
29
|
+
options?: Array<{
|
|
30
|
+
optionId: string;
|
|
31
|
+
name: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
export interface SessionHandle {
|
|
36
|
+
readonly backend: SessionBackendId;
|
|
37
|
+
readonly pid: number;
|
|
38
|
+
isAlive(): boolean;
|
|
39
|
+
snapshot(): SessionSnapshot;
|
|
40
|
+
submitPrompt(text: string): Promise<TurnResult>;
|
|
41
|
+
interrupt(): Promise<void>;
|
|
42
|
+
respondPermission(permissionId: string, optionId: string): boolean;
|
|
43
|
+
eventsSince(seq: number): SessionEvent[];
|
|
44
|
+
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
45
|
+
setControllerAttached(attached: boolean): void;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/spawn.d.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
import { type ApprovalMode, type FilesystemMode, type SessionBackendId, type UnattendedMode } from './config.js';
|
|
1
2
|
import { type OpsDeps } from './ops.js';
|
|
2
3
|
export interface SpawnOpts {
|
|
3
4
|
name: string;
|
|
4
5
|
temp?: boolean;
|
|
5
6
|
harness?: string;
|
|
7
|
+
session?: SessionBackendId;
|
|
6
8
|
mission?: string;
|
|
7
9
|
identity?: string;
|
|
8
10
|
cwd?: string;
|
|
9
11
|
coordinator?: string;
|
|
10
12
|
model?: string;
|
|
11
13
|
permissionMode?: string;
|
|
14
|
+
approval?: ApprovalMode;
|
|
15
|
+
filesystem?: FilesystemMode;
|
|
16
|
+
unattended?: UnattendedMode;
|
|
12
17
|
sandbox?: string;
|
|
13
18
|
profile?: string;
|
|
14
19
|
launcher?: string;
|
package/dist/spawn.js
CHANGED
|
@@ -3,13 +3,15 @@ import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'no
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { stringify } from 'yaml';
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
|
-
import { loadConfig, resolveMonitorConfig } from './config.js';
|
|
6
|
+
import { loadConfig, resolveMonitorConfig, resolvePermissions, } from './config.js';
|
|
7
7
|
import { applyRole, up } from './ops.js';
|
|
8
8
|
import { START_STAGGER_FILE } from './runner.js';
|
|
9
9
|
function roleFromOpts(o, defaultHarness) {
|
|
10
10
|
const r = {};
|
|
11
11
|
if (o.harness)
|
|
12
12
|
r.harness = o.harness;
|
|
13
|
+
if (o.session)
|
|
14
|
+
r.session = o.session;
|
|
13
15
|
if (o.identity)
|
|
14
16
|
r.identity = o.identity;
|
|
15
17
|
if (o.cwd)
|
|
@@ -40,12 +42,29 @@ function roleFromOpts(o, defaultHarness) {
|
|
|
40
42
|
harnessOptions.monitor = true;
|
|
41
43
|
if (Object.keys(harnessOptions).length)
|
|
42
44
|
r.harness_options = harnessOptions;
|
|
45
|
+
if (o.approval || o.filesystem || o.unattended) {
|
|
46
|
+
r.permissions = {
|
|
47
|
+
...(o.approval ? { approval: o.approval } : {}),
|
|
48
|
+
...(o.filesystem ? { filesystem: o.filesystem } : {}),
|
|
49
|
+
...(o.unattended ? { unattended: o.unattended } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
43
52
|
if (o.bioFile)
|
|
44
53
|
r.bio = readFileSync(o.bioFile, 'utf8').trim();
|
|
45
54
|
if (o.personaFile)
|
|
46
55
|
r.persona = readFileSync(o.personaFile, 'utf8').trim();
|
|
47
56
|
return r;
|
|
48
57
|
}
|
|
58
|
+
function validateSpawnOpts(o) {
|
|
59
|
+
if (o.session && !['tmux', 'acp'].includes(o.session))
|
|
60
|
+
throw new Error(`invalid --session '${o.session}'; allowed: tmux, acp`);
|
|
61
|
+
if (o.approval && !['ask', 'allow', 'deny'].includes(o.approval))
|
|
62
|
+
throw new Error(`invalid --approval '${o.approval}'; allowed: ask, allow, deny`);
|
|
63
|
+
if (o.filesystem && !['read-only', 'workspace', 'unrestricted'].includes(o.filesystem))
|
|
64
|
+
throw new Error(`invalid --filesystem '${o.filesystem}'; allowed: read-only, workspace, unrestricted`);
|
|
65
|
+
if (o.unattended && !['deny', 'wait'].includes(o.unattended))
|
|
66
|
+
throw new Error(`invalid --unattended '${o.unattended}'; allowed: deny, wait`);
|
|
67
|
+
}
|
|
49
68
|
function assertNameFree(o) {
|
|
50
69
|
const cfg = loadConfig(o.configPath);
|
|
51
70
|
if (cfg.roles.some(r => r.name === o.name))
|
|
@@ -55,6 +74,7 @@ function assertNameFree(o) {
|
|
|
55
74
|
}
|
|
56
75
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
57
76
|
export async function spawnPermanent(o, deps) {
|
|
77
|
+
validateSpawnOpts(o);
|
|
58
78
|
assertNameFree(o);
|
|
59
79
|
const cfg = loadConfig(o.configPath);
|
|
60
80
|
mkdirSync(fleetDDir(), { recursive: true });
|
|
@@ -76,6 +96,7 @@ const detachedSupervisor = (binPath, args, dir) => {
|
|
|
76
96
|
};
|
|
77
97
|
/** Temp spawn: state under ~/.ours-fleet/tmp, plain tmux, auto-clean on exit. */
|
|
78
98
|
export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
|
|
99
|
+
validateSpawnOpts(o);
|
|
79
100
|
assertNameFree(o);
|
|
80
101
|
const cfg = loadConfig(o.configPath);
|
|
81
102
|
const defaultHarness = cfg.defaults.harness;
|
|
@@ -88,9 +109,11 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor) {
|
|
|
88
109
|
...fromOpts,
|
|
89
110
|
name: o.name,
|
|
90
111
|
harness: o.harness ?? defaultHarness ?? 'claude-code',
|
|
112
|
+
session: o.session ?? cfg.defaults.session ?? 'tmux',
|
|
91
113
|
identity: o.identity ?? o.name,
|
|
92
114
|
model: o.model?.trim() || cfg.defaults.model,
|
|
93
115
|
harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
|
|
116
|
+
permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
|
|
94
117
|
// Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
|
|
95
118
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
|
|
96
119
|
sourceFile: '(temp)',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux
|
|
3
|
+
"version": "0.10.0-nightly.1",
|
|
4
|
+
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"prepublishOnly": "npm run build && npm test"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"@agentclientprotocol/sdk": "^1.3.0",
|
|
29
30
|
"commander": "^12.1.0",
|
|
30
31
|
"yaml": "^2.5.0"
|
|
31
32
|
},
|