@ours.network/fleet 0.15.7 → 0.17.0

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 (36) hide show
  1. package/README.md +29 -3
  2. package/dist/application/role-creation-service.d.ts +2 -2
  3. package/dist/config.d.ts +4 -2
  4. package/dist/config.js +3 -2
  5. package/dist/docs.d.ts +1 -1
  6. package/dist/docs.js +24 -4
  7. package/dist/monitor.d.ts +4 -3
  8. package/dist/monitor.js +11 -2
  9. package/dist/owner-channel/channel.js +4 -1
  10. package/dist/runner.js +20 -5
  11. package/dist/session/acp.d.ts +23 -0
  12. package/dist/session/acp.js +183 -10
  13. package/dist/session/arbiter.d.ts +5 -0
  14. package/dist/session/arbiter.js +8 -0
  15. package/dist/session/conversation-normalizer.d.ts +6 -0
  16. package/dist/session/conversation-normalizer.js +4 -3
  17. package/dist/session/conversation-types.d.ts +9 -2
  18. package/dist/session/types.d.ts +13 -1
  19. package/dist/web/fleet-config-service.js +51 -15
  20. package/dist/web/runtime.js +9 -2
  21. package/dist/web/server.d.ts +6 -2
  22. package/dist/web/server.js +50 -0
  23. package/dist/web/topology-draft-store.d.ts +80 -0
  24. package/dist/web/topology-draft-store.js +337 -0
  25. package/dist/web/topology-model.d.ts +62 -0
  26. package/dist/web/topology-model.js +220 -0
  27. package/dist/web/topology-promote.d.ts +31 -0
  28. package/dist/web/topology-promote.js +168 -0
  29. package/dist/web/yaml-document-edit.d.ts +31 -0
  30. package/dist/web/yaml-document-edit.js +401 -0
  31. package/dist/web-app/assets/{TerminalView-BCbgag6j.js → TerminalView-B3rnVWbo.js} +1 -1
  32. package/dist/web-app/assets/index-CliHATFt.js +10 -0
  33. package/dist/web-app/assets/{index-COg4Azq1.css → index-DuC-xnX4.css} +1 -1
  34. package/dist/web-app/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/web-app/assets/index-BstfCP_F.js +0 -10
@@ -13,6 +13,9 @@ const CANCEL_SETTLE_GRACE_MS = 15_000;
13
13
  const PERMISSION_TIMEOUT_MS = 10 * 60_000;
14
14
  /** Spec §4.3: 10-15 s before a vanished controller triggers the unattended policy. */
15
15
  const CONTROLLER_GRACE_MS = 12_000;
16
+ /** Bound safe-boundary waiting without turning a hung tool into cancellation. */
17
+ export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
18
+ const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
16
19
  const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
17
20
  const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
18
21
  const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
@@ -95,9 +98,13 @@ export class AcpSession {
95
98
  runtimeModel;
96
99
  reasoningEffort;
97
100
  controllerCount = 0;
101
+ closing = false;
98
102
  /** Armed when the last controller detaches; unattended policy applies on fire. */
99
103
  controllerGrace;
100
104
  cancelEscalation;
105
+ /** ACP-authenticated in-flight calls, including independently reserved permissions. */
106
+ activeToolCalls = new Map();
107
+ toolBoundaryWaiters = new Set();
101
108
  activeTurn;
102
109
  constructor(options, child, connection) {
103
110
  this.options = options;
@@ -215,6 +222,142 @@ export class AcpSession {
215
222
  permissionMode: this.options.permissionMode,
216
223
  };
217
224
  }
225
+ toolCall(toolCallId) {
226
+ const existing = this.activeToolCalls.get(toolCallId);
227
+ if (existing)
228
+ return existing;
229
+ const created = { lifecycle: false, permissions: new Map() };
230
+ this.activeToolCalls.set(toolCallId, created);
231
+ return created;
232
+ }
233
+ reserveTool(toolCallId) {
234
+ if (toolCallId)
235
+ this.toolCall(toolCallId).lifecycle = true;
236
+ }
237
+ reservePermission(toolCallId, permissionId) {
238
+ if (toolCallId)
239
+ this.toolCall(toolCallId).permissions.set(permissionId, 'pending');
240
+ }
241
+ allowPermission(toolCallId, permissionId) {
242
+ if (!toolCallId)
243
+ return;
244
+ const permission = this.activeToolCalls.get(toolCallId)?.permissions;
245
+ if (permission?.has(permissionId))
246
+ permission.set(permissionId, 'allowed');
247
+ }
248
+ releasePermission(toolCallId, permissionId) {
249
+ const call = toolCallId && this.activeToolCalls.get(toolCallId);
250
+ if (!call || !call.permissions.delete(permissionId))
251
+ return;
252
+ this.releaseToolIfIdle(toolCallId, call);
253
+ }
254
+ releaseTool(toolCallId) {
255
+ const call = toolCallId && this.activeToolCalls.get(toolCallId);
256
+ if (!call)
257
+ return;
258
+ call.lifecycle = false;
259
+ // Terminal tool evidence consumes permissions already granted for this
260
+ // call, but never a separate request that is still awaiting a decision.
261
+ for (const [permissionId, state] of call.permissions)
262
+ if (state === 'allowed')
263
+ call.permissions.delete(permissionId);
264
+ this.releaseToolIfIdle(toolCallId, call);
265
+ }
266
+ releaseToolIfIdle(toolCallId, call) {
267
+ if (call.lifecycle || call.permissions.size > 0)
268
+ return;
269
+ if (!this.activeToolCalls.delete(toolCallId) || this.activeToolCalls.size > 0)
270
+ return;
271
+ for (const notify of [...this.toolBoundaryWaiters])
272
+ notify();
273
+ }
274
+ releaseAllTools() {
275
+ if (this.activeToolCalls.size === 0)
276
+ return;
277
+ this.activeToolCalls.clear();
278
+ for (const notify of [...this.toolBoundaryWaiters])
279
+ notify();
280
+ }
281
+ waitForToolBoundary(timeoutMs) {
282
+ if (this.activeToolCalls.size === 0)
283
+ return Promise.resolve(true);
284
+ return new Promise(resolve => {
285
+ let settled = false;
286
+ const finish = (atBoundary) => {
287
+ if (settled)
288
+ return;
289
+ settled = true;
290
+ clearTimeout(timer);
291
+ this.toolBoundaryWaiters.delete(check);
292
+ resolve(atBoundary);
293
+ };
294
+ const check = () => {
295
+ if (this.activeToolCalls.size === 0 || !this.isAlive())
296
+ finish(this.activeToolCalls.size === 0);
297
+ };
298
+ const timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
299
+ timer.unref?.();
300
+ this.toolBoundaryWaiters.add(check);
301
+ // Close the subscribe/check race without guessing about elapsed time.
302
+ check();
303
+ });
304
+ }
305
+ recordAfterToolDelivery(state, activeToolCount, waitedMs) {
306
+ this.events.emit('monitor_delivery', {
307
+ status: state, monitorPolicy: 'after_tool', activeToolCount, waitedMs,
308
+ });
309
+ this.conversation.appendSafe({
310
+ kind: 'monitor.delivery', sessionGeneration: this.sessionGeneration,
311
+ acpSessionId: this.sessionId, source: 'fleet_monitor',
312
+ payload: { policy: 'after_tool', state, activeToolCount, waitedMs },
313
+ });
314
+ }
315
+ /**
316
+ * Monitor-only safe-boundary delivery. Steering is the interruption: this
317
+ * path never calls session/cancel and never resolves a pending permission.
318
+ */
319
+ async submitPromptAfterTool(text, options = {}) {
320
+ if (this.closing || !this.isAlive())
321
+ return turnResult(false, 'failed', this.lastError ?? 'ACP session is closing');
322
+ const startedAt = Date.now();
323
+ const initialToolCount = this.activeToolCalls.size;
324
+ if (!this.steeringSupported) {
325
+ this.recordAfterToolDelivery('unsupported', initialToolCount, 0);
326
+ const result = await this.submitPrompt(text, { ...options, interrupt: false, steer: false });
327
+ return {
328
+ ...result,
329
+ safeBoundary: { state: 'unsupported', waitedMs: 0, activeToolCount: initialToolCount },
330
+ };
331
+ }
332
+ if (initialToolCount === 0) {
333
+ this.recordAfterToolDelivery('direct', 0, 0);
334
+ const result = await this.steerPrompt(text);
335
+ return { ...result, safeBoundary: { state: 'direct', waitedMs: 0, activeToolCount: 0 } };
336
+ }
337
+ this.recordAfterToolDelivery('deferred', initialToolCount, 0);
338
+ const timeoutMs = this.options.afterToolBoundaryTimeoutMs ?? AFTER_TOOL_BOUNDARY_TIMEOUT_MS;
339
+ const deadline = startedAt + timeoutMs;
340
+ let atBoundary = false;
341
+ // Re-check after every wake: another authenticated tool event may have
342
+ // arrived before this continuation ran. Only an empty tracked set is safe.
343
+ while (this.activeToolCalls.size > 0) {
344
+ const remaining = deadline - Date.now();
345
+ if (remaining <= 0 || !(await this.waitForToolBoundary(remaining)))
346
+ break;
347
+ }
348
+ atBoundary = this.activeToolCalls.size === 0;
349
+ if (this.closing || !this.isAlive())
350
+ return turnResult(false, 'failed', this.lastError ?? 'ACP session closed during after_tool wait');
351
+ const waitedMs = Math.max(0, Date.now() - startedAt);
352
+ const state = atBoundary ? 'after_tool' : 'timeout';
353
+ const remainingToolCount = this.activeToolCalls.size;
354
+ this.recordAfterToolDelivery(state, remainingToolCount, waitedMs);
355
+ const result = await this.steerPrompt(text);
356
+ return {
357
+ ...result,
358
+ safeBoundary: { state, waitedMs, activeToolCount: remainingToolCount },
359
+ };
360
+ }
218
361
  /**
219
362
  * Accept responsibility for a prompt, then return. The turn itself may run
220
363
  * for minutes behind other queued turns; making an interactive caller wait
@@ -365,6 +508,10 @@ export class AcpSession {
365
508
  clearTimeout(pending.expiry);
366
509
  pending.resolve({ outcome: { outcome: 'selected', optionId } });
367
510
  const decision = chosen.kind.startsWith('reject') ? 'denied' : 'allowed';
511
+ if (decision === 'allowed')
512
+ this.allowPermission(pending.toolCallId, permissionId);
513
+ else
514
+ this.releasePermission(pending.toolCallId, permissionId);
368
515
  this.events.emit('permission', {
369
516
  turnId: this.activeTurn?.id,
370
517
  origin: this.activeTurn?.origin,
@@ -381,7 +528,7 @@ export class AcpSession {
381
528
  promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
382
529
  payload: { decision, decisionSource: 'manual', optionId },
383
530
  });
384
- this.readiness = 'running';
531
+ this.readiness = this.pendingPermissions.size > 0 ? 'awaiting_permission' : 'running';
385
532
  return true;
386
533
  }
387
534
  /**
@@ -446,11 +593,12 @@ export class AcpSession {
446
593
  ? { outcome: { outcome: 'selected', optionId: rejectOption.optionId } }
447
594
  : { outcome: { outcome: 'cancelled' } });
448
595
  const settled = decision === 'denied' && !rejectOption ? 'cancelled' : decision;
596
+ this.releasePermission(pending.toolCallId, permissionId);
449
597
  this.events.emit('permission', {
450
598
  turnId: this.activeTurn?.id,
451
599
  origin: this.activeTurn?.origin,
452
600
  permissionId,
453
- toolCallId: pending.toolCallId,
601
+ toolCallId: pending.eventToolCallId,
454
602
  status: 'completed',
455
603
  decision: settled === 'expired' ? 'cancelled' : settled,
456
604
  decisionSource: 'automatic',
@@ -462,7 +610,7 @@ export class AcpSession {
462
610
  kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
463
611
  acpSessionId: this.sessionId, permissionId,
464
612
  promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
465
- toolCallId: pending.toolCallId,
613
+ toolCallId: pending.eventToolCallId,
466
614
  payload: {
467
615
  decision: settled, decisionSource: 'automatic',
468
616
  ...(policy ? { policy } : {}), reason,
@@ -476,6 +624,7 @@ export class AcpSession {
476
624
  return this.exit;
477
625
  }
478
626
  async close() {
627
+ this.closing = true;
479
628
  if (this.cancelEscalation)
480
629
  clearTimeout(this.cancelEscalation);
481
630
  this.cancelEscalation = undefined;
@@ -484,6 +633,7 @@ export class AcpSession {
484
633
  this.controllerGrace = undefined;
485
634
  for (const [permissionId, pending] of [...this.pendingPermissions])
486
635
  this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
636
+ this.releaseAllTools();
487
637
  if (this.sessionId && this.capabilities?.sessionCapabilities?.close != null) {
488
638
  await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.sessionId }).catch(() => undefined);
489
639
  }
@@ -627,6 +777,7 @@ export class AcpSession {
627
777
  return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
628
778
  }
629
779
  finally {
780
+ this.releaseAllTools();
630
781
  if (this.activeTurn?.id === turnId) {
631
782
  if (this.cancelEscalation)
632
783
  clearTimeout(this.cancelEscalation);
@@ -667,9 +818,19 @@ export class AcpSession {
667
818
  }
668
819
  return undefined;
669
820
  };
821
+ const toolCallId = params.toolCall.toolCallId;
822
+ const permissionId = randomUUID();
823
+ // Permission is part of the tool lifecycle. Reserve before any policy or
824
+ // human decision so a monitor wake cannot slip between request and answer.
825
+ this.reservePermission(toolCallId, permissionId);
670
826
  if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
671
827
  const option = choose(['allow_always', 'allow_once']);
672
- return Promise.resolve(this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`));
828
+ const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
829
+ if (option)
830
+ this.allowPermission(toolCallId, permissionId);
831
+ else
832
+ this.releasePermission(toolCallId, permissionId);
833
+ return Promise.resolve(response);
673
834
  }
674
835
  // A live grace window still counts as attended: the controller may be
675
836
  // mid-reconnect, and denying instantly is exactly what the grace prevents.
@@ -680,11 +841,12 @@ export class AcpSession {
680
841
  // a decision no human made, so one unattended denial would silently disable
681
842
  // the tool for the rest of the session.
682
843
  const option = choose(['reject_once', 'reject_always']);
683
- return Promise.resolve(this.settleAutomatically(params, option, 'denied', unattended ? 'permissions.unattended=deny' : 'permissions.approval=deny', unattended
844
+ const response = this.settleAutomatically(params, option, 'denied', unattended ? 'permissions.unattended=deny' : 'permissions.approval=deny', unattended
684
845
  ? 'no controller is attached, so the request cannot be shown to anyone'
685
- : 'the role denies every permission request by policy'));
846
+ : 'the role denies every permission request by policy');
847
+ this.releasePermission(toolCallId, permissionId);
848
+ return Promise.resolve(response);
686
849
  }
687
- const permissionId = randomUUID();
688
850
  const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
689
851
  const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
690
852
  this.readiness = 'awaiting_permission';
@@ -723,8 +885,8 @@ export class AcpSession {
723
885
  return new Promise(resolve => {
724
886
  const pending = {
725
887
  options: params.options, resolve,
726
- toolCallId: scheduledTurn(this.activeTurn)
727
- ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
888
+ toolCallId,
889
+ eventToolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : toolCallId,
728
890
  };
729
891
  pending.expiry = setTimeout(() => {
730
892
  this.settlePendingAutomatically(permissionId, pending, 'expired', undefined, `no decision arrived within ${Math.round(timeoutMs / 1000)}s`);
@@ -832,6 +994,10 @@ export class AcpSession {
832
994
  title: scheduled ? 'scheduled-loop tool' : update.title,
833
995
  status: update.status,
834
996
  });
997
+ if (TERMINAL_TOOL_STATUSES.has(update.status ?? ''))
998
+ this.releaseTool(update.toolCallId);
999
+ else
1000
+ this.reserveTool(update.toolCallId);
835
1001
  break;
836
1002
  case 'tool_call_update':
837
1003
  this.events.emit('tool_update', {
@@ -841,6 +1007,10 @@ export class AcpSession {
841
1007
  title: scheduled ? 'scheduled-loop tool' : update.title ?? undefined,
842
1008
  status: update.status ?? undefined,
843
1009
  });
1010
+ if (TERMINAL_TOOL_STATUSES.has(update.status ?? ''))
1011
+ this.releaseTool(update.toolCallId);
1012
+ else if (update.status !== undefined)
1013
+ this.reserveTool(update.toolCallId);
844
1014
  break;
845
1015
  default:
846
1016
  break;
@@ -864,7 +1034,10 @@ export class AcpSession {
864
1034
  }
865
1035
  /** Normalize every ACP update losslessly into the durable ledger. */
866
1036
  recordConversationUpdate(update, scheduled, commentary = false) {
867
- const normalized = normalizeSessionUpdate(update, scheduled ? { redactText: SCHEDULED_LOOP_REDACTION }
1037
+ const normalized = normalizeSessionUpdate(update, scheduled ? {
1038
+ redactText: SCHEDULED_LOOP_REDACTION,
1039
+ redactToolCallId: 'scheduled-loop-tool',
1040
+ }
868
1041
  : commentary ? { redactText: OWNER_COMMENTARY_REDACTION } : {});
869
1042
  this.conversation.appendSafe({
870
1043
  kind: normalized.kind,
@@ -27,6 +27,11 @@ export declare class RoleTurnArbiter implements SessionHandle {
27
27
  private track;
28
28
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
29
29
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
30
+ /**
31
+ * Do not hold `exclusive` while ACP waits for a tool boundary: permission
32
+ * answers and explicit interrupts must remain able to pass immediately.
33
+ */
34
+ submitPromptAfterTool(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
30
35
  tryScheduled(text: string, origin: Extract<PromptOrigin, {
31
36
  kind: 'scheduled-loop';
32
37
  }>, beforeQueue?: () => void | Promise<void>): Promise<ScheduledAttempt>;
@@ -32,6 +32,14 @@ export class RoleTurnArbiter {
32
32
  async submitPrompt(text, options = {}) {
33
33
  return (await this.queuePrompt(text, options)).completion;
34
34
  }
35
+ /**
36
+ * Do not hold `exclusive` while ACP waits for a tool boundary: permission
37
+ * answers and explicit interrupts must remain able to pass immediately.
38
+ */
39
+ submitPromptAfterTool(text, options = {}) {
40
+ return this.session.submitPromptAfterTool?.(text, options)
41
+ ?? this.session.submitPrompt(text, { ...options, interrupt: false, steer: false });
42
+ }
35
43
  async tryScheduled(text, origin, beforeQueue) {
36
44
  // Give owner/console/I/O callbacks already ready in this event-loop turn a
37
45
  // chance to claim the arbiter first. Scheduled work is best-effort; humans
@@ -23,6 +23,12 @@ export interface NormalizeOptions {
23
23
  * digest. Used for scheduled-loop turns whose output must not be retained.
24
24
  */
25
25
  redactText?: string;
26
+ /**
27
+ * Replace ACP tool-call IDs in both the normalized event correlation field
28
+ * and payload. The caller may still use the original update for in-memory
29
+ * lifecycle tracking without persisting its private identifier.
30
+ */
31
+ redactToolCallId?: string;
26
32
  }
27
33
  export interface NormalizedUpdate {
28
34
  kind: ConversationEventKind;
@@ -203,9 +203,10 @@ function normalizeToolContent(raw, redact) {
203
203
  }
204
204
  });
205
205
  }
206
- function toolUpsert(update, snapshot, redact) {
206
+ function toolUpsert(update, snapshot, options) {
207
+ const redact = options.redactText;
207
208
  const payload = {
208
- toolCallId: asString(update.toolCallId) ?? '',
209
+ toolCallId: options.redactToolCallId ?? asString(update.toolCallId) ?? '',
209
210
  snapshot,
210
211
  };
211
212
  if (asString(update.title) !== undefined)
@@ -271,7 +272,7 @@ export function normalizeSessionUpdate(update, options = {}) {
271
272
  }
272
273
  case 'tool_call':
273
274
  case 'tool_call_update': {
274
- const payload = toolUpsert(raw, raw.sessionUpdate === 'tool_call', redact);
275
+ const payload = toolUpsert(raw, raw.sessionUpdate === 'tool_call', options);
275
276
  return withMeta({
276
277
  kind: 'tool.upsert', payload,
277
278
  ...(payload.toolCallId ? { toolCallId: payload.toolCallId } : {}),
@@ -7,7 +7,7 @@ import type { PromptOrigin, TurnCancellationSource, TurnOutcome } from './types.
7
7
  * file touches the wire: ACP updates are reduced into these shapes by the
8
8
  * normalizer, and the store (phase 1) assigns `seq`/`eventId`/timestamps.
9
9
  */
10
- export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
10
+ export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
11
11
  /** A well-formed ACP update this version cannot represent. Bounded, never a crash. */
12
12
  | 'unsupported';
13
13
  /** Where a conversation record came from. Typed provenance, never prompt text. */
@@ -178,6 +178,13 @@ export interface PermissionResolvedPayload {
178
178
  policy?: string;
179
179
  reason?: string;
180
180
  }
181
+ /** Body-free monitor evidence. Tool ids, titles, output, and wake text are never stored. */
182
+ export interface MonitorDeliveryPayload {
183
+ policy: 'after_tool';
184
+ state: 'deferred' | 'direct' | 'after_tool' | 'timeout' | 'unsupported';
185
+ activeToolCount: number;
186
+ waitedMs: number;
187
+ }
181
188
  export interface TurnStatePayload {
182
189
  state: 'queued' | 'running' | 'awaiting_permission' | 'interrupt_requested';
183
190
  }
@@ -201,7 +208,7 @@ export interface BoundedJson {
201
208
  digest?: string;
202
209
  redacted?: true;
203
210
  }
204
- export type ConversationPayload = MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | SessionLifecyclePayload | ErrorPayload;
211
+ export type ConversationPayload = MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload;
205
212
  export interface ConversationEventV1 {
206
213
  schemaVersion: 1;
207
214
  roleId: string;
@@ -49,6 +49,12 @@ export interface TurnResult {
49
49
  cancellationSource?: TurnCancellationSource;
50
50
  /** Final assistant text captured structurally by a backend, when available. */
51
51
  output?: string;
52
+ /** Body-free monitor safe-boundary disposition, when this was an after_tool wake. */
53
+ safeBoundary?: {
54
+ state: 'direct' | 'after_tool' | 'timeout' | 'unsupported';
55
+ waitedMs: number;
56
+ activeToolCount: number;
57
+ };
52
58
  }
53
59
  /**
54
60
  * Why a control operation failed. The distinctions exist because collapsing
@@ -137,7 +143,7 @@ export interface SessionSnapshot {
137
143
  nativeMode: string;
138
144
  };
139
145
  }
140
- export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'turn_stop' | 'error';
146
+ export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'monitor_delivery' | 'turn_stop' | 'error';
141
147
  /** What a settled permission request resolved to. */
142
148
  export type PermissionDecision = 'allowed' | 'denied' | 'cancelled';
143
149
  export interface SessionEvent {
@@ -178,6 +184,10 @@ export interface SessionEvent {
178
184
  reason?: string;
179
185
  /** The option actually selected, when one was. */
180
186
  optionId?: string;
187
+ /** Body-free evidence for monitor safe-boundary delivery. */
188
+ monitorPolicy?: 'after_tool';
189
+ activeToolCount?: number;
190
+ waitedMs?: number;
181
191
  }
182
192
  export interface ConversationHandlePage {
183
193
  events: ConversationEventV1[];
@@ -206,6 +216,8 @@ export interface SessionHandle {
206
216
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
207
217
  /** Queue a prompt and wait for its terminal result. */
208
218
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
219
+ /** Monitor-only ACP safe-boundary delivery. Never implies human/control cancellation. */
220
+ submitPromptAfterTool?(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
209
221
  interrupt(source?: TurnCancellationSource): Promise<void>;
210
222
  respondPermission(permissionId: string, optionId: string): boolean;
211
223
  /** Generation-bound browser decision; stale/settled/invalid all fail closed. */
@@ -1,14 +1,15 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { chmodSync, existsSync, lstatSync, readFileSync, rmSync, } from 'node:fs';
3
3
  import { basename, dirname, join } from 'node:path';
4
- import { stringify } from 'yaml';
5
4
  import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
6
5
  import { loadConfig } from '../config.js';
7
6
  import { parseFleetDocument } from '../config-yaml.js';
8
7
  import { defaultConfigPath } from '../paths.js';
9
8
  import { FleetError } from '../application/errors.js';
9
+ import { redactSourceSecrets, renderModelOntoSource } from './yaml-document-edit.js';
10
10
  export const REDACTED_ENV_VALUE = '__OURS_FLEET_SECRET_REDACTED__';
11
11
  const MAX_CONFIG_BYTES = 256 * 1024;
12
+ const DIFF_CONTEXT_LINES = 3;
12
13
  const emptyReport = () => ({ ok: true, checks: [] });
13
14
  export class FleetConfigService {
14
15
  path;
@@ -32,13 +33,13 @@ export class FleetConfigService {
32
33
  this.assertRevision(baseRevision, currentSource);
33
34
  const current = parseFleetDocument(this.path, currentSource, 'strict').value;
34
35
  const restored = restoreRedactions(assertModel(model), current);
35
- const source = serialize(restored);
36
- const candidate = this.validateCandidate(source);
37
- const [redactedCurrent, redactedNext] = [redactModel(current), redactModel(restored)];
36
+ const nextSource = render(currentSource, restored);
37
+ const candidate = this.validateCandidate(nextSource);
38
+ const redactedNext = redactModel(restored);
38
39
  const preflight = await this.preflight(candidate.path).finally(candidate.remove);
39
40
  return {
40
41
  valid: true, revision: digest(currentSource), normalizedModel: redactedNext.model,
41
- diff: exactDiff(serialize(redactedCurrent.model), serialize(redactedNext.model)),
42
+ diff: sourceDiff(currentSource, nextSource),
42
43
  redactions: redactedNext.paths,
43
44
  impact: restartImpact(current, restored), preflight,
44
45
  };
@@ -49,13 +50,12 @@ export class FleetConfigService {
49
50
  this.assertRevision(baseRevision, currentSource);
50
51
  const current = parseFleetDocument(this.path, currentSource, 'strict').value;
51
52
  const restored = restoreRedactions(assertModel(model), current);
52
- const nextSource = serialize(restored);
53
+ const nextSource = render(currentSource, restored);
53
54
  const candidate = this.validateCandidate(nextSource);
54
55
  const preflight = await this.preflight(candidate.path).finally(candidate.remove);
55
56
  // The lock coordinates trusted web/agent writers. An operator's editor does
56
57
  // not take it, so re-check immediately before replacement as well.
57
58
  this.assertRevision(baseRevision, this.readSource());
58
- const redactedCurrent = redactModel(current);
59
59
  const redactedNext = redactModel(restored);
60
60
  let backup;
61
61
  if (existsSync(this.path)) {
@@ -67,7 +67,7 @@ export class FleetConfigService {
67
67
  return {
68
68
  saved: true, valid: true, revision: digest(currentSource), newRevision: digest(nextSource),
69
69
  normalizedModel: redactedNext.model,
70
- diff: exactDiff(serialize(redactedCurrent.model), serialize(redactedNext.model)),
70
+ diff: sourceDiff(currentSource, nextSource),
71
71
  redactions: redactedNext.paths, impact: restartImpact(current, restored), preflight,
72
72
  backup,
73
73
  };
@@ -110,8 +110,17 @@ function assertModel(value) {
110
110
  throw new FleetError('invalid_request', 'model must be a JSON object');
111
111
  return structuredClone(value);
112
112
  }
113
- function serialize(model) {
114
- return stringify(model, { lineWidth: 0, sortMapEntries: false });
113
+ /**
114
+ * Apply the edited model onto the user's own document rather than re-serializing
115
+ * it, so comments, key order and formatting outside the edit survive the save.
116
+ */
117
+ function render(currentSource, model) {
118
+ try {
119
+ return renderModelOntoSource(currentSource, model);
120
+ }
121
+ catch (error) {
122
+ throw new FleetError('invalid_request', error.message);
123
+ }
115
124
  }
116
125
  function digest(source) {
117
126
  return createHash('sha256').update(source).digest('hex');
@@ -173,12 +182,39 @@ function restoreRedactions(next, current) {
173
182
  }
174
183
  return next;
175
184
  }
176
- function exactDiff(before, after) {
177
- if (before === after)
185
+ /**
186
+ * Unified-style diff of the real file text with env secrets masked. Common
187
+ * leading/trailing lines are trimmed, so a surgical edit reviews as a small
188
+ * hunk instead of the whole configuration twice.
189
+ */
190
+ function sourceDiff(beforeSource, afterSource) {
191
+ const before = splitLines(redactSourceSecrets(beforeSource, REDACTED_ENV_VALUE));
192
+ const after = splitLines(redactSourceSecrets(afterSource, REDACTED_ENV_VALUE));
193
+ let head = 0;
194
+ while (head < before.length && head < after.length && before[head] === after[head])
195
+ head += 1;
196
+ let tail = 0;
197
+ while (tail < before.length - head && tail < after.length - head
198
+ && before[before.length - 1 - tail] === after[after.length - 1 - tail])
199
+ tail += 1;
200
+ if (head === before.length && head === after.length)
178
201
  return '';
179
- return ['--- fleet.yaml (current)', '+++ fleet.yaml (proposed)',
180
- ...before.trimEnd().split('\n').map(line => `-${line}`),
181
- ...after.trimEnd().split('\n').map(line => `+${line}`), ''].join('\n');
202
+ const start = Math.max(0, head - DIFF_CONTEXT_LINES);
203
+ const trailing = Math.min(DIFF_CONTEXT_LINES, tail);
204
+ const beforeSpan = before.length - tail + trailing - start;
205
+ const afterSpan = after.length - tail + trailing - start;
206
+ return [
207
+ '--- fleet.yaml (current)', '+++ fleet.yaml (proposed)',
208
+ `@@ -${start + 1},${beforeSpan} +${start + 1},${afterSpan} @@`,
209
+ ...before.slice(start, head).map(line => ` ${line}`),
210
+ ...before.slice(head, before.length - tail).map(line => `-${line}`),
211
+ ...after.slice(head, after.length - tail).map(line => `+${line}`),
212
+ ...before.slice(before.length - tail, before.length - tail + trailing).map(line => ` ${line}`),
213
+ '',
214
+ ].join('\n');
215
+ }
216
+ function splitLines(source) {
217
+ return source.replace(/\n$/, '').split('\n');
182
218
  }
183
219
  function objectKeys(value) {
184
220
  return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
@@ -19,7 +19,9 @@ import { AuditSink } from './audit.js';
19
19
  import { FleetEventBus } from './events.js';
20
20
  import { buildWebServer } from './server.js';
21
21
  import { FleetConfigService } from './fleet-config-service.js';
22
- import { deriveTopology } from './topology.js';
22
+ import { mergeTopology } from './topology-model.js';
23
+ import { TopologyDraftStore } from './topology-draft-store.js';
24
+ import { TopologyPromoteService } from './topology-promote.js';
23
25
  import { doctor } from '../doctor.js';
24
26
  import { TerminalBridgeManager } from './terminal/bridge.js';
25
27
  import { acquireWebServerLock } from './lock.js';
@@ -148,11 +150,16 @@ export async function startWebConsole(options) {
148
150
  configPath: options.configPath,
149
151
  preflight: path => doctor({ configPath: path, yamlMode: 'strict' }),
150
152
  });
153
+ const topologyDrafts = new TopologyDraftStore({ dir: webDir });
154
+ const readTopology = async () => mergeTopology(loadConfig(options.configPath), await query.list(), topologyDrafts.read());
155
+ const topologyPromote = new TopologyPromoteService({
156
+ drafts: topologyDrafts, configuration, topology: readTopology,
157
+ });
151
158
  let server;
152
159
  try {
153
160
  server = await buildWebServer({
154
161
  query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
155
- topology: async () => deriveTopology(loadConfig(options.configPath), await query.list()),
162
+ topology: readTopology, topologyDrafts, topologyPromote,
156
163
  terminalUpgrade: terminalAvailable
157
164
  ? async (socket, _request, roleId, _ticket, hello) => terminals.connect(socket, roleId, hello)
158
165
  : undefined,
@@ -11,7 +11,9 @@ import { AuditSink } from './audit.js';
11
11
  import { WebAuth } from './auth.js';
12
12
  import { FleetEventBus } from './events.js';
13
13
  import type { FleetConfigService } from './fleet-config-service.js';
14
- import type { TopologySnapshot } from './topology.js';
14
+ import type { MergedTopology } from './topology-model.js';
15
+ import type { TopologyDraftStore } from './topology-draft-store.js';
16
+ import type { TopologyPromoteService } from './topology-promote.js';
15
17
  import type { RoleRemovalService } from '../application/role-removal-service.js';
16
18
  export interface WebServices {
17
19
  query: FleetQueryService;
@@ -24,7 +26,9 @@ export interface WebServices {
24
26
  events?: FleetEventBus;
25
27
  watchdogs?: WatchdogQueryService;
26
28
  configuration?: FleetConfigService;
27
- topology?: () => Promise<TopologySnapshot>;
29
+ topology?: () => Promise<MergedTopology>;
30
+ topologyDrafts?: TopologyDraftStore;
31
+ topologyPromote?: TopologyPromoteService;
28
32
  removal?: RoleRemovalService;
29
33
  terminalUpgrade?: (socket: WebSocket, request: FastifyRequest, roleId: string, ticket: string, hello: Record<string, unknown>) => Promise<void>;
30
34
  }