@ours.network/fleet 0.18.0-nightly.6 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -43
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/role-creation-service.js +3 -1
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.js +9 -2
- package/dist/build-info.json +5 -5
- package/dist/cli.js +37 -7
- package/dist/config.d.ts +6 -3
- package/dist/config.js +28 -14
- package/dist/creation.d.ts +14 -15
- package/dist/creation.js +19 -13
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +92 -33
- package/dist/doctor.d.ts +1 -5
- package/dist/doctor.js +11 -18
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +200 -11
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +70 -12
- package/dist/harness/types.d.ts +54 -4
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +69 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/monitor.js +1 -1
- package/dist/ops.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -25
- package/dist/owner-channel/attachments.js +5 -61
- package/dist/owner-channel/channel.d.ts +30 -29
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +145 -0
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +237 -85
- package/dist/session/acp.d.ts +104 -0
- package/dist/session/acp.js +213 -10
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/conversation-normalizer.d.ts +6 -0
- package/dist/session/conversation-normalizer.js +153 -10
- package/dist/session/conversation-types.d.ts +23 -4
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.js +29 -17
- package/dist/supervisor/systemd.js +2 -29
- package/dist/watchdog/briefing.js +7 -0
- package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
- package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +7 -1
- package/dist/worklog.js +191 -39
- package/package.json +1 -3
- package/dist/owner-channel/message-recovery.d.ts +0 -25
- package/dist/owner-channel/message-recovery.js +0 -114
- package/dist/owner-channel/ours-client.d.ts +0 -148
- package/dist/owner-channel/ours-client.js +0 -231
package/dist/session/acp.js
CHANGED
|
@@ -16,6 +16,21 @@ const PERMISSION_TIMEOUT_MS = 10 * 60_000;
|
|
|
16
16
|
const CONTROLLER_GRACE_MS = 12_000;
|
|
17
17
|
/** Bound safe-boundary waiting without turning a hung tool into cancellation. */
|
|
18
18
|
export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
|
|
19
|
+
/**
|
|
20
|
+
* How long a steering-started turn is presumed to still own the adapter after
|
|
21
|
+
* its last update. Such a turn has no prompt id, so it never reports a
|
|
22
|
+
* stopReason and there is no exact end to observe — silence is the only signal
|
|
23
|
+
* available, and this is the bound that turns it into a decision.
|
|
24
|
+
*
|
|
25
|
+
* Sized from the fleet's own scheduled-run history: across 1513 completed
|
|
26
|
+
* scheduled runs the longest silence WITHIN a working turn was 120.2 s (p99
|
|
27
|
+
* 41.0 s; 5 runs above 60 s). A shorter grace would release the lease while the
|
|
28
|
+
* adapter is still working and re-admit a prompt into a busy turn, which is the
|
|
29
|
+
* FLEET-003 failure itself. The costs are deliberately asymmetric: holding too
|
|
30
|
+
* long skips one best-effort maintenance tick, releasing too early SIGTERMs a
|
|
31
|
+
* live role.
|
|
32
|
+
*/
|
|
33
|
+
export const STEERING_OCCUPANCY_IDLE_MS = 150_000;
|
|
19
34
|
const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
|
|
20
35
|
const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
|
|
21
36
|
const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
@@ -193,6 +208,8 @@ export class AcpSession {
|
|
|
193
208
|
child;
|
|
194
209
|
events;
|
|
195
210
|
conversation;
|
|
211
|
+
/** Cursor before this runner generation began; older durable events stay off the live console. */
|
|
212
|
+
conversationStartCursor;
|
|
196
213
|
/** New on every runner start; permission/turn IDs from prior generations are stale. */
|
|
197
214
|
sessionGeneration = randomUUID();
|
|
198
215
|
/** True while `session/load` replays history as ordinary updates. */
|
|
@@ -202,6 +219,12 @@ export class AcpSession {
|
|
|
202
219
|
connection;
|
|
203
220
|
sessionId;
|
|
204
221
|
readiness = 'starting';
|
|
222
|
+
/**
|
|
223
|
+
* Last non-replayed session update from the agent. `readiness` cannot answer
|
|
224
|
+
* "is this agent working" for a steered turn (FLEET-002), and this is the
|
|
225
|
+
* evidence that can.
|
|
226
|
+
*/
|
|
227
|
+
lastUpdateAt;
|
|
205
228
|
lastError;
|
|
206
229
|
promptTail = Promise.resolve();
|
|
207
230
|
queueDepth = 0;
|
|
@@ -217,6 +240,13 @@ export class AcpSession {
|
|
|
217
240
|
cancelEscalation;
|
|
218
241
|
cancelForceKill;
|
|
219
242
|
cancelRecoveryReason;
|
|
243
|
+
/**
|
|
244
|
+
* Held while a steering-started turn is believed to own the adapter. It is a
|
|
245
|
+
* lease, not a latch: `steeringRelease` always fires, so the role can never be
|
|
246
|
+
* stranded busy by a wake whose turn ended without telling anyone.
|
|
247
|
+
*/
|
|
248
|
+
steeringOccupied = false;
|
|
249
|
+
steeringRelease;
|
|
220
250
|
/**
|
|
221
251
|
* Rejects the moment the adapter process is gone. Every in-flight ACP request
|
|
222
252
|
* races it, so a dead adapter can never leave a turn — and therefore a
|
|
@@ -237,6 +267,7 @@ export class AcpSession {
|
|
|
237
267
|
this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
|
|
238
268
|
roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
|
|
239
269
|
});
|
|
270
|
+
this.conversationStartCursor = this.conversation.lastCursor();
|
|
240
271
|
this.sessionFile = join(options.stateDir, '.acp-session-id');
|
|
241
272
|
this.terminated = new Promise((_resolve, reject) => { this.terminate = reject; });
|
|
242
273
|
// Nothing awaits this promise until a request races it; an unobserved
|
|
@@ -247,6 +278,7 @@ export class AcpSession {
|
|
|
247
278
|
if (this.cancelForceKill)
|
|
248
279
|
clearTimeout(this.cancelForceKill);
|
|
249
280
|
this.cancelForceKill = undefined;
|
|
281
|
+
this.releaseSteeringOccupancy('adapter exited');
|
|
250
282
|
// Record the child's real exit code/signal. The tmux path can only see a
|
|
251
283
|
// shell's `$?`; here the truth is available, so keep it.
|
|
252
284
|
const classified = classifyChildExit(code, signal);
|
|
@@ -347,17 +379,60 @@ export class AcpSession {
|
|
|
347
379
|
// terminal fact (signal exits deliberately leave exitCode null).
|
|
348
380
|
return this.child.exitCode === null && (this.child.signalCode ?? null) === null;
|
|
349
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Take the occupancy lease for a turn the adapter started on its own behalf.
|
|
384
|
+
* Refreshed by every adapter update, so it tracks work actually happening
|
|
385
|
+
* rather than a fixed guess at how long a wake takes.
|
|
386
|
+
*/
|
|
387
|
+
holdSteeringOccupancy() {
|
|
388
|
+
if (this.closing || !this.isAlive())
|
|
389
|
+
return;
|
|
390
|
+
this.steeringOccupied = true;
|
|
391
|
+
this.refreshSteeringOccupancy();
|
|
392
|
+
}
|
|
393
|
+
refreshSteeringOccupancy() {
|
|
394
|
+
if (!this.steeringOccupied)
|
|
395
|
+
return;
|
|
396
|
+
if (this.steeringRelease)
|
|
397
|
+
clearTimeout(this.steeringRelease);
|
|
398
|
+
this.steeringRelease = setTimeout(() => this.releaseSteeringOccupancy('adapter silent'), this.options.steeringOccupancyIdleMs ?? STEERING_OCCUPANCY_IDLE_MS);
|
|
399
|
+
this.steeringRelease.unref?.();
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Every exit from occupancy comes through here, including the ones that are
|
|
403
|
+
* not the timer: a real turn boundary, close, and adapter exit. A lease that
|
|
404
|
+
* can leak is worse than the bug it fixes — it would leave the role reporting
|
|
405
|
+
* `running` forever and starve scheduled admission permanently.
|
|
406
|
+
*/
|
|
407
|
+
releaseSteeringOccupancy(reason) {
|
|
408
|
+
if (this.steeringRelease)
|
|
409
|
+
clearTimeout(this.steeringRelease);
|
|
410
|
+
this.steeringRelease = undefined;
|
|
411
|
+
if (!this.steeringOccupied)
|
|
412
|
+
return;
|
|
413
|
+
this.steeringOccupied = false;
|
|
414
|
+
this.options.log(`[${this.options.name}] steering-started turn no longer holds the adapter (${reason})`);
|
|
415
|
+
}
|
|
350
416
|
snapshot() {
|
|
351
417
|
return {
|
|
352
418
|
backend: 'acp',
|
|
353
419
|
alive: this.isAlive(),
|
|
354
|
-
|
|
420
|
+
// A steering-started turn is real work with no prompt id. Reporting the
|
|
421
|
+
// session idle while it runs is what let the arbiter admit a scheduled
|
|
422
|
+
// prompt into a busy adapter, whose `session/prompt` then never returned
|
|
423
|
+
// a stopReason and ended in a cancellation deadline and a SIGTERM.
|
|
424
|
+
readiness: this.readiness === 'idle' && this.steeringOccupied
|
|
425
|
+
? 'running' : this.readiness,
|
|
355
426
|
sessionId: this.sessionId,
|
|
356
427
|
lastError: this.lastError,
|
|
357
428
|
pendingPermissionId: this.pendingPermissions.keys().next().value,
|
|
358
429
|
runtimeModel: this.runtimeModel,
|
|
359
430
|
reasoningEffort: this.reasoningEffort,
|
|
360
431
|
permissionMode: this.options.permissionMode,
|
|
432
|
+
activity: {
|
|
433
|
+
activeToolCalls: this.activeToolCalls.size,
|
|
434
|
+
...(this.lastUpdateAt ? { lastUpdateAt: this.lastUpdateAt } : {}),
|
|
435
|
+
},
|
|
361
436
|
};
|
|
362
437
|
}
|
|
363
438
|
toolCall(toolCallId) {
|
|
@@ -526,15 +601,19 @@ export class AcpSession {
|
|
|
526
601
|
throw new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED);
|
|
527
602
|
if (this.closing || !this.sessionId || !this.isAlive())
|
|
528
603
|
throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
|
|
529
|
-
|
|
530
|
-
await this.
|
|
604
|
+
const delivery = options.interrupt
|
|
605
|
+
? await this.prepareInterruptingDelivery(options.interruptSource ?? 'local-console')
|
|
606
|
+
: undefined;
|
|
531
607
|
// Interrupting delivery must still use steering when supported. With no
|
|
532
608
|
// live turn, the extension starts one and acknowledges `startedNewTurn`
|
|
533
609
|
// immediately; a normal session/prompt would keep the monitor blocked until
|
|
534
610
|
// the entire wake-triggered turn terminated.
|
|
535
611
|
if (options.steer && this.steeringSupported) {
|
|
536
612
|
const promptId = randomUUID();
|
|
537
|
-
return {
|
|
613
|
+
return {
|
|
614
|
+
promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin,
|
|
615
|
+
...(delivery ? { delivery } : {}),
|
|
616
|
+
};
|
|
538
617
|
}
|
|
539
618
|
const promptId = randomUUID();
|
|
540
619
|
const queuedBehind = this.queueDepth;
|
|
@@ -546,7 +625,46 @@ export class AcpSession {
|
|
|
546
625
|
this.queueDepth = Math.max(0, this.queueDepth - 1);
|
|
547
626
|
return turnResult(false, 'failed', error?.message ?? String(error));
|
|
548
627
|
});
|
|
549
|
-
return {
|
|
628
|
+
return {
|
|
629
|
+
promptId, queuedBehind, completion, origin: options.origin,
|
|
630
|
+
delivery: delivery ?? (queuedBehind > 0 ? 'queued' : 'started'),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Prepare the session for a prompt that asked to pre-empt current work.
|
|
635
|
+
*
|
|
636
|
+
* The old behaviour was one unconditional `session/cancel` notification
|
|
637
|
+
* followed immediately by `session/prompt`. That is what produced the owner's
|
|
638
|
+
* "request failed before completion":
|
|
639
|
+
*
|
|
640
|
+
* - `cancelActive` only awaits settlement when `this.activeTurn` is set, and
|
|
641
|
+
* a turn the ADAPTER started (steering's `startedNewTurn`) is never tracked
|
|
642
|
+
* here. So the cancel raced the adapter's own transcript repair and the new
|
|
643
|
+
* prompt landed while the last assistant message still held an unresolved
|
|
644
|
+
* `tool_use` — rejected with `stop_reason=tool_use`.
|
|
645
|
+
* - With nothing running at all, it still sent the cancel, and the prompt
|
|
646
|
+
* landed on a bare interrupted user message — rejected with
|
|
647
|
+
* `stop_reason=null`.
|
|
648
|
+
*
|
|
649
|
+
* So: never cancel across a tool boundary, and never cancel something whose
|
|
650
|
+
* settlement cannot be awaited. Everything else is queued, which the ACP queue
|
|
651
|
+
* already does correctly. The returned state is what the caller may claim to a
|
|
652
|
+
* human — `interrupted` only when a turn really was cancelled.
|
|
653
|
+
*/
|
|
654
|
+
async prepareInterruptingDelivery(source) {
|
|
655
|
+
if (!this.sessionId)
|
|
656
|
+
return 'started';
|
|
657
|
+
// No fleet-tracked turn to await. Either the session is idle — cancelling it
|
|
658
|
+
// corrupts the transcript for no gain — or the adapter is running a turn
|
|
659
|
+
// fleet never started, whose settlement nothing here can wait for. Queue in
|
|
660
|
+
// both cases: the ACP queue already orders this correctly.
|
|
661
|
+
if (!this.activeTurn)
|
|
662
|
+
return this.activeToolCalls.size > 0 ? 'deferred' : 'started';
|
|
663
|
+
// A tracked turn IS safe to cancel: cancelActive settles pending permissions
|
|
664
|
+
// and awaits the turn's own settlement before this returns, so the prompt
|
|
665
|
+
// below cannot race the adapter's transcript repair.
|
|
666
|
+
await this.cancelActive(source);
|
|
667
|
+
return 'interrupted';
|
|
550
668
|
}
|
|
551
669
|
/**
|
|
552
670
|
* Durably record a prompt admission BEFORE acceptance is returned. Browser
|
|
@@ -855,6 +973,7 @@ export class AcpSession {
|
|
|
855
973
|
if (this.controllerGrace)
|
|
856
974
|
clearTimeout(this.controllerGrace);
|
|
857
975
|
this.controllerGrace = undefined;
|
|
976
|
+
this.releaseSteeringOccupancy('session closed');
|
|
858
977
|
for (const [permissionId, pending] of [...this.pendingPermissions])
|
|
859
978
|
this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
|
|
860
979
|
this.releaseAllTools();
|
|
@@ -872,6 +991,17 @@ export class AcpSession {
|
|
|
872
991
|
});
|
|
873
992
|
this.conversation.close();
|
|
874
993
|
}
|
|
994
|
+
/**
|
|
995
|
+
* The role's declared MCP servers, or `[]`.
|
|
996
|
+
*
|
|
997
|
+
* Sent on resume and load as well as on new: the agent builds its server set
|
|
998
|
+
* once per session, so a resumed session that omitted them would come back
|
|
999
|
+
* without the tools the role's config declares — which is exactly the shape of
|
|
1000
|
+
* silent drop this plumbing exists to end.
|
|
1001
|
+
*/
|
|
1002
|
+
declaredMcpServers() {
|
|
1003
|
+
return this.options.mcpServers ?? [];
|
|
1004
|
+
}
|
|
875
1005
|
async initialize() {
|
|
876
1006
|
const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
|
|
877
1007
|
protocolVersion: acp.PROTOCOL_VERSION,
|
|
@@ -891,7 +1021,7 @@ export class AcpSession {
|
|
|
891
1021
|
const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
|
|
892
1022
|
sessionId: persisted,
|
|
893
1023
|
cwd: this.options.cwd,
|
|
894
|
-
mcpServers:
|
|
1024
|
+
mcpServers: this.declaredMcpServers(),
|
|
895
1025
|
});
|
|
896
1026
|
this.captureRuntimeMetadata(resumed.configOptions);
|
|
897
1027
|
this.sessionId = persisted;
|
|
@@ -904,7 +1034,7 @@ export class AcpSession {
|
|
|
904
1034
|
const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
|
|
905
1035
|
sessionId: persisted,
|
|
906
1036
|
cwd: this.options.cwd,
|
|
907
|
-
mcpServers:
|
|
1037
|
+
mcpServers: this.declaredMcpServers(),
|
|
908
1038
|
});
|
|
909
1039
|
this.captureRuntimeMetadata(loaded.configOptions);
|
|
910
1040
|
}
|
|
@@ -916,7 +1046,8 @@ export class AcpSession {
|
|
|
916
1046
|
else {
|
|
917
1047
|
const created = await this.connection.agent.request(acp.methods.agent.session.new, {
|
|
918
1048
|
cwd: this.options.cwd,
|
|
919
|
-
mcpServers:
|
|
1049
|
+
mcpServers: this.declaredMcpServers(),
|
|
1050
|
+
...(this.options.sessionMeta ? { _meta: this.options.sessionMeta } : {}),
|
|
920
1051
|
});
|
|
921
1052
|
this.sessionId = created.sessionId;
|
|
922
1053
|
this.captureRuntimeMetadata(created.configOptions);
|
|
@@ -1013,6 +1144,10 @@ export class AcpSession {
|
|
|
1013
1144
|
}
|
|
1014
1145
|
finally {
|
|
1015
1146
|
this.releaseAllTools();
|
|
1147
|
+
// A turn this client owned has ended, so the adapter has reported a
|
|
1148
|
+
// boundary: whatever a steering call started before it is over too. This
|
|
1149
|
+
// is the release path that does not depend on the silence timer.
|
|
1150
|
+
this.releaseSteeringOccupancy('turn boundary');
|
|
1016
1151
|
if (this.activeTurn?.id === turnId) {
|
|
1017
1152
|
this.activeTurn.settle();
|
|
1018
1153
|
if (this.cancelEscalation)
|
|
@@ -1035,6 +1170,12 @@ export class AcpSession {
|
|
|
1035
1170
|
]);
|
|
1036
1171
|
if (response.outcome === 'failed')
|
|
1037
1172
|
return turnResult(false, 'failed', 'ACP steering failed');
|
|
1173
|
+
// `injected` joined a turn this client already owns and will settle.
|
|
1174
|
+
// `startedNewTurn` created one nobody owns: the adapter is working and
|
|
1175
|
+
// will never answer for it, so admission has to learn about it here or
|
|
1176
|
+
// not at all.
|
|
1177
|
+
if (response.outcome === 'startedNewTurn')
|
|
1178
|
+
this.holdSteeringOccupancy();
|
|
1038
1179
|
return turnResult(true, 'inconclusive', response.outcome);
|
|
1039
1180
|
}
|
|
1040
1181
|
catch (error) {
|
|
@@ -1062,6 +1203,17 @@ export class AcpSession {
|
|
|
1062
1203
|
// Permission is part of the tool lifecycle. Reserve before any policy or
|
|
1063
1204
|
// human decision so a monitor wake cannot slip between request and answer.
|
|
1064
1205
|
this.reservePermission(toolCallId, permissionId);
|
|
1206
|
+
if (this.isEffectiveCodexProtectedMcpApproval(params)) {
|
|
1207
|
+
// Protected MCP approval is already the tool's narrow gate. Never turn
|
|
1208
|
+
// this one decision into an adapter-wide standing grant.
|
|
1209
|
+
const option = choose(['allow_once']);
|
|
1210
|
+
const response = this.settleAutomatically(params, option, 'allowed', 'permissionMode.fleetMode=allow', 'the trusted Codex adapter authenticated a protected MCP approval request');
|
|
1211
|
+
if (option)
|
|
1212
|
+
this.allowPermission(toolCallId, permissionId);
|
|
1213
|
+
else
|
|
1214
|
+
this.releasePermission(toolCallId, permissionId);
|
|
1215
|
+
return Promise.resolve(response);
|
|
1216
|
+
}
|
|
1065
1217
|
if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
|
|
1066
1218
|
const option = choose(['allow_always', 'allow_once']);
|
|
1067
1219
|
const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
|
|
@@ -1188,7 +1340,34 @@ export class AcpSession {
|
|
|
1188
1340
|
const cwd = resolve(this.options.cwd);
|
|
1189
1341
|
return canonicallyWithin(cwd, locations.map(location => resolve(location.path)));
|
|
1190
1342
|
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Codex ACP 1.1.7 marks its protected MCP elicitation bridge on a locationless
|
|
1345
|
+
* execute request. The marker is meaningful only together with the runner's
|
|
1346
|
+
* independently supplied, adapter-authenticated metadata vocabulary and effective
|
|
1347
|
+
* mode: an arbitrary ACP process cannot gain this path by copying `_meta` alone.
|
|
1348
|
+
* Exact option ids/kinds bind recognition to the protected-MCP shape and keep
|
|
1349
|
+
* malformed requests on the ordinary fail-closed path.
|
|
1350
|
+
*/
|
|
1351
|
+
isEffectiveCodexProtectedMcpApproval(params) {
|
|
1352
|
+
const locations = params.toolCall.locations ?? [];
|
|
1353
|
+
return this.options.permissionMetadataSource === 'codex-acp'
|
|
1354
|
+
&& this.options.permissionMode?.fleetMode === 'allow'
|
|
1355
|
+
&& params.toolCall.kind === 'execute'
|
|
1356
|
+
&& params.toolCall.status === 'pending'
|
|
1357
|
+
&& locations.length === 0
|
|
1358
|
+
&& params._meta?.is_mcp_tool_approval === true
|
|
1359
|
+
&& params.options.some(option => option.optionId === 'allow_once' && option.kind === 'allow_once')
|
|
1360
|
+
&& params.options.some(option => option.optionId === 'decline' && option.kind === 'reject_once');
|
|
1361
|
+
}
|
|
1191
1362
|
recordUpdate(update) {
|
|
1363
|
+
// Replayed history is not current activity: `session/load` would otherwise
|
|
1364
|
+
// make a cold session look like it had just been working. The same reason
|
|
1365
|
+
// keeps it from extending the steering lease, which is evidence the adapter
|
|
1366
|
+
// is working right now — for a steering-started turn, the only evidence.
|
|
1367
|
+
if (!this.replaying) {
|
|
1368
|
+
this.lastUpdateAt = new Date().toISOString();
|
|
1369
|
+
this.refreshSteeringOccupancy();
|
|
1370
|
+
}
|
|
1192
1371
|
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
1193
1372
|
const messagePhase = update.sessionUpdate === 'agent_message_chunk'
|
|
1194
1373
|
? this.codexMessagePhase(update) : undefined;
|
|
@@ -1288,7 +1467,25 @@ export class AcpSession {
|
|
|
1288
1467
|
}
|
|
1289
1468
|
// ── conversation ledger access (SessionHandle) ─────────────────────────────
|
|
1290
1469
|
conversationPage(request = {}) {
|
|
1291
|
-
|
|
1470
|
+
const floor = Number(this.conversationStartCursor ?? 0);
|
|
1471
|
+
const requested = Number(request.after ?? 0);
|
|
1472
|
+
let after = String(Math.max(Number.isSafeInteger(floor) ? floor : 0, Number.isSafeInteger(requested) ? requested : 0));
|
|
1473
|
+
const limit = Math.min(Math.max(request.limit ?? 200, 1), 1_000);
|
|
1474
|
+
let page = this.conversation.page({ after, limit });
|
|
1475
|
+
let visible = page.events.filter(event => this.isCurrentConversationEvent(event));
|
|
1476
|
+
// A resumed adapter may replay a page made entirely of prior session/load
|
|
1477
|
+
// history. Advance over it without exposing it or making the browser stop
|
|
1478
|
+
// before later current-session records.
|
|
1479
|
+
while (!visible.length && page.hasMore && page.nextCursor && page.nextCursor !== after) {
|
|
1480
|
+
after = page.nextCursor;
|
|
1481
|
+
page = this.conversation.page({ after, limit });
|
|
1482
|
+
visible = page.events.filter(event => this.isCurrentConversationEvent(event));
|
|
1483
|
+
}
|
|
1484
|
+
return {
|
|
1485
|
+
...page,
|
|
1486
|
+
events: visible,
|
|
1487
|
+
snapshot: this.conversationSnapshot(),
|
|
1488
|
+
};
|
|
1292
1489
|
}
|
|
1293
1490
|
conversationSnapshot() {
|
|
1294
1491
|
return {
|
|
@@ -1300,7 +1497,13 @@ export class AcpSession {
|
|
|
1300
1497
|
};
|
|
1301
1498
|
}
|
|
1302
1499
|
subscribeConversation(listener) {
|
|
1303
|
-
return this.conversation.subscribe(
|
|
1500
|
+
return this.conversation.subscribe(event => {
|
|
1501
|
+
if (this.isCurrentConversationEvent(event))
|
|
1502
|
+
listener(event);
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
isCurrentConversationEvent(event) {
|
|
1506
|
+
return event.sessionGeneration === this.sessionGeneration && event.source !== 'agent_replay';
|
|
1304
1507
|
}
|
|
1305
1508
|
fail(error) {
|
|
1306
1509
|
this.lastError = error?.message ?? String(error);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SessionActivity } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* How long after the agent's last session update it still counts as working.
|
|
4
|
+
*
|
|
5
|
+
* FLEET-002: a wake delivered by ACP steering answers `startedNewTurn` and runs
|
|
6
|
+
* an entire turn that fleet never receives a `session/prompt` response for (ACP
|
|
7
|
+
* has no turn-end session update), so `readiness` stays `idle` throughout. Tool
|
|
8
|
+
* reservations and update recency are the only activity evidence fleet holds.
|
|
9
|
+
* The trade-off is deliberate and one-directional: at worst a role reads busy
|
|
10
|
+
* for one window after it genuinely stopped, instead of reading ready — or
|
|
11
|
+
* being classified stalled — while it is executing tools.
|
|
12
|
+
*/
|
|
13
|
+
export declare const ACTIVITY_WINDOW_MS = 60000;
|
|
14
|
+
export type ActivityState = 'active' | 'quiet' | 'unobservable';
|
|
15
|
+
export interface ObservedActivity {
|
|
16
|
+
state: ActivityState;
|
|
17
|
+
activeToolCalls?: number;
|
|
18
|
+
lastUpdateAt?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
|
|
22
|
+
* cannot see the agent (tmux) has no evidence, and no evidence must never be
|
|
23
|
+
* reported as "doing nothing".
|
|
24
|
+
*/
|
|
25
|
+
export declare function classifyActivity(activity: SessionActivity | undefined, now?: number): ObservedActivity;
|
|
26
|
+
/**
|
|
27
|
+
* One operator-facing line that never lets turn occupancy pose as liveness:
|
|
28
|
+
* the readiness value is labelled as the turn field it is, and the activity
|
|
29
|
+
* verdict is stated separately with the evidence behind it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function describeSessionState(readiness: string | undefined, activity: SessionActivity | undefined, now?: number): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long after the agent's last session update it still counts as working.
|
|
3
|
+
*
|
|
4
|
+
* FLEET-002: a wake delivered by ACP steering answers `startedNewTurn` and runs
|
|
5
|
+
* an entire turn that fleet never receives a `session/prompt` response for (ACP
|
|
6
|
+
* has no turn-end session update), so `readiness` stays `idle` throughout. Tool
|
|
7
|
+
* reservations and update recency are the only activity evidence fleet holds.
|
|
8
|
+
* The trade-off is deliberate and one-directional: at worst a role reads busy
|
|
9
|
+
* for one window after it genuinely stopped, instead of reading ready — or
|
|
10
|
+
* being classified stalled — while it is executing tools.
|
|
11
|
+
*/
|
|
12
|
+
export const ACTIVITY_WINDOW_MS = 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
|
|
15
|
+
* cannot see the agent (tmux) has no evidence, and no evidence must never be
|
|
16
|
+
* reported as "doing nothing".
|
|
17
|
+
*/
|
|
18
|
+
export function classifyActivity(activity, now = Date.now()) {
|
|
19
|
+
if (!activity)
|
|
20
|
+
return { state: 'unobservable' };
|
|
21
|
+
const lastUpdate = activity.lastUpdateAt ? Date.parse(activity.lastUpdateAt) : NaN;
|
|
22
|
+
const recent = Number.isFinite(lastUpdate) && now - lastUpdate <= ACTIVITY_WINDOW_MS;
|
|
23
|
+
return {
|
|
24
|
+
state: activity.activeToolCalls > 0 || recent ? 'active' : 'quiet',
|
|
25
|
+
activeToolCalls: activity.activeToolCalls,
|
|
26
|
+
...(activity.lastUpdateAt ? { lastUpdateAt: activity.lastUpdateAt } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One operator-facing line that never lets turn occupancy pose as liveness:
|
|
31
|
+
* the readiness value is labelled as the turn field it is, and the activity
|
|
32
|
+
* verdict is stated separately with the evidence behind it.
|
|
33
|
+
*/
|
|
34
|
+
export function describeSessionState(readiness, activity, now = Date.now()) {
|
|
35
|
+
const observed = classifyActivity(activity, now);
|
|
36
|
+
const evidence = [];
|
|
37
|
+
if (observed.activeToolCalls)
|
|
38
|
+
evidence.push(`${observed.activeToolCalls} tool calls in flight`);
|
|
39
|
+
if (observed.lastUpdateAt) {
|
|
40
|
+
const age = Math.max(0, Math.round((now - Date.parse(observed.lastUpdateAt)) / 1000));
|
|
41
|
+
if (Number.isFinite(age))
|
|
42
|
+
evidence.push(`last agent update ${age}s ago`);
|
|
43
|
+
}
|
|
44
|
+
const detail = observed.state === 'unobservable'
|
|
45
|
+
? 'no agent-side evidence on this backend'
|
|
46
|
+
: evidence.join(', ') || 'no updates yet';
|
|
47
|
+
return `turn: ${readiness ?? 'unknown'} (turn occupancy only) | activity: ${observed.state} (${detail})`;
|
|
48
|
+
}
|
|
@@ -11,6 +11,12 @@ import type { AdapterMeta, ConversationEventKind, ConversationPayload } from './
|
|
|
11
11
|
*/
|
|
12
12
|
/** Cap for any single normalized text payload (spec §5.3). */
|
|
13
13
|
export declare const MAX_TEXT_BYTES: number;
|
|
14
|
+
/** Cap for each retained side of an oversized snapshot-style file diff. */
|
|
15
|
+
export declare const MAX_DIFF_TEXT_BYTES: number;
|
|
16
|
+
/** Cap for attacker-controlled filesystem paths while retaining their useful basename tail. */
|
|
17
|
+
export declare const MAX_PATH_BYTES: number;
|
|
18
|
+
/** Hard cap for the complete normalized update before the durable event envelope is added. */
|
|
19
|
+
export declare const MAX_NORMALIZED_UPDATE_BYTES: number;
|
|
14
20
|
/** Cap for one adapter `_meta` namespace value. */
|
|
15
21
|
export declare const MAX_META_BYTES: number;
|
|
16
22
|
/** Cap for serialized raw tool input/output retained as structured JSON. */
|