@ours.network/fleet 0.9.5 → 0.9.8
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 +101 -0
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +95 -21
- package/dist/config.d.ts +15 -1
- package/dist/config.js +47 -2
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +28 -1
- package/dist/docs.js +132 -0
- package/dist/doctor.js +74 -16
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +126 -24
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +57 -10
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +50 -3
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +30 -3
- package/dist/monitor.js +145 -30
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +239 -19
- package/dist/session/acp.d.ts +22 -1
- package/dist/session/acp.js +110 -26
- package/dist/session/control.d.ts +49 -1
- package/dist/session/control.js +116 -12
- package/dist/session/tmux.d.ts +8 -1
- package/dist/session/tmux.js +34 -4
- package/dist/session/types.d.ts +92 -1
- package/dist/session/types.js +42 -1
- package/dist/spawn.d.ts +27 -2
- package/dist/spawn.js +153 -15
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- package/package.json +1 -1
package/dist/session/acp.js
CHANGED
|
@@ -5,6 +5,19 @@ import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
|
5
5
|
import { Readable, Writable } from 'node:stream';
|
|
6
6
|
import * as acp from '@agentclientprotocol/sdk';
|
|
7
7
|
import { SessionEvents } from './events.js';
|
|
8
|
+
import { SessionControlError, classifyChildExit, turnResult } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Classify an ACP `stopReason` into a terminal outcome. A refusal and a
|
|
11
|
+
* cancellation are the two ways a delivered prompt ends without being carried
|
|
12
|
+
* out; every other stop reason ran the turn to an end the agent chose.
|
|
13
|
+
*/
|
|
14
|
+
export function classifyStopReason(stopReason) {
|
|
15
|
+
switch (stopReason) {
|
|
16
|
+
case 'refusal': return 'refused';
|
|
17
|
+
case 'cancelled': return 'cancelled';
|
|
18
|
+
default: return 'completed';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
8
21
|
/**
|
|
9
22
|
* Persistent ACP v1 client. It is the sole owner of the agent's stdio; all
|
|
10
23
|
* human/automation attachment happens through the fleet role-control protocol.
|
|
@@ -22,6 +35,8 @@ export class AcpSession {
|
|
|
22
35
|
readiness = 'starting';
|
|
23
36
|
lastError;
|
|
24
37
|
promptTail = Promise.resolve();
|
|
38
|
+
queueDepth = 0;
|
|
39
|
+
exit = null;
|
|
25
40
|
capabilities;
|
|
26
41
|
controllerCount = 0;
|
|
27
42
|
constructor(options, child, connection) {
|
|
@@ -33,9 +48,12 @@ export class AcpSession {
|
|
|
33
48
|
this.sessionFile = join(options.stateDir, '.acp-session-id');
|
|
34
49
|
child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
|
|
35
50
|
child.once('exit', (code, signal) => {
|
|
51
|
+
// Record the child's real exit code/signal. The tmux path can only see a
|
|
52
|
+
// shell's `$?`; here the truth is available, so keep it.
|
|
53
|
+
this.exit = classifyChildExit(code, signal);
|
|
36
54
|
if (this.readiness !== 'failed') {
|
|
37
55
|
this.readiness = 'failed';
|
|
38
|
-
this.lastError = `ACP agent
|
|
56
|
+
this.lastError = `ACP agent ${this.exit.detail}`;
|
|
39
57
|
}
|
|
40
58
|
this.events.emit('state', { status: 'failed', text: this.lastError });
|
|
41
59
|
});
|
|
@@ -88,10 +106,33 @@ export class AcpSession {
|
|
|
88
106
|
pendingPermissionId: this.pendingPermissions.keys().next().value,
|
|
89
107
|
};
|
|
90
108
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Accept responsibility for a prompt, then return. The turn itself may run
|
|
111
|
+
* for minutes behind other queued turns; making an interactive caller wait
|
|
112
|
+
* for it is what turned a busy agent into a timeout and then into "dead".
|
|
113
|
+
*/
|
|
114
|
+
async queuePrompt(text) {
|
|
115
|
+
if (!this.sessionId || !this.isAlive())
|
|
116
|
+
throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
|
|
117
|
+
const promptId = randomUUID();
|
|
118
|
+
const queuedBehind = this.queueDepth++;
|
|
119
|
+
const run = this.promptTail.then(() => this.runPrompt(text, promptId));
|
|
120
|
+
this.promptTail = run.then(() => undefined, () => undefined);
|
|
121
|
+
const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
|
|
122
|
+
this.queueDepth = Math.max(0, this.queueDepth - 1);
|
|
123
|
+
return turnResult(false, 'failed', error?.message ?? String(error));
|
|
124
|
+
});
|
|
125
|
+
return { promptId, queuedBehind, completion };
|
|
126
|
+
}
|
|
127
|
+
async submitPrompt(text) {
|
|
128
|
+
try {
|
|
129
|
+
return await (await this.queuePrompt(text)).completion;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof SessionControlError)
|
|
133
|
+
return turnResult(false, 'failed', error.message);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
95
136
|
}
|
|
96
137
|
async interrupt() {
|
|
97
138
|
if (!this.sessionId)
|
|
@@ -100,10 +141,19 @@ export class AcpSession {
|
|
|
100
141
|
}
|
|
101
142
|
respondPermission(permissionId, optionId) {
|
|
102
143
|
const pending = this.pendingPermissions.get(permissionId);
|
|
103
|
-
|
|
144
|
+
const chosen = pending?.options.find(option => option.optionId === optionId);
|
|
145
|
+
if (!pending || !chosen)
|
|
104
146
|
return false;
|
|
105
147
|
this.pendingPermissions.delete(permissionId);
|
|
106
148
|
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
|
149
|
+
this.events.emit('permission', {
|
|
150
|
+
permissionId,
|
|
151
|
+
status: 'completed',
|
|
152
|
+
decision: chosen.kind.startsWith('reject') ? 'denied' : 'allowed',
|
|
153
|
+
decisionSource: 'manual',
|
|
154
|
+
reason: `answered from an attached controller (${chosen.kind})`,
|
|
155
|
+
optionId,
|
|
156
|
+
});
|
|
107
157
|
this.readiness = 'running';
|
|
108
158
|
return true;
|
|
109
159
|
}
|
|
@@ -116,6 +166,9 @@ export class AcpSession {
|
|
|
116
166
|
setControllerAttached(attached) {
|
|
117
167
|
this.controllerCount = Math.max(0, this.controllerCount + (attached ? 1 : -1));
|
|
118
168
|
}
|
|
169
|
+
exitResult() {
|
|
170
|
+
return this.exit;
|
|
171
|
+
}
|
|
119
172
|
async close() {
|
|
120
173
|
for (const pending of this.pendingPermissions.values())
|
|
121
174
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
|
@@ -166,11 +219,10 @@ export class AcpSession {
|
|
|
166
219
|
this.readiness = 'idle';
|
|
167
220
|
this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
|
|
168
221
|
}
|
|
169
|
-
async runPrompt(text) {
|
|
222
|
+
async runPrompt(text, turnId = randomUUID()) {
|
|
170
223
|
if (!this.sessionId || !this.isAlive())
|
|
171
|
-
return
|
|
224
|
+
return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
|
|
172
225
|
this.readiness = 'running';
|
|
173
|
-
const turnId = randomUUID();
|
|
174
226
|
this.events.emit('state', { turnId, status: 'running' });
|
|
175
227
|
try {
|
|
176
228
|
const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
|
|
@@ -180,12 +232,9 @@ export class AcpSession {
|
|
|
180
232
|
this.readiness = 'idle';
|
|
181
233
|
this.events.emit('turn_stop', { turnId, stopReason: response.stopReason });
|
|
182
234
|
this.events.emit('state', { status: 'idle' });
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
? 'refused'
|
|
187
|
-
: 'completed';
|
|
188
|
-
return { accepted: true, outcome, detail: response.stopReason };
|
|
235
|
+
// The prompt was accepted either way — the agent answered. Whether the
|
|
236
|
+
// turn SUCCEEDED is a separate question, and only `stopReason` answers it.
|
|
237
|
+
return turnResult(true, classifyStopReason(response.stopReason), response.stopReason);
|
|
189
238
|
}
|
|
190
239
|
catch (error) {
|
|
191
240
|
this.lastError = error?.message ?? String(error);
|
|
@@ -193,23 +242,35 @@ export class AcpSession {
|
|
|
193
242
|
this.events.emit('error', { turnId, text: this.lastError });
|
|
194
243
|
if (this.isAlive())
|
|
195
244
|
this.events.emit('state', { status: 'idle' });
|
|
196
|
-
return
|
|
245
|
+
return turnResult(false, 'failed', this.lastError);
|
|
197
246
|
}
|
|
198
247
|
}
|
|
199
248
|
requestPermission(params) {
|
|
200
|
-
|
|
249
|
+
// `kinds` is a PRIORITY order. Scanning the agent's option array instead
|
|
250
|
+
// (`options.find(o => kinds.includes(o.kind))`) hands the choice to whatever
|
|
251
|
+
// order the agent happened to list, which is exactly how an automatic denial
|
|
252
|
+
// could land on `reject_always`.
|
|
253
|
+
const choose = (kinds) => {
|
|
254
|
+
for (const kind of kinds) {
|
|
255
|
+
const option = params.options.find(o => o.kind === kind);
|
|
256
|
+
if (option)
|
|
257
|
+
return option;
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
};
|
|
201
261
|
if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
|
|
202
262
|
const option = choose(['allow_always', 'allow_once']);
|
|
203
|
-
return Promise.resolve(option
|
|
204
|
-
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
205
|
-
: { outcome: { outcome: 'cancelled' } });
|
|
263
|
+
return Promise.resolve(this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`));
|
|
206
264
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
265
|
+
const unattended = this.controllerCount === 0 && this.options.permissions.unattended === 'deny';
|
|
266
|
+
if (this.options.permissions.approval === 'deny' || unattended) {
|
|
267
|
+
// reject_once FIRST: `reject_always` teaches the agent a standing rule from
|
|
268
|
+
// a decision no human made, so one unattended denial would silently disable
|
|
269
|
+
// the tool for the rest of the session.
|
|
270
|
+
const option = choose(['reject_once', 'reject_always']);
|
|
271
|
+
return Promise.resolve(this.settleAutomatically(params, option, 'denied', unattended ? 'permissions.unattended=deny' : 'permissions.approval=deny', unattended
|
|
272
|
+
? 'no controller is attached, so the request cannot be shown to anyone'
|
|
273
|
+
: 'the role denies every permission request by policy'));
|
|
213
274
|
}
|
|
214
275
|
const permissionId = randomUUID();
|
|
215
276
|
this.readiness = 'awaiting_permission';
|
|
@@ -226,6 +287,29 @@ export class AcpSession {
|
|
|
226
287
|
this.pendingPermissions.set(permissionId, { options: params.options, resolve });
|
|
227
288
|
});
|
|
228
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Resolve a permission request from policy alone and leave a record of it.
|
|
292
|
+
* Nothing else in the system can observe an automatic decision, so an
|
|
293
|
+
* unrecorded one is indistinguishable from a request that was never made.
|
|
294
|
+
*/
|
|
295
|
+
settleAutomatically(params, option, decision, policy, reason) {
|
|
296
|
+
const settled = option ? decision : 'cancelled';
|
|
297
|
+
this.events.emit('permission', {
|
|
298
|
+
permissionId: randomUUID(),
|
|
299
|
+
toolCallId: params.toolCall.toolCallId,
|
|
300
|
+
title: params.toolCall.title ?? 'Permission requested',
|
|
301
|
+
status: 'completed',
|
|
302
|
+
decision: settled,
|
|
303
|
+
decisionSource: 'automatic',
|
|
304
|
+
policy,
|
|
305
|
+
reason: option ? reason : `${reason}, but the agent offered no matching option`,
|
|
306
|
+
optionId: option?.optionId,
|
|
307
|
+
options: params.options.map(o => ({ optionId: o.optionId, name: o.name, kind: o.kind })),
|
|
308
|
+
});
|
|
309
|
+
return option
|
|
310
|
+
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
311
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
312
|
+
}
|
|
229
313
|
withinAutomaticBoundary(params) {
|
|
230
314
|
const filesystem = this.options.permissions.filesystem;
|
|
231
315
|
if (filesystem === 'unrestricted')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Socket } from 'node:net';
|
|
2
|
-
import type { SessionHandle } from './types.js';
|
|
2
|
+
import type { ControlFailureKind, SessionHandle } from './types.js';
|
|
3
3
|
export interface ControlRequest {
|
|
4
4
|
version: 1;
|
|
5
5
|
id: string;
|
|
@@ -16,7 +16,49 @@ export interface ControlResponse {
|
|
|
16
16
|
ok: boolean;
|
|
17
17
|
result?: unknown;
|
|
18
18
|
error?: string;
|
|
19
|
+
/** Why it failed, so the caller does not have to guess from the text. */
|
|
20
|
+
kind?: ControlFailureKind;
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* One line saying what a control failure does — and does not — prove about the
|
|
24
|
+
* agent. Only `offline` is evidence that it is gone; every other kind used to
|
|
25
|
+
* be rendered as "is not running", which is how a busy agent got restarted.
|
|
26
|
+
*/
|
|
27
|
+
export declare function livenessNote(kind: ControlFailureKind, name: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* The result taxonomy an overseer judges a role by (7.2).
|
|
30
|
+
*
|
|
31
|
+
* One console command is not a liveness verdict. `peek` and `send` can fail for
|
|
32
|
+
* five distinct reasons and succeed for one, and only ONE of the six says the
|
|
33
|
+
* agent is gone — collapsing them into "not running" is how busy agents got
|
|
34
|
+
* restarted. This is the single definition of that vocabulary: the generated
|
|
35
|
+
* briefing renders it, and the shipped oversee-agents skills quote it. The
|
|
36
|
+
* per-result wording comes from `livenessNote` rather than being restated, so
|
|
37
|
+
* the words an overseer reads in its instructions are the words the CLI prints.
|
|
38
|
+
*/
|
|
39
|
+
export interface OversightResult {
|
|
40
|
+
/** What the command reported: the one success, or the failure kind. */
|
|
41
|
+
result: 'queued' | ControlFailureKind;
|
|
42
|
+
/** What it proves about the agent. */
|
|
43
|
+
meaning: string;
|
|
44
|
+
/** What the overseer does next. */
|
|
45
|
+
action: string;
|
|
46
|
+
/**
|
|
47
|
+
* Whether this result ALONE justifies restarting the role. True for exactly
|
|
48
|
+
* one result. Every other one requires corroboration before touching a role
|
|
49
|
+
* that may simply be working.
|
|
50
|
+
*/
|
|
51
|
+
restartJustified: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** The taxonomy, in the order generated guidance presents it. */
|
|
54
|
+
export declare function oversightTaxonomy(name?: string): OversightResult[];
|
|
55
|
+
/**
|
|
56
|
+
* The taxonomy as guidance lines, for a briefing or any generated document.
|
|
57
|
+
* The shipped oversee-agents skills carry these same lines, and a test holds
|
|
58
|
+
* them to it — so an overseer reading its briefing and an overseer reading the
|
|
59
|
+
* skill cannot be given different rules.
|
|
60
|
+
*/
|
|
61
|
+
export declare function oversightTaxonomyLines(name?: string): string[];
|
|
20
62
|
export declare const controlSocketPath: (stateDir: string) => string;
|
|
21
63
|
export declare const controlTokenPath: (stateDir: string) => string;
|
|
22
64
|
/** Private, versioned JSONL control plane for CLI and future console frontends. */
|
|
@@ -34,6 +76,12 @@ export declare class RoleControlServer {
|
|
|
34
76
|
private handle;
|
|
35
77
|
private write;
|
|
36
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Send one control request. Every failure mode is classified: a missing token
|
|
81
|
+
* or socket is `control-unavailable`, a silent server is `timeout`, and a
|
|
82
|
+
* response that is not parseable JSON is `backend`. The caller never has to
|
|
83
|
+
* infer liveness from an exception message.
|
|
84
|
+
*/
|
|
37
85
|
export declare function controlRequest(stateDir: string, request: Omit<ControlRequest, 'version' | 'id' | 'token'>, timeoutMs?: number): Promise<ControlResponse>;
|
|
38
86
|
export declare function followControl(stateDir: string, onMessage: (message: Record<string, unknown>) => void): Promise<{
|
|
39
87
|
socket: Socket;
|
package/dist/session/control.js
CHANGED
|
@@ -2,7 +2,84 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
|
2
2
|
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { createConnection, createServer } from 'node:net';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { SessionControlError } from './types.js';
|
|
5
6
|
const MAX_LINE_BYTES = 64 * 1024;
|
|
7
|
+
/**
|
|
8
|
+
* One line saying what a control failure does — and does not — prove about the
|
|
9
|
+
* agent. Only `offline` is evidence that it is gone; every other kind used to
|
|
10
|
+
* be rendered as "is not running", which is how a busy agent got restarted.
|
|
11
|
+
*/
|
|
12
|
+
export function livenessNote(kind, name) {
|
|
13
|
+
switch (kind) {
|
|
14
|
+
case 'offline':
|
|
15
|
+
return `'${name}' is confirmed offline.`;
|
|
16
|
+
case 'control-unavailable':
|
|
17
|
+
return `this says nothing about whether '${name}' is alive — its control plane did not answer; ` +
|
|
18
|
+
`check: ours-fleet status ${name}`;
|
|
19
|
+
case 'timeout':
|
|
20
|
+
return `'${name}' did not answer in time; a busy agent looks exactly like this. ` +
|
|
21
|
+
`Check: ours-fleet status ${name}`;
|
|
22
|
+
case 'rejected':
|
|
23
|
+
return `'${name}' is running and refused the request.`;
|
|
24
|
+
case 'backend':
|
|
25
|
+
return `this is a transport failure, not evidence that '${name}' is gone; ` +
|
|
26
|
+
`check: ours-fleet status ${name}`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** The taxonomy, in the order generated guidance presents it. */
|
|
30
|
+
export function oversightTaxonomy(name = '<Name>') {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
result: 'queued',
|
|
34
|
+
meaning: `the session accepted the prompt for '${name}'; a turn already running is not a failure.`,
|
|
35
|
+
action: 'Nothing. Do not resend, and do not read the absence of a reply as a stall — '
|
|
36
|
+
+ `check progress with: ours-fleet peek ${name}`,
|
|
37
|
+
restartJustified: false,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
result: 'timeout',
|
|
41
|
+
meaning: livenessNote('timeout', name),
|
|
42
|
+
action: 'Treat delivery as UNCERTAIN — the request may already have been acted on, so do not '
|
|
43
|
+
+ 'resend it blindly.',
|
|
44
|
+
restartJustified: false,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
result: 'rejected',
|
|
48
|
+
meaning: livenessNote('rejected', name),
|
|
49
|
+
action: 'Fix the request, not the agent. A refusal is proof of life.',
|
|
50
|
+
restartJustified: false,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
result: 'control-unavailable',
|
|
54
|
+
meaning: livenessNote('control-unavailable', name),
|
|
55
|
+
action: 'Read the role logs as well. The control plane and the agent are separate things, '
|
|
56
|
+
+ 'and one being unreachable is not evidence about the other.',
|
|
57
|
+
restartJustified: false,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
result: 'backend',
|
|
61
|
+
meaning: livenessNote('backend', name),
|
|
62
|
+
action: 'Investigate the transport, not the agent.',
|
|
63
|
+
restartJustified: false,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
result: 'offline',
|
|
67
|
+
meaning: livenessNote('offline', name),
|
|
68
|
+
action: `This is the ONLY result that justifies a restart on its own: ours-fleet restart ${name} `
|
|
69
|
+
+ 'for a permanent role. Read the logs first.',
|
|
70
|
+
restartJustified: true,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The taxonomy as guidance lines, for a briefing or any generated document.
|
|
76
|
+
* The shipped oversee-agents skills carry these same lines, and a test holds
|
|
77
|
+
* them to it — so an overseer reading its briefing and an overseer reading the
|
|
78
|
+
* skill cannot be given different rules.
|
|
79
|
+
*/
|
|
80
|
+
export function oversightTaxonomyLines(name = '<Name>') {
|
|
81
|
+
return oversightTaxonomy(name).map(r => `- **${r.result}** — ${r.meaning} → ${r.action}`);
|
|
82
|
+
}
|
|
6
83
|
export const controlSocketPath = (stateDir) => join(stateDir, '.control.sock');
|
|
7
84
|
export const controlTokenPath = (stateDir) => join(stateDir, '.control-token');
|
|
8
85
|
function sameToken(actual, supplied) {
|
|
@@ -111,19 +188,28 @@ export class RoleControlServer {
|
|
|
111
188
|
return;
|
|
112
189
|
case 'submit_prompt': {
|
|
113
190
|
if (!request.text?.trim())
|
|
114
|
-
throw new
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
191
|
+
throw new SessionControlError('rejected', 'text is required');
|
|
192
|
+
// Answer on QUEUE ACCEPTANCE, not on turn completion. A turn can run
|
|
193
|
+
// for minutes; blocking here made every `send` into a busy agent time
|
|
194
|
+
// out, and the timeout was then reported as a dead agent.
|
|
195
|
+
const queued = await this.session.queuePrompt(request.text);
|
|
196
|
+
this.write(socket, {
|
|
197
|
+
version: 1, id: request.id, ok: true,
|
|
198
|
+
result: {
|
|
199
|
+
state: 'queued', promptId: queued.promptId, queuedBehind: queued.queuedBehind,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
118
202
|
return;
|
|
119
203
|
}
|
|
120
204
|
case 'respond_permission': {
|
|
121
205
|
if (!request.permissionId || !request.optionId)
|
|
122
|
-
throw new
|
|
206
|
+
throw new SessionControlError('rejected', 'permissionId and optionId are required');
|
|
123
207
|
const accepted = this.session.respondPermission(request.permissionId, request.optionId);
|
|
124
208
|
this.write(socket, {
|
|
125
209
|
version: 1, id: request.id, ok: accepted,
|
|
126
|
-
result: { accepted },
|
|
210
|
+
result: { accepted },
|
|
211
|
+
error: accepted ? undefined : 'stale or invalid permission response',
|
|
212
|
+
kind: accepted ? undefined : 'rejected',
|
|
127
213
|
});
|
|
128
214
|
return;
|
|
129
215
|
}
|
|
@@ -146,11 +232,14 @@ export class RoleControlServer {
|
|
|
146
232
|
}
|
|
147
233
|
}
|
|
148
234
|
catch (error) {
|
|
235
|
+
// Carry the session's own classification to the caller. Losing it here is
|
|
236
|
+
// what forced the CLI to invent one.
|
|
149
237
|
this.write(socket, {
|
|
150
238
|
version: 1,
|
|
151
239
|
id: request.id,
|
|
152
240
|
ok: false,
|
|
153
241
|
error: error?.message ?? String(error),
|
|
242
|
+
kind: error instanceof SessionControlError ? error.kind : 'backend',
|
|
154
243
|
});
|
|
155
244
|
}
|
|
156
245
|
}
|
|
@@ -159,18 +248,34 @@ export class RoleControlServer {
|
|
|
159
248
|
socket.write(JSON.stringify(response) + '\n');
|
|
160
249
|
}
|
|
161
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Send one control request. Every failure mode is classified: a missing token
|
|
253
|
+
* or socket is `control-unavailable`, a silent server is `timeout`, and a
|
|
254
|
+
* response that is not parseable JSON is `backend`. The caller never has to
|
|
255
|
+
* infer liveness from an exception message.
|
|
256
|
+
*/
|
|
162
257
|
export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
|
|
163
|
-
|
|
258
|
+
let token;
|
|
259
|
+
try {
|
|
260
|
+
token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
throw new SessionControlError('control-unavailable', `cannot read the role control token: ${error?.message ?? String(error)}`);
|
|
264
|
+
}
|
|
164
265
|
const id = randomUUID();
|
|
165
266
|
const socket = createConnection(controlSocketPath(stateDir));
|
|
166
267
|
socket.setEncoding('utf8');
|
|
167
|
-
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
168
269
|
const timer = setTimeout(() => {
|
|
169
270
|
socket.destroy();
|
|
170
|
-
reject(new
|
|
271
|
+
reject(new SessionControlError('timeout', `the role control plane did not answer '${request.command}' within ${timeoutMs}ms`));
|
|
171
272
|
}, timeoutMs);
|
|
172
273
|
let buffer = '';
|
|
173
|
-
socket.once('error', error => {
|
|
274
|
+
socket.once('error', error => {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
const code = error.code;
|
|
277
|
+
reject(new SessionControlError(code === 'ENOENT' || code === 'ECONNREFUSED' ? 'control-unavailable' : 'backend', `role control socket: ${error.message}`));
|
|
278
|
+
});
|
|
174
279
|
socket.on('data', chunk => {
|
|
175
280
|
buffer += chunk;
|
|
176
281
|
const newline = buffer.indexOf('\n');
|
|
@@ -181,7 +286,7 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
|
|
|
181
286
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
|
182
287
|
}
|
|
183
288
|
catch (error) {
|
|
184
|
-
reject(error);
|
|
289
|
+
reject(new SessionControlError('backend', `malformed control response: ${error?.message ?? String(error)}`));
|
|
185
290
|
}
|
|
186
291
|
socket.end();
|
|
187
292
|
});
|
|
@@ -189,7 +294,6 @@ export async function controlRequest(stateDir, request, timeoutMs = 120_000) {
|
|
|
189
294
|
version: 1, id, token, ...request,
|
|
190
295
|
}) + '\n'));
|
|
191
296
|
});
|
|
192
|
-
return response;
|
|
193
297
|
}
|
|
194
298
|
export async function followControl(stateDir, onMessage) {
|
|
195
299
|
const token = readFileSync(controlTokenPath(stateDir), 'utf8').trim();
|
package/dist/session/tmux.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Tmux } from '../tmux.js';
|
|
2
|
-
import type { SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
|
|
2
|
+
import type { ExitRecord, QueuedPrompt, SessionEvent, SessionHandle, SessionSnapshot, TurnResult } from './types.js';
|
|
3
3
|
/** SessionHandle adapter for the existing tmux transport. */
|
|
4
4
|
export declare class TmuxSession implements SessionHandle {
|
|
5
5
|
private readonly name;
|
|
@@ -10,11 +10,18 @@ export declare class TmuxSession implements SessionHandle {
|
|
|
10
10
|
constructor(name: string, pid: number, tmux: Tmux, processAlive: (pid: number) => boolean);
|
|
11
11
|
isAlive(): boolean;
|
|
12
12
|
snapshot(): SessionSnapshot;
|
|
13
|
+
queuePrompt(text: string): Promise<QueuedPrompt>;
|
|
13
14
|
submitPrompt(text: string): Promise<TurnResult>;
|
|
14
15
|
interrupt(): Promise<void>;
|
|
15
16
|
respondPermission(): boolean;
|
|
16
17
|
eventsSince(): SessionEvent[];
|
|
17
18
|
subscribe(): () => void;
|
|
18
19
|
setControllerAttached(): void;
|
|
20
|
+
/**
|
|
21
|
+
* A tmux pane's exit is only visible through the record its shell wrapper
|
|
22
|
+
* writes; the runner owns that file and classifies it. Nothing observable
|
|
23
|
+
* from here, so say `null` rather than guess.
|
|
24
|
+
*/
|
|
25
|
+
exitResult(): ExitRecord | null;
|
|
19
26
|
close(): Promise<void>;
|
|
20
27
|
}
|
package/dist/session/tmux.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { SessionControlError, turnResult } from './types.js';
|
|
1
3
|
/** SessionHandle adapter for the existing tmux transport. */
|
|
2
4
|
export class TmuxSession {
|
|
3
5
|
name;
|
|
@@ -21,11 +23,31 @@ export class TmuxSession {
|
|
|
21
23
|
readiness: this.isAlive() ? 'idle' : 'failed',
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
|
-
async
|
|
26
|
+
async queuePrompt(text) {
|
|
25
27
|
if (!this.isAlive())
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
throw new SessionControlError('offline', `tmux pane for '${this.name}' is offline`);
|
|
29
|
+
try {
|
|
30
|
+
await this.tmux.sendText(this.name, text);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new SessionControlError('backend', error?.message ?? String(error));
|
|
34
|
+
}
|
|
35
|
+
// Keystrokes carry no terminal result: tmux cannot tell us how the turn ended.
|
|
36
|
+
return {
|
|
37
|
+
promptId: randomUUID(),
|
|
38
|
+
queuedBehind: 0,
|
|
39
|
+
completion: Promise.resolve(turnResult(true, 'inconclusive')),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async submitPrompt(text) {
|
|
43
|
+
try {
|
|
44
|
+
return await (await this.queuePrompt(text)).completion;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof SessionControlError)
|
|
48
|
+
return turnResult(false, 'failed', error.message);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
29
51
|
}
|
|
30
52
|
async interrupt() {
|
|
31
53
|
await this.tmux.sendKey(this.name, 'C-c');
|
|
@@ -40,6 +62,14 @@ export class TmuxSession {
|
|
|
40
62
|
return () => { };
|
|
41
63
|
}
|
|
42
64
|
setControllerAttached() { }
|
|
65
|
+
/**
|
|
66
|
+
* A tmux pane's exit is only visible through the record its shell wrapper
|
|
67
|
+
* writes; the runner owns that file and classifies it. Nothing observable
|
|
68
|
+
* from here, so say `null` rather than guess.
|
|
69
|
+
*/
|
|
70
|
+
exitResult() {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
43
73
|
async close() {
|
|
44
74
|
await this.tmux.kill(this.name);
|
|
45
75
|
}
|