@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
package/dist/runner.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
5
|
import { agentDir, home, stateRoot } from './paths.js';
|
|
6
|
-
import { loadConfig, findRole } from './config.js';
|
|
6
|
+
import { loadConfig, findRole, resolvePermissions } from './config.js';
|
|
7
7
|
import { getAdapter } from './harness/registry.js';
|
|
8
8
|
import { Tmux } from './tmux.js';
|
|
9
9
|
import { createMonitor } from './monitor.js';
|
|
@@ -11,6 +11,9 @@ import { realExec, shq } from './exec.js';
|
|
|
11
11
|
import { resolveIsolation } from './isolation/policy.js';
|
|
12
12
|
import { selectIsolationBackend } from './isolation/registry.js';
|
|
13
13
|
import { resourceArgs, cpuControllerDelegated } from './isolation/resources.js';
|
|
14
|
+
import { AcpSession } from './session/acp.js';
|
|
15
|
+
import { RoleControlServer } from './session/control.js';
|
|
16
|
+
import { TmuxSession } from './session/tmux.js';
|
|
14
17
|
const defaultDeps = () => ({
|
|
15
18
|
tmux: new Tmux(),
|
|
16
19
|
exec: realExec,
|
|
@@ -182,10 +185,17 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
182
185
|
writeFileSync(bootedFile, '');
|
|
183
186
|
const runCwd = role.cwd && existsSync(role.cwd) ? role.cwd : dir;
|
|
184
187
|
const prep = await adapter.prepareSession(role, { stateDir: dir, runCwd });
|
|
185
|
-
const
|
|
188
|
+
const sessionBackend = role.session ?? 'tmux';
|
|
189
|
+
const launch = sessionBackend === 'acp'
|
|
190
|
+
? (() => {
|
|
191
|
+
if (!adapter.buildAcpLaunch)
|
|
192
|
+
throw new Error(`harness '${role.harness}' does not support the ACP session backend`);
|
|
193
|
+
return adapter.buildAcpLaunch(role, prep);
|
|
194
|
+
})()
|
|
195
|
+
: adapter.buildLaunch(role, mode, { sessionId }, prep);
|
|
186
196
|
// Isolation is additive: only roles that declare `isolation:` are wrapped. The
|
|
187
197
|
// env prefix + exit capture in buildPaneCommand stay host-side (see §5.3).
|
|
188
|
-
let
|
|
198
|
+
let wrappedArgv = launch.argv;
|
|
189
199
|
if (role.isolation) {
|
|
190
200
|
const addDirs = role.harness === 'codex'
|
|
191
201
|
? (role.harness_options?.add_dirs ?? [])
|
|
@@ -204,14 +214,14 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
204
214
|
deps.log(`[${name}] isolation: ${sel.backend.id} (net=${policy.network}) ${sel.detail}`);
|
|
205
215
|
rmSync(degradedMarker, { force: true });
|
|
206
216
|
}
|
|
207
|
-
|
|
217
|
+
wrappedArgv = sel.backend.wrap(launch.argv, policy, ctx);
|
|
208
218
|
// Resource caps wrap the sandbox from OUTSIDE, at the pane's own cgroup scope
|
|
209
219
|
// (§5.4). Applies even when the sandbox degraded to none.
|
|
210
220
|
const { argv: rprefix, warnings } = resourceArgs(policy.resources, deps.cpuDelegated());
|
|
211
221
|
for (const w of warnings)
|
|
212
222
|
deps.log(`[${name}] WARNING ${w}`);
|
|
213
223
|
if (rprefix.length)
|
|
214
|
-
|
|
224
|
+
wrappedArgv = [...rprefix, ...wrappedArgv];
|
|
215
225
|
}
|
|
216
226
|
// Start-stagger: space this launch at least start_stagger_ms after the previous
|
|
217
227
|
// agent launch across the whole host, so a burst of boots (systemd starts every
|
|
@@ -232,34 +242,79 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
232
242
|
// (backlog before the tip is the SessionStart hook's job). Disabled roles keep
|
|
233
243
|
// the legacy in-session watch. Temp snapshots predating `monitor:` are treated
|
|
234
244
|
// as disabled (monitor may be undefined on an old role.yaml).
|
|
245
|
+
const resolvedMonitorDeps = monitorDeps(deps, role.env);
|
|
235
246
|
const monitor = role.monitor?.enabled ? deps.createMonitor({
|
|
236
|
-
name, agentDir: dir, cfg: role.monitor,
|
|
237
|
-
deps:
|
|
247
|
+
name, identity: role.identity, agentDir: dir, cfg: role.monitor,
|
|
248
|
+
deps: resolvedMonitorDeps,
|
|
238
249
|
}) : null;
|
|
239
250
|
if (monitor)
|
|
240
251
|
await monitor.prime();
|
|
241
252
|
rmSync(exitFile, { force: true });
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
let pid;
|
|
254
|
+
let sessionHandle;
|
|
255
|
+
let acpSession;
|
|
256
|
+
let control;
|
|
257
|
+
if (sessionBackend === 'acp') {
|
|
258
|
+
acpSession = await AcpSession.start({
|
|
259
|
+
name,
|
|
260
|
+
argv: wrappedArgv,
|
|
261
|
+
cwd: runCwd,
|
|
262
|
+
env: { ...launch.env, ...(role.env ?? {}) },
|
|
263
|
+
stateDir: dir,
|
|
264
|
+
mode,
|
|
265
|
+
permissions: role.permissions ?? resolvePermissions(undefined, undefined),
|
|
266
|
+
log: deps.log,
|
|
267
|
+
});
|
|
268
|
+
pid = acpSession.pid;
|
|
269
|
+
sessionHandle = acpSession;
|
|
270
|
+
control = new RoleControlServer(dir, acpSession, deps.log);
|
|
271
|
+
await control.start();
|
|
272
|
+
resolvedMonitorDeps.delivery = {
|
|
273
|
+
submit: async (text) => {
|
|
274
|
+
const result = await acpSession.submitPrompt(text);
|
|
275
|
+
return { accepted: result.accepted, detail: result.detail };
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
const firstPrompt = mode === 'fresh'
|
|
279
|
+
? `Read and follow ${join(dir, 'briefing.md')} now.`
|
|
280
|
+
: adapter.vocabulary.restartPrompt(role.identity, join(dir, 'WORKLOG.md'), role);
|
|
281
|
+
const started = await acpSession.submitPrompt(firstPrompt);
|
|
282
|
+
if (!started.accepted) {
|
|
283
|
+
monitor?.stop();
|
|
284
|
+
await control.close();
|
|
285
|
+
await acpSession.close();
|
|
286
|
+
throw new Error(`[${name}] ACP session rejected startup prompt: ${started.detail ?? started.outcome}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
await deps.tmux.kill(name);
|
|
291
|
+
await deps.tmux.newSession(name, runCwd, buildPaneCommand(launch, role.env, exitFile, wrappedArgv));
|
|
292
|
+
let panePid = null;
|
|
293
|
+
for (let i = 0; i < 40 && panePid === null; i++) {
|
|
294
|
+
panePid = await deps.tmux.panePid(name);
|
|
295
|
+
if (panePid === null)
|
|
296
|
+
await deps.sleep(250);
|
|
297
|
+
}
|
|
298
|
+
if (panePid === null)
|
|
299
|
+
throw new Error(`[${name}] could not resolve tmux pane pid`);
|
|
300
|
+
pid = panePid;
|
|
301
|
+
sessionHandle = new TmuxSession(name, pid, deps.tmux, deps.isAlive);
|
|
249
302
|
}
|
|
250
|
-
|
|
251
|
-
throw new Error(`[${name}] could not resolve tmux pane pid`);
|
|
252
|
-
deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} mode=${mode}`);
|
|
303
|
+
deps.log(`[${name}] up; pid=${pid} cwd=${runCwd} harness=${role.harness} session=${sessionBackend} mode=${mode}`);
|
|
253
304
|
// The monitor loop lives exactly as long as the session: it starts once the
|
|
254
305
|
// pane pid is known and is stopped when that pid dies (task dies with runner).
|
|
255
306
|
const monitorLoop = monitor?.run(pid);
|
|
256
307
|
const start = deps.now();
|
|
257
|
-
while (
|
|
308
|
+
while (sessionHandle.isAlive())
|
|
258
309
|
await deps.sleep(2000);
|
|
259
310
|
if (monitor) {
|
|
260
311
|
monitor.stop();
|
|
261
312
|
await monitorLoop;
|
|
262
313
|
}
|
|
314
|
+
if (control)
|
|
315
|
+
await control.close();
|
|
316
|
+
if (acpSession)
|
|
317
|
+
await acpSession.close();
|
|
263
318
|
const elapsed = (deps.now() - start) / 1000;
|
|
264
319
|
const code = existsSync(exitFile) ? readFileSync(exitFile, 'utf8').trim() : 'crash';
|
|
265
320
|
const rotate = (why) => {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CommonPermissions } from '../config.js';
|
|
2
|
+
import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
|
|
3
|
+
export interface AcpSessionOptions {
|
|
4
|
+
name: string;
|
|
5
|
+
argv: string[];
|
|
6
|
+
cwd: string;
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
stateDir: string;
|
|
9
|
+
mode: 'fresh' | 'resume';
|
|
10
|
+
permissions: CommonPermissions;
|
|
11
|
+
log(line: string): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
|
|
15
|
+
* human/automation attachment happens through the fleet role-control protocol.
|
|
16
|
+
*/
|
|
17
|
+
export declare class AcpSession implements SessionHandle {
|
|
18
|
+
private readonly options;
|
|
19
|
+
readonly backend: "acp";
|
|
20
|
+
readonly pid: number;
|
|
21
|
+
private readonly child;
|
|
22
|
+
private readonly events;
|
|
23
|
+
private readonly sessionFile;
|
|
24
|
+
private readonly pendingPermissions;
|
|
25
|
+
private connection;
|
|
26
|
+
private sessionId?;
|
|
27
|
+
private readiness;
|
|
28
|
+
private lastError?;
|
|
29
|
+
private promptTail;
|
|
30
|
+
private capabilities?;
|
|
31
|
+
private controllerCount;
|
|
32
|
+
private constructor();
|
|
33
|
+
static start(options: AcpSessionOptions): Promise<AcpSession>;
|
|
34
|
+
isAlive(): boolean;
|
|
35
|
+
snapshot(): SessionSnapshot;
|
|
36
|
+
submitPrompt(text: string): Promise<TurnResult>;
|
|
37
|
+
interrupt(): Promise<void>;
|
|
38
|
+
respondPermission(permissionId: string, optionId: string): boolean;
|
|
39
|
+
eventsSince(seq: number): SessionEvent[];
|
|
40
|
+
subscribe(listener: (event: SessionEvent) => void): () => void;
|
|
41
|
+
setControllerAttached(attached: boolean): void;
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
private initialize;
|
|
44
|
+
private runPrompt;
|
|
45
|
+
private requestPermission;
|
|
46
|
+
private withinAutomaticBoundary;
|
|
47
|
+
private recordUpdate;
|
|
48
|
+
private fail;
|
|
49
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { Readable, Writable } from 'node:stream';
|
|
6
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
7
|
+
import { SessionEvents } from './events.js';
|
|
8
|
+
/**
|
|
9
|
+
* Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
|
|
10
|
+
* human/automation attachment happens through the fleet role-control protocol.
|
|
11
|
+
*/
|
|
12
|
+
export class AcpSession {
|
|
13
|
+
options;
|
|
14
|
+
backend = 'acp';
|
|
15
|
+
pid;
|
|
16
|
+
child;
|
|
17
|
+
events;
|
|
18
|
+
sessionFile;
|
|
19
|
+
pendingPermissions = new Map();
|
|
20
|
+
connection;
|
|
21
|
+
sessionId;
|
|
22
|
+
readiness = 'starting';
|
|
23
|
+
lastError;
|
|
24
|
+
promptTail = Promise.resolve();
|
|
25
|
+
capabilities;
|
|
26
|
+
controllerCount = 0;
|
|
27
|
+
constructor(options, child, connection) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.child = child;
|
|
30
|
+
this.connection = connection;
|
|
31
|
+
this.pid = child.pid ?? -1;
|
|
32
|
+
this.events = new SessionEvents(join(options.stateDir, '.session-events.jsonl'));
|
|
33
|
+
this.sessionFile = join(options.stateDir, '.acp-session-id');
|
|
34
|
+
child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
|
|
35
|
+
child.once('exit', (code, signal) => {
|
|
36
|
+
if (this.readiness !== 'failed') {
|
|
37
|
+
this.readiness = 'failed';
|
|
38
|
+
this.lastError = `ACP agent exited (${code ?? signal ?? 'unknown'})`;
|
|
39
|
+
}
|
|
40
|
+
this.events.emit('state', { status: 'failed', text: this.lastError });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
static async start(options) {
|
|
44
|
+
if (!options.argv.length)
|
|
45
|
+
throw new Error('ACP agent command is empty');
|
|
46
|
+
const child = spawn(options.argv[0], options.argv.slice(1), {
|
|
47
|
+
cwd: options.cwd,
|
|
48
|
+
env: { ...process.env, ...options.env },
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
});
|
|
51
|
+
await new Promise((resolve, reject) => {
|
|
52
|
+
child.once('spawn', resolve);
|
|
53
|
+
child.once('error', reject);
|
|
54
|
+
});
|
|
55
|
+
let instance;
|
|
56
|
+
const app = acp.client({ name: 'ours-fleet' })
|
|
57
|
+
.onNotification(acp.methods.client.session.update, ({ params }) => {
|
|
58
|
+
instance?.recordUpdate(params.update);
|
|
59
|
+
})
|
|
60
|
+
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
|
|
61
|
+
if (!instance)
|
|
62
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
63
|
+
return instance.requestPermission(params);
|
|
64
|
+
});
|
|
65
|
+
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
|
66
|
+
const connection = app.connect(stream);
|
|
67
|
+
instance = new AcpSession(options, child, connection);
|
|
68
|
+
try {
|
|
69
|
+
await instance.initialize();
|
|
70
|
+
return instance;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
instance.fail(error);
|
|
74
|
+
await instance.close();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
isAlive() {
|
|
79
|
+
return this.child.exitCode === null && !this.child.killed;
|
|
80
|
+
}
|
|
81
|
+
snapshot() {
|
|
82
|
+
return {
|
|
83
|
+
backend: 'acp',
|
|
84
|
+
alive: this.isAlive(),
|
|
85
|
+
readiness: this.readiness,
|
|
86
|
+
sessionId: this.sessionId,
|
|
87
|
+
lastError: this.lastError,
|
|
88
|
+
pendingPermissionId: this.pendingPermissions.keys().next().value,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
submitPrompt(text) {
|
|
92
|
+
const operation = this.promptTail.then(() => this.runPrompt(text));
|
|
93
|
+
this.promptTail = operation.then(() => undefined, () => undefined);
|
|
94
|
+
return operation;
|
|
95
|
+
}
|
|
96
|
+
async interrupt() {
|
|
97
|
+
if (!this.sessionId)
|
|
98
|
+
return;
|
|
99
|
+
await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
|
|
100
|
+
}
|
|
101
|
+
respondPermission(permissionId, optionId) {
|
|
102
|
+
const pending = this.pendingPermissions.get(permissionId);
|
|
103
|
+
if (!pending || !pending.options.some(option => option.optionId === optionId))
|
|
104
|
+
return false;
|
|
105
|
+
this.pendingPermissions.delete(permissionId);
|
|
106
|
+
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
|
107
|
+
this.readiness = 'running';
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
eventsSince(seq) {
|
|
111
|
+
return this.events.since(seq);
|
|
112
|
+
}
|
|
113
|
+
subscribe(listener) {
|
|
114
|
+
return this.events.subscribe(listener);
|
|
115
|
+
}
|
|
116
|
+
setControllerAttached(attached) {
|
|
117
|
+
this.controllerCount = Math.max(0, this.controllerCount + (attached ? 1 : -1));
|
|
118
|
+
}
|
|
119
|
+
async close() {
|
|
120
|
+
for (const pending of this.pendingPermissions.values())
|
|
121
|
+
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
|
122
|
+
this.pendingPermissions.clear();
|
|
123
|
+
if (this.sessionId && this.capabilities?.sessionCapabilities?.close != null) {
|
|
124
|
+
await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.sessionId }).catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
this.connection.close();
|
|
127
|
+
if (this.isAlive())
|
|
128
|
+
this.child.kill('SIGTERM');
|
|
129
|
+
}
|
|
130
|
+
async initialize() {
|
|
131
|
+
const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
|
|
132
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
133
|
+
clientCapabilities: {},
|
|
134
|
+
clientInfo: { name: 'ours-fleet', version: '1' },
|
|
135
|
+
});
|
|
136
|
+
if (initialized.protocolVersion !== acp.PROTOCOL_VERSION)
|
|
137
|
+
throw new Error(`ACP protocol mismatch: agent selected ${initialized.protocolVersion}, client supports ${acp.PROTOCOL_VERSION}`);
|
|
138
|
+
this.capabilities = initialized.agentCapabilities;
|
|
139
|
+
const persisted = this.options.mode === 'resume' && existsSync(this.sessionFile)
|
|
140
|
+
? readFileSync(this.sessionFile, 'utf8').trim()
|
|
141
|
+
: '';
|
|
142
|
+
if (persisted && this.capabilities?.sessionCapabilities?.resume != null) {
|
|
143
|
+
await this.connection.agent.request(acp.methods.agent.session.resume, {
|
|
144
|
+
sessionId: persisted,
|
|
145
|
+
cwd: this.options.cwd,
|
|
146
|
+
mcpServers: [],
|
|
147
|
+
});
|
|
148
|
+
this.sessionId = persisted;
|
|
149
|
+
}
|
|
150
|
+
else if (persisted && this.capabilities?.loadSession) {
|
|
151
|
+
await this.connection.agent.request(acp.methods.agent.session.load, {
|
|
152
|
+
sessionId: persisted,
|
|
153
|
+
cwd: this.options.cwd,
|
|
154
|
+
mcpServers: [],
|
|
155
|
+
});
|
|
156
|
+
this.sessionId = persisted;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const created = await this.connection.agent.request(acp.methods.agent.session.new, {
|
|
160
|
+
cwd: this.options.cwd,
|
|
161
|
+
mcpServers: [],
|
|
162
|
+
});
|
|
163
|
+
this.sessionId = created.sessionId;
|
|
164
|
+
}
|
|
165
|
+
writeFileSync(this.sessionFile, this.sessionId + '\n', { mode: 0o600 });
|
|
166
|
+
this.readiness = 'idle';
|
|
167
|
+
this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
|
|
168
|
+
}
|
|
169
|
+
async runPrompt(text) {
|
|
170
|
+
if (!this.sessionId || !this.isAlive())
|
|
171
|
+
return { accepted: false, outcome: 'failed', detail: this.lastError ?? 'ACP session is offline' };
|
|
172
|
+
this.readiness = 'running';
|
|
173
|
+
const turnId = randomUUID();
|
|
174
|
+
this.events.emit('state', { turnId, status: 'running' });
|
|
175
|
+
try {
|
|
176
|
+
const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
|
|
177
|
+
sessionId: this.sessionId,
|
|
178
|
+
prompt: [{ type: 'text', text }],
|
|
179
|
+
});
|
|
180
|
+
this.readiness = 'idle';
|
|
181
|
+
this.events.emit('turn_stop', { turnId, stopReason: response.stopReason });
|
|
182
|
+
this.events.emit('state', { status: 'idle' });
|
|
183
|
+
const outcome = response.stopReason === 'cancelled'
|
|
184
|
+
? 'cancelled'
|
|
185
|
+
: response.stopReason === 'refusal'
|
|
186
|
+
? 'refused'
|
|
187
|
+
: 'completed';
|
|
188
|
+
return { accepted: true, outcome, detail: response.stopReason };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
this.lastError = error?.message ?? String(error);
|
|
192
|
+
this.readiness = this.isAlive() ? 'idle' : 'failed';
|
|
193
|
+
this.events.emit('error', { turnId, text: this.lastError });
|
|
194
|
+
if (this.isAlive())
|
|
195
|
+
this.events.emit('state', { status: 'idle' });
|
|
196
|
+
return { accepted: false, outcome: 'failed', detail: this.lastError };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
requestPermission(params) {
|
|
200
|
+
const choose = (kinds) => params.options.find(option => kinds.includes(option.kind));
|
|
201
|
+
if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
|
|
202
|
+
const option = choose(['allow_always', 'allow_once']);
|
|
203
|
+
return Promise.resolve(option
|
|
204
|
+
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
205
|
+
: { outcome: { outcome: 'cancelled' } });
|
|
206
|
+
}
|
|
207
|
+
if (this.options.permissions.approval === 'deny'
|
|
208
|
+
|| (this.controllerCount === 0 && this.options.permissions.unattended === 'deny')) {
|
|
209
|
+
const option = choose(['reject_always', 'reject_once']);
|
|
210
|
+
return Promise.resolve(option
|
|
211
|
+
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
212
|
+
: { outcome: { outcome: 'cancelled' } });
|
|
213
|
+
}
|
|
214
|
+
const permissionId = randomUUID();
|
|
215
|
+
this.readiness = 'awaiting_permission';
|
|
216
|
+
this.events.emit('permission', {
|
|
217
|
+
permissionId,
|
|
218
|
+
toolCallId: params.toolCall.toolCallId,
|
|
219
|
+
title: params.toolCall.title ?? 'Permission requested',
|
|
220
|
+
status: 'pending',
|
|
221
|
+
options: params.options.map(option => ({
|
|
222
|
+
optionId: option.optionId, name: option.name, kind: option.kind,
|
|
223
|
+
})),
|
|
224
|
+
});
|
|
225
|
+
return new Promise(resolve => {
|
|
226
|
+
this.pendingPermissions.set(permissionId, { options: params.options, resolve });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
withinAutomaticBoundary(params) {
|
|
230
|
+
const filesystem = this.options.permissions.filesystem;
|
|
231
|
+
if (filesystem === 'unrestricted')
|
|
232
|
+
return true;
|
|
233
|
+
if (filesystem === 'read-only' && params.toolCall.kind !== 'read')
|
|
234
|
+
return false;
|
|
235
|
+
const locations = params.toolCall.locations ?? [];
|
|
236
|
+
if (locations.length === 0)
|
|
237
|
+
return false;
|
|
238
|
+
const cwd = resolve(this.options.cwd);
|
|
239
|
+
return locations.every(location => {
|
|
240
|
+
const path = resolve(location.path);
|
|
241
|
+
const rel = relative(cwd, path);
|
|
242
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
recordUpdate(update) {
|
|
246
|
+
switch (update.sessionUpdate) {
|
|
247
|
+
case 'agent_message_chunk':
|
|
248
|
+
this.events.emit('agent_text', {
|
|
249
|
+
text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
250
|
+
});
|
|
251
|
+
break;
|
|
252
|
+
case 'agent_thought_chunk':
|
|
253
|
+
this.events.emit('thought', {
|
|
254
|
+
text: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
255
|
+
});
|
|
256
|
+
break;
|
|
257
|
+
case 'tool_call':
|
|
258
|
+
this.events.emit('tool_call', {
|
|
259
|
+
toolCallId: update.toolCallId,
|
|
260
|
+
title: update.title,
|
|
261
|
+
status: update.status,
|
|
262
|
+
});
|
|
263
|
+
break;
|
|
264
|
+
case 'tool_call_update':
|
|
265
|
+
this.events.emit('tool_update', {
|
|
266
|
+
toolCallId: update.toolCallId,
|
|
267
|
+
title: update.title ?? undefined,
|
|
268
|
+
status: update.status ?? undefined,
|
|
269
|
+
});
|
|
270
|
+
break;
|
|
271
|
+
default:
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
fail(error) {
|
|
276
|
+
this.lastError = error?.message ?? String(error);
|
|
277
|
+
this.readiness = 'failed';
|
|
278
|
+
this.events.emit('error', { text: this.lastError });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Socket } from 'node:net';
|
|
2
|
+
import type { SessionHandle } from './types.js';
|
|
3
|
+
export interface ControlRequest {
|
|
4
|
+
version: 1;
|
|
5
|
+
id: string;
|
|
6
|
+
token: string;
|
|
7
|
+
command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow';
|
|
8
|
+
text?: string;
|
|
9
|
+
permissionId?: string;
|
|
10
|
+
optionId?: string;
|
|
11
|
+
since?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ControlResponse {
|
|
14
|
+
version: 1;
|
|
15
|
+
id: string;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
result?: unknown;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const controlSocketPath: (stateDir: string) => string;
|
|
21
|
+
export declare const controlTokenPath: (stateDir: string) => string;
|
|
22
|
+
/** Private, versioned JSONL control plane for CLI and future console frontends. */
|
|
23
|
+
export declare class RoleControlServer {
|
|
24
|
+
private readonly session;
|
|
25
|
+
private readonly log;
|
|
26
|
+
private readonly server;
|
|
27
|
+
private readonly socketPath;
|
|
28
|
+
private readonly token;
|
|
29
|
+
private readonly sockets;
|
|
30
|
+
constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
|
|
31
|
+
start(): Promise<void>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
private accept;
|
|
34
|
+
private handle;
|
|
35
|
+
private write;
|
|
36
|
+
}
|
|
37
|
+
export declare function controlRequest(stateDir: string, request: Omit<ControlRequest, 'version' | 'id' | 'token'>, timeoutMs?: number): Promise<ControlResponse>;
|
|
38
|
+
export declare function followControl(stateDir: string, onMessage: (message: Record<string, unknown>) => void): Promise<{
|
|
39
|
+
socket: Socket;
|
|
40
|
+
send(request: Omit<ControlRequest, 'version' | 'id' | 'token'>): void;
|
|
41
|
+
}>;
|