@ours.network/fleet 0.18.1 → 0.19.0-nightly.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -91
- package/dist/application/fleet-query-service.js +0 -12
- package/dist/application/role-creation-service.js +10 -7
- package/dist/application/types.d.ts +0 -11
- package/dist/briefing.js +6 -15
- package/dist/build-info.json +5 -5
- package/dist/cli.js +7 -37
- package/dist/config.d.ts +3 -6
- package/dist/config.js +14 -28
- package/dist/creation.d.ts +38 -22
- package/dist/creation.js +111 -24
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +44 -95
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +18 -11
- package/dist/fleet-proxy.d.ts +0 -5
- package/dist/harness/acp-agent.js +6 -11
- package/dist/harness/claude-code.js +11 -200
- package/dist/harness/codex.d.ts +1 -4
- package/dist/harness/codex.js +12 -70
- package/dist/harness/types.d.ts +4 -54
- package/dist/loops/manager.d.ts +1 -30
- package/dist/loops/manager.js +6 -69
- package/dist/loops/state.d.ts +0 -18
- package/dist/loops/state.js +0 -4
- package/dist/monitor.js +1 -1
- package/dist/ops.d.ts +3 -0
- package/dist/ops.js +8 -3
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +29 -30
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/message-recovery.d.ts +25 -0
- package/dist/owner-channel/message-recovery.js +114 -0
- package/dist/owner-channel/notices.d.ts +0 -7
- package/dist/owner-channel/notices.js +0 -9
- package/dist/owner-channel/ours-client.d.ts +148 -0
- package/dist/owner-channel/ours-client.js +231 -0
- package/dist/runner.d.ts +0 -48
- package/dist/runner.js +94 -236
- package/dist/session/acp.d.ts +0 -104
- package/dist/session/acp.js +10 -213
- package/dist/session/conversation-normalizer.d.ts +0 -6
- package/dist/session/conversation-normalizer.js +10 -153
- package/dist/session/conversation-types.d.ts +4 -23
- package/dist/session/types.d.ts +0 -35
- package/dist/spawn.js +26 -33
- package/dist/supervisor/systemd.js +29 -2
- package/dist/watchdog/briefing.js +0 -7
- package/dist/watchdog/run.js +3 -3
- package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
- package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +1 -7
- package/dist/worklog.js +39 -191
- package/package.json +3 -1
- package/dist/model-env.d.ts +0 -71
- package/dist/model-env.js +0 -106
- package/dist/owner-channel/mcp.d.ts +0 -24
- package/dist/owner-channel/mcp.js +0 -145
- package/dist/session/activity.d.ts +0 -31
- package/dist/session/activity.js +0 -48
package/dist/session/acp.js
CHANGED
|
@@ -16,21 +16,6 @@ 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;
|
|
34
19
|
const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
|
|
35
20
|
const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
|
|
36
21
|
const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
|
|
@@ -208,8 +193,6 @@ export class AcpSession {
|
|
|
208
193
|
child;
|
|
209
194
|
events;
|
|
210
195
|
conversation;
|
|
211
|
-
/** Cursor before this runner generation began; older durable events stay off the live console. */
|
|
212
|
-
conversationStartCursor;
|
|
213
196
|
/** New on every runner start; permission/turn IDs from prior generations are stale. */
|
|
214
197
|
sessionGeneration = randomUUID();
|
|
215
198
|
/** True while `session/load` replays history as ordinary updates. */
|
|
@@ -219,12 +202,6 @@ export class AcpSession {
|
|
|
219
202
|
connection;
|
|
220
203
|
sessionId;
|
|
221
204
|
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;
|
|
228
205
|
lastError;
|
|
229
206
|
promptTail = Promise.resolve();
|
|
230
207
|
queueDepth = 0;
|
|
@@ -240,13 +217,6 @@ export class AcpSession {
|
|
|
240
217
|
cancelEscalation;
|
|
241
218
|
cancelForceKill;
|
|
242
219
|
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;
|
|
250
220
|
/**
|
|
251
221
|
* Rejects the moment the adapter process is gone. Every in-flight ACP request
|
|
252
222
|
* races it, so a dead adapter can never leave a turn — and therefore a
|
|
@@ -267,7 +237,6 @@ export class AcpSession {
|
|
|
267
237
|
this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
|
|
268
238
|
roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
|
|
269
239
|
});
|
|
270
|
-
this.conversationStartCursor = this.conversation.lastCursor();
|
|
271
240
|
this.sessionFile = join(options.stateDir, '.acp-session-id');
|
|
272
241
|
this.terminated = new Promise((_resolve, reject) => { this.terminate = reject; });
|
|
273
242
|
// Nothing awaits this promise until a request races it; an unobserved
|
|
@@ -278,7 +247,6 @@ export class AcpSession {
|
|
|
278
247
|
if (this.cancelForceKill)
|
|
279
248
|
clearTimeout(this.cancelForceKill);
|
|
280
249
|
this.cancelForceKill = undefined;
|
|
281
|
-
this.releaseSteeringOccupancy('adapter exited');
|
|
282
250
|
// Record the child's real exit code/signal. The tmux path can only see a
|
|
283
251
|
// shell's `$?`; here the truth is available, so keep it.
|
|
284
252
|
const classified = classifyChildExit(code, signal);
|
|
@@ -379,60 +347,17 @@ export class AcpSession {
|
|
|
379
347
|
// terminal fact (signal exits deliberately leave exitCode null).
|
|
380
348
|
return this.child.exitCode === null && (this.child.signalCode ?? null) === null;
|
|
381
349
|
}
|
|
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
|
-
}
|
|
416
350
|
snapshot() {
|
|
417
351
|
return {
|
|
418
352
|
backend: 'acp',
|
|
419
353
|
alive: this.isAlive(),
|
|
420
|
-
|
|
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,
|
|
354
|
+
readiness: this.readiness,
|
|
426
355
|
sessionId: this.sessionId,
|
|
427
356
|
lastError: this.lastError,
|
|
428
357
|
pendingPermissionId: this.pendingPermissions.keys().next().value,
|
|
429
358
|
runtimeModel: this.runtimeModel,
|
|
430
359
|
reasoningEffort: this.reasoningEffort,
|
|
431
360
|
permissionMode: this.options.permissionMode,
|
|
432
|
-
activity: {
|
|
433
|
-
activeToolCalls: this.activeToolCalls.size,
|
|
434
|
-
...(this.lastUpdateAt ? { lastUpdateAt: this.lastUpdateAt } : {}),
|
|
435
|
-
},
|
|
436
361
|
};
|
|
437
362
|
}
|
|
438
363
|
toolCall(toolCallId) {
|
|
@@ -601,19 +526,15 @@ export class AcpSession {
|
|
|
601
526
|
throw new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED);
|
|
602
527
|
if (this.closing || !this.sessionId || !this.isAlive())
|
|
603
528
|
throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
: undefined;
|
|
529
|
+
if (options.interrupt)
|
|
530
|
+
await this.cancelActive(options.interruptSource ?? 'local-console');
|
|
607
531
|
// Interrupting delivery must still use steering when supported. With no
|
|
608
532
|
// live turn, the extension starts one and acknowledges `startedNewTurn`
|
|
609
533
|
// immediately; a normal session/prompt would keep the monitor blocked until
|
|
610
534
|
// the entire wake-triggered turn terminated.
|
|
611
535
|
if (options.steer && this.steeringSupported) {
|
|
612
536
|
const promptId = randomUUID();
|
|
613
|
-
return {
|
|
614
|
-
promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin,
|
|
615
|
-
...(delivery ? { delivery } : {}),
|
|
616
|
-
};
|
|
537
|
+
return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
|
|
617
538
|
}
|
|
618
539
|
const promptId = randomUUID();
|
|
619
540
|
const queuedBehind = this.queueDepth;
|
|
@@ -625,46 +546,7 @@ export class AcpSession {
|
|
|
625
546
|
this.queueDepth = Math.max(0, this.queueDepth - 1);
|
|
626
547
|
return turnResult(false, 'failed', error?.message ?? String(error));
|
|
627
548
|
});
|
|
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';
|
|
549
|
+
return { promptId, queuedBehind, completion, origin: options.origin };
|
|
668
550
|
}
|
|
669
551
|
/**
|
|
670
552
|
* Durably record a prompt admission BEFORE acceptance is returned. Browser
|
|
@@ -973,7 +855,6 @@ export class AcpSession {
|
|
|
973
855
|
if (this.controllerGrace)
|
|
974
856
|
clearTimeout(this.controllerGrace);
|
|
975
857
|
this.controllerGrace = undefined;
|
|
976
|
-
this.releaseSteeringOccupancy('session closed');
|
|
977
858
|
for (const [permissionId, pending] of [...this.pendingPermissions])
|
|
978
859
|
this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
|
|
979
860
|
this.releaseAllTools();
|
|
@@ -991,17 +872,6 @@ export class AcpSession {
|
|
|
991
872
|
});
|
|
992
873
|
this.conversation.close();
|
|
993
874
|
}
|
|
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
|
-
}
|
|
1005
875
|
async initialize() {
|
|
1006
876
|
const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
|
|
1007
877
|
protocolVersion: acp.PROTOCOL_VERSION,
|
|
@@ -1021,7 +891,7 @@ export class AcpSession {
|
|
|
1021
891
|
const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
|
|
1022
892
|
sessionId: persisted,
|
|
1023
893
|
cwd: this.options.cwd,
|
|
1024
|
-
mcpServers:
|
|
894
|
+
mcpServers: [],
|
|
1025
895
|
});
|
|
1026
896
|
this.captureRuntimeMetadata(resumed.configOptions);
|
|
1027
897
|
this.sessionId = persisted;
|
|
@@ -1034,7 +904,7 @@ export class AcpSession {
|
|
|
1034
904
|
const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
|
|
1035
905
|
sessionId: persisted,
|
|
1036
906
|
cwd: this.options.cwd,
|
|
1037
|
-
mcpServers:
|
|
907
|
+
mcpServers: [],
|
|
1038
908
|
});
|
|
1039
909
|
this.captureRuntimeMetadata(loaded.configOptions);
|
|
1040
910
|
}
|
|
@@ -1046,8 +916,7 @@ export class AcpSession {
|
|
|
1046
916
|
else {
|
|
1047
917
|
const created = await this.connection.agent.request(acp.methods.agent.session.new, {
|
|
1048
918
|
cwd: this.options.cwd,
|
|
1049
|
-
mcpServers:
|
|
1050
|
-
...(this.options.sessionMeta ? { _meta: this.options.sessionMeta } : {}),
|
|
919
|
+
mcpServers: [],
|
|
1051
920
|
});
|
|
1052
921
|
this.sessionId = created.sessionId;
|
|
1053
922
|
this.captureRuntimeMetadata(created.configOptions);
|
|
@@ -1144,10 +1013,6 @@ export class AcpSession {
|
|
|
1144
1013
|
}
|
|
1145
1014
|
finally {
|
|
1146
1015
|
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');
|
|
1151
1016
|
if (this.activeTurn?.id === turnId) {
|
|
1152
1017
|
this.activeTurn.settle();
|
|
1153
1018
|
if (this.cancelEscalation)
|
|
@@ -1170,12 +1035,6 @@ export class AcpSession {
|
|
|
1170
1035
|
]);
|
|
1171
1036
|
if (response.outcome === 'failed')
|
|
1172
1037
|
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();
|
|
1179
1038
|
return turnResult(true, 'inconclusive', response.outcome);
|
|
1180
1039
|
}
|
|
1181
1040
|
catch (error) {
|
|
@@ -1203,17 +1062,6 @@ export class AcpSession {
|
|
|
1203
1062
|
// Permission is part of the tool lifecycle. Reserve before any policy or
|
|
1204
1063
|
// human decision so a monitor wake cannot slip between request and answer.
|
|
1205
1064
|
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
|
-
}
|
|
1217
1065
|
if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
|
|
1218
1066
|
const option = choose(['allow_always', 'allow_once']);
|
|
1219
1067
|
const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
|
|
@@ -1340,34 +1188,7 @@ export class AcpSession {
|
|
|
1340
1188
|
const cwd = resolve(this.options.cwd);
|
|
1341
1189
|
return canonicallyWithin(cwd, locations.map(location => resolve(location.path)));
|
|
1342
1190
|
}
|
|
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
|
-
}
|
|
1362
1191
|
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
|
-
}
|
|
1371
1192
|
const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
|
|
1372
1193
|
const messagePhase = update.sessionUpdate === 'agent_message_chunk'
|
|
1373
1194
|
? this.codexMessagePhase(update) : undefined;
|
|
@@ -1467,25 +1288,7 @@ export class AcpSession {
|
|
|
1467
1288
|
}
|
|
1468
1289
|
// ── conversation ledger access (SessionHandle) ─────────────────────────────
|
|
1469
1290
|
conversationPage(request = {}) {
|
|
1470
|
-
|
|
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
|
-
};
|
|
1291
|
+
return { ...this.conversation.page(request), snapshot: this.conversationSnapshot() };
|
|
1489
1292
|
}
|
|
1490
1293
|
conversationSnapshot() {
|
|
1491
1294
|
return {
|
|
@@ -1497,13 +1300,7 @@ export class AcpSession {
|
|
|
1497
1300
|
};
|
|
1498
1301
|
}
|
|
1499
1302
|
subscribeConversation(listener) {
|
|
1500
|
-
return this.conversation.subscribe(
|
|
1501
|
-
if (this.isCurrentConversationEvent(event))
|
|
1502
|
-
listener(event);
|
|
1503
|
-
});
|
|
1504
|
-
}
|
|
1505
|
-
isCurrentConversationEvent(event) {
|
|
1506
|
-
return event.sessionGeneration === this.sessionGeneration && event.source !== 'agent_replay';
|
|
1303
|
+
return this.conversation.subscribe(listener);
|
|
1507
1304
|
}
|
|
1508
1305
|
fail(error) {
|
|
1509
1306
|
this.lastError = error?.message ?? String(error);
|
|
@@ -11,12 +11,6 @@ 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;
|
|
20
14
|
/** Cap for one adapter `_meta` namespace value. */
|
|
21
15
|
export declare const MAX_META_BYTES: number;
|
|
22
16
|
/** Cap for serialized raw tool input/output retained as structured JSON. */
|
|
@@ -10,12 +10,6 @@ import { createHash } from 'node:crypto';
|
|
|
10
10
|
*/
|
|
11
11
|
/** Cap for any single normalized text payload (spec §5.3). */
|
|
12
12
|
export const MAX_TEXT_BYTES = 256 * 1024;
|
|
13
|
-
/** Cap for each retained side of an oversized snapshot-style file diff. */
|
|
14
|
-
export const MAX_DIFF_TEXT_BYTES = 64 * 1024;
|
|
15
|
-
/** Cap for attacker-controlled filesystem paths while retaining their useful basename tail. */
|
|
16
|
-
export const MAX_PATH_BYTES = 4 * 1024;
|
|
17
|
-
/** Hard cap for the complete normalized update before the durable event envelope is added. */
|
|
18
|
-
export const MAX_NORMALIZED_UPDATE_BYTES = 320 * 1024;
|
|
19
13
|
/** Cap for one adapter `_meta` namespace value. */
|
|
20
14
|
export const MAX_META_BYTES = 16 * 1024;
|
|
21
15
|
/** Cap for serialized raw tool input/output retained as structured JSON. */
|
|
@@ -33,28 +27,6 @@ function truncateUtf8(text, maxBytes) {
|
|
|
33
27
|
const buffer = Buffer.from(text).subarray(0, maxBytes);
|
|
34
28
|
return buffer.toString('utf8').replace(/�+$/u, '');
|
|
35
29
|
}
|
|
36
|
-
/** Keep a UTF-8-safe suffix. Paths and identifiers have no line semantics. */
|
|
37
|
-
function truncateUtf8Tail(text, maxBytes) {
|
|
38
|
-
const buffer = Buffer.from(text);
|
|
39
|
-
let start = Math.max(0, buffer.length - maxBytes);
|
|
40
|
-
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
|
|
41
|
-
start++;
|
|
42
|
-
return { text: buffer.subarray(start).toString('utf8'), omittedPrefixBytes: start };
|
|
43
|
-
}
|
|
44
|
-
function boundedPath(raw) {
|
|
45
|
-
const path = asString(raw) ?? '';
|
|
46
|
-
const pathBytes = Buffer.byteLength(path);
|
|
47
|
-
if (pathBytes <= MAX_PATH_BYTES)
|
|
48
|
-
return { path };
|
|
49
|
-
const retained = truncateUtf8Tail(path, MAX_PATH_BYTES);
|
|
50
|
-
return {
|
|
51
|
-
path: retained.text,
|
|
52
|
-
pathBytes,
|
|
53
|
-
pathTruncated: true,
|
|
54
|
-
pathDigest: digest24(path),
|
|
55
|
-
pathOmittedPrefixBytes: retained.omittedPrefixBytes,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
30
|
function cappedText(raw, redact) {
|
|
59
31
|
const text = asString(raw) ?? '';
|
|
60
32
|
const bytes = Buffer.byteLength(text);
|
|
@@ -67,95 +39,6 @@ function cappedText(raw, redact) {
|
|
|
67
39
|
truncated: true, digest: digest24(text),
|
|
68
40
|
};
|
|
69
41
|
}
|
|
70
|
-
/** Keep the newest UTF-8 tail, aligning to a whole line whenever one fits. */
|
|
71
|
-
function cappedTextTail(text, maxBytes) {
|
|
72
|
-
const buffer = Buffer.from(text);
|
|
73
|
-
const bytes = buffer.length;
|
|
74
|
-
if (bytes <= maxBytes)
|
|
75
|
-
return { text, bytes };
|
|
76
|
-
let start = bytes - maxBytes;
|
|
77
|
-
while (start < bytes && (buffer[start] & 0xc0) === 0x80)
|
|
78
|
-
start++;
|
|
79
|
-
let startsMidLine = start > 0 && buffer[start - 1] !== 0x0a;
|
|
80
|
-
if (startsMidLine) {
|
|
81
|
-
const newline = buffer.indexOf(0x0a, start);
|
|
82
|
-
if (newline >= 0 && newline + 1 < bytes) {
|
|
83
|
-
start = newline + 1;
|
|
84
|
-
startsMidLine = false;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return {
|
|
88
|
-
text: buffer.subarray(start).toString('utf8'), bytes,
|
|
89
|
-
truncated: true, digest: digest24(text), omittedPrefixBytes: start,
|
|
90
|
-
...(startsMidLine ? { startsMidLine: true } : {}),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
/** Common unchanged edges in UTF-16 indices, adjusted away from split surrogates. */
|
|
94
|
-
function commonEdges(oldText, newText) {
|
|
95
|
-
const limit = Math.min(oldText.length, newText.length);
|
|
96
|
-
let prefix = 0;
|
|
97
|
-
while (prefix < limit && oldText.charCodeAt(prefix) === newText.charCodeAt(prefix))
|
|
98
|
-
prefix++;
|
|
99
|
-
if (prefix > 0 && prefix < limit
|
|
100
|
-
&& oldText.charCodeAt(prefix) >= 0xdc00 && oldText.charCodeAt(prefix) <= 0xdfff)
|
|
101
|
-
prefix--;
|
|
102
|
-
let oldEnd = oldText.length;
|
|
103
|
-
let newEnd = newText.length;
|
|
104
|
-
while (oldEnd > prefix && newEnd > prefix
|
|
105
|
-
&& oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
|
|
106
|
-
oldEnd--;
|
|
107
|
-
newEnd--;
|
|
108
|
-
}
|
|
109
|
-
// A suffix must never begin at the low half of a surrogate pair.
|
|
110
|
-
if (oldEnd < oldText.length && oldText.charCodeAt(oldEnd) >= 0xdc00
|
|
111
|
-
&& oldText.charCodeAt(oldEnd) <= 0xdfff) {
|
|
112
|
-
oldEnd++;
|
|
113
|
-
newEnd++;
|
|
114
|
-
}
|
|
115
|
-
return { prefix, suffix: oldText.length - oldEnd };
|
|
116
|
-
}
|
|
117
|
-
function normalizedDiff(item, redact) {
|
|
118
|
-
const path = boundedPath(item.path);
|
|
119
|
-
const oldText = asString(item.oldText);
|
|
120
|
-
const newText = asString(item.newText) ?? '';
|
|
121
|
-
// Preserve the established small-diff contract exactly. Redacted turns also
|
|
122
|
-
// retain their established placeholder shape and never derive private text.
|
|
123
|
-
if (redact !== undefined || oldText === undefined
|
|
124
|
-
|| (Buffer.byteLength(oldText) <= MAX_TEXT_BYTES
|
|
125
|
-
&& Buffer.byteLength(newText) <= MAX_TEXT_BYTES)) {
|
|
126
|
-
return {
|
|
127
|
-
type: 'diff', ...path,
|
|
128
|
-
newText: cappedText(newText, redact),
|
|
129
|
-
...(oldText !== undefined ? { oldText: cappedText(oldText, redact) } : {}),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
// ACP adapters may describe an append by sending two complete file snapshots.
|
|
133
|
-
// Persist only the changed region: otherwise a multi-megabyte historical file
|
|
134
|
-
// contributes its prefix twice while the current append disappears past the cap.
|
|
135
|
-
const { prefix, suffix } = commonEdges(oldText, newText);
|
|
136
|
-
const oldEnd = oldText.length - suffix;
|
|
137
|
-
const newEnd = newText.length - suffix;
|
|
138
|
-
const oldDelta = oldText.slice(prefix, oldEnd);
|
|
139
|
-
const newDelta = newText.slice(prefix, newEnd);
|
|
140
|
-
const beforeBytes = Buffer.byteLength(oldText);
|
|
141
|
-
const afterBytes = Buffer.byteLength(newText);
|
|
142
|
-
const commonPrefixBytes = Buffer.byteLength(oldText.slice(0, prefix));
|
|
143
|
-
const commonSuffixBytes = Buffer.byteLength(oldText.slice(oldEnd));
|
|
144
|
-
const operation = oldDelta.length === 0 && newDelta.length === 0
|
|
145
|
-
? 'noop'
|
|
146
|
-
: oldDelta.length === 0 && prefix === oldText.length
|
|
147
|
-
? 'append'
|
|
148
|
-
: newDelta.length === 0
|
|
149
|
-
? 'delete'
|
|
150
|
-
: prefix === 0 && suffix === 0 ? 'replace' : 'edit';
|
|
151
|
-
return {
|
|
152
|
-
type: 'diff', ...path, operation, beforeBytes, afterBytes,
|
|
153
|
-
commonPrefixBytes, commonSuffixBytes, bounded: true,
|
|
154
|
-
newText: cappedTextTail(newDelta, MAX_DIFF_TEXT_BYTES),
|
|
155
|
-
...(oldDelta.length > 0
|
|
156
|
-
? { oldText: cappedTextTail(oldDelta, MAX_DIFF_TEXT_BYTES) } : {}),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
42
|
function normalizedText(raw, redact) {
|
|
160
43
|
const text = asString(raw) ?? '';
|
|
161
44
|
const bytes = Buffer.byteLength(text);
|
|
@@ -306,7 +189,12 @@ function normalizeToolContent(raw, redact) {
|
|
|
306
189
|
return raw.filter(isRecord).map((item) => {
|
|
307
190
|
switch (item.type) {
|
|
308
191
|
case 'diff':
|
|
309
|
-
return
|
|
192
|
+
return {
|
|
193
|
+
type: 'diff',
|
|
194
|
+
path: asString(item.path) ?? '',
|
|
195
|
+
newText: cappedText(item.newText, redact),
|
|
196
|
+
...(item.oldText != null ? { oldText: cappedText(item.oldText, redact) } : {}),
|
|
197
|
+
};
|
|
310
198
|
case 'terminal':
|
|
311
199
|
return { type: 'terminal', terminalId: asString(item.terminalId) ?? '' };
|
|
312
200
|
case 'content':
|
|
@@ -332,7 +220,7 @@ function toolUpsert(update, snapshot, options) {
|
|
|
332
220
|
payload.content = content;
|
|
333
221
|
if (Array.isArray(update.locations)) {
|
|
334
222
|
payload.locations = update.locations.filter(isRecord).map(location => ({
|
|
335
|
-
|
|
223
|
+
path: asString(location.path) ?? '',
|
|
336
224
|
...(asFiniteNumber(location.line) !== undefined ? { line: asFiniteNumber(location.line) } : {}),
|
|
337
225
|
}));
|
|
338
226
|
}
|
|
@@ -352,49 +240,18 @@ function unsupported(update) {
|
|
|
352
240
|
}
|
|
353
241
|
const kind = isRecord(update) ? asString(update.sessionUpdate) : undefined;
|
|
354
242
|
return {
|
|
355
|
-
sessionUpdate:
|
|
243
|
+
sessionUpdate: kind ?? 'unknown',
|
|
356
244
|
bytes: Buffer.byteLength(serialized),
|
|
357
245
|
preview: serialized.slice(0, MAX_UNSUPPORTED_PREVIEW_CHARS),
|
|
358
|
-
...(Buffer.byteLength(serialized) > Buffer.byteLength(serialized.slice(0, MAX_UNSUPPORTED_PREVIEW_CHARS))
|
|
359
|
-
? { digest: digest24(serialized) } : {}),
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
function capNormalizedUpdate(result, sessionUpdate) {
|
|
363
|
-
let serialized;
|
|
364
|
-
try {
|
|
365
|
-
serialized = JSON.stringify(result);
|
|
366
|
-
}
|
|
367
|
-
catch {
|
|
368
|
-
return {
|
|
369
|
-
kind: 'unsupported',
|
|
370
|
-
payload: {
|
|
371
|
-
sessionUpdate: truncateUtf8(sessionUpdate, 256),
|
|
372
|
-
bytes: 0,
|
|
373
|
-
preview: '[normalized update was not serializable]',
|
|
374
|
-
},
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
const bytes = Buffer.byteLength(serialized);
|
|
378
|
-
if (bytes <= MAX_NORMALIZED_UPDATE_BYTES)
|
|
379
|
-
return result;
|
|
380
|
-
return {
|
|
381
|
-
kind: 'unsupported',
|
|
382
|
-
payload: {
|
|
383
|
-
sessionUpdate: truncateUtf8(sessionUpdate, 256),
|
|
384
|
-
bytes,
|
|
385
|
-
digest: digest24(serialized),
|
|
386
|
-
preview: `[normalized update exceeded ${MAX_NORMALIZED_UPDATE_BYTES}-byte durable-event cap]`,
|
|
387
|
-
},
|
|
388
246
|
};
|
|
389
247
|
}
|
|
390
248
|
export function normalizeSessionUpdate(update, options = {}) {
|
|
391
249
|
const redact = options.redactText;
|
|
392
250
|
const raw = update;
|
|
393
251
|
if (!isRecord(raw) || typeof raw.sessionUpdate !== 'string')
|
|
394
|
-
return
|
|
395
|
-
const sessionUpdate = raw.sessionUpdate;
|
|
252
|
+
return { kind: 'unsupported', payload: unsupported(raw) };
|
|
396
253
|
const adapterMeta = quarantineMeta(raw._meta);
|
|
397
|
-
const withMeta = (result) =>
|
|
254
|
+
const withMeta = (result) => adapterMeta ? { ...result, adapterMeta } : result;
|
|
398
255
|
switch (raw.sessionUpdate) {
|
|
399
256
|
case 'user_message_chunk':
|
|
400
257
|
case 'agent_message_chunk': {
|
|
@@ -48,33 +48,15 @@ export interface CappedText {
|
|
|
48
48
|
bytes: number;
|
|
49
49
|
truncated?: true;
|
|
50
50
|
digest?: string;
|
|
51
|
-
/** Bytes omitted from the front when the retained fragment is a tail. */
|
|
52
|
-
omittedPrefixBytes?: number;
|
|
53
|
-
/** The retained tail starts inside one logical line because that line alone exceeded the cap. */
|
|
54
|
-
startsMidLine?: true;
|
|
55
|
-
}
|
|
56
|
-
/** A path kept as a UTF-8-safe tail, with additive provenance only when capped. */
|
|
57
|
-
export interface BoundedPath {
|
|
58
|
-
path: string;
|
|
59
|
-
pathBytes?: number;
|
|
60
|
-
pathTruncated?: true;
|
|
61
|
-
pathDigest?: string;
|
|
62
|
-
pathOmittedPrefixBytes?: number;
|
|
63
51
|
}
|
|
64
52
|
export type NormalizedToolContent = {
|
|
65
53
|
type: 'content';
|
|
66
54
|
content: NormalizedContentBlock;
|
|
67
|
-
} |
|
|
55
|
+
} | {
|
|
68
56
|
type: 'diff';
|
|
57
|
+
path: string;
|
|
69
58
|
newText: CappedText;
|
|
70
59
|
oldText?: CappedText;
|
|
71
|
-
/** Additive provenance for oversized snapshot diffs reduced to their changed region. */
|
|
72
|
-
operation?: 'append' | 'edit' | 'delete' | 'noop' | 'replace';
|
|
73
|
-
beforeBytes?: number;
|
|
74
|
-
afterBytes?: number;
|
|
75
|
-
commonPrefixBytes?: number;
|
|
76
|
-
commonSuffixBytes?: number;
|
|
77
|
-
bounded?: true;
|
|
78
60
|
}
|
|
79
61
|
/** A tool-owned display terminal reference — never a PTY attachment. */
|
|
80
62
|
| {
|
|
@@ -123,7 +105,8 @@ export interface ToolUpsertPayload {
|
|
|
123
105
|
kind?: string;
|
|
124
106
|
status?: string;
|
|
125
107
|
content?: NormalizedToolContent[];
|
|
126
|
-
locations?: Array<
|
|
108
|
+
locations?: Array<{
|
|
109
|
+
path: string;
|
|
127
110
|
line?: number;
|
|
128
111
|
}>;
|
|
129
112
|
rawInput?: BoundedJson;
|
|
@@ -157,8 +140,6 @@ export interface UnsupportedPayload {
|
|
|
157
140
|
/** The wire discriminant (or 'unknown' when even that was absent). */
|
|
158
141
|
sessionUpdate: string;
|
|
159
142
|
bytes: number;
|
|
160
|
-
/** Digest of the omitted original/normalized representation when it was bounded. */
|
|
161
|
-
digest?: string;
|
|
162
143
|
/** Sanitized JSON preview, capped; enough to diagnose, never to exhaust. */
|
|
163
144
|
preview?: string;
|
|
164
145
|
}
|