maka-agent 0.2.0-dev.49.20260924 → 0.2.0-dev.50.20260925

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 (34) hide show
  1. package/LICENSE +35 -0
  2. package/dist/acp/maka-acp-agent.js +71 -11
  3. package/dist/acp/session-event-mapper.js +59 -3
  4. package/dist/acp/session-interactions.js +33 -3
  5. package/dist/acp/session-mcp.js +160 -11
  6. package/dist/acp/session-registry.js +1014 -242
  7. package/dist/acp/turn-observation.js +260 -0
  8. package/dist/mcp-capability-publication.js +17 -1
  9. package/dist/pi-transcript-tools.js +8 -3
  10. package/dist/runtime-host-session-channel.js +71 -47
  11. package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
  12. package/node_modules/@maka/core/dist/relative-time.js +9 -1
  13. package/node_modules/@maka/core/dist/session.js +0 -15
  14. package/node_modules/@maka/core/dist/tool-quiet-preview.js +31 -0
  15. package/node_modules/@maka/core/dist/transcript-search.js +0 -1
  16. package/node_modules/@maka/runtime/dist/conversation-copy.js +7 -1
  17. package/node_modules/@maka/runtime/dist/runtime-event-backfill.js +0 -1
  18. package/node_modules/@maka/runtime-host/dist/client/client-capability-channel.js +3 -1
  19. package/node_modules/@maka/runtime-host/dist/client/connection.js +2 -2
  20. package/node_modules/@maka/runtime-host/dist/client/session-subscription.js +0 -3
  21. package/node_modules/@maka/runtime-host/dist/protocol/client-capability.js +19 -1
  22. package/node_modules/@maka/runtime-host/dist/protocol/index.js +4 -1
  23. package/node_modules/@maka/runtime-host/dist/protocol/session-continuity.js +1 -2
  24. package/node_modules/@maka/runtime-host/dist/server/client-capability-coordinator.js +122 -9
  25. package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +11 -6
  26. package/node_modules/@maka/runtime-host/dist/server/root-turn-coordinator.js +5 -0
  27. package/node_modules/@maka/runtime-host/dist/server/session-continuity-coordinator.js +0 -16
  28. package/node_modules/@maka/runtime-host/dist/server/session-transcript-reader.js +3 -38
  29. package/node_modules/@maka/runtime-host/dist/server/shared-session-transcript.js +0 -3
  30. package/node_modules/@maka/storage/dist/interaction-store.js +0 -13
  31. package/node_modules/@maka/storage/dist/runtime-partial-values.js +5 -1
  32. package/node_modules/@maka/storage/dist/sqlite-core-execution-schema.js +1 -3
  33. package/node_modules/@maka/storage/dist/usage-screen.js +101 -35
  34. package/package.json +1 -1
package/LICENSE CHANGED
@@ -574,6 +574,41 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
574
574
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
575
575
  SOFTWARE.
576
576
 
577
+ virtua dependency patch
578
+
579
+ Source: https://www.npmjs.com/package/virtua/v/0.52.7
580
+ Repository: https://github.com/inokawa/virtua
581
+ Version: 0.52.7
582
+ Dependency patch: patches/virtua+0.52.7.patch
583
+ Copyright (c) 2022 inokawa
584
+ License: MIT
585
+
586
+ Maka redistributes a source patch that applies virtua's scroll correction
587
+ absolutely after the browser clamps the scroll offset. The following MIT
588
+ License applies to that material:
589
+
590
+ MIT License
591
+
592
+ Copyright (c) 2022 inokawa
593
+
594
+ Permission is hereby granted, free of charge, to any person obtaining a copy
595
+ of this software and associated documentation files (the "Software"), to deal
596
+ in the Software without restriction, including without limitation the rights
597
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
598
+ copies of the Software, and to permit persons to whom the Software is
599
+ furnished to do so, subject to the following conditions:
600
+
601
+ The above copyright notice and this permission notice shall be included in all
602
+ copies or substantial portions of the Software.
603
+
604
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
605
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
606
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
607
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
608
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
609
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
610
+ SOFTWARE.
611
+
577
612
  trycua/cua cursor-overlay
578
613
 
579
614
  Source: https://github.com/trycua/cua
@@ -16,7 +16,8 @@
16
16
  * specific language governing permissions and limitations
17
17
  * under the License.
18
18
  */
19
- import { agent, methods } from '@agentclientprotocol/sdk';
19
+ import { agent, methods, RequestError, } from '@agentclientprotocol/sdk';
20
+ import { HOST_OPERATION_SPECS } from '@maka/runtime-host/protocol';
20
21
  export function createMakaAcpAgent(options) {
21
22
  let clientCapabilities = {};
22
23
  return agent({ name: 'maka' })
@@ -24,25 +25,84 @@ export function createMakaAcpAgent(options) {
24
25
  clientCapabilities = structuredClone(params.clientCapabilities ?? {});
25
26
  return {
26
27
  protocolVersion: 1,
27
- agentCapabilities: { sessionCapabilities: { list: {}, close: {} } },
28
+ agentCapabilities: {
29
+ loadSession: true,
30
+ sessionCapabilities: { list: {}, resume: {}, close: {} },
31
+ },
28
32
  authMethods: [],
29
33
  agentInfo: { name: 'maka', title: 'Maka', version: options.version },
30
34
  };
31
35
  })
32
36
  .onRequest(methods.agent.session.new, ({ params, signal }) => options.sessionRegistry.create(params, signal))
37
+ .onRequest(methods.agent.session.load, ({ params, signal, client }) => options.sessionRegistry.load(params, sessionContext(client, signal, clientCapabilities, true)))
38
+ .onRequest(methods.agent.session.resume, ({ params, signal, client }) => options.sessionRegistry.resume(params, sessionContext(client, signal, clientCapabilities, true)))
39
+ .onRequest('_maka/turn/resume', (value) => {
40
+ try {
41
+ return HOST_OPERATION_SPECS['turn.resume.query'].decodeInput(value);
42
+ }
43
+ catch {
44
+ throw RequestError.invalidParams({ reason: 'invalid_resume_query' }, 'Invalid Turn resume request');
45
+ }
46
+ }, ({ params, signal, client }) => options.sessionRegistry.resumeTurn(params, sessionContext(client, signal, clientCapabilities, true)))
47
+ .onRequest('_maka/session/copy-source/query', {
48
+ parse: (value) => {
49
+ try {
50
+ return HOST_OPERATION_SPECS['session.turns.query'].decodeInput(value);
51
+ }
52
+ catch {
53
+ throw RequestError.invalidParams({ reason: 'invalid_copy_source_query' }, 'Invalid Session copy source query');
54
+ }
55
+ },
56
+ }, ({ params }) => options.sessionRegistry.queryCopySource(params))
57
+ .onRequest('_maka/session/branch/create', {
58
+ parse: (value) => {
59
+ try {
60
+ return HOST_OPERATION_SPECS['session.branch.create'].decodeInput(value);
61
+ }
62
+ catch {
63
+ throw RequestError.invalidParams({ reason: 'invalid_branch_input' }, 'Invalid Session branch request');
64
+ }
65
+ },
66
+ }, ({ params }) => options.sessionRegistry.branch(params))
67
+ .onRequest('_maka/session/revision/create', {
68
+ parse: (value) => {
69
+ try {
70
+ return HOST_OPERATION_SPECS['session.revision.create'].decodeInput(value);
71
+ }
72
+ catch {
73
+ throw RequestError.invalidParams({ reason: 'invalid_revision_input' }, 'Invalid Session revision request');
74
+ }
75
+ },
76
+ }, ({ params }) => options.sessionRegistry.createRevision(params))
77
+ .onRequest('_maka/session/revision/abandon', {
78
+ parse: (value) => {
79
+ try {
80
+ return HOST_OPERATION_SPECS['session.revision.abandon'].decodeInput(value);
81
+ }
82
+ catch {
83
+ throw RequestError.invalidParams({ reason: 'invalid_abandon_input' }, 'Invalid Session abandon request');
84
+ }
85
+ },
86
+ }, ({ params }) => options.sessionRegistry.abandonRevision(params))
33
87
  .onRequest(methods.agent.session.list, ({ params }) => options.sessionRegistry.list(params))
34
88
  .onRequest(methods.agent.session.setConfigOption, ({ params }) => options.sessionRegistry.setConfigOption(params))
35
- .onRequest(methods.agent.session.prompt, ({ params, signal, client }) => options.sessionRegistry.prompt(params, {
89
+ .onRequest(methods.agent.session.prompt, ({ params, signal, client }) => options.sessionRegistry.prompt(params, sessionContext(client, signal, clientCapabilities)))
90
+ .onNotification(methods.agent.session.cancel, ({ params }) => options.sessionRegistry.cancel(params))
91
+ .onRequest(methods.agent.session.close, ({ params }) => options.sessionRegistry.close(params));
92
+ }
93
+ function sessionContext(client, signal, capabilities, turnStatus = false) {
94
+ return {
36
95
  signal,
37
96
  notify: (notification) => client.notify(methods.client.session.update, notification),
38
97
  interactions: {
39
- capabilities: clientCapabilities,
40
- requestPermission: (params, cancellationSignal) => client.request(methods.client.session.requestPermission, params, {
41
- cancellationSignal,
42
- }),
43
- createElicitation: (params, cancellationSignal) => client.request(methods.client.elicitation.create, params, { cancellationSignal }),
98
+ capabilities,
99
+ requestPermission: (request, cancellationSignal) => client.request(methods.client.session.requestPermission, request, { cancellationSignal }),
100
+ createElicitation: (request, cancellationSignal) => client.request(methods.client.elicitation.create, request, { cancellationSignal }),
44
101
  },
45
- }))
46
- .onNotification(methods.agent.session.cancel, ({ params }) => options.sessionRegistry.cancel(params))
47
- .onRequest(methods.agent.session.close, ({ params }) => options.sessionRegistry.close(params));
102
+ ...(turnStatus && capabilities._meta?.['_maka/turnStatus'] === true
103
+ ? {
104
+ notifyTurnStatus: (status) => client.notify('_maka/turn/status', status),
105
+ }
106
+ : {}),
107
+ };
48
108
  }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { foldRuntimeHostAssistantDelta } from '@maka/runtime-host/adapter';
20
20
  import { RequestError, } from '@agentclientprotocol/sdk';
21
+ import { userFacingText } from '@maka/core/session';
21
22
  import { whileActive } from './active-promise.js';
22
23
  import { AcpToolEventMapper } from './tool-event-mapper.js';
23
24
  /** Serializes one ACP prompt's live projection delivery. */
@@ -67,20 +68,72 @@ export class AcpSessionEventMapper {
67
68
  return this.acceptTranscriptMessages(turnId, messages);
68
69
  }
69
70
  /** Apply a bounded authoritative batch; absence from one batch never removes a tool. */
70
- acceptTranscriptMessages(turnId, messages) {
71
+ acceptTranscriptMessages(turnId, messages, ignoreOlder = false) {
71
72
  return this.#enqueue(async () => {
72
73
  for (const message of messages) {
73
74
  if (message.turnId !== turnId)
74
75
  continue;
75
76
  if (message.type === 'assistant') {
76
- await this.#acceptText('thinking', message.id, message.thinking?.text ?? '');
77
- await this.#acceptText('text', message.id, message.text);
77
+ const thinking = message.thinking?.text ?? '';
78
+ if (!ignoreOlder ||
79
+ thinking.startsWith(this.#streams.get(streamKey('thinking', message.id)) ?? ''))
80
+ await this.#acceptText('thinking', message.id, thinking);
81
+ if (!ignoreOlder ||
82
+ message.text.startsWith(this.#streams.get(streamKey('text', message.id)) ?? ''))
83
+ await this.#acceptText('text', message.id, message.text);
78
84
  }
79
85
  else
80
86
  await this.#tools.acceptMessage(message);
81
87
  }
82
88
  });
83
89
  }
90
+ /** Replay one durable row in transcript order for session/load. */
91
+ acceptHistoricalMessage(message) {
92
+ return this.#enqueue(async () => {
93
+ if (message.type === 'user') {
94
+ // ACP v1 has no mid-Turn steering update. Live projection omits these
95
+ // rows, so historical replay must not present them as new user turns.
96
+ if (message.steeringEventId)
97
+ return;
98
+ const attachments = message.attachments ?? [];
99
+ const text = [
100
+ userFacingText(message),
101
+ ...attachments.map((attachment) => `[Attachment: ${attachment.name} (${attachment.mimeType}, ${attachment.bytes} bytes)]`),
102
+ ]
103
+ .filter(Boolean)
104
+ .join('\n\n');
105
+ if (text) {
106
+ await this.#deliver({
107
+ sessionId: this.#sessionId,
108
+ update: {
109
+ sessionUpdate: 'user_message_chunk',
110
+ content: { type: 'text', text },
111
+ messageId: message.id,
112
+ ...(attachments.length || message.origin
113
+ ? {
114
+ _meta: {
115
+ ...(attachments.length
116
+ ? { '_maka/attachments': structuredClone(attachments) }
117
+ : {}),
118
+ ...(message.origin
119
+ ? { '_maka/origin': structuredClone(message.origin) }
120
+ : {}),
121
+ },
122
+ }
123
+ : {}),
124
+ },
125
+ });
126
+ }
127
+ }
128
+ else if (message.type === 'assistant') {
129
+ await this.#acceptText('thinking', message.id, message.thinking?.text ?? '');
130
+ await this.#acceptText('text', message.id, message.text);
131
+ }
132
+ else {
133
+ await this.#tools.acceptMessage(message);
134
+ }
135
+ });
136
+ }
84
137
  finishTools(turnId, terminalStatus = 'completed') {
85
138
  return this.#enqueue(() => this.#tools.finishTools(turnId, terminalStatus));
86
139
  }
@@ -97,6 +150,9 @@ export class AcpSessionEventMapper {
97
150
  throw this.#failure;
98
151
  });
99
152
  }
153
+ textForMessage(kind, messageId) {
154
+ return this.#streams.get(streamKey(kind, messageId));
155
+ }
100
156
  async #acceptText(kind, hostMessageId, nextText) {
101
157
  const key = streamKey(kind, hostMessageId);
102
158
  const current = this.#streams.get(key) ?? '';
@@ -33,6 +33,8 @@ function questionAnswerMaxChars(count) {
33
33
  /** Connection-local presentation of Host interactions; the Host owns every answer and grant. */
34
34
  export class AcpSessionInteractions {
35
35
  #options;
36
+ #client;
37
+ #clientLease;
36
38
  #lifetime = new AbortController();
37
39
  #pending = new Map();
38
40
  #resolving = new Map();
@@ -41,6 +43,34 @@ export class AcpSessionInteractions {
41
43
  #cancelledInteractions = new Set();
42
44
  constructor(options) {
43
45
  this.#options = options;
46
+ this.#client = options.client;
47
+ this.#clientLease = { client: options.client, valid: true };
48
+ }
49
+ setClient(client) {
50
+ const lease = {
51
+ client,
52
+ previous: this.#clientLease,
53
+ valid: true,
54
+ };
55
+ this.#clientLease = lease;
56
+ this.#client = client;
57
+ return {
58
+ rollback: () => {
59
+ lease.valid = false;
60
+ if (this.#clientLease !== lease)
61
+ return;
62
+ let previous = lease.previous;
63
+ while (previous && !previous.valid)
64
+ previous = previous.previous;
65
+ if (!previous)
66
+ return;
67
+ this.#clientLease = previous;
68
+ this.#client = previous.client;
69
+ },
70
+ commit: () => {
71
+ lease.previous = undefined;
72
+ },
73
+ };
44
74
  }
45
75
  pending(snapshot) {
46
76
  if (this.#lifetime.signal.aborted) {
@@ -159,7 +189,7 @@ export class AcpSessionInteractions {
159
189
  }
160
190
  const request = pending.request;
161
191
  if ((request.kind === 'question' || request.kind === 'form') &&
162
- this.#options.client.capabilities.elicitation?.form == null) {
192
+ this.#client.capabilities.elicitation?.form == null) {
163
193
  throw interactionError(pending, 'unsupported_interaction', 'Client requires form elicitation');
164
194
  }
165
195
  const presented = await whileActive(Promise.resolve().then(() => {
@@ -170,7 +200,7 @@ export class AcpSessionInteractions {
170
200
  return;
171
201
  let answer;
172
202
  if (request.kind === 'question' || request.kind === 'form') {
173
- const response = await whileActive(this.#options.client.createElicitation(elicitationRequest(pending), signal), signal);
203
+ const response = await whileActive(this.#client.createElicitation(elicitationRequest(pending), signal), signal);
174
204
  if (!response.active)
175
205
  return;
176
206
  if (request.kind === 'question' && response.value.action === 'cancel') {
@@ -188,7 +218,7 @@ export class AcpSessionInteractions {
188
218
  }
189
219
  else {
190
220
  const presentation = permissionPresentation(pending);
191
- const response = await whileActive(this.#options.client.requestPermission(presentation.request, signal), signal);
221
+ const response = await whileActive(this.#client.requestPermission(presentation.request, signal), signal);
192
222
  if (!response.active)
193
223
  return;
194
224
  if (response.value.outcome.outcome === 'cancelled') {
@@ -16,12 +16,14 @@
16
16
  * specific language governing permissions and limitations
17
17
  * under the License.
18
18
  */
19
+ import { createHash } from 'node:crypto';
19
20
  import { isAbsolute } from 'node:path';
20
21
  import { RequestError } from '@agentclientprotocol/sdk';
21
22
  import { MCP_CONFIG_VERSION } from '@maka/core/mcp';
23
+ import { stableJsonStringify } from '@maka/core/canonical-json';
22
24
  import { McpClientManager } from '@maka/mcp';
23
25
  import { normalizeMcpConfig } from '@maka/storage/mcp-config-store';
24
- import { abortable } from '@maka/runtime-host/client';
26
+ import { RuntimeHostOperationError, abortable, } from '@maka/runtime-host/client';
25
27
  import { createMcpCapabilityProvider } from '../mcp-capability-provider.js';
26
28
  import { McpCapabilityPublication } from '../mcp-capability-publication.js';
27
29
  /** Validates before any process or Host work; never writes a user MCP configuration. */
@@ -80,6 +82,7 @@ export function createAcpMcpConfig(params) {
80
82
  export class AcpSessionMcp {
81
83
  #sessionId;
82
84
  #config;
85
+ #configurationTail = Promise.resolve();
83
86
  #manager = new McpClientManager({
84
87
  clientName: 'maka-acp',
85
88
  excludedStdioEnvironmentKeys: ['MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'],
@@ -87,6 +90,11 @@ export class AcpSessionMcp {
87
90
  #publication;
88
91
  #unsubscribeManager;
89
92
  #unsubscribeConnection;
93
+ #pendingManager;
94
+ #retainedManagers = new Set();
95
+ #retirementTasks = new Set();
96
+ #publicationRevision = 0;
97
+ #requireIdlePublication = false;
90
98
  #availability;
91
99
  #prepared = false;
92
100
  #closed = false;
@@ -99,7 +107,7 @@ export class AcpSessionMcp {
99
107
  connectionIdentity: () => this.#availability?.kind === 'connected'
100
108
  ? this.#availability.hostEpoch + '\0' + this.#availability.connectionId
101
109
  : undefined,
102
- revision: () => this.#manager.toolSnapshot().revision,
110
+ revision: () => this.#publicationRevision,
103
111
  createProvider: () => createMcpCapabilityProvider(this.#manager, {
104
112
  admission: 'mcp',
105
113
  onCurrentRegistrationRetired: () => this.#retire(),
@@ -110,14 +118,21 @@ export class AcpSessionMcp {
110
118
  offers: () => [],
111
119
  currentRegistrationRetired: () => this.#retire(),
112
120
  },
113
- replace: (provider) => connection.replaceClientCapabilities(provider, { sessionId }),
121
+ replace: (provider) => connection.replaceClientCapabilities(provider, {
122
+ sessionId,
123
+ sessionConfigurationId: `sha256:${createHash('sha256').update(stableJsonStringify(this.#config)).digest('hex')}`,
124
+ ...(this.#requireIdlePublication ? { requireIdleSession: true } : {}),
125
+ }),
114
126
  unregister: () => connection.unregisterClientCapabilities({ sessionId }),
115
- onState: () => undefined,
116
- });
117
- this.#unsubscribeManager = this.#manager.onChange(() => {
118
- if (this.#prepared && !this.#closed)
119
- this.#publication.request();
127
+ onState: (state) => {
128
+ if ((state === 'published' || state === 'not_published') &&
129
+ this.#publication.publishedRevision === this.#publicationRevision) {
130
+ this.#requireIdlePublication = false;
131
+ this.#retireRetainedManagers();
132
+ }
133
+ },
120
134
  });
135
+ this.#unsubscribeManager = this.#observeManager(this.#manager);
121
136
  this.#unsubscribeConnection = connection.subscribeConnectionAvailability((availability) => {
122
137
  this.#availability = availability;
123
138
  if (availability.kind !== 'connected')
@@ -150,13 +165,118 @@ export class AcpSessionMcp {
150
165
  signal?.removeEventListener('abort', cancel);
151
166
  }
152
167
  }
168
+ get config() {
169
+ return structuredClone(this.#config);
170
+ }
171
+ reconfigure(config, signal, assertCanChange) {
172
+ const task = this.#configurationTail
173
+ .catch(() => undefined)
174
+ .then(async () => {
175
+ this.#assertOpen('mcp.prepare');
176
+ signal?.throwIfAborted();
177
+ if (sameMcpConfig(this.#config, config))
178
+ return this.#settlePublication(signal);
179
+ if (this.#retainedManagers.size > 0)
180
+ await this.#settlePublication(signal);
181
+ await assertCanChange?.();
182
+ signal?.throwIfAborted();
183
+ const previous = this.#manager;
184
+ const previousConfig = this.#config;
185
+ const staged = new McpClientManager({
186
+ clientName: 'maka-acp',
187
+ excludedStdioEnvironmentKeys: ['MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'],
188
+ });
189
+ this.#pendingManager = staged;
190
+ let swapped = false;
191
+ let previousRevision = this.#publicationRevision;
192
+ try {
193
+ await staged.sync(config);
194
+ signal?.throwIfAborted();
195
+ this.#assertConnected(config, staged);
196
+ previousRevision = this.#publicationRevision;
197
+ this.#unsubscribeManager();
198
+ this.#manager = staged;
199
+ this.#config = config;
200
+ this.#unsubscribeManager = this.#observeManager(staged);
201
+ this.#retainedManagers.add(previous);
202
+ this.#pendingManager = undefined;
203
+ swapped = true;
204
+ this.#requireIdlePublication = true;
205
+ this.#publicationRevision += 1;
206
+ this.#publication.request();
207
+ await this.#settlePublication(signal);
208
+ this.#requireIdlePublication = false;
209
+ this.#retireRetainedManagers();
210
+ await Promise.allSettled([...this.#retirementTasks]);
211
+ }
212
+ catch (error) {
213
+ if (swapped && !this.#closed) {
214
+ // Cancellation can win after the Host committed a new provider but
215
+ // before its response arrived. Resolve the in-flight publication
216
+ // before deciding which manager may be released.
217
+ await this.#publication.waitForPending();
218
+ if (this.#publication.publishedRevision === previousRevision &&
219
+ this.#publication.lastError instanceof RuntimeHostOperationError) {
220
+ this.#unsubscribeManager();
221
+ this.#manager = previous;
222
+ this.#config = previousConfig;
223
+ this.#unsubscribeManager = this.#observeManager(previous);
224
+ this.#retainedManagers.delete(previous);
225
+ this.#publicationRevision = previousRevision;
226
+ this.#requireIdlePublication = false;
227
+ this.#publication.request();
228
+ await this.#publication.settle().catch(() => undefined);
229
+ await staged.close().catch(() => undefined);
230
+ }
231
+ else {
232
+ // A committed or unknown outcome may already be serving a Turn.
233
+ // Keep that provider and its manager; the previous manager stays
234
+ // available until a definitive publication or Session close.
235
+ this.#retireRetainedManagers();
236
+ }
237
+ }
238
+ else if (!swapped) {
239
+ this.#pendingManager = undefined;
240
+ await staged.close().catch(() => undefined);
241
+ }
242
+ if (error instanceof RequestError)
243
+ throw error;
244
+ throw mcpUnavailable(this.#sessionId, 'mcp.prepare', 'mcp_reconfiguration_failed');
245
+ }
246
+ });
247
+ this.#configurationTail = task;
248
+ return task;
249
+ }
153
250
  async ready(signal) {
251
+ await abortable(() => this.#configurationTail.catch(() => undefined), signal);
252
+ await this.#settlePublication(signal);
253
+ }
254
+ async #settlePublication(signal) {
154
255
  signal?.throwIfAborted();
155
256
  this.#assertOpen('mcp.ready');
156
257
  const state = await abortable(() => this.#publication.settle(), signal);
157
258
  signal?.throwIfAborted();
158
259
  this.#assertOpen('mcp.ready');
159
260
  if (state !== 'published' && state !== 'not_published') {
261
+ if (this.#publication.lastError instanceof RuntimeHostOperationError &&
262
+ this.#publication.lastError.code === 'session_binding_conflict') {
263
+ throw RequestError.internalError({
264
+ source: 'runtime_host',
265
+ operation: 'mcp.prepare',
266
+ sessionId: this.#sessionId,
267
+ code: 'session_binding_conflict',
268
+ }, 'Session MCP configuration conflicts with an active or frozen provider; restore with the same configuration or close the active attachment');
269
+ }
270
+ if (this.#requireIdlePublication &&
271
+ this.#publication.lastError instanceof RuntimeHostOperationError &&
272
+ this.#publication.lastError.code === 'session_busy') {
273
+ throw RequestError.internalError({
274
+ source: 'runtime_host',
275
+ operation: 'mcp.prepare',
276
+ sessionId: this.#sessionId,
277
+ code: 'session_busy',
278
+ }, 'Cannot replace Session MCP configuration during an active Turn');
279
+ }
160
280
  throw mcpUnavailable(this.#sessionId, 'mcp.ready', 'mcp_publication_failed');
161
281
  }
162
282
  }
@@ -173,7 +293,12 @@ export class AcpSessionMcp {
173
293
  this.#closed = true;
174
294
  this.#unsubscribeManager();
175
295
  this.#unsubscribeConnection();
176
- const managerClose = this.#manager.close();
296
+ const managerClose = Promise.allSettled([
297
+ this.#manager.close(),
298
+ this.#pendingManager?.close(),
299
+ ...[...this.#retainedManagers].map((manager) => manager.close()),
300
+ ...this.#retirementTasks,
301
+ ]);
177
302
  this.#closeTask = (async () => {
178
303
  try {
179
304
  try {
@@ -193,9 +318,30 @@ export class AcpSessionMcp {
193
318
  })();
194
319
  return this.#closeTask;
195
320
  }
196
- #assertConnected() {
321
+ #observeManager(manager) {
322
+ return manager.onChange(() => {
323
+ this.#publicationRevision += 1;
324
+ if (this.#prepared && !this.#closed)
325
+ this.#publication.request();
326
+ });
327
+ }
328
+ #retireRetainedManagers() {
329
+ if (this.#publication.publishedRevision !== this.#publicationRevision)
330
+ return;
331
+ for (const manager of this.#retainedManagers) {
332
+ this.#retainedManagers.delete(manager);
333
+ if (manager === this.#manager)
334
+ continue;
335
+ const task = manager.close().catch((error) => {
336
+ console.error('[acp] Previous Session MCP cleanup failed:', error);
337
+ });
338
+ this.#retirementTasks.add(task);
339
+ void task.finally(() => this.#retirementTasks.delete(task));
340
+ }
341
+ }
342
+ #assertConnected(config = this.#config, manager = this.#manager) {
197
343
  this.#assertOpen('mcp.prepare');
198
- if (Object.keys(this.#config.mcpServers).some((serverId) => this.#manager.status(serverId)?.state !== 'connected')) {
344
+ if (Object.keys(config.mcpServers).some((serverId) => manager.status(serverId)?.state !== 'connected')) {
199
345
  throw mcpUnavailable(this.#sessionId, 'mcp.prepare', 'mcp_not_ready');
200
346
  }
201
347
  }
@@ -204,6 +350,9 @@ export class AcpSessionMcp {
204
350
  throw mcpUnavailable(this.#sessionId, operation, 'mcp_not_ready');
205
351
  }
206
352
  }
353
+ function sameMcpConfig(left, right) {
354
+ return stableJsonStringify(left) === stableJsonStringify(right);
355
+ }
207
356
  function invalidMcpInput(reason) {
208
357
  return RequestError.invalidParams({ field: 'mcpServers', reason }, 'Invalid ACP stdio MCP configuration');
209
358
  }