@ours.network/fleet 0.19.0-nightly.9 → 1.0.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.
Files changed (114) hide show
  1. package/README.md +66 -11
  2. package/dist/application/fleet-query-service.d.ts +1 -1
  3. package/dist/application/fleet-query-service.js +12 -0
  4. package/dist/application/role-creation-service.js +3 -1
  5. package/dist/application/types.d.ts +11 -0
  6. package/dist/briefing.d.ts +1 -1
  7. package/dist/briefing.js +103 -10
  8. package/dist/build-info.json +5 -5
  9. package/dist/cli.js +41 -11
  10. package/dist/config.d.ts +21 -7
  11. package/dist/config.js +32 -18
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/docs.d.ts +2 -2
  14. package/dist/docs.js +117 -24
  15. package/dist/doctor.js +10 -10
  16. package/dist/duration.js +1 -1
  17. package/dist/fleet-proxy.d.ts +5 -0
  18. package/dist/harness/acp-agent.js +11 -6
  19. package/dist/harness/claude-code.js +201 -8
  20. package/dist/harness/codex.d.ts +4 -1
  21. package/dist/harness/codex.js +72 -13
  22. package/dist/harness/types.d.ts +57 -6
  23. package/dist/isolation/bubblewrap.d.ts +4 -4
  24. package/dist/isolation/bubblewrap.js +4 -4
  25. package/dist/isolation/policy.d.ts +1 -1
  26. package/dist/isolation/policy.js +2 -2
  27. package/dist/isolation/registry.d.ts +3 -3
  28. package/dist/isolation/registry.js +5 -5
  29. package/dist/isolation/resources.d.ts +1 -1
  30. package/dist/isolation/resources.js +1 -1
  31. package/dist/isolation/types.d.ts +3 -3
  32. package/dist/loops/manager.d.ts +30 -1
  33. package/dist/loops/manager.js +69 -6
  34. package/dist/loops/state.d.ts +18 -0
  35. package/dist/loops/state.js +4 -0
  36. package/dist/model-env.d.ts +71 -0
  37. package/dist/model-env.js +106 -0
  38. package/dist/monitor.d.ts +2 -2
  39. package/dist/monitor.js +4 -4
  40. package/dist/ops.d.ts +2 -2
  41. package/dist/ops.js +9 -9
  42. package/dist/owner-channel/channel.d.ts +17 -0
  43. package/dist/owner-channel/channel.js +104 -23
  44. package/dist/owner-channel/commands.d.ts +9 -0
  45. package/dist/owner-channel/commands.js +226 -99
  46. package/dist/owner-channel/notices.d.ts +7 -0
  47. package/dist/owner-channel/notices.js +9 -0
  48. package/dist/rooms-tasks/cli.js +731 -219
  49. package/dist/rooms-tasks/close.d.ts +35 -0
  50. package/dist/rooms-tasks/close.js +190 -0
  51. package/dist/rooms-tasks/cowork-adapter.d.ts +24 -0
  52. package/dist/rooms-tasks/cowork-adapter.js +120 -0
  53. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  54. package/dist/rooms-tasks/external-worker.js +40 -0
  55. package/dist/rooms-tasks/index.d.ts +2 -0
  56. package/dist/rooms-tasks/index.js +2 -0
  57. package/dist/rooms-tasks/markdown.d.ts +49 -0
  58. package/dist/rooms-tasks/markdown.js +206 -0
  59. package/dist/rooms-tasks/member-startup.d.ts +51 -0
  60. package/dist/rooms-tasks/member-startup.js +151 -0
  61. package/dist/rooms-tasks/provision.d.ts +8 -0
  62. package/dist/rooms-tasks/provision.js +418 -97
  63. package/dist/rooms-tasks/room-state.d.ts +13 -1
  64. package/dist/rooms-tasks/room-state.js +149 -1
  65. package/dist/rooms-tasks/task-state.d.ts +13 -2
  66. package/dist/rooms-tasks/task-state.js +131 -3
  67. package/dist/rooms-tasks/terminal.d.ts +23 -0
  68. package/dist/rooms-tasks/terminal.js +56 -0
  69. package/dist/rooms-tasks/types.d.ts +108 -2
  70. package/dist/runner.d.ts +48 -0
  71. package/dist/runner.js +252 -100
  72. package/dist/session/acp.d.ts +106 -2
  73. package/dist/session/acp.js +217 -14
  74. package/dist/session/activity.d.ts +31 -0
  75. package/dist/session/activity.js +48 -0
  76. package/dist/session/control.d.ts +1 -1
  77. package/dist/session/conversation-normalizer.d.ts +7 -1
  78. package/dist/session/conversation-normalizer.js +154 -11
  79. package/dist/session/conversation-store.js +1 -1
  80. package/dist/session/conversation-types.d.ts +25 -6
  81. package/dist/session/types.d.ts +35 -0
  82. package/dist/spawn.d.ts +4 -2
  83. package/dist/spawn.js +39 -26
  84. package/dist/supervisor/launchd.d.ts +2 -2
  85. package/dist/supervisor/launchd.js +4 -4
  86. package/dist/supervisor/systemd.js +4 -4
  87. package/dist/supervisor/types.d.ts +1 -1
  88. package/dist/temp-lifecycle.d.ts +14 -2
  89. package/dist/temp-lifecycle.js +90 -0
  90. package/dist/watchdog/alerts.d.ts +2 -2
  91. package/dist/watchdog/alerts.js +2 -2
  92. package/dist/watchdog/briefing.d.ts +5 -5
  93. package/dist/watchdog/briefing.js +13 -6
  94. package/dist/watchdog/config.js +1 -1
  95. package/dist/watchdog/query.d.ts +2 -2
  96. package/dist/watchdog/query.js +2 -2
  97. package/dist/watchdog/report.js +2 -2
  98. package/dist/watchdog/run.d.ts +2 -2
  99. package/dist/watchdog/run.js +6 -6
  100. package/dist/watchdog/scheduler.d.ts +7 -7
  101. package/dist/watchdog/scheduler.js +10 -10
  102. package/dist/watchdog/service.d.ts +4 -4
  103. package/dist/watchdog/service.js +5 -6
  104. package/dist/watchdog/store.d.ts +4 -4
  105. package/dist/watchdog/store.js +4 -4
  106. package/dist/web/runtime.js +1 -1
  107. package/dist/web/server.js +1 -1
  108. package/dist/web/topology-promote.js +1 -1
  109. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  110. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  111. package/dist/web-app/index.html +1 -1
  112. package/dist/worklog.d.ts +7 -1
  113. package/dist/worklog.js +191 -39
  114. package/package.json +1 -1
@@ -1,10 +1,26 @@
1
1
  import * as acp from '@agentclientprotocol/sdk';
2
2
  import type { CommonPermissions } from '../config.js';
3
+ import type { AcpMcpServer } from '../harness/types.js';
3
4
  import { ConversationEventStore } from './conversation-store.js';
4
5
  import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
5
6
  import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, RuntimeSelectorMetadata, SessionHandle, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
6
7
  /** Bound safe-boundary waiting without turning a hung tool into cancellation. */
7
8
  export declare const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120000;
9
+ /**
10
+ * How long a steering-started turn is presumed to still own the adapter after
11
+ * its last update. Such a turn has no prompt id, so it never reports a
12
+ * stopReason and there is no exact end to observe — silence is the only signal
13
+ * available, and this is the bound that turns it into a decision.
14
+ *
15
+ * Sized from the fleet's own scheduled-run history: across 1513 completed
16
+ * scheduled runs the longest silence WITHIN a working turn was 120.2 s (p99
17
+ * 41.0 s; 5 runs above 60 s). A shorter grace would release the lease while the
18
+ * adapter is still working and re-admit a prompt into a busy turn, which is the
19
+ * FLEET-003 failure itself. The costs are deliberately asymmetric: holding too
20
+ * long skips one best-effort maintenance tick, releasing too early SIGTERMs a
21
+ * live role.
22
+ */
23
+ export declare const STEERING_OCCUPANCY_IDLE_MS = 150000;
8
24
  /** Server-generated typed provenance followed by the exact human-authored body. */
9
25
  export declare function promptContentBlocks(text: string, origin?: PromptOrigin): acp.ContentBlock[];
10
26
  export declare function runtimeSelector(options: acp.SessionConfigOption[] | null | undefined, category: string): RuntimeSelectorMetadata | undefined;
@@ -20,6 +36,22 @@ export interface AcpSessionOptions {
20
36
  modeId?: string;
21
37
  /** Adapter-resolved live permission policy; separate from ACP agent-specific session modes. */
22
38
  permissionMode?: NonNullable<SessionSnapshot['permissionMode']>;
39
+ /** Adapter-authenticated request-metadata vocabulary; never inferred from ACP `_meta`. */
40
+ permissionMetadataSource?: 'codex-acp';
41
+ /**
42
+ * MCP servers the ROLE declares, for every session/new, resume and load. Empty
43
+ * or omitted sends `[]`, which is what fleet has always sent and leaves the
44
+ * agent's own configuration untouched.
45
+ */
46
+ mcpServers?: AcpMcpServer[];
47
+ /**
48
+ * Adapter-supplied `_meta` for session/new — the only route by which a
49
+ * capability the CLI takes as a flag reaches an agent that accepts none.
50
+ * Per-agent vocabulary, so the ADAPTER decides whether there is anything to
51
+ * send; this layer only forwards it. Never sent on resume or load: it carries
52
+ * session-creation options the agent has already applied.
53
+ */
54
+ sessionMeta?: Record<string, unknown>;
23
55
  log(line: string): void;
24
56
  /** Test seam for the cancel-escalation grace period; production uses the default. */
25
57
  cancelGraceMs?: number;
@@ -31,6 +63,8 @@ export interface AcpSessionOptions {
31
63
  controllerGraceMs?: number;
32
64
  /** Test seam; production uses AFTER_TOOL_BOUNDARY_TIMEOUT_MS. */
33
65
  afterToolBoundaryTimeoutMs?: number;
66
+ /** Test seam; production uses STEERING_OCCUPANCY_IDLE_MS. */
67
+ steeringOccupancyIdleMs?: number;
34
68
  }
35
69
  /**
36
70
  * Classify an ACP `stopReason` into a terminal outcome. A refusal and a
@@ -49,6 +83,8 @@ export declare class AcpSession implements SessionHandle {
49
83
  private readonly child;
50
84
  private readonly events;
51
85
  private readonly conversation;
86
+ /** Cursor before this runner generation began; older durable events stay off the live console. */
87
+ private readonly conversationStartCursor?;
52
88
  /** New on every runner start; permission/turn IDs from prior generations are stale. */
53
89
  private readonly sessionGeneration;
54
90
  /** True while `session/load` replays history as ordinary updates. */
@@ -58,6 +94,12 @@ export declare class AcpSession implements SessionHandle {
58
94
  private connection;
59
95
  private sessionId?;
60
96
  private readiness;
97
+ /**
98
+ * Last non-replayed session update from the agent. `readiness` cannot answer
99
+ * "is this agent working" for a steered turn (FLEET-002), and this is the
100
+ * evidence that can.
101
+ */
102
+ private lastUpdateAt?;
61
103
  private lastError?;
62
104
  private promptTail;
63
105
  private queueDepth;
@@ -73,6 +115,13 @@ export declare class AcpSession implements SessionHandle {
73
115
  private cancelEscalation?;
74
116
  private cancelForceKill?;
75
117
  private cancelRecoveryReason?;
118
+ /**
119
+ * Held while a steering-started turn is believed to own the adapter. It is a
120
+ * lease, not a latch: `steeringRelease` always fires, so the role can never be
121
+ * stranded busy by a wake whose turn ended without telling anyone.
122
+ */
123
+ private steeringOccupied;
124
+ private steeringRelease?;
76
125
  /**
77
126
  * Rejects the moment the adapter process is gone. Every in-flight ACP request
78
127
  * races it, so a dead adapter can never leave a turn — and therefore a
@@ -87,13 +136,27 @@ export declare class AcpSession implements SessionHandle {
87
136
  private constructor();
88
137
  static start(options: AcpSessionOptions): Promise<AcpSession>;
89
138
  /**
90
- * Honest restart recovery (spec §5.3): a prompt that was admitted but never
139
+ * Honest restart recovery: a prompt that was admitted but never
91
140
  * started is safe to dispatch again; a turn that had already started may
92
141
  * have caused side effects, so it is closed as `unknown_after_restart` —
93
142
  * never silently replayed.
94
143
  */
95
144
  private recoverOpenPrompts;
96
145
  isAlive(): boolean;
146
+ /**
147
+ * Take the occupancy lease for a turn the adapter started on its own behalf.
148
+ * Refreshed by every adapter update, so it tracks work actually happening
149
+ * rather than a fixed guess at how long a wake takes.
150
+ */
151
+ private holdSteeringOccupancy;
152
+ private refreshSteeringOccupancy;
153
+ /**
154
+ * Every exit from occupancy comes through here, including the ones that are
155
+ * not the timer: a real turn boundary, close, and adapter exit. A lease that
156
+ * can leak is worse than the bug it fixes — it would leave the role reporting
157
+ * `running` forever and starve scheduled admission permanently.
158
+ */
159
+ private releaseSteeringOccupancy;
97
160
  snapshot(): SessionSnapshot;
98
161
  private toolCall;
99
162
  private reserveTool;
@@ -126,11 +189,33 @@ export declare class AcpSession implements SessionHandle {
126
189
  * for it is what turned a busy agent into a timeout and then into "dead".
127
190
  */
128
191
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
192
+ /**
193
+ * Prepare the session for a prompt that asked to pre-empt current work.
194
+ *
195
+ * The old behaviour was one unconditional `session/cancel` notification
196
+ * followed immediately by `session/prompt`. That is what produced the owner's
197
+ * "request failed before completion":
198
+ *
199
+ * - `cancelActive` only awaits settlement when `this.activeTurn` is set, and
200
+ * a turn the ADAPTER started (steering's `startedNewTurn`) is never tracked
201
+ * here. So the cancel raced the adapter's own transcript repair and the new
202
+ * prompt landed while the last assistant message still held an unresolved
203
+ * `tool_use` — rejected with `stop_reason=tool_use`.
204
+ * - With nothing running at all, it still sent the cancel, and the prompt
205
+ * landed on a bare interrupted user message — rejected with
206
+ * `stop_reason=null`.
207
+ *
208
+ * So: never cancel across a tool boundary, and never cancel something whose
209
+ * settlement cannot be awaited. Everything else is queued, which the ACP queue
210
+ * already does correctly. The returned state is what the caller may claim to a
211
+ * human — `interrupted` only when a turn really was cancelled.
212
+ */
213
+ private prepareInterruptingDelivery;
129
214
  /**
130
215
  * Durably record a prompt admission BEFORE acceptance is returned. Browser
131
216
  * admissions are transactional — a prompt the ledger cannot hold is refused,
132
217
  * because an acknowledged-then-lost prompt is worse than an error. Every
133
- * other source degrades to best-effort so the agent keeps working (§5.3).
218
+ * other source degrades to best-effort so the agent keeps working.
134
219
  */
135
220
  private admitToLedger;
136
221
  /** Idempotent browser prompt admission (control v3 `submit_prompt_v2`). */
@@ -173,6 +258,15 @@ export declare class AcpSession implements SessionHandle {
173
258
  private settlePendingAutomatically;
174
259
  exitResult(): ExitRecord | null;
175
260
  close(): Promise<void>;
261
+ /**
262
+ * The role's declared MCP servers, or `[]`.
263
+ *
264
+ * Sent on resume and load as well as on new: the agent builds its server set
265
+ * once per session, so a resumed session that omitted them would come back
266
+ * without the tools the role's config declares — which is exactly the shape of
267
+ * silent drop this plumbing exists to end.
268
+ */
269
+ private declaredMcpServers;
176
270
  private initialize;
177
271
  private captureRuntimeMetadata;
178
272
  private runPrompt;
@@ -185,6 +279,15 @@ export declare class AcpSession implements SessionHandle {
185
279
  */
186
280
  private settleAutomatically;
187
281
  private withinAutomaticBoundary;
282
+ /**
283
+ * Codex ACP 1.1.7 marks its protected MCP elicitation bridge on a locationless
284
+ * execute request. The marker is meaningful only together with the runner's
285
+ * independently supplied, adapter-authenticated metadata vocabulary and effective
286
+ * mode: an arbitrary ACP process cannot gain this path by copying `_meta` alone.
287
+ * Exact option ids/kinds bind recognition to the protected-MCP shape and keep
288
+ * malformed requests on the ordinary fail-closed path.
289
+ */
290
+ private isEffectiveCodexProtectedMcpApproval;
188
291
  private recordUpdate;
189
292
  /**
190
293
  * Codex ACP's phase extension is the only currently supported visibility
@@ -199,5 +302,6 @@ export declare class AcpSession implements SessionHandle {
199
302
  }): ConversationHandlePage;
200
303
  conversationSnapshot(): ConversationSnapshot;
201
304
  subscribeConversation(listener: Parameters<ConversationEventStore['subscribe']>[0]): () => void;
305
+ private isCurrentConversationEvent;
202
306
  private fail;
203
307
  }
@@ -12,10 +12,25 @@ const CANCEL_SETTLE_GRACE_MS = 15_000;
12
12
  const CANCEL_TERMINATE_GRACE_MS = 5_000;
13
13
  /** A permission no human answered is eventually a decision nobody made. */
14
14
  const PERMISSION_TIMEOUT_MS = 10 * 60_000;
15
- /** Spec §4.3: 10-15 s before a vanished controller triggers the unattended policy. */
15
+ /** Wait 1015 seconds before a vanished controller triggers the unattended policy. */
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]';
@@ -136,7 +151,7 @@ function canonicallyWithin(root, candidates) {
136
151
  * Map typed prompt provenance to the conversation ledger's source vocabulary.
137
152
  * Only operator-authored local sources may persist prompt bodies; external
138
153
  * E2E bodies (owner channel, monitor wakes) and scheduled-loop content are
139
- * recorded as digest/size placeholders (spec §8.3).
154
+ * recorded as digest/size placeholders.
140
155
  */
141
156
  function conversationSource(origin) {
142
157
  switch (origin?.kind) {
@@ -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);
@@ -306,7 +338,7 @@ export class AcpSession {
306
338
  }
307
339
  }
308
340
  /**
309
- * Honest restart recovery (spec §5.3): a prompt that was admitted but never
341
+ * Honest restart recovery: a prompt that was admitted but never
310
342
  * started is safe to dispatch again; a turn that had already started may
311
343
  * have caused side effects, so it is closed as `unknown_after_restart` —
312
344
  * never silently replayed.
@@ -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
- readiness: this.readiness,
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
- if (options.interrupt)
530
- await this.cancelActive(options.interruptSource ?? 'local-console');
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 { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
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,13 +625,52 @@ 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 { promptId, queuedBehind, completion, origin: options.origin };
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
553
671
  * admissions are transactional — a prompt the ledger cannot hold is refused,
554
672
  * because an acknowledged-then-lost prompt is worse than an error. Every
555
- * other source degrades to best-effort so the agent keeps working (§5.3).
673
+ * other source degrades to best-effort so the agent keeps working.
556
674
  */
557
675
  admitToLedger(promptId, text, queuedBehind, options) {
558
676
  const { source, persistBody } = conversationSource(options.origin);
@@ -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
- return { ...this.conversation.page(request), snapshot: this.conversationSnapshot() };
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(listener);
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;