@ours.network/fleet 0.15.3 → 0.15.5
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 +54 -19
- package/dist/application/fleet-query-service.js +3 -0
- package/dist/application/model-catalog.d.ts +20 -0
- package/dist/application/model-catalog.js +57 -0
- package/dist/application/role-creation-service.d.ts +7 -0
- package/dist/application/role-creation-service.js +21 -4
- package/dist/application/role-removal-service.d.ts +32 -0
- package/dist/application/role-removal-service.js +87 -0
- package/dist/application/role-repository.js +13 -1
- package/dist/application/session-control.d.ts +74 -0
- package/dist/application/session-control.js +66 -1
- package/dist/application/types.d.ts +18 -0
- package/dist/briefing.js +10 -1
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +4 -1
- package/dist/config.js +3 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +14 -5
- package/dist/fleet-proxy.js +3 -1
- package/dist/harness/claude-code.js +20 -3
- package/dist/harness/codex.js +14 -2
- package/dist/harness/types.d.ts +6 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/owner-channel/attachments.d.ts +11 -1
- package/dist/owner-channel/attachments.js +24 -3
- package/dist/owner-channel/channel.d.ts +2 -0
- package/dist/owner-channel/channel.js +134 -12
- package/dist/owner-channel/state.d.ts +1 -0
- package/dist/owner-channel/state.js +6 -3
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.js +7 -0
- package/dist/runner.js +2 -0
- package/dist/session/acp.d.ts +66 -1
- package/dist/session/acp.js +427 -22
- package/dist/session/arbiter.d.ts +10 -1
- package/dist/session/arbiter.js +24 -0
- package/dist/session/control.d.ts +28 -2
- package/dist/session/control.js +145 -5
- package/dist/session/conversation-normalizer.d.ts +34 -0
- package/dist/session/conversation-normalizer.js +356 -0
- package/dist/session/conversation-store.d.ts +88 -0
- package/dist/session/conversation-store.js +347 -0
- package/dist/session/conversation-types.d.ts +274 -0
- package/dist/session/conversation-types.js +1 -0
- package/dist/session/events.js +6 -1
- package/dist/session/types.d.ts +49 -0
- package/dist/spawn.d.ts +1 -0
- package/dist/spawn.js +9 -4
- package/dist/web/auth.d.ts +1 -1
- package/dist/web/fleet-config-service.d.ts +47 -0
- package/dist/web/fleet-config-service.js +204 -0
- package/dist/web/runtime.js +14 -1
- package/dist/web/server.d.ts +6 -0
- package/dist/web/server.js +181 -9
- package/dist/web/topology.d.ts +31 -0
- package/dist/web/topology.js +61 -0
- package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
- package/dist/web-app/assets/index-COg4Azq1.css +1 -0
- package/dist/web-app/assets/index-Cde9auW0.js +10 -0
- package/dist/web-app/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
- package/dist/web-app/assets/index-B6T8JLSd.js +0 -9
package/dist/session/acp.js
CHANGED
|
@@ -4,9 +4,56 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
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
|
+
import { normalizeSessionUpdate } from './conversation-normalizer.js';
|
|
8
|
+
import { ConversationEventStore } from './conversation-store.js';
|
|
7
9
|
import { SessionEvents } from './events.js';
|
|
8
10
|
import { SessionControlError, classifyChildExit, turnResult } from './types.js';
|
|
9
11
|
const CANCEL_SETTLE_GRACE_MS = 15_000;
|
|
12
|
+
/** A permission no human answered is eventually a decision nobody made. */
|
|
13
|
+
const PERMISSION_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
+
/** Spec §4.3: 10-15 s before a vanished controller triggers the unattended policy. */
|
|
15
|
+
const CONTROLLER_GRACE_MS = 12_000;
|
|
16
|
+
const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
|
|
17
|
+
const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
18
|
+
const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
|
|
19
|
+
/**
|
|
20
|
+
* Map typed prompt provenance to the conversation ledger's source vocabulary.
|
|
21
|
+
* Only operator-authored local sources may persist prompt bodies; external
|
|
22
|
+
* E2E bodies (owner channel, monitor wakes) and scheduled-loop content are
|
|
23
|
+
* recorded as digest/size placeholders (spec §8.3).
|
|
24
|
+
*/
|
|
25
|
+
function conversationSource(origin) {
|
|
26
|
+
switch (origin?.kind) {
|
|
27
|
+
case 'owner-admin-console': return { source: 'owner_admin_console', persistBody: true };
|
|
28
|
+
case 'startup': return { source: 'startup', persistBody: true };
|
|
29
|
+
case 'owner': return { source: 'owner_channel', persistBody: false };
|
|
30
|
+
case 'fleet-monitor': return { source: 'fleet_monitor', persistBody: false };
|
|
31
|
+
case 'scheduled-loop': return { source: 'scheduled_loop', persistBody: false };
|
|
32
|
+
case 'local-console':
|
|
33
|
+
default:
|
|
34
|
+
return { source: 'local_console', persistBody: true };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Server-generated typed provenance followed by the exact human-authored body. */
|
|
38
|
+
export function promptContentBlocks(text, origin) {
|
|
39
|
+
if (origin?.kind !== 'owner-admin-console')
|
|
40
|
+
return [{ type: 'text', text }];
|
|
41
|
+
return [{
|
|
42
|
+
type: 'resource_link',
|
|
43
|
+
uri: 'ours-fleet://prompt-provenance?source=owner_admin_console',
|
|
44
|
+
name: 'Direct owner admin console',
|
|
45
|
+
description: 'Server-authenticated paired console provenance; accompanying text is direct owner input.',
|
|
46
|
+
mimeType: 'application/vnd.ours-fleet.prompt-provenance+json',
|
|
47
|
+
}, { type: 'text', text }];
|
|
48
|
+
}
|
|
49
|
+
export function runtimeSelector(options, category) {
|
|
50
|
+
const option = options?.find(candidate => candidate.category === category);
|
|
51
|
+
if (!option || typeof option.currentValue !== 'string')
|
|
52
|
+
return undefined;
|
|
53
|
+
const choices = Array.isArray(option.options) ? option.options.flatMap(choice => 'options' in choice ? choice.options : [choice]) : [];
|
|
54
|
+
const selected = choices.find(choice => choice.value === option.currentValue);
|
|
55
|
+
return { value: option.currentValue, ...(selected?.name ? { label: selected.name } : {}) };
|
|
56
|
+
}
|
|
10
57
|
/**
|
|
11
58
|
* Classify an ACP `stopReason` into a terminal outcome. A refusal and a
|
|
12
59
|
* cancellation are the two ways a delivered prompt ends without being carried
|
|
@@ -29,6 +76,11 @@ export class AcpSession {
|
|
|
29
76
|
pid;
|
|
30
77
|
child;
|
|
31
78
|
events;
|
|
79
|
+
conversation;
|
|
80
|
+
/** New on every runner start; permission/turn IDs from prior generations are stale. */
|
|
81
|
+
sessionGeneration = randomUUID();
|
|
82
|
+
/** True while `session/load` replays history as ordinary updates. */
|
|
83
|
+
replaying = false;
|
|
32
84
|
sessionFile;
|
|
33
85
|
pendingPermissions = new Map();
|
|
34
86
|
connection;
|
|
@@ -40,7 +92,11 @@ export class AcpSession {
|
|
|
40
92
|
exit = null;
|
|
41
93
|
steeringSupported = false;
|
|
42
94
|
capabilities;
|
|
95
|
+
runtimeModel;
|
|
96
|
+
reasoningEffort;
|
|
43
97
|
controllerCount = 0;
|
|
98
|
+
/** Armed when the last controller detaches; unattended policy applies on fire. */
|
|
99
|
+
controllerGrace;
|
|
44
100
|
cancelEscalation;
|
|
45
101
|
activeTurn;
|
|
46
102
|
constructor(options, child, connection) {
|
|
@@ -49,6 +105,9 @@ export class AcpSession {
|
|
|
49
105
|
this.connection = connection;
|
|
50
106
|
this.pid = child.pid ?? -1;
|
|
51
107
|
this.events = new SessionEvents(join(options.stateDir, '.session-events.jsonl'));
|
|
108
|
+
this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
|
|
109
|
+
roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
|
|
110
|
+
});
|
|
52
111
|
this.sessionFile = join(options.stateDir, '.acp-session-id');
|
|
53
112
|
child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
|
|
54
113
|
child.once('exit', (code, signal) => {
|
|
@@ -61,6 +120,11 @@ export class AcpSession {
|
|
|
61
120
|
this.lastError = `ACP agent ${this.exit.detail}`;
|
|
62
121
|
}
|
|
63
122
|
this.events.emit('state', { status: 'failed', text: this.lastError });
|
|
123
|
+
this.conversation.appendSafe({
|
|
124
|
+
kind: 'session.state', sessionGeneration: this.sessionGeneration,
|
|
125
|
+
acpSessionId: this.sessionId,
|
|
126
|
+
payload: { status: 'failed', detail: this.lastError },
|
|
127
|
+
});
|
|
64
128
|
});
|
|
65
129
|
}
|
|
66
130
|
static async start(options) {
|
|
@@ -90,6 +154,7 @@ export class AcpSession {
|
|
|
90
154
|
instance = new AcpSession(options, child, connection);
|
|
91
155
|
try {
|
|
92
156
|
await instance.initialize();
|
|
157
|
+
instance.recoverOpenPrompts();
|
|
93
158
|
return instance;
|
|
94
159
|
}
|
|
95
160
|
catch (error) {
|
|
@@ -98,6 +163,42 @@ export class AcpSession {
|
|
|
98
163
|
throw error;
|
|
99
164
|
}
|
|
100
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Honest restart recovery (spec §5.3): a prompt that was admitted but never
|
|
168
|
+
* started is safe to dispatch again; a turn that had already started may
|
|
169
|
+
* have caused side effects, so it is closed as `unknown_after_restart` —
|
|
170
|
+
* never silently replayed.
|
|
171
|
+
*/
|
|
172
|
+
recoverOpenPrompts() {
|
|
173
|
+
for (const open of this.conversation.openPrompts()) {
|
|
174
|
+
if (open.sessionGeneration === this.sessionGeneration)
|
|
175
|
+
continue;
|
|
176
|
+
if (open.state === 'started') {
|
|
177
|
+
this.conversation.appendSafe({
|
|
178
|
+
kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
|
|
179
|
+
promptId: open.promptId, turnId: open.promptId,
|
|
180
|
+
payload: { outcome: 'unknown_after_restart' },
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (open.text === undefined) {
|
|
185
|
+
// Admitted, never started, and the body was deliberately not retained
|
|
186
|
+
// (external E2E source): there is nothing faithful left to dispatch.
|
|
187
|
+
this.conversation.appendSafe({
|
|
188
|
+
kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
|
|
189
|
+
promptId: open.promptId, turnId: open.promptId,
|
|
190
|
+
payload: { outcome: 'failed', stopReason: 'prompt-body-not-retained' },
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const text = open.text;
|
|
195
|
+
const promptId = open.promptId;
|
|
196
|
+
this.queueDepth++;
|
|
197
|
+
const run = this.promptTail.then(() => this.runPrompt(text, promptId));
|
|
198
|
+
this.promptTail = run.then(() => undefined, () => undefined);
|
|
199
|
+
void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
101
202
|
isAlive() {
|
|
102
203
|
return this.child.exitCode === null && !this.child.killed;
|
|
103
204
|
}
|
|
@@ -109,6 +210,9 @@ export class AcpSession {
|
|
|
109
210
|
sessionId: this.sessionId,
|
|
110
211
|
lastError: this.lastError,
|
|
111
212
|
pendingPermissionId: this.pendingPermissions.keys().next().value,
|
|
213
|
+
runtimeModel: this.runtimeModel,
|
|
214
|
+
reasoningEffort: this.reasoningEffort,
|
|
215
|
+
permissionMode: this.options.permissionMode,
|
|
112
216
|
};
|
|
113
217
|
}
|
|
114
218
|
/**
|
|
@@ -130,7 +234,9 @@ export class AcpSession {
|
|
|
130
234
|
return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
|
|
131
235
|
}
|
|
132
236
|
const promptId = randomUUID();
|
|
133
|
-
const queuedBehind = this.queueDepth
|
|
237
|
+
const queuedBehind = this.queueDepth;
|
|
238
|
+
this.admitToLedger(promptId, text, queuedBehind, options);
|
|
239
|
+
this.queueDepth++;
|
|
134
240
|
const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
|
|
135
241
|
this.promptTail = run.then(() => undefined, () => undefined);
|
|
136
242
|
const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
|
|
@@ -139,6 +245,66 @@ export class AcpSession {
|
|
|
139
245
|
});
|
|
140
246
|
return { promptId, queuedBehind, completion, origin: options.origin };
|
|
141
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Durably record a prompt admission BEFORE acceptance is returned. Browser
|
|
250
|
+
* admissions are transactional — a prompt the ledger cannot hold is refused,
|
|
251
|
+
* because an acknowledged-then-lost prompt is worse than an error. Every
|
|
252
|
+
* other source degrades to best-effort so the agent keeps working (§5.3).
|
|
253
|
+
*/
|
|
254
|
+
admitToLedger(promptId, text, queuedBehind, options) {
|
|
255
|
+
const { source, persistBody } = conversationSource(options.origin);
|
|
256
|
+
const bytes = Buffer.byteLength(text);
|
|
257
|
+
const draft = {
|
|
258
|
+
kind: 'prompt.admitted',
|
|
259
|
+
sessionGeneration: this.sessionGeneration,
|
|
260
|
+
acpSessionId: this.sessionId,
|
|
261
|
+
promptId, turnId: promptId, source,
|
|
262
|
+
...(options.origin?.kind === 'owner-admin-console' ? { commandId: options.origin.commandId } : {}),
|
|
263
|
+
...(options.actor ? { actor: options.actor } : {}),
|
|
264
|
+
payload: {
|
|
265
|
+
queuedBehind,
|
|
266
|
+
...(options.origin?.kind === 'owner' && options.origin.displayText !== undefined
|
|
267
|
+
? { displayText: { type: 'text', text: options.origin.displayText,
|
|
268
|
+
bytes: Buffer.byteLength(options.origin.displayText) } }
|
|
269
|
+
: {}),
|
|
270
|
+
...(persistBody
|
|
271
|
+
? { text: { type: 'text', text, bytes } }
|
|
272
|
+
: { external: { digest: ConversationEventStore.bodyDigest(text), bytes } }),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
if (source === 'owner_admin_console') {
|
|
276
|
+
try {
|
|
277
|
+
this.conversation.append(draft);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
throw new SessionControlError('backend', `conversation store cannot record the prompt: ${error.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
this.conversation.appendSafe(draft);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Idempotent browser prompt admission (control v3 `submit_prompt_v2`). */
|
|
288
|
+
async submitPromptBrowser(command) {
|
|
289
|
+
const bodyDigest = ConversationEventStore.bodyDigest(command.text);
|
|
290
|
+
const existing = this.conversation.receiptFor(command.commandId, bodyDigest);
|
|
291
|
+
if (existing)
|
|
292
|
+
return existing;
|
|
293
|
+
const queued = await this.queuePrompt(command.text, {
|
|
294
|
+
origin: { kind: 'owner-admin-console', commandId: command.commandId },
|
|
295
|
+
actor: { browserSession: command.actorBrowserSession },
|
|
296
|
+
});
|
|
297
|
+
const receipt = {
|
|
298
|
+
commandId: command.commandId,
|
|
299
|
+
promptId: queued.promptId,
|
|
300
|
+
state: queued.queuedBehind > 0 ? 'queued' : 'starting',
|
|
301
|
+
queuedBehind: queued.queuedBehind,
|
|
302
|
+
acceptedAt: new Date().toISOString(),
|
|
303
|
+
eventCursor: this.conversation.lastCursor() ?? '0',
|
|
304
|
+
};
|
|
305
|
+
this.conversation.recordReceipt(command.commandId, receipt, bodyDigest);
|
|
306
|
+
return receipt;
|
|
307
|
+
}
|
|
142
308
|
async submitPrompt(text, options = {}) {
|
|
143
309
|
try {
|
|
144
310
|
return await (await this.queuePrompt(text, options)).completion;
|
|
@@ -168,6 +334,11 @@ export class AcpSession {
|
|
|
168
334
|
throw error;
|
|
169
335
|
}
|
|
170
336
|
if (active) {
|
|
337
|
+
this.conversation.appendSafe({
|
|
338
|
+
kind: 'prompt.interrupt_requested', sessionGeneration: this.sessionGeneration,
|
|
339
|
+
acpSessionId: this.sessionId, promptId: active.id, turnId: active.id,
|
|
340
|
+
payload: { cancellationSource: source },
|
|
341
|
+
});
|
|
171
342
|
if (this.cancelEscalation)
|
|
172
343
|
clearTimeout(this.cancelEscalation);
|
|
173
344
|
const turnId = active.id;
|
|
@@ -181,9 +352,8 @@ export class AcpSession {
|
|
|
181
352
|
}, this.options.cancelGraceMs ?? CANCEL_SETTLE_GRACE_MS);
|
|
182
353
|
this.cancelEscalation.unref?.();
|
|
183
354
|
}
|
|
184
|
-
for (const pending of this.pendingPermissions
|
|
185
|
-
|
|
186
|
-
this.pendingPermissions.clear();
|
|
355
|
+
for (const [permissionId, pending] of [...this.pendingPermissions])
|
|
356
|
+
this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the turn was cancelled while this request was pending');
|
|
187
357
|
}
|
|
188
358
|
respondPermission(permissionId, optionId) {
|
|
189
359
|
const pending = this.pendingPermissions.get(permissionId);
|
|
@@ -191,20 +361,39 @@ export class AcpSession {
|
|
|
191
361
|
if (!pending || !chosen)
|
|
192
362
|
return false;
|
|
193
363
|
this.pendingPermissions.delete(permissionId);
|
|
364
|
+
if (pending.expiry)
|
|
365
|
+
clearTimeout(pending.expiry);
|
|
194
366
|
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
|
367
|
+
const decision = chosen.kind.startsWith('reject') ? 'denied' : 'allowed';
|
|
195
368
|
this.events.emit('permission', {
|
|
196
369
|
turnId: this.activeTurn?.id,
|
|
197
370
|
origin: this.activeTurn?.origin,
|
|
198
371
|
permissionId,
|
|
199
372
|
status: 'completed',
|
|
200
|
-
decision
|
|
373
|
+
decision,
|
|
201
374
|
decisionSource: 'manual',
|
|
202
375
|
reason: `answered from an attached controller (${chosen.kind})`,
|
|
203
376
|
optionId,
|
|
204
377
|
});
|
|
378
|
+
this.conversation.appendSafe({
|
|
379
|
+
kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
|
|
380
|
+
acpSessionId: this.sessionId, permissionId,
|
|
381
|
+
promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
|
|
382
|
+
payload: { decision, decisionSource: 'manual', optionId },
|
|
383
|
+
});
|
|
205
384
|
this.readiness = 'running';
|
|
206
385
|
return true;
|
|
207
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* A v2 decision binds to the session generation it was shown under. A stale
|
|
389
|
+
* generation, an already-settled request, or an unknown option are all the
|
|
390
|
+
* same answer: someone else's decision (or a restart) got there first.
|
|
391
|
+
*/
|
|
392
|
+
respondPermissionV2(permissionId, optionId, sessionGeneration) {
|
|
393
|
+
if (sessionGeneration !== this.sessionGeneration)
|
|
394
|
+
return 'stale';
|
|
395
|
+
return this.respondPermission(permissionId, optionId) ? 'accepted' : 'stale';
|
|
396
|
+
}
|
|
208
397
|
eventsSince(seq) {
|
|
209
398
|
return this.events.since(seq);
|
|
210
399
|
}
|
|
@@ -212,7 +401,76 @@ export class AcpSession {
|
|
|
212
401
|
return this.events.subscribe(listener);
|
|
213
402
|
}
|
|
214
403
|
setControllerAttached(attached) {
|
|
404
|
+
const before = this.controllerCount;
|
|
215
405
|
this.controllerCount = Math.max(0, this.controllerCount + (attached ? 1 : -1));
|
|
406
|
+
if (attached) {
|
|
407
|
+
if (this.controllerGrace)
|
|
408
|
+
clearTimeout(this.controllerGrace);
|
|
409
|
+
this.controllerGrace = undefined;
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// The LAST controller just walked away with permissions possibly pending.
|
|
413
|
+
// Nothing is decided yet: a reconnect within the grace keeps everything
|
|
414
|
+
// alive; only its expiry hands the pendings to the unattended policy.
|
|
415
|
+
if (before > 0 && this.controllerCount === 0 && this.pendingPermissions.size > 0)
|
|
416
|
+
this.armControllerGrace();
|
|
417
|
+
}
|
|
418
|
+
armControllerGrace() {
|
|
419
|
+
if (this.controllerGrace)
|
|
420
|
+
clearTimeout(this.controllerGrace);
|
|
421
|
+
this.controllerGrace = setTimeout(() => {
|
|
422
|
+
this.controllerGrace = undefined;
|
|
423
|
+
if (this.controllerCount > 0 || this.options.permissions.unattended !== 'deny')
|
|
424
|
+
return;
|
|
425
|
+
for (const [permissionId, pending] of [...this.pendingPermissions])
|
|
426
|
+
this.settlePendingAutomatically(permissionId, pending, 'denied', 'permissions.unattended=deny', 'the last attached controller disconnected and the grace period expired');
|
|
427
|
+
}, this.options.controllerGraceMs ?? CONTROLLER_GRACE_MS);
|
|
428
|
+
this.controllerGrace.unref?.();
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Settle one pending request without a human decision — unattended policy,
|
|
432
|
+
* expiry, or cancellation — and leave the same durable evidence a manual
|
|
433
|
+
* decision would. A denial selects the agent's own one-shot reject option;
|
|
434
|
+
* everything else resolves as cancelled toward the agent.
|
|
435
|
+
*/
|
|
436
|
+
settlePendingAutomatically(permissionId, pending, decision, policy, reason) {
|
|
437
|
+
if (!this.pendingPermissions.delete(permissionId))
|
|
438
|
+
return;
|
|
439
|
+
if (pending.expiry)
|
|
440
|
+
clearTimeout(pending.expiry);
|
|
441
|
+
const rejectOption = decision === 'denied'
|
|
442
|
+
? pending.options.find(option => option.kind === 'reject_once')
|
|
443
|
+
?? pending.options.find(option => option.kind === 'reject_always')
|
|
444
|
+
: undefined;
|
|
445
|
+
pending.resolve(rejectOption
|
|
446
|
+
? { outcome: { outcome: 'selected', optionId: rejectOption.optionId } }
|
|
447
|
+
: { outcome: { outcome: 'cancelled' } });
|
|
448
|
+
const settled = decision === 'denied' && !rejectOption ? 'cancelled' : decision;
|
|
449
|
+
this.events.emit('permission', {
|
|
450
|
+
turnId: this.activeTurn?.id,
|
|
451
|
+
origin: this.activeTurn?.origin,
|
|
452
|
+
permissionId,
|
|
453
|
+
toolCallId: pending.toolCallId,
|
|
454
|
+
status: 'completed',
|
|
455
|
+
decision: settled === 'expired' ? 'cancelled' : settled,
|
|
456
|
+
decisionSource: 'automatic',
|
|
457
|
+
policy,
|
|
458
|
+
reason,
|
|
459
|
+
optionId: rejectOption?.optionId,
|
|
460
|
+
});
|
|
461
|
+
this.conversation.appendSafe({
|
|
462
|
+
kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
|
|
463
|
+
acpSessionId: this.sessionId, permissionId,
|
|
464
|
+
promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
|
|
465
|
+
toolCallId: pending.toolCallId,
|
|
466
|
+
payload: {
|
|
467
|
+
decision: settled, decisionSource: 'automatic',
|
|
468
|
+
...(policy ? { policy } : {}), reason,
|
|
469
|
+
...(rejectOption ? { optionId: rejectOption.optionId } : {}),
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
if (this.pendingPermissions.size === 0 && this.readiness === 'awaiting_permission')
|
|
473
|
+
this.readiness = 'running';
|
|
216
474
|
}
|
|
217
475
|
exitResult() {
|
|
218
476
|
return this.exit;
|
|
@@ -221,15 +479,22 @@ export class AcpSession {
|
|
|
221
479
|
if (this.cancelEscalation)
|
|
222
480
|
clearTimeout(this.cancelEscalation);
|
|
223
481
|
this.cancelEscalation = undefined;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.
|
|
482
|
+
if (this.controllerGrace)
|
|
483
|
+
clearTimeout(this.controllerGrace);
|
|
484
|
+
this.controllerGrace = undefined;
|
|
485
|
+
for (const [permissionId, pending] of [...this.pendingPermissions])
|
|
486
|
+
this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
|
|
227
487
|
if (this.sessionId && this.capabilities?.sessionCapabilities?.close != null) {
|
|
228
488
|
await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.sessionId }).catch(() => undefined);
|
|
229
489
|
}
|
|
230
490
|
this.connection.close();
|
|
231
491
|
if (this.isAlive())
|
|
232
492
|
this.child.kill('SIGTERM');
|
|
493
|
+
this.conversation.appendSafe({
|
|
494
|
+
kind: 'session.state', sessionGeneration: this.sessionGeneration,
|
|
495
|
+
acpSessionId: this.sessionId, payload: { status: 'offline' },
|
|
496
|
+
});
|
|
497
|
+
this.conversation.close();
|
|
233
498
|
}
|
|
234
499
|
async initialize() {
|
|
235
500
|
const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
|
|
@@ -247,19 +512,29 @@ export class AcpSession {
|
|
|
247
512
|
? readFileSync(this.sessionFile, 'utf8').trim()
|
|
248
513
|
: '';
|
|
249
514
|
if (persisted && this.capabilities?.sessionCapabilities?.resume != null) {
|
|
250
|
-
await this.connection.agent.request(acp.methods.agent.session.resume, {
|
|
515
|
+
const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
|
|
251
516
|
sessionId: persisted,
|
|
252
517
|
cwd: this.options.cwd,
|
|
253
518
|
mcpServers: [],
|
|
254
519
|
});
|
|
520
|
+
this.captureRuntimeMetadata(resumed.configOptions);
|
|
255
521
|
this.sessionId = persisted;
|
|
256
522
|
}
|
|
257
523
|
else if (persisted && this.capabilities?.loadSession) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
524
|
+
// `session/load` replays prior history as ordinary updates before the
|
|
525
|
+
// response; those records carry `agent_replay` provenance, never `agent`.
|
|
526
|
+
this.replaying = true;
|
|
527
|
+
try {
|
|
528
|
+
const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
|
|
529
|
+
sessionId: persisted,
|
|
530
|
+
cwd: this.options.cwd,
|
|
531
|
+
mcpServers: [],
|
|
532
|
+
});
|
|
533
|
+
this.captureRuntimeMetadata(loaded.configOptions);
|
|
534
|
+
}
|
|
535
|
+
finally {
|
|
536
|
+
this.replaying = false;
|
|
537
|
+
}
|
|
263
538
|
this.sessionId = persisted;
|
|
264
539
|
}
|
|
265
540
|
else {
|
|
@@ -268,6 +543,7 @@ export class AcpSession {
|
|
|
268
543
|
mcpServers: [],
|
|
269
544
|
});
|
|
270
545
|
this.sessionId = created.sessionId;
|
|
546
|
+
this.captureRuntimeMetadata(created.configOptions);
|
|
271
547
|
}
|
|
272
548
|
writeFileSync(this.sessionFile, this.sessionId + '\n', { mode: 0o600 });
|
|
273
549
|
// Deliver the configured permission mode whichever way the session came up
|
|
@@ -288,6 +564,14 @@ export class AcpSession {
|
|
|
288
564
|
}
|
|
289
565
|
this.readiness = 'idle';
|
|
290
566
|
this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
|
|
567
|
+
this.conversation.appendSafe({
|
|
568
|
+
kind: 'session.state', sessionGeneration: this.sessionGeneration,
|
|
569
|
+
acpSessionId: this.sessionId, payload: { status: 'idle' },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
captureRuntimeMetadata(options) {
|
|
573
|
+
this.runtimeModel = runtimeSelector(options, 'model');
|
|
574
|
+
this.reasoningEffort = runtimeSelector(options, 'thought_level');
|
|
291
575
|
}
|
|
292
576
|
async runPrompt(text, turnId = randomUUID(), origin) {
|
|
293
577
|
if (!this.sessionId || !this.isAlive())
|
|
@@ -295,18 +579,32 @@ export class AcpSession {
|
|
|
295
579
|
this.readiness = 'running';
|
|
296
580
|
this.activeTurn = { id: turnId, output: '', origin };
|
|
297
581
|
this.events.emit('state', { turnId, status: 'running', origin });
|
|
582
|
+
this.conversation.appendSafe({
|
|
583
|
+
kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
|
|
584
|
+
acpSessionId: this.sessionId, promptId: turnId, turnId,
|
|
585
|
+
source: conversationSource(origin).source, payload: {},
|
|
586
|
+
});
|
|
298
587
|
try {
|
|
299
588
|
const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
|
|
300
589
|
sessionId: this.sessionId,
|
|
301
|
-
prompt:
|
|
590
|
+
prompt: promptContentBlocks(text, origin),
|
|
302
591
|
});
|
|
303
592
|
this.readiness = 'idle';
|
|
593
|
+
const cancellationSource = this.activeTurn?.id === turnId
|
|
594
|
+
? this.activeTurn.cancellationSource : undefined;
|
|
304
595
|
this.events.emit('turn_stop', {
|
|
305
|
-
turnId, stopReason: response.stopReason, origin,
|
|
306
|
-
cancellationSource: this.activeTurn?.id === turnId
|
|
307
|
-
? this.activeTurn.cancellationSource : undefined,
|
|
596
|
+
turnId, stopReason: response.stopReason, origin, cancellationSource,
|
|
308
597
|
});
|
|
309
598
|
this.events.emit('state', { status: 'idle' });
|
|
599
|
+
this.conversation.appendSafe({
|
|
600
|
+
kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
|
|
601
|
+
acpSessionId: this.sessionId, promptId: turnId, turnId,
|
|
602
|
+
payload: {
|
|
603
|
+
outcome: classifyStopReason(response.stopReason),
|
|
604
|
+
stopReason: response.stopReason,
|
|
605
|
+
...(cancellationSource ? { cancellationSource } : {}),
|
|
606
|
+
},
|
|
607
|
+
});
|
|
310
608
|
// The prompt was accepted either way — the agent answered. Whether the
|
|
311
609
|
// turn SUCCEEDED is a separate question, and only `stopReason` answers it.
|
|
312
610
|
return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined, this.activeTurn?.id === turnId ? this.activeTurn.cancellationSource : undefined);
|
|
@@ -321,6 +619,11 @@ export class AcpSession {
|
|
|
321
619
|
});
|
|
322
620
|
if (this.isAlive())
|
|
323
621
|
this.events.emit('state', { status: 'idle' });
|
|
622
|
+
this.conversation.appendSafe({
|
|
623
|
+
kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
|
|
624
|
+
acpSessionId: this.sessionId, promptId: turnId, turnId,
|
|
625
|
+
payload: { outcome: 'failed', stopReason: this.lastError },
|
|
626
|
+
});
|
|
324
627
|
return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
|
|
325
628
|
}
|
|
326
629
|
finally {
|
|
@@ -368,7 +671,10 @@ export class AcpSession {
|
|
|
368
671
|
const option = choose(['allow_always', 'allow_once']);
|
|
369
672
|
return Promise.resolve(this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`));
|
|
370
673
|
}
|
|
371
|
-
|
|
674
|
+
// A live grace window still counts as attended: the controller may be
|
|
675
|
+
// mid-reconnect, and denying instantly is exactly what the grace prevents.
|
|
676
|
+
const unattended = this.controllerCount === 0 && !this.controllerGrace
|
|
677
|
+
&& this.options.permissions.unattended === 'deny';
|
|
372
678
|
if (this.options.permissions.approval === 'deny' || unattended) {
|
|
373
679
|
// reject_once FIRST: `reject_always` teaches the agent a standing rule from
|
|
374
680
|
// a decision no human made, so one unattended denial would silently disable
|
|
@@ -379,6 +685,8 @@ export class AcpSession {
|
|
|
379
685
|
: 'the role denies every permission request by policy'));
|
|
380
686
|
}
|
|
381
687
|
const permissionId = randomUUID();
|
|
688
|
+
const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
|
|
689
|
+
const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
|
|
382
690
|
this.readiness = 'awaiting_permission';
|
|
383
691
|
this.events.emit('permission', {
|
|
384
692
|
turnId: this.activeTurn?.id,
|
|
@@ -395,8 +703,34 @@ export class AcpSession {
|
|
|
395
703
|
kind: option.kind,
|
|
396
704
|
})),
|
|
397
705
|
});
|
|
706
|
+
this.conversation.appendSafe({
|
|
707
|
+
kind: 'permission.requested', sessionGeneration: this.sessionGeneration,
|
|
708
|
+
acpSessionId: this.sessionId, permissionId,
|
|
709
|
+
promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
|
|
710
|
+
toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
711
|
+
payload: {
|
|
712
|
+
toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
713
|
+
title: scheduledTurn(this.activeTurn)
|
|
714
|
+
? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
|
|
715
|
+
options: params.options.map(option => ({
|
|
716
|
+
optionId: option.optionId,
|
|
717
|
+
name: scheduledTurn(this.activeTurn) ? option.kind : option.name,
|
|
718
|
+
kind: option.kind,
|
|
719
|
+
})),
|
|
720
|
+
expiresAt,
|
|
721
|
+
},
|
|
722
|
+
});
|
|
398
723
|
return new Promise(resolve => {
|
|
399
|
-
|
|
724
|
+
const pending = {
|
|
725
|
+
options: params.options, resolve,
|
|
726
|
+
toolCallId: scheduledTurn(this.activeTurn)
|
|
727
|
+
? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
728
|
+
};
|
|
729
|
+
pending.expiry = setTimeout(() => {
|
|
730
|
+
this.settlePendingAutomatically(permissionId, pending, 'expired', undefined, `no decision arrived within ${Math.round(timeoutMs / 1000)}s`);
|
|
731
|
+
}, timeoutMs);
|
|
732
|
+
pending.expiry.unref?.();
|
|
733
|
+
this.pendingPermissions.set(permissionId, pending);
|
|
400
734
|
});
|
|
401
735
|
}
|
|
402
736
|
/**
|
|
@@ -426,6 +760,17 @@ export class AcpSession {
|
|
|
426
760
|
kind: o.kind,
|
|
427
761
|
})),
|
|
428
762
|
});
|
|
763
|
+
this.conversation.appendSafe({
|
|
764
|
+
kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
|
|
765
|
+
acpSessionId: this.sessionId, permissionId: randomUUID(),
|
|
766
|
+
promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
|
|
767
|
+
toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
|
|
768
|
+
payload: {
|
|
769
|
+
decision: settled,
|
|
770
|
+
decisionSource: 'automatic', policy, reason,
|
|
771
|
+
...(option ? { optionId: option.optionId } : {}),
|
|
772
|
+
},
|
|
773
|
+
});
|
|
429
774
|
return option
|
|
430
775
|
? { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
431
776
|
: { outcome: { outcome: 'cancelled' } };
|
|
@@ -448,17 +793,29 @@ export class AcpSession {
|
|
|
448
793
|
}
|
|
449
794
|
recordUpdate(update) {
|
|
450
795
|
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
796
|
+
const messagePhase = update.sessionUpdate === 'agent_message_chunk'
|
|
797
|
+
? this.codexMessagePhase(update) : undefined;
|
|
798
|
+
this.recordConversationUpdate(update, scheduled, messagePhase === 'commentary');
|
|
799
|
+
if (update.sessionUpdate === 'config_option_update')
|
|
800
|
+
this.captureRuntimeMetadata(update.configOptions);
|
|
451
801
|
switch (update.sessionUpdate) {
|
|
452
|
-
case 'agent_message_chunk':
|
|
453
|
-
|
|
802
|
+
case 'agent_message_chunk': {
|
|
803
|
+
const phase = messagePhase;
|
|
804
|
+
if (this.activeTurn && update.content.type === 'text'
|
|
805
|
+
&& phase !== 'commentary' && phase !== 'ambiguous')
|
|
454
806
|
this.activeTurn.output += update.content.text;
|
|
455
807
|
this.events.emit('agent_text', {
|
|
456
808
|
turnId: this.activeTurn?.id,
|
|
457
809
|
origin: this.activeTurn?.origin,
|
|
458
810
|
text: scheduled ? '[scheduled-loop output redacted]'
|
|
459
811
|
: update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
|
|
812
|
+
...(phase === 'commentary' || phase === 'final_answer'
|
|
813
|
+
? { messagePhase: phase } : {}),
|
|
814
|
+
...(typeof update.messageId === 'string' ? { messageId: update.messageId } : {}),
|
|
815
|
+
...(this.replaying ? { replayed: true } : {}),
|
|
460
816
|
});
|
|
461
817
|
break;
|
|
818
|
+
}
|
|
462
819
|
case 'agent_thought_chunk':
|
|
463
820
|
this.events.emit('thought', {
|
|
464
821
|
turnId: this.activeTurn?.id,
|
|
@@ -489,6 +846,54 @@ export class AcpSession {
|
|
|
489
846
|
break;
|
|
490
847
|
}
|
|
491
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Codex ACP's phase extension is the only currently supported visibility
|
|
851
|
+
* signal. Never infer commentary from text, message order, or unknown meta.
|
|
852
|
+
*/
|
|
853
|
+
codexMessagePhase(update) {
|
|
854
|
+
const meta = update._meta;
|
|
855
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta))
|
|
856
|
+
return undefined;
|
|
857
|
+
const codex = meta.codex;
|
|
858
|
+
if (!codex || typeof codex !== 'object' || Array.isArray(codex))
|
|
859
|
+
return undefined;
|
|
860
|
+
const phase = codex.phase;
|
|
861
|
+
if (phase === undefined)
|
|
862
|
+
return undefined;
|
|
863
|
+
return phase === 'commentary' || phase === 'final_answer' ? phase : 'ambiguous';
|
|
864
|
+
}
|
|
865
|
+
/** Normalize every ACP update losslessly into the durable ledger. */
|
|
866
|
+
recordConversationUpdate(update, scheduled, commentary = false) {
|
|
867
|
+
const normalized = normalizeSessionUpdate(update, scheduled ? { redactText: SCHEDULED_LOOP_REDACTION }
|
|
868
|
+
: commentary ? { redactText: OWNER_COMMENTARY_REDACTION } : {});
|
|
869
|
+
this.conversation.appendSafe({
|
|
870
|
+
kind: normalized.kind,
|
|
871
|
+
sessionGeneration: this.sessionGeneration,
|
|
872
|
+
acpSessionId: this.sessionId,
|
|
873
|
+
...(this.activeTurn ? { promptId: this.activeTurn.id, turnId: this.activeTurn.id } : {}),
|
|
874
|
+
...(normalized.messageId ? { messageId: normalized.messageId } : {}),
|
|
875
|
+
...(normalized.toolCallId ? { toolCallId: normalized.toolCallId } : {}),
|
|
876
|
+
source: this.replaying ? 'agent_replay' : 'agent',
|
|
877
|
+
payload: normalized.payload,
|
|
878
|
+
...(normalized.adapterMeta ? { adapterMeta: normalized.adapterMeta } : {}),
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
// ── conversation ledger access (SessionHandle) ─────────────────────────────
|
|
882
|
+
conversationPage(request = {}) {
|
|
883
|
+
return { ...this.conversation.page(request), snapshot: this.conversationSnapshot() };
|
|
884
|
+
}
|
|
885
|
+
conversationSnapshot() {
|
|
886
|
+
return {
|
|
887
|
+
sessionGeneration: this.sessionGeneration,
|
|
888
|
+
readiness: this.isAlive() ? this.readiness : 'offline',
|
|
889
|
+
queueDepth: this.queueDepth,
|
|
890
|
+
pendingPermissionIds: [...this.pendingPermissions.keys()],
|
|
891
|
+
...(this.conversation.degraded ? { historyDegraded: true } : {}),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
subscribeConversation(listener) {
|
|
895
|
+
return this.conversation.subscribe(listener);
|
|
896
|
+
}
|
|
492
897
|
fail(error) {
|
|
493
898
|
this.lastError = error?.message ?? String(error);
|
|
494
899
|
this.readiness = 'failed';
|