@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.
Files changed (60) hide show
  1. package/README.md +81 -43
  2. package/dist/application/fleet-query-service.js +12 -0
  3. package/dist/application/role-creation-service.js +3 -1
  4. package/dist/application/types.d.ts +11 -0
  5. package/dist/briefing.js +9 -2
  6. package/dist/build-info.json +5 -5
  7. package/dist/cli.js +37 -7
  8. package/dist/config.d.ts +6 -3
  9. package/dist/config.js +28 -14
  10. package/dist/creation.d.ts +14 -15
  11. package/dist/creation.js +19 -13
  12. package/dist/docs.d.ts +1 -1
  13. package/dist/docs.js +92 -33
  14. package/dist/doctor.d.ts +1 -5
  15. package/dist/doctor.js +11 -18
  16. package/dist/fleet-proxy.d.ts +5 -0
  17. package/dist/harness/acp-agent.js +11 -6
  18. package/dist/harness/claude-code.js +200 -11
  19. package/dist/harness/codex.d.ts +4 -1
  20. package/dist/harness/codex.js +70 -12
  21. package/dist/harness/types.d.ts +54 -4
  22. package/dist/loops/manager.d.ts +30 -1
  23. package/dist/loops/manager.js +69 -6
  24. package/dist/loops/state.d.ts +18 -0
  25. package/dist/loops/state.js +4 -0
  26. package/dist/model-env.d.ts +71 -0
  27. package/dist/model-env.js +106 -0
  28. package/dist/monitor.js +1 -1
  29. package/dist/ops.js +1 -1
  30. package/dist/owner-channel/attachments.d.ts +2 -25
  31. package/dist/owner-channel/attachments.js +5 -61
  32. package/dist/owner-channel/channel.d.ts +30 -29
  33. package/dist/owner-channel/channel.js +291 -291
  34. package/dist/owner-channel/mcp.d.ts +24 -0
  35. package/dist/owner-channel/mcp.js +145 -0
  36. package/dist/owner-channel/notices.d.ts +7 -0
  37. package/dist/owner-channel/notices.js +9 -0
  38. package/dist/runner.d.ts +48 -0
  39. package/dist/runner.js +237 -85
  40. package/dist/session/acp.d.ts +104 -0
  41. package/dist/session/acp.js +213 -10
  42. package/dist/session/activity.d.ts +31 -0
  43. package/dist/session/activity.js +48 -0
  44. package/dist/session/conversation-normalizer.d.ts +6 -0
  45. package/dist/session/conversation-normalizer.js +153 -10
  46. package/dist/session/conversation-types.d.ts +23 -4
  47. package/dist/session/types.d.ts +35 -0
  48. package/dist/spawn.js +29 -17
  49. package/dist/supervisor/systemd.js +2 -29
  50. package/dist/watchdog/briefing.js +7 -0
  51. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  52. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  53. package/dist/web-app/index.html +1 -1
  54. package/dist/worklog.d.ts +7 -1
  55. package/dist/worklog.js +191 -39
  56. package/package.json +1 -3
  57. package/dist/owner-channel/message-recovery.d.ts +0 -25
  58. package/dist/owner-channel/message-recovery.js +0 -114
  59. package/dist/owner-channel/ours-client.d.ts +0 -148
  60. package/dist/owner-channel/ours-client.js +0 -231
@@ -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
- 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,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 { 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
@@ -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;
@@ -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. */