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