@ours.network/fleet 0.17.7 → 0.17.9

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.
@@ -1,5 +1,6 @@
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';
@@ -37,6 +38,20 @@ export interface AcpSessionOptions {
37
38
  permissionMode?: NonNullable<SessionSnapshot['permissionMode']>;
38
39
  /** Adapter-authenticated request-metadata vocabulary; never inferred from ACP `_meta`. */
39
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>;
40
55
  log(line: string): void;
41
56
  /** Test seam for the cancel-escalation grace period; production uses the default. */
42
57
  cancelGraceMs?: number;
@@ -172,6 +187,28 @@ export declare class AcpSession implements SessionHandle {
172
187
  * for it is what turned a busy agent into a timeout and then into "dead".
173
188
  */
174
189
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
190
+ /**
191
+ * Prepare the session for a prompt that asked to pre-empt current work.
192
+ *
193
+ * The old behaviour was one unconditional `session/cancel` notification
194
+ * followed immediately by `session/prompt`. That is what produced the owner's
195
+ * "request failed before completion":
196
+ *
197
+ * - `cancelActive` only awaits settlement when `this.activeTurn` is set, and
198
+ * a turn the ADAPTER started (steering's `startedNewTurn`) is never tracked
199
+ * here. So the cancel raced the adapter's own transcript repair and the new
200
+ * prompt landed while the last assistant message still held an unresolved
201
+ * `tool_use` — rejected with `stop_reason=tool_use`.
202
+ * - With nothing running at all, it still sent the cancel, and the prompt
203
+ * landed on a bare interrupted user message — rejected with
204
+ * `stop_reason=null`.
205
+ *
206
+ * So: never cancel across a tool boundary, and never cancel something whose
207
+ * settlement cannot be awaited. Everything else is queued, which the ACP queue
208
+ * already does correctly. The returned state is what the caller may claim to a
209
+ * human — `interrupted` only when a turn really was cancelled.
210
+ */
211
+ private prepareInterruptingDelivery;
175
212
  /**
176
213
  * Durably record a prompt admission BEFORE acceptance is returned. Browser
177
214
  * admissions are transactional — a prompt the ledger cannot hold is refused,
@@ -219,6 +256,15 @@ export declare class AcpSession implements SessionHandle {
219
256
  private settlePendingAutomatically;
220
257
  exitResult(): ExitRecord | null;
221
258
  close(): Promise<void>;
259
+ /**
260
+ * The role's declared MCP servers, or `[]`.
261
+ *
262
+ * Sent on resume and load as well as on new: the agent builds its server set
263
+ * once per session, so a resumed session that omitted them would come back
264
+ * without the tools the role's config declares — which is exactly the shape of
265
+ * silent drop this plumbing exists to end.
266
+ */
267
+ private declaredMcpServers;
222
268
  private initialize;
223
269
  private captureRuntimeMetadata;
224
270
  private runPrompt;
@@ -598,15 +598,19 @@ export class AcpSession {
598
598
  throw new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED);
599
599
  if (this.closing || !this.sessionId || !this.isAlive())
600
600
  throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
601
- if (options.interrupt)
602
- await this.cancelActive(options.interruptSource ?? 'local-console');
601
+ const delivery = options.interrupt
602
+ ? await this.prepareInterruptingDelivery(options.interruptSource ?? 'local-console')
603
+ : undefined;
603
604
  // Interrupting delivery must still use steering when supported. With no
604
605
  // live turn, the extension starts one and acknowledges `startedNewTurn`
605
606
  // immediately; a normal session/prompt would keep the monitor blocked until
606
607
  // the entire wake-triggered turn terminated.
607
608
  if (options.steer && this.steeringSupported) {
608
609
  const promptId = randomUUID();
609
- return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
610
+ return {
611
+ promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin,
612
+ ...(delivery ? { delivery } : {}),
613
+ };
610
614
  }
611
615
  const promptId = randomUUID();
612
616
  const queuedBehind = this.queueDepth;
@@ -618,7 +622,46 @@ export class AcpSession {
618
622
  this.queueDepth = Math.max(0, this.queueDepth - 1);
619
623
  return turnResult(false, 'failed', error?.message ?? String(error));
620
624
  });
621
- return { promptId, queuedBehind, completion, origin: options.origin };
625
+ return {
626
+ promptId, queuedBehind, completion, origin: options.origin,
627
+ delivery: delivery ?? (queuedBehind > 0 ? 'queued' : 'started'),
628
+ };
629
+ }
630
+ /**
631
+ * Prepare the session for a prompt that asked to pre-empt current work.
632
+ *
633
+ * The old behaviour was one unconditional `session/cancel` notification
634
+ * followed immediately by `session/prompt`. That is what produced the owner's
635
+ * "request failed before completion":
636
+ *
637
+ * - `cancelActive` only awaits settlement when `this.activeTurn` is set, and
638
+ * a turn the ADAPTER started (steering's `startedNewTurn`) is never tracked
639
+ * here. So the cancel raced the adapter's own transcript repair and the new
640
+ * prompt landed while the last assistant message still held an unresolved
641
+ * `tool_use` — rejected with `stop_reason=tool_use`.
642
+ * - With nothing running at all, it still sent the cancel, and the prompt
643
+ * landed on a bare interrupted user message — rejected with
644
+ * `stop_reason=null`.
645
+ *
646
+ * So: never cancel across a tool boundary, and never cancel something whose
647
+ * settlement cannot be awaited. Everything else is queued, which the ACP queue
648
+ * already does correctly. The returned state is what the caller may claim to a
649
+ * human — `interrupted` only when a turn really was cancelled.
650
+ */
651
+ async prepareInterruptingDelivery(source) {
652
+ if (!this.sessionId)
653
+ return 'started';
654
+ // No fleet-tracked turn to await. Either the session is idle — cancelling it
655
+ // corrupts the transcript for no gain — or the adapter is running a turn
656
+ // fleet never started, whose settlement nothing here can wait for. Queue in
657
+ // both cases: the ACP queue already orders this correctly.
658
+ if (!this.activeTurn)
659
+ return this.activeToolCalls.size > 0 ? 'deferred' : 'started';
660
+ // A tracked turn IS safe to cancel: cancelActive settles pending permissions
661
+ // and awaits the turn's own settlement before this returns, so the prompt
662
+ // below cannot race the adapter's transcript repair.
663
+ await this.cancelActive(source);
664
+ return 'interrupted';
622
665
  }
623
666
  /**
624
667
  * Durably record a prompt admission BEFORE acceptance is returned. Browser
@@ -945,6 +988,17 @@ export class AcpSession {
945
988
  });
946
989
  this.conversation.close();
947
990
  }
991
+ /**
992
+ * The role's declared MCP servers, or `[]`.
993
+ *
994
+ * Sent on resume and load as well as on new: the agent builds its server set
995
+ * once per session, so a resumed session that omitted them would come back
996
+ * without the tools the role's config declares — which is exactly the shape of
997
+ * silent drop this plumbing exists to end.
998
+ */
999
+ declaredMcpServers() {
1000
+ return this.options.mcpServers ?? [];
1001
+ }
948
1002
  async initialize() {
949
1003
  const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
950
1004
  protocolVersion: acp.PROTOCOL_VERSION,
@@ -964,7 +1018,7 @@ export class AcpSession {
964
1018
  const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
965
1019
  sessionId: persisted,
966
1020
  cwd: this.options.cwd,
967
- mcpServers: [],
1021
+ mcpServers: this.declaredMcpServers(),
968
1022
  });
969
1023
  this.captureRuntimeMetadata(resumed.configOptions);
970
1024
  this.sessionId = persisted;
@@ -977,7 +1031,7 @@ export class AcpSession {
977
1031
  const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
978
1032
  sessionId: persisted,
979
1033
  cwd: this.options.cwd,
980
- mcpServers: [],
1034
+ mcpServers: this.declaredMcpServers(),
981
1035
  });
982
1036
  this.captureRuntimeMetadata(loaded.configOptions);
983
1037
  }
@@ -989,7 +1043,8 @@ export class AcpSession {
989
1043
  else {
990
1044
  const created = await this.connection.agent.request(acp.methods.agent.session.new, {
991
1045
  cwd: this.options.cwd,
992
- mcpServers: [],
1046
+ mcpServers: this.declaredMcpServers(),
1047
+ ...(this.options.sessionMeta ? { _meta: this.options.sessionMeta } : {}),
993
1048
  });
994
1049
  this.sessionId = created.sessionId;
995
1050
  this.captureRuntimeMetadata(created.configOptions);
@@ -110,6 +110,15 @@ export declare function interruptOutcome(result: InterruptResult): InterruptOutc
110
110
  * stop here: the session has the prompt, and waiting for the turn to finish is
111
111
  * a different question with a different, much longer, timescale.
112
112
  */
113
+ /**
114
+ * What actually happened to an admitted prompt, so a caller reporting to a
115
+ * human can be accurate instead of repeating what it asked for.
116
+ *
117
+ * `interrupted` is only ever returned when a turn was really cancelled for this
118
+ * prompt. `deferred` says the session is busy with work this prompt could not
119
+ * safely pre-empt — the prompt is admitted and will run, just not yet.
120
+ */
121
+ export type PromptDelivery = 'started' | 'queued' | 'interrupted' | 'deferred';
113
122
  export interface QueuedPrompt {
114
123
  promptId: string;
115
124
  /** Turns already queued ahead of this one. 0 means it starts immediately. */
@@ -117,6 +126,8 @@ export interface QueuedPrompt {
117
126
  origin?: PromptOrigin;
118
127
  /** The turn's terminal result. Never rejects. */
119
128
  completion: Promise<TurnResult>;
129
+ /** Observed admission outcome. Absent on backends that do not report it. */
130
+ delivery?: PromptDelivery;
120
131
  }
121
132
  /**
122
133
  * How a session's process ended.
package/dist/spawn.js CHANGED
@@ -224,7 +224,7 @@ export function spawnDryRun(o) {
224
224
  const adapter = getAdapter(resolvedRole.harness);
225
225
  if (resolvedRole.auth_proxy && resolvedRole.harness !== 'claude-code')
226
226
  throw new Error('auth_proxy is supported only by claude-code');
227
- const optionProblems = adapter.validateOptions(resolvedRole.harness_options);
227
+ const optionProblems = adapter.validateOptions(resolvedRole.harness_options, resolvedRole);
228
228
  if (optionProblems.length)
229
229
  throw new Error(optionProblems.map(problem => `${problem.path}: ${problem.message}`).join('; '));
230
230
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.17.7",
3
+ "version": "0.17.9",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",