maka-agent 0.2.0-dev.67.20261002 → 0.2.0-dev.69.20261004

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 (28) hide show
  1. package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
  2. package/node_modules/@maka/acp-executor-plugin/dist/acp-catalog.js +132 -0
  3. package/node_modules/@maka/acp-executor-plugin/dist/index.js +251 -113
  4. package/node_modules/@maka/acp-executor-plugin/dist/plugin.mjs +431 -108
  5. package/node_modules/@maka/core/dist/executor-catalog.js +25 -3
  6. package/node_modules/@maka/core/dist/recall.js +13 -14
  7. package/node_modules/@maka/runtime/dist/admission-limiter.js +3 -2
  8. package/node_modules/@maka/runtime/dist/ai-sdk-turn.js +0 -3
  9. package/node_modules/@maka/runtime/dist/model-history.js +4 -14
  10. package/node_modules/@maka/runtime/dist/plugin-data-services.js +7 -0
  11. package/node_modules/@maka/runtime/dist/plugin-executor-service.js +19 -3
  12. package/node_modules/@maka/runtime/dist/runtime-read-model.js +23 -4
  13. package/node_modules/@maka/runtime/dist/session-manager.js +1 -0
  14. package/node_modules/@maka/runtime/package.json +1 -0
  15. package/node_modules/@maka/runtime-host/dist/antigravity-program.js +49 -0
  16. package/node_modules/@maka/runtime-host/dist/protocol/index.js +3 -1
  17. package/node_modules/@maka/runtime-host/dist/protocol/plugin-platform.js +8 -2
  18. package/node_modules/@maka/runtime-host/dist/server/acp/antigravity.js +5 -19
  19. package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +14 -3
  20. package/node_modules/@maka/runtime-host/dist/server/external-agent-setup-coordinator.js +3 -0
  21. package/node_modules/@maka/runtime-host/dist/server/plugin-data-runtime.js +6 -0
  22. package/node_modules/@maka/runtime-host/dist/server/plugin-scratch-directory.js +55 -0
  23. package/node_modules/@maka/runtime-host/dist/server/session-catalog-coordinator.js +40 -6
  24. package/node_modules/@maka/runtime-host/package.json +1 -0
  25. package/node_modules/@maka/storage/dist/execution-stores.js +10 -0
  26. package/node_modules/@maka/storage/dist/runtime-event-persistence.js +1 -0
  27. package/node_modules/@maka/storage/dist/sqlite-runtime-store.js +74 -5
  28. package/package.json +1 -1
@@ -18,10 +18,13 @@
18
18
  */
19
19
  import { createHash } from 'node:crypto';
20
20
  import { constants, createReadStream } from 'node:fs';
21
- import { access, mkdtemp, rm, realpath, stat } from 'node:fs/promises';
21
+ import { access, mkdir, mkdtemp, realpath, rm, stat } from 'node:fs/promises';
22
22
  import { tmpdir } from 'node:os';
23
- import { dirname, isAbsolute, resolve } from 'node:path';
23
+ import { AdmissionLimiter } from '@maka/runtime/admission-limiter';
24
+ import { AcpCatalog } from './acp-catalog.js';
25
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
24
26
  import { methods, } from '@agentclientprotocol/sdk';
27
+ import { pluginIdentity } from '@maka/runtime/plugin-runtime';
25
28
  import { AcpRuntimeError } from './acp-errors.js';
26
29
  import { readWorkspaceTextFile, writeWorkspaceTextFile } from './acp-filesystem.js';
27
30
  import { createAcpConnection } from './acp-process.js';
@@ -39,7 +42,8 @@ export class AcpExecutor {
39
42
  #sessions = new Map();
40
43
  #disposed = false;
41
44
  #catalog;
42
- #discovery;
45
+ #withCatalogDirectory;
46
+ #temporaryDirectory;
43
47
  constructor(adapter, config, options = {}) {
44
48
  this.#adapter = validateAdapter(adapter);
45
49
  this.id = adapter.id;
@@ -47,88 +51,118 @@ export class AcpExecutor {
47
51
  this.#configured = validateConfiguredAgent(adapter.configure(config));
48
52
  this.#createConnection = options.createConnection ?? createAcpConnection;
49
53
  this.#state = options.state;
54
+ this.#withCatalogDirectory =
55
+ options.withCatalogDirectory ??
56
+ (async (signal, use) => {
57
+ // Standalone embeddings have no Host storage; keep their cwd stable for
58
+ // this executor's lifetime. Registered adapters use durable scratch paths.
59
+ signal.throwIfAborted();
60
+ this.#temporaryDirectory ??= mkdtemp(join(tmpdir(), 'maka-acp-catalog-'));
61
+ const directory = await this.#temporaryDirectory;
62
+ await mkdir(directory, { recursive: true, mode: 0o700 });
63
+ try {
64
+ return await use(await realpath(directory));
65
+ }
66
+ finally {
67
+ await rm(directory, { recursive: true, force: true });
68
+ }
69
+ });
70
+ this.#catalog = new AcpCatalog(options.catalogAdmission ?? new AdmissionLimiter(1), (signal) => this.#probeCatalog(signal), () => this.#catalogEntry('unavailable'));
50
71
  }
51
72
  async discover(input) {
52
- if (this.#disposed)
53
- return this.#catalogEntry('unavailable');
54
- if (this.#catalog)
55
- return this.#catalog;
56
- if (this.#discovery)
57
- return this.#discovery;
58
- const probe = async () => {
59
- // A bounded disposable ACP probe never creates a Maka task or joins the retained-session map.
60
- const cwd = await mkdtemp(resolve(tmpdir(), 'maka-acp-catalog-'));
61
- const session = {
73
+ // The generic Host query still supplies its target cwd. ACP draft candidates
74
+ // belong to this configured executor, and never initialize the user's workspace.
75
+ return await this.#catalog.get(input.signal, input.refresh);
76
+ }
77
+ async #probeCatalog(signal) {
78
+ return await this.#withCatalogDirectory(signal, (directory) => this.#readCatalog(directory, signal));
79
+ }
80
+ async #readCatalog(directory, signal) {
81
+ let session;
82
+ try {
83
+ signal.throwIfAborted();
84
+ session = {
62
85
  conversationKey: 'catalog-probe',
63
- cwd,
86
+ cwd: await realpath(directory),
64
87
  configOptions: [],
65
88
  lost: false,
66
89
  };
67
- try {
68
- await this.#initialize(session, input.signal, true);
69
- const result = this.#catalogEntry('ready', session.configOptions);
70
- this.#catalog = result;
71
- return result;
72
- }
73
- catch (error) {
74
- return this.#catalogEntry(error?.code === -32000
75
- ? 'authentication_required'
76
- : 'unavailable');
77
- }
78
- finally {
79
- await this.#disposeSession(session);
80
- await rm(cwd, { recursive: true, force: true });
81
- }
82
- };
83
- this.#discovery = probe().finally(() => {
84
- this.#discovery = undefined;
85
- });
86
- return this.#discovery;
90
+ await this.#initialize(session, signal, true);
91
+ return this.#catalogEntry('ready', session.configOptions);
92
+ }
93
+ catch (error) {
94
+ return this.#catalogEntry(isAuthenticationFailure(error) ? 'authentication_required' : 'unavailable');
95
+ }
96
+ finally {
97
+ // The directory lease outlives process cleanup, including failure cleanup.
98
+ if (session)
99
+ await (session.loss ?? this.#disposeSession(session));
100
+ }
101
+ }
102
+ invalidateCatalog() {
103
+ this.#catalog.invalidate();
87
104
  }
88
105
  async inspectConversation(input) {
89
106
  const session = this.#sessions.get(input.conversationKey);
90
107
  if (this.#disposed)
91
108
  return this.#catalogEntry('unavailable');
92
109
  if (session && session.cwd !== resolve(input.cwd))
93
- return this.#catalogEntry('history_only', [], input.configuration?.model);
110
+ return this.#catalogEntry('history_only', [], input.configuration);
94
111
  if (session?.restoring)
95
- return this.#catalogEntry('restoring', session.configOptions, input.configuration?.model);
112
+ return this.#catalogEntry('restoring', session.configOptions, input.configuration);
96
113
  if (session?.restoreFailed)
97
- return this.#catalogEntry('restore_failed', session.configOptions, input.configuration?.model);
114
+ return this.#catalogEntry('restore_failed', session.configOptions, input.configuration);
98
115
  if (session?.historyGap)
99
- return this.#catalogEntry('history_gap', session.configOptions, input.configuration?.model);
116
+ return this.#catalogEntry('history_gap', session.configOptions, input.configuration);
100
117
  if (session && !session.lost)
101
- return this.#catalogEntry('ready', session.configOptions, input.configuration?.model);
118
+ return this.#catalogEntry('ready', session.configOptions, input.configuration);
102
119
  if (session?.lost && !session.record)
103
- return this.#catalogEntry('history_only', session.configOptions, input.configuration?.model);
120
+ return this.#catalogEntry('history_only', session.configOptions, input.configuration);
104
121
  const stored = this.#state?.read
105
122
  ? decodeContinuity(await this.#state.read(input.conversationKey))
106
123
  : (await this.#state?.has(input.conversationKey, input.cwd))
107
124
  ? 'legacy'
108
125
  : undefined;
109
126
  if (!stored)
110
- return this.#catalogEntry('ready', [], input.configuration?.model);
127
+ return this.#catalogEntry('ready', [], input.configuration);
111
128
  if (stored === 'invalid' || stored === 'legacy' || stored.cwd !== resolve(input.cwd))
112
- return this.#catalogEntry('history_only', [], input.configuration?.model);
129
+ return this.#catalogEntry('history_only', [], input.configuration);
113
130
  if (stored.phase === 'reserved')
114
- return this.#catalogEntry('history_only', [], input.configuration?.model);
131
+ return this.#catalogEntry('history_only', [], input.configuration);
115
132
  return this.#catalogEntry(stored.phase === 'prompt_pending' || stored.phase === 'history_gap'
116
133
  ? 'history_gap'
117
- : 'restorable', [], input.configuration?.model ?? stored.confirmedModel);
134
+ : 'restorable', [], {
135
+ model: input.configuration?.model ?? stored.confirmedModel,
136
+ mode: input.configuration?.mode ?? stored.confirmedMode,
137
+ });
118
138
  }
119
139
  async configureConversation(input, signal) {
120
- if (!input.configuration?.model)
121
- throw new Error('ACP model change is unavailable');
140
+ if (!input.configuration?.model && !input.configuration?.mode)
141
+ throw new Error('ACP configuration change is unavailable');
122
142
  const session = await this.#session(input);
123
- if (session.active || session.configuring)
124
- throw new Error('ACP model change is unavailable');
143
+ if (session.active || session.configuring || session.restoring || session.awaitingAck)
144
+ throw new Error('ACP configuration change is unavailable while the Session is busy');
125
145
  session.configuring = true;
126
146
  const wasConnected = !!session.connection;
127
147
  try {
128
- await this.#ensureInitialized(session, signal);
129
- await this.#applyInitialConfig(session, { model: input.configuration.model }, signal, true);
148
+ // A fresh Session must select the requested model before checking which
149
+ // modes that model exposes. Do not apply the launch defaults separately.
150
+ await this.#ensureInitialized(session, signal, true);
151
+ await this.#applyInitialConfig(session, !wasConnected && !session.restoredFromState
152
+ ? { ...this.#configured.launch.initialConfig, ...input.configuration }
153
+ : input.configuration, signal, true);
154
+ return {
155
+ ...(currentAcpModel(session.configOptions)
156
+ ? { model: currentAcpModel(session.configOptions) }
157
+ : {}),
158
+ ...(acpOption(session.configOptions, 'mode')?.currentValue
159
+ ? { mode: acpOption(session.configOptions, 'mode').currentValue }
160
+ : {}),
161
+ };
130
162
  }
131
163
  catch (error) {
164
+ if (isAuthenticationFailure(error))
165
+ this.invalidateCatalog();
132
166
  if (!wasConnected || session.lost) {
133
167
  await this.#lose(session);
134
168
  if (session.record?.sessionId && !session.historyGap)
@@ -149,7 +183,17 @@ export class AcpExecutor {
149
183
  ? model.options
150
184
  .flatMap((entry) => ('options' in entry ? entry.options : [entry]))
151
185
  .map((entry) => ({ id: entry.value, name: entry.name }))
152
- : (this.#catalog?.models ?? []);
186
+ : selected?.model
187
+ ? [{ id: selected.model, name: selected.model }]
188
+ : [];
189
+ const mode = options.find((option) => option.type === 'select' && (option.category === 'mode' || option.id === 'mode'));
190
+ const modes = mode?.type === 'select'
191
+ ? mode.options
192
+ .flatMap((entry) => ('options' in entry ? entry.options : [entry]))
193
+ .map((entry) => ({ id: entry.value, name: entry.name }))
194
+ : selected?.mode
195
+ ? [{ id: selected.mode, name: selected.mode }]
196
+ : [];
153
197
  return {
154
198
  id: this.id,
155
199
  displayName: this.displayName,
@@ -158,13 +202,19 @@ export class AcpExecutor {
158
202
  ? (this.#adapter.describeModels?.(models) ?? { models })
159
203
  : {
160
204
  models,
161
- ...(this.#catalog?.modelGroups ? { modelGroups: this.#catalog.modelGroups } : {}),
162
205
  }),
163
206
  ...(model?.type === 'select'
164
207
  ? { currentModel: model.currentValue }
165
- : selected
166
- ? { currentModel: selected }
208
+ : selected?.model
209
+ ? { currentModel: selected.model }
167
210
  : {}),
211
+ ...(mode?.type === 'select'
212
+ ? { modes, currentMode: mode.currentValue, supportsModeChange: true }
213
+ : {
214
+ modes,
215
+ ...(selected?.mode ? { currentMode: selected.mode } : {}),
216
+ supportsModeChange: false,
217
+ }),
168
218
  supportsAttachments: false,
169
219
  supportsModelChange: model?.type === 'select',
170
220
  };
@@ -182,7 +232,7 @@ export class AcpExecutor {
182
232
  return failure('Conflicting executor models', 'acp_config_invalid');
183
233
  const model = request.configuration?.model ?? (request.model === this.id ? undefined : request.model);
184
234
  if (model)
185
- request = { ...request, configuration: { model } };
235
+ request = { ...request, configuration: { ...request.configuration, model } };
186
236
  let session;
187
237
  try {
188
238
  session = await this.#session(request);
@@ -197,8 +247,8 @@ export class AcpExecutor {
197
247
  try {
198
248
  await this.#ensureInitialized(session, context.signal);
199
249
  context.signal.throwIfAborted();
200
- if (request.configuration?.model)
201
- await this.#applyInitialConfig(session, { model: request.configuration.model }, context.signal);
250
+ if (request.configuration?.model || request.configuration?.mode)
251
+ await this.#applyInitialConfig(session, request.configuration, context.signal);
202
252
  await this.#beginPrompt(session, request.turnId);
203
253
  const prompt = session.connection.agent.request(methods.agent.session.prompt, {
204
254
  sessionId: session.acpSessionId,
@@ -224,6 +274,8 @@ export class AcpExecutor {
224
274
  return { status: 'completed', text: active.text };
225
275
  }
226
276
  catch (error) {
277
+ if (isAuthenticationFailure(error))
278
+ this.invalidateCatalog();
227
279
  if (errorCode(error) === 'acp_history_gap')
228
280
  session.historyGap = true;
229
281
  await this.#lose(session);
@@ -259,6 +311,7 @@ export class AcpExecutor {
259
311
  if (this.#disposed)
260
312
  return;
261
313
  this.#disposed = true;
314
+ await this.#catalog.dispose();
262
315
  const sessions = [...this.#sessions.values()];
263
316
  this.#sessions.clear();
264
317
  const settlements = await Promise.allSettled(sessions.map((session) => session.loss ?? this.#disposeSession(session)));
@@ -293,10 +346,10 @@ export class AcpExecutor {
293
346
  this.#sessions.set(request.conversationKey, created);
294
347
  return created;
295
348
  }
296
- async #ensureInitialized(session, signal) {
349
+ async #ensureInitialized(session, signal, deferSelectedConfig = false) {
297
350
  if (session.initialization)
298
351
  return await session.initialization;
299
- const initialization = this.#initialize(session, signal);
352
+ const initialization = this.#initialize(session, signal, false, deferSelectedConfig);
300
353
  session.initialization = initialization;
301
354
  try {
302
355
  await initialization;
@@ -308,7 +361,7 @@ export class AcpExecutor {
308
361
  throw error;
309
362
  }
310
363
  }
311
- async #initialize(session, signal, probe = false) {
364
+ async #initialize(session, signal, probe = false, deferSelectedConfig = false) {
312
365
  const timeout = AbortSignal.timeout(INITIALIZE_TIMEOUT_MS);
313
366
  const startupSignal = AbortSignal.any([signal, timeout]);
314
367
  startupSignal.throwIfAborted();
@@ -328,6 +381,7 @@ export class AcpExecutor {
328
381
  throw new AcpRuntimeError('ACP conversation has no recoverable external Session identity', 'acp_history_only');
329
382
  if (stored?.phase === 'history_gap')
330
383
  throw new AcpRuntimeError('External Session history cannot be aligned with saved conversation events', 'acp_history_gap');
384
+ session.restoredFromState = !!stored;
331
385
  if (stored) {
332
386
  session.record = stored;
333
387
  session.acpSessionId = stored.sessionId;
@@ -378,8 +432,22 @@ export class AcpExecutor {
378
432
  ]);
379
433
  session.configOptions = restored.configOptions ?? [];
380
434
  const restoredModel = currentAcpModel(session.configOptions);
381
- if (restoredModel)
382
- await this.#persistConfirmedModel(session, restoredModel);
435
+ const restoredMode = acpOption(session.configOptions, 'mode')?.currentValue;
436
+ if ((stored.confirmedModel && restoredModel !== stored.confirmedModel) ||
437
+ (stored.confirmedMode && restoredMode !== stored.confirmedMode)) {
438
+ const confirmed = await this.#applyInitialConfig(session, {
439
+ ...(stored.confirmedModel ? { model: stored.confirmedModel } : {}),
440
+ ...(stored.confirmedMode ? { mode: stored.confirmedMode } : {}),
441
+ }, startupSignal, false,
442
+ // Only an explicit model-only configuration can recover a removed
443
+ // saved mode. Ordinary execution still requires the exact saved pair.
444
+ deferSelectedConfig && !!session.configuration?.model && !session.configuration.mode);
445
+ if ((confirmed.model && currentAcpModel(session.configOptions) !== confirmed.model) ||
446
+ (confirmed.mode &&
447
+ acpOption(session.configOptions, 'mode')?.currentValue !== confirmed.mode))
448
+ throw new AcpRuntimeError('Restored Agent configuration differs from the saved task', 'acp_config_unconfirmed');
449
+ }
450
+ await this.#persistConfirmedConfig(session);
383
451
  session.restoring = false;
384
452
  if (pending) {
385
453
  // The Agent may have progressed beyond Maka's last durable event. Replay
@@ -436,13 +504,11 @@ export class AcpExecutor {
436
504
  };
437
505
  await this.#state?.write?.(session.conversationKey, established);
438
506
  session.record = established;
439
- const createdModel = currentAcpModel(session.configOptions);
440
- if (createdModel)
441
- await this.#persistConfirmedModel(session, createdModel);
507
+ await this.#persistConfirmedConfig(session);
442
508
  }
443
- if (!probe) {
444
- await this.#applyInitialConfig(session, session.configuration?.model
445
- ? { model: session.configuration.model }
509
+ if (!probe && !deferSelectedConfig) {
510
+ await this.#applyInitialConfig(session, session.configuration?.model || session.configuration?.mode
511
+ ? { ...launch.initialConfig, ...session.configuration }
446
512
  : (launch.initialConfig ?? {}), startupSignal);
447
513
  }
448
514
  }
@@ -504,60 +570,103 @@ export class AcpExecutor {
504
570
  await this.#lose(session);
505
571
  }
506
572
  }
507
- async #applyInitialConfig(session, values, signal, restoreOnFailure = false) {
508
- for (const [key, value] of Object.entries(values)) {
509
- const option = session.configOptions.find((candidate) => candidate.type === 'select' && (candidate.id === key || candidate.category === key));
510
- if (!option || option.type !== 'select')
573
+ async #applyInitialConfig(session, values, signal, restoreOnFailure = false, omitUnavailableMode = false) {
574
+ let target = Object.entries(values).filter((entry) => entry[1] !== undefined);
575
+ const before = session.configOptions;
576
+ const selectedModel = target.find(([key]) => key === 'model')?.[1];
577
+ const changingModel = selectedModel !== undefined && acpOption(before, 'model')?.currentValue !== selectedModel;
578
+ const validate = (options, key, value) => {
579
+ const option = acpOption(options, key);
580
+ if (!option)
511
581
  throw new AcpRuntimeError(`ACP configuration is unavailable: ${key}`, 'acp_config_unavailable');
512
- const options = option.options.flatMap((entry) => 'options' in entry ? entry.options : [entry]);
513
- if (!options.some((entry) => entry.value === value))
582
+ const candidates = option.options.flatMap((entry) => 'options' in entry ? entry.options : [entry]);
583
+ if (!candidates.some((entry) => entry.value === value))
514
584
  throw new AcpRuntimeError(`ACP configuration value is unavailable: ${key}`, 'acp_config_invalid');
515
- if (option.currentValue === value) {
516
- if (key === 'model')
517
- await this.#persistConfirmedModel(session, value);
585
+ };
586
+ // A model change can add, remove or replace the mode option. Validate that
587
+ // dependent option against the Agent's post-model response instead.
588
+ for (const [key, value] of target) {
589
+ if (omitUnavailableMode && key === 'mode')
518
590
  continue;
519
- }
520
- signal.throwIfAborted();
521
- try {
591
+ if (!changingModel || key === 'model')
592
+ validate(before, key, value);
593
+ }
594
+ const ordered = [
595
+ ...target.filter(([key]) => key === 'model'),
596
+ ...target.filter(([key]) => key !== 'model'),
597
+ ];
598
+ let mutationAttempted = false;
599
+ try {
600
+ for (const [key, value] of ordered) {
601
+ const option = acpOption(session.configOptions, key);
602
+ if (omitUnavailableMode &&
603
+ key === 'mode' &&
604
+ !option?.options
605
+ .flatMap((entry) => ('options' in entry ? entry.options : [entry]))
606
+ .some((entry) => entry.value === value)) {
607
+ target = target.filter(([candidate]) => candidate !== 'mode');
608
+ continue;
609
+ }
610
+ // Every remaining value requires confirmation. Outside explicit recovery,
611
+ // a model change cannot waive a requested or saved mode.
612
+ validate(session.configOptions, key, value);
613
+ if (!option)
614
+ throw new Error('Validated ACP option disappeared');
615
+ if (option.currentValue === value)
616
+ continue;
617
+ signal.throwIfAborted();
618
+ mutationAttempted = true;
522
619
  const updated = await session.connection.agent.request(methods.agent.session.setConfigOption, { sessionId: session.acpSessionId, configId: option.id, value }, { cancellationSignal: signal });
523
- const confirmed = updated.configOptions.find((candidate) => candidate.id === option.id);
524
- if (confirmed?.type !== 'select' || confirmed.currentValue !== value)
525
- throw new AcpRuntimeError('Agent did not confirm the selected configuration', 'acp_config_unconfirmed');
526
620
  session.configOptions = updated.configOptions;
527
- if (key === 'model')
528
- await this.#persistConfirmedModel(session, value);
621
+ if (acpOption(updated.configOptions, key)?.currentValue !== value)
622
+ throw new AcpRuntimeError('Agent did not confirm the selected configuration', 'acp_config_unconfirmed');
529
623
  }
530
- catch (error) {
531
- // A rejected/unconfirmed mutation can have reached the Agent. Restore the
532
- // previous real ID and require an acknowledgement before permitting retry.
533
- let restored = false;
534
- if (restoreOnFailure && !session.lost && !signal.aborted) {
535
- try {
536
- const rollback = await session.connection.agent.request(methods.agent.session.setConfigOption, { sessionId: session.acpSessionId, configId: option.id, value: option.currentValue }, { cancellationSignal: AbortSignal.timeout(5_000) });
537
- const confirmed = rollback.configOptions.find((candidate) => candidate.id === option.id);
538
- if (confirmed?.type === 'select' &&
539
- confirmed.currentValue === option.currentValue &&
540
- !session.lost) {
541
- session.configOptions = rollback.configOptions;
542
- if (key === 'model')
543
- await this.#persistConfirmedModel(session, option.currentValue);
544
- restored = true;
545
- }
546
- }
547
- catch {
548
- /* Uncertain configuration remains history-only. */
624
+ if (target.some(([key, value]) => acpOption(session.configOptions, key)?.currentValue !== value))
625
+ throw new AcpRuntimeError('Agent changed another selected configuration', 'acp_config_unconfirmed');
626
+ await this.#persistConfirmedConfig(session);
627
+ return Object.fromEntries(target);
628
+ }
629
+ catch (error) {
630
+ let restored = false;
631
+ if (restoreOnFailure && !session.lost && !signal.aborted) {
632
+ try {
633
+ // A rejected response can still have changed the Agent. Restore the
634
+ // model before its dependent mode, then verify the original snapshot.
635
+ for (const key of ['model', 'mode']) {
636
+ const original = acpOption(before, key);
637
+ const current = acpOption(session.configOptions, key);
638
+ if (!original ||
639
+ !current ||
640
+ (!mutationAttempted && current.currentValue === original.currentValue))
641
+ continue;
642
+ const rollback = await session.connection.agent.request(methods.agent.session.setConfigOption, {
643
+ sessionId: session.acpSessionId,
644
+ configId: current.id,
645
+ value: original.currentValue,
646
+ }, { cancellationSignal: AbortSignal.timeout(5_000) });
647
+ session.configOptions = rollback.configOptions;
549
648
  }
649
+ restored = ['model', 'mode'].every((key) => {
650
+ const original = acpOption(before, key);
651
+ return (!original ||
652
+ acpOption(session.configOptions, key)?.currentValue === original.currentValue);
653
+ });
654
+ }
655
+ catch {
656
+ /* Uncertain configuration remains unavailable. */
550
657
  }
551
- if (!restored)
552
- await this.#lose(session);
553
- throw error;
554
658
  }
659
+ if (!restored)
660
+ await this.#lose(session);
661
+ throw error;
555
662
  }
556
663
  }
557
- async #persistConfirmedModel(session, model) {
558
- if (!session.record || session.record.confirmedModel === model)
664
+ async #persistConfirmedConfig(session, configOptions = session.configOptions) {
665
+ if (!session.record)
666
+ return;
667
+ const record = withConfirmedConfiguration(session.record, configOptions);
668
+ if (record === session.record)
559
669
  return;
560
- const record = { ...session.record, confirmedModel: model };
561
670
  await this.#state?.write?.(session.conversationKey, record);
562
671
  session.record = record;
563
672
  }
@@ -608,8 +717,11 @@ export class AcpExecutor {
608
717
  return;
609
718
  if (update.sessionUpdate === 'config_option_update') {
610
719
  // During our mutation, its response (or rollback response) is authoritative.
611
- if (!session.configuring && !session.lost)
720
+ if (!session.configuring && !session.lost) {
612
721
  session.configOptions = update.configOptions;
722
+ // Agent notifications report observed state. Only a confirmed response
723
+ // to our own configuration request may change the durable selection.
724
+ }
613
725
  return;
614
726
  }
615
727
  const active = session.active;
@@ -751,8 +863,27 @@ export class AcpExecutor {
751
863
  }
752
864
  }
753
865
  function currentAcpModel(options) {
754
- const option = options.find((candidate) => candidate.type === 'select' && (candidate.id === 'model' || candidate.category === 'model'));
755
- return option?.type === 'select' ? option.currentValue : undefined;
866
+ return acpOption(options, 'model')?.currentValue;
867
+ }
868
+ function withConfirmedConfiguration(record, options) {
869
+ const model = currentAcpModel(options);
870
+ const mode = acpOption(options, 'mode')?.currentValue;
871
+ const { confirmedModel, confirmedMode, ...base } = record;
872
+ if (confirmedModel === model && confirmedMode === mode)
873
+ return record;
874
+ return {
875
+ ...base,
876
+ ...(model ? { confirmedModel: model } : {}),
877
+ ...(mode ? { confirmedMode: mode } : {}),
878
+ };
879
+ }
880
+ function isAuthenticationFailure(error) {
881
+ return (error?.code === -32000 ||
882
+ (error instanceof Error &&
883
+ /authentication required|unauthorized/u.test(error.message.toLowerCase())));
884
+ }
885
+ function acpOption(options, key) {
886
+ return options.find((candidate) => candidate.type === 'select' && (candidate.id === key || candidate.category === key));
756
887
  }
757
888
  function toolTextContent(content) {
758
889
  return content
@@ -769,6 +900,8 @@ function toolTextContent(content) {
769
900
  * immutable package generation.
770
901
  */
771
902
  export class AcpRuntimeService {
903
+ // Shared by every adapter below this ACP runtime Entry, including queued probes.
904
+ #catalogAdmission = new AdmissionLimiter(2);
772
905
  constructor(ctx) {
773
906
  ctx.provide('acp', this);
774
907
  }
@@ -776,8 +909,11 @@ export class AcpRuntimeService {
776
909
  const storage = consumer.get('storage');
777
910
  if (!storage)
778
911
  throw new Error('ACP continuity storage is unavailable');
912
+ const directoryKey = `acp-catalog/${adapter.id}/${createHash('sha256').update(pluginIdentity(consumer).entryId).digest('hex')}`;
779
913
  const provider = new AcpExecutor(adapter, config, {
780
914
  state: pluginStateStore(storage, adapter.id),
915
+ catalogAdmission: this.#catalogAdmission,
916
+ withCatalogDirectory: (signal, use) => storage.withScratchDirectory(directoryKey, signal, use),
781
917
  });
782
918
  consumer.effect(() => () => provider.dispose(), `acp.dispose(${JSON.stringify(adapter.id)})`);
783
919
  return consumer.executors.register(provider);
@@ -824,6 +960,8 @@ function decodeContinuity(value) {
824
960
  (typeof record.pendingTurnId !== 'string' || !record.pendingTurnId)) ||
825
961
  (record.confirmedModel !== undefined &&
826
962
  (typeof record.confirmedModel !== 'string' || !record.confirmedModel)) ||
963
+ (record.confirmedMode !== undefined &&
964
+ (typeof record.confirmedMode !== 'string' || !record.confirmedMode)) ||
827
965
  (record.phase === 'reserved' && record.sessionId !== undefined) ||
828
966
  (record.phase !== 'reserved' && !record.sessionId) ||
829
967
  (record.phase === 'prompt_pending' && !record.pendingTurnId) ||