maka-agent 0.2.0-dev.53.20260927 → 0.2.0-dev.62.20260928

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 (55) hide show
  1. package/LICENSE +6 -5
  2. package/dist/workspace-root.js +2 -2
  3. package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
  4. package/node_modules/@maka/acp-executor-plugin/dist/index.js +333 -28
  5. package/node_modules/@maka/acp-executor-plugin/dist/plugin.mjs +283 -27
  6. package/node_modules/@maka/core/dist/executor-catalog.js +10 -1
  7. package/node_modules/@maka/core/dist/message-queue-order.js +47 -0
  8. package/node_modules/@maka/core/dist/model-metadata.js +2 -1
  9. package/node_modules/@maka/core/dist/redaction.js +42 -3
  10. package/node_modules/@maka/core/dist/relative-time-policy.js +71 -0
  11. package/node_modules/@maka/core/dist/relative-time.js +3 -54
  12. package/node_modules/@maka/core/package.json +1 -0
  13. package/node_modules/@maka/eval/dist/egress-audit.js +127 -0
  14. package/node_modules/@maka/eval/dist/harness-executor.js +16 -96
  15. package/node_modules/@maka/eval/harbor/egress-proxy/entrypoint.sh +4 -4
  16. package/node_modules/@maka/eval/harbor/egress_filter.py +153 -104
  17. package/node_modules/@maka/eval/harbor/eval_framework.py +37 -14
  18. package/node_modules/@maka/eval/harbor/relay_agent.py +4 -6
  19. package/node_modules/@maka/eval/harbor/run_trial.py +11 -4
  20. package/node_modules/@maka/runtime/dist/agent-graph-supervisor-wake.js +33 -6
  21. package/node_modules/@maka/runtime/dist/ai-sdk-turn.js +1 -1
  22. package/node_modules/@maka/runtime/dist/plugin-executor-backend.js +26 -5
  23. package/node_modules/@maka/runtime/dist/plugin-executor-service.js +36 -0
  24. package/node_modules/@maka/runtime/dist/provider-error-classification.js +8 -73
  25. package/node_modules/@maka/runtime/dist/provider-error-signals.js +31 -0
  26. package/node_modules/@maka/runtime/dist/provider-retry-policy.js +79 -0
  27. package/node_modules/@maka/runtime/dist/session-manager.js +6 -4
  28. package/node_modules/@maka/runtime/dist/session-projection-helpers.js +2 -0
  29. package/node_modules/@maka/runtime/dist/shell-run-manager.js +8 -8
  30. package/node_modules/@maka/runtime/dist/terminal-run-commit.js +7 -1
  31. package/node_modules/@maka/runtime/dist/tool-runtime.js +5 -4
  32. package/node_modules/@maka/runtime-host/dist/adapter/provider-retry-projector.js +63 -0
  33. package/node_modules/@maka/runtime-host/dist/adapter/session-projector.js +7 -51
  34. package/node_modules/@maka/runtime-host/dist/protocol/index.js +9 -3
  35. package/node_modules/@maka/runtime-host/dist/protocol/message-queue-limits.js +21 -0
  36. package/node_modules/@maka/runtime-host/dist/protocol/message.js +6 -104
  37. package/node_modules/@maka/runtime-host/dist/protocol/queue-mutation.js +136 -0
  38. package/node_modules/@maka/runtime-host/dist/protocol/turn-provider-retry.js +69 -0
  39. package/node_modules/@maka/runtime-host/dist/protocol/turn.js +2 -43
  40. package/node_modules/@maka/runtime-host/dist/server/goal-coordinator.js +42 -5
  41. package/node_modules/@maka/runtime-host/dist/server/message-coordinator.js +105 -233
  42. package/node_modules/@maka/runtime-host/dist/server/message-queue-state.js +69 -0
  43. package/node_modules/@maka/runtime-host/dist/server/provider-retry-projection.js +71 -0
  44. package/node_modules/@maka/runtime-host/dist/server/queued-mutation-executor.js +94 -0
  45. package/node_modules/@maka/runtime-host/dist/server/root-turn-coordinator.js +1 -1
  46. package/node_modules/@maka/runtime-host/dist/server/session-continuity-coordinator.js +9 -58
  47. package/node_modules/@maka/storage/dist/agent-run-store-contract.js +0 -3
  48. package/node_modules/@maka/storage/dist/agent-run-store.js +15 -6
  49. package/node_modules/@maka/storage/dist/execution-persistence-provider.js +1 -0
  50. package/node_modules/@maka/storage/dist/execution-stores.js +1 -0
  51. package/node_modules/@maka/storage/dist/project-catalog.js +41 -42
  52. package/node_modules/@maka/storage/dist/sqlite-runtime-store.js +102 -31
  53. package/node_modules/@maka/storage/dist/sqlite-session-metadata-schema.js +52 -1
  54. package/node_modules/@maka/storage/dist/sqlite-session-metadata-store.js +34 -2
  55. package/package.json +1 -1
package/LICENSE CHANGED
@@ -469,17 +469,18 @@ THE SOFTWARE.
469
469
 
470
470
  electron-builder dependency patches
471
471
 
472
- Source: https://www.npmjs.com/package/app-builder-lib/v/26.15.3
472
+ Source: https://www.npmjs.com/package/app-builder-lib/v/26.16.1
473
473
  https://www.npmjs.com/package/electron-updater/v/6.8.9
474
474
  Repository: https://github.com/electron-userland/electron-builder
475
- Versions: app-builder-lib 26.15.3; electron-updater 6.8.9
476
- Dependency patch: patches/app-builder-lib+26.15.3.patch
475
+ Versions: app-builder-lib 26.16.1; electron-updater 6.8.9
476
+ Dependency patch: patches/app-builder-lib+26.16.1.patch
477
477
  patches/electron-updater+6.8.9.patch
478
478
  License: MIT
479
479
 
480
480
  Maka redistributes source patches that omit modification and access times
481
- from ZIP archives and skip withdrawn prereleases whose update metadata is
482
- missing. The following MIT License applies to that material:
481
+ from 7-Zip ZIP archives, keep macOS ZIP archives on the system zip, and skip
482
+ withdrawn prereleases whose update metadata is missing. The following MIT
483
+ License applies to that material:
483
484
 
484
485
  The MIT License (MIT)
485
486
 
@@ -16,6 +16,6 @@
16
16
  * specific language governing permissions and limitations
17
17
  * under the License.
18
18
  */
19
- // Workspace-root resolution now lives in @maka/storage. This file remains
20
- // as a re-export so existing relative CLI importers keep working.
19
+ // Keep the CLI compatibility path while storage owns workspace-root resolution.
20
+ // New callers should import the storage subpath directly.
21
21
  export { deriveMakaDataRoots, resolveMakaClientDataRoot, resolveMakaDataRoots, resolveMakaWorkspaceRoot, } from '@maka/storage/workspace-root';
@@ -17,7 +17,7 @@
17
17
  * under the License.
18
18
  */
19
19
  import { createHash } from 'node:crypto';
20
- import { constants } from 'node:fs';
20
+ import { constants, createReadStream } from 'node:fs';
21
21
  import { access, mkdtemp, rm, realpath, stat } from 'node:fs/promises';
22
22
  import { tmpdir } from 'node:os';
23
23
  import { dirname, isAbsolute, resolve } from 'node:path';
@@ -89,22 +89,56 @@ export class AcpExecutor {
89
89
  const session = this.#sessions.get(input.conversationKey);
90
90
  if (this.#disposed)
91
91
  return this.#catalogEntry('unavailable');
92
- if (session?.lost || (!session && (await this.#state?.has(input.conversationKey, input.cwd))))
93
- return this.#catalogEntry('history_only');
94
- return this.#catalogEntry('ready', session?.configOptions ?? [], input.configuration?.model);
92
+ if (session && session.cwd !== resolve(input.cwd))
93
+ return this.#catalogEntry('history_only', [], input.configuration?.model);
94
+ if (session?.restoring)
95
+ return this.#catalogEntry('restoring', session.configOptions, input.configuration?.model);
96
+ if (session?.restoreFailed)
97
+ return this.#catalogEntry('restore_failed', session.configOptions, input.configuration?.model);
98
+ if (session?.historyGap)
99
+ return this.#catalogEntry('history_gap', session.configOptions, input.configuration?.model);
100
+ if (session && !session.lost)
101
+ return this.#catalogEntry('ready', session.configOptions, input.configuration?.model);
102
+ if (session?.lost && !session.record)
103
+ return this.#catalogEntry('history_only', session.configOptions, input.configuration?.model);
104
+ const stored = this.#state?.read
105
+ ? decodeContinuity(await this.#state.read(input.conversationKey))
106
+ : (await this.#state?.has(input.conversationKey, input.cwd))
107
+ ? 'legacy'
108
+ : undefined;
109
+ if (!stored)
110
+ return this.#catalogEntry('ready', [], input.configuration?.model);
111
+ if (stored === 'invalid' || stored === 'legacy' || stored.cwd !== resolve(input.cwd))
112
+ return this.#catalogEntry('history_only', [], input.configuration?.model);
113
+ if (stored.phase === 'reserved')
114
+ return this.#catalogEntry('history_only', [], input.configuration?.model);
115
+ return this.#catalogEntry(stored.phase === 'prompt_pending' || stored.phase === 'history_gap'
116
+ ? 'history_gap'
117
+ : 'restorable', [], input.configuration?.model ?? stored.confirmedModel);
95
118
  }
96
119
  async configureConversation(input, signal) {
97
- const session = this.#sessions.get(input.conversationKey);
98
- if (!session?.connection ||
99
- session.lost ||
100
- session.active ||
101
- session.configuring ||
102
- !input.configuration?.model)
120
+ if (!input.configuration?.model)
121
+ throw new Error('ACP model change is unavailable');
122
+ const session = await this.#session(input);
123
+ if (session.active || session.configuring)
103
124
  throw new Error('ACP model change is unavailable');
104
125
  session.configuring = true;
126
+ const wasConnected = !!session.connection;
105
127
  try {
128
+ await this.#ensureInitialized(session, signal);
106
129
  await this.#applyInitialConfig(session, { model: input.configuration.model }, signal, true);
107
130
  }
131
+ catch (error) {
132
+ if (!wasConnected || session.lost) {
133
+ await this.#lose(session);
134
+ if (session.record?.sessionId && !session.historyGap)
135
+ session.restoreFailed = true;
136
+ const retryable = !session.acpSessionId && !session.continuityReserved && !session.record;
137
+ if (retryable && this.#sessions.get(session.conversationKey) === session)
138
+ this.#sessions.delete(session.conversationKey);
139
+ }
140
+ throw error;
141
+ }
108
142
  finally {
109
143
  session.configuring = false;
110
144
  }
@@ -115,12 +149,17 @@ export class AcpExecutor {
115
149
  ? model.options
116
150
  .flatMap((entry) => ('options' in entry ? entry.options : [entry]))
117
151
  .map((entry) => ({ id: entry.value, name: entry.name }))
118
- : [];
152
+ : (this.#catalog?.models ?? []);
119
153
  return {
120
154
  id: this.id,
121
155
  displayName: this.displayName,
122
156
  readiness,
123
- ...(this.#adapter.describeModels?.(models) ?? { models }),
157
+ ...(model?.type === 'select'
158
+ ? (this.#adapter.describeModels?.(models) ?? { models })
159
+ : {
160
+ models,
161
+ ...(this.#catalog?.modelGroups ? { modelGroups: this.#catalog.modelGroups } : {}),
162
+ }),
124
163
  ...(model?.type === 'select'
125
164
  ? { currentModel: model.currentValue }
126
165
  : selected
@@ -146,12 +185,12 @@ export class AcpExecutor {
146
185
  request = { ...request, configuration: { model } };
147
186
  let session;
148
187
  try {
149
- session = this.#session(request);
188
+ session = await this.#session(request);
150
189
  }
151
190
  catch (error) {
152
191
  return failure(safeErrorMessage(error), errorCode(error));
153
192
  }
154
- if (session.active || session.configuring)
193
+ if (session.active || session.configuring || session.awaitingAck)
155
194
  return failure(`${this.displayName} ACP Session is busy`, 'acp_busy', true);
156
195
  const active = { context, tools: new Map(), text: '' };
157
196
  session.active = active;
@@ -160,6 +199,7 @@ export class AcpExecutor {
160
199
  context.signal.throwIfAborted();
161
200
  if (request.configuration?.model)
162
201
  await this.#applyInitialConfig(session, { model: request.configuration.model }, context.signal);
202
+ await this.#beginPrompt(session, request.turnId);
163
203
  const prompt = session.connection.agent.request(methods.agent.session.prompt, {
164
204
  sessionId: session.acpSessionId,
165
205
  prompt: [{ type: 'text', text: promptText(request) }],
@@ -167,6 +207,11 @@ export class AcpExecutor {
167
207
  const response = await this.#awaitPrompt(session, prompt, context.signal);
168
208
  if (!response)
169
209
  return { status: 'cancelled', reason: 'timeout' };
210
+ // A settled prompt can be checkpointed even when it was cancelled or failed.
211
+ // Timeouts and transport failures never reach this point, and the Runtime
212
+ // must still durably consume the terminal event before acknowledging it.
213
+ if (session.record)
214
+ session.awaitingAck = request.turnId;
170
215
  if (context.signal.aborted || response.stopReason === 'cancelled') {
171
216
  return { status: 'cancelled', providerStopReason: response.stopReason };
172
217
  }
@@ -179,12 +224,17 @@ export class AcpExecutor {
179
224
  return { status: 'completed', text: active.text };
180
225
  }
181
226
  catch (error) {
227
+ if (errorCode(error) === 'acp_history_gap')
228
+ session.historyGap = true;
182
229
  await this.#lose(session);
230
+ if (session.record?.sessionId && !session.historyGap)
231
+ session.restoreFailed = true;
183
232
  // Retry only when no session/new was attempted. Once continuity is
184
233
  // reserved, a lost response may hide an established Agent Session.
185
234
  const retryable = !session.acpSessionId &&
186
235
  !session.continuityReserved &&
187
- errorCode(error) !== 'acp_history_only';
236
+ errorCode(error) !== 'acp_history_only' &&
237
+ errorCode(error) !== 'acp_history_gap';
188
238
  if (retryable && this.#sessions.get(session.conversationKey) === session)
189
239
  this.#sessions.delete(session.conversationKey);
190
240
  if (context.signal.aborted) {
@@ -216,15 +266,22 @@ export class AcpExecutor {
216
266
  if (failures.length)
217
267
  throw new AggregateError(failures, 'ACP process cleanup failed');
218
268
  }
219
- #session(request) {
269
+ async #session(request) {
220
270
  const cwd = resolve(request.cwd);
221
271
  const existing = this.#sessions.get(request.conversationKey);
222
272
  if (existing) {
223
273
  if (existing.cwd !== cwd)
224
274
  throw new AcpRuntimeError('ACP conversation cannot change workspace', 'acp_cwd_changed');
225
- if (existing.lost)
275
+ if (!existing.lost)
276
+ return existing;
277
+ if (existing.active || existing.configuring)
278
+ throw new AcpRuntimeError('ACP Session is busy', 'acp_busy');
279
+ if (!existing.record)
226
280
  throw new AcpRuntimeError('ACP conversation is history-only because its external process was lost', 'acp_history_only');
227
- return existing;
281
+ await existing.loss;
282
+ if (this.#sessions.get(request.conversationKey) !== existing)
283
+ return this.#session(request);
284
+ this.#sessions.delete(request.conversationKey);
228
285
  }
229
286
  const created = {
230
287
  conversationKey: request.conversationKey,
@@ -246,17 +303,41 @@ export class AcpExecutor {
246
303
  }
247
304
  catch (error) {
248
305
  session.initialization = undefined;
306
+ session.restoring = false;
307
+ session.replay = undefined;
249
308
  throw error;
250
309
  }
251
310
  }
252
311
  async #initialize(session, signal, probe = false) {
253
- if (!probe && (await this.#state?.has(session.conversationKey, session.cwd))) {
254
- throw new AcpRuntimeError('ACP conversation is history-only after the Plugin or Host was restarted', 'acp_history_only');
255
- }
256
312
  const timeout = AbortSignal.timeout(INITIALIZE_TIMEOUT_MS);
257
313
  const startupSignal = AbortSignal.any([signal, timeout]);
258
314
  startupSignal.throwIfAborted();
259
315
  const launch = this.#configured.launch;
316
+ const stored = probe
317
+ ? undefined
318
+ : this.#state?.read
319
+ ? decodeContinuity(await this.#state.read(session.conversationKey))
320
+ : (await this.#state?.has(session.conversationKey, session.cwd))
321
+ ? 'legacy'
322
+ : undefined;
323
+ if (stored === 'legacy')
324
+ throw new AcpRuntimeError('ACP conversation is history-only after the Plugin or Host was restarted', 'acp_history_only');
325
+ if (stored === 'invalid' ||
326
+ (stored && stored.cwd !== session.cwd) ||
327
+ stored?.phase === 'reserved')
328
+ throw new AcpRuntimeError('ACP conversation has no recoverable external Session identity', 'acp_history_only');
329
+ if (stored?.phase === 'history_gap')
330
+ throw new AcpRuntimeError('External Session history cannot be aligned with saved conversation events', 'acp_history_gap');
331
+ if (stored) {
332
+ session.record = stored;
333
+ session.acpSessionId = stored.sessionId;
334
+ session.restoring = true;
335
+ }
336
+ const binding = !probe && this.#state?.write
337
+ ? await launchBinding(this.id, launch, startupSignal)
338
+ : undefined;
339
+ if (stored && stored.binding !== binding)
340
+ throw new AcpRuntimeError('ACP Agent installation changed', 'acp_restore_identity_changed');
260
341
  const executable = await checkedExecutable(launch.executable);
261
342
  await Promise.all((launch.requiredExecutables ?? []).map(checkedExecutable));
262
343
  const owner = this.#createConnection({
@@ -283,11 +364,62 @@ export class AcpExecutor {
283
364
  ]);
284
365
  if (initialized.protocolVersion !== 1)
285
366
  throw new Error('Unsupported ACP protocol version');
367
+ if (stored) {
368
+ session.replay = { updates: 0, userChunks: 0 };
369
+ const pending = stored.phase === 'prompt_pending';
370
+ const supported = pending
371
+ ? initialized.agentCapabilities?.loadSession
372
+ : initialized.agentCapabilities?.sessionCapabilities?.resume;
373
+ if (!supported)
374
+ throw new AcpRuntimeError('ACP Agent cannot restore this Session', 'acp_restore_unsupported');
375
+ const restored = await Promise.race([
376
+ owner.connection.agent.request(pending ? methods.agent.session.load : methods.agent.session.resume, { sessionId: stored.sessionId, cwd: session.cwd, mcpServers: [] }, { cancellationSignal: startupSignal }),
377
+ owner.failed,
378
+ ]);
379
+ session.configOptions = restored.configOptions ?? [];
380
+ const restoredModel = currentAcpModel(session.configOptions);
381
+ if (restoredModel)
382
+ await this.#persistConfirmedModel(session, restoredModel);
383
+ session.restoring = false;
384
+ if (pending) {
385
+ // The Agent may have progressed beyond Maka's last durable event. Replay
386
+ // identities are not sufficient to fabricate canonical history: failed
387
+ // turns can change tool IDs between live output and load. Keep every
388
+ // replay notification separate and expose an explicit history gap.
389
+ session.historyGap = true;
390
+ session.record = {
391
+ ...session.record,
392
+ phase: 'history_gap',
393
+ pendingTurnId: undefined,
394
+ gapEvidence: {
395
+ replayedUpdates: session.replay?.updates ?? 0,
396
+ replayedUserChunks: session.replay?.userChunks ?? 0,
397
+ },
398
+ };
399
+ await this.#state?.write?.(session.conversationKey, session.record);
400
+ session.replay = undefined;
401
+ throw new AcpRuntimeError('External Session was restored, but conversation history may have a gap', 'acp_history_gap');
402
+ }
403
+ session.replay = undefined;
404
+ return;
405
+ }
286
406
  if (!probe) {
287
407
  startupSignal.throwIfAborted();
288
408
  // Reserve continuity before asking the Agent to create a Session. A failed
289
409
  // or interrupted session/new may still have created one remotely.
290
- await this.#state?.mark(session.conversationKey, session.cwd);
410
+ if (this.#state?.write && binding) {
411
+ session.record = {
412
+ version: 2,
413
+ cwd: session.cwd,
414
+ binding,
415
+ phase: 'reserved',
416
+ committedPrompts: 0,
417
+ };
418
+ await this.#state.write(session.conversationKey, session.record);
419
+ }
420
+ else {
421
+ await this.#state?.mark(session.conversationKey, session.cwd);
422
+ }
291
423
  session.continuityReserved = true;
292
424
  }
293
425
  const created = await Promise.race([
@@ -296,12 +428,82 @@ export class AcpExecutor {
296
428
  ]);
297
429
  session.acpSessionId = created.sessionId;
298
430
  session.configOptions = created.configOptions ?? [];
431
+ if (session.record) {
432
+ const established = {
433
+ ...session.record,
434
+ phase: 'established',
435
+ sessionId: created.sessionId,
436
+ };
437
+ await this.#state?.write?.(session.conversationKey, established);
438
+ session.record = established;
439
+ const createdModel = currentAcpModel(session.configOptions);
440
+ if (createdModel)
441
+ await this.#persistConfirmedModel(session, createdModel);
442
+ }
299
443
  if (!probe) {
300
444
  await this.#applyInitialConfig(session, session.configuration?.model
301
445
  ? { model: session.configuration.model }
302
446
  : (launch.initialConfig ?? {}), startupSignal);
303
447
  }
304
448
  }
449
+ async #beginPrompt(session, turnId) {
450
+ if (!session.record)
451
+ return;
452
+ const pending = {
453
+ ...session.record,
454
+ phase: 'prompt_pending',
455
+ pendingTurnId: turnId,
456
+ };
457
+ await this.#state?.write?.(session.conversationKey, pending);
458
+ session.record = pending;
459
+ }
460
+ /** Called only after the Runtime Kernel has durably accepted the terminal event. */
461
+ async acknowledgeExecution(conversationKey, turnId) {
462
+ const session = this.#sessions.get(conversationKey);
463
+ if (!session?.record ||
464
+ session.awaitingAck !== turnId ||
465
+ session.record.phase !== 'prompt_pending' ||
466
+ session.record.pendingTurnId !== turnId)
467
+ return;
468
+ const committed = {
469
+ ...session.record,
470
+ phase: 'committed',
471
+ committedPrompts: session.record.committedPrompts + 1,
472
+ pendingTurnId: undefined,
473
+ };
474
+ try {
475
+ await this.#state?.write?.(conversationKey, committed);
476
+ }
477
+ catch (error) {
478
+ session.historyGap = true;
479
+ await this.#lose(session);
480
+ throw error;
481
+ }
482
+ session.record = committed;
483
+ session.awaitingAck = undefined;
484
+ }
485
+ /** The external turn settled, but Maka did not durably consume its terminal event. */
486
+ async abandonExecution(conversationKey, turnId) {
487
+ const session = this.#sessions.get(conversationKey);
488
+ if (!session?.record ||
489
+ session.awaitingAck !== turnId ||
490
+ session.record.phase !== 'prompt_pending' ||
491
+ session.record.pendingTurnId !== turnId)
492
+ return;
493
+ session.historyGap = true;
494
+ session.awaitingAck = undefined;
495
+ session.record = {
496
+ ...session.record,
497
+ phase: 'history_gap',
498
+ pendingTurnId: undefined,
499
+ };
500
+ try {
501
+ await this.#state?.write?.(conversationKey, session.record);
502
+ }
503
+ finally {
504
+ await this.#lose(session);
505
+ }
506
+ }
305
507
  async #applyInitialConfig(session, values, signal, restoreOnFailure = false) {
306
508
  for (const [key, value] of Object.entries(values)) {
307
509
  const option = session.configOptions.find((candidate) => candidate.type === 'select' && (candidate.id === key || candidate.category === key));
@@ -310,8 +512,11 @@ export class AcpExecutor {
310
512
  const options = option.options.flatMap((entry) => 'options' in entry ? entry.options : [entry]);
311
513
  if (!options.some((entry) => entry.value === value))
312
514
  throw new AcpRuntimeError(`ACP configuration value is unavailable: ${key}`, 'acp_config_invalid');
313
- if (option.currentValue === value)
515
+ if (option.currentValue === value) {
516
+ if (key === 'model')
517
+ await this.#persistConfirmedModel(session, value);
314
518
  continue;
519
+ }
315
520
  signal.throwIfAborted();
316
521
  try {
317
522
  const updated = await session.connection.agent.request(methods.agent.session.setConfigOption, { sessionId: session.acpSessionId, configId: option.id, value }, { cancellationSignal: signal });
@@ -319,6 +524,8 @@ export class AcpExecutor {
319
524
  if (confirmed?.type !== 'select' || confirmed.currentValue !== value)
320
525
  throw new AcpRuntimeError('Agent did not confirm the selected configuration', 'acp_config_unconfirmed');
321
526
  session.configOptions = updated.configOptions;
527
+ if (key === 'model')
528
+ await this.#persistConfirmedModel(session, value);
322
529
  }
323
530
  catch (error) {
324
531
  // A rejected/unconfirmed mutation can have reached the Agent. Restore the
@@ -332,6 +539,8 @@ export class AcpExecutor {
332
539
  confirmed.currentValue === option.currentValue &&
333
540
  !session.lost) {
334
541
  session.configOptions = rollback.configOptions;
542
+ if (key === 'model')
543
+ await this.#persistConfirmedModel(session, option.currentValue);
335
544
  restored = true;
336
545
  }
337
546
  }
@@ -345,6 +554,13 @@ export class AcpExecutor {
345
554
  }
346
555
  }
347
556
  }
557
+ async #persistConfirmedModel(session, model) {
558
+ if (!session.record || session.record.confirmedModel === model)
559
+ return;
560
+ const record = { ...session.record, confirmedModel: model };
561
+ await this.#state?.write?.(session.conversationKey, record);
562
+ session.record = record;
563
+ }
348
564
  #configureClient(session, app) {
349
565
  app
350
566
  .onNotification(methods.client.session.update, ({ params }) => {
@@ -352,13 +568,13 @@ export class AcpExecutor {
352
568
  this.#acceptUpdate(session, params.update);
353
569
  })
354
570
  .onRequest(methods.client.fs.readTextFile, async ({ params }) => {
355
- this.#assertSession(session, params.sessionId);
571
+ this.#assertWorkspaceAccess(session, params.sessionId);
356
572
  return {
357
573
  content: await readWorkspaceTextFile(session.cwd, params.path, params.line, params.limit),
358
574
  };
359
575
  })
360
576
  .onRequest(methods.client.fs.writeTextFile, async ({ params }) => {
361
- this.#assertSession(session, params.sessionId);
577
+ this.#assertWorkspaceAccess(session, params.sessionId);
362
578
  await writeWorkspaceTextFile(session.cwd, params.path, params.content);
363
579
  return {};
364
580
  })
@@ -366,6 +582,8 @@ export class AcpExecutor {
366
582
  }
367
583
  async #requestPermission(session, request) {
368
584
  this.#assertSession(session, request.sessionId);
585
+ if (session.restoring || session.historyGap || session.lost)
586
+ return { outcome: { outcome: 'cancelled' } };
369
587
  const active = session.active;
370
588
  if (!active)
371
589
  return { outcome: { outcome: 'cancelled' } };
@@ -378,6 +596,16 @@ export class AcpExecutor {
378
596
  return { outcome };
379
597
  }
380
598
  #acceptUpdate(session, update) {
599
+ if (session.restoring) {
600
+ if (session.replay) {
601
+ session.replay.updates++;
602
+ if (update.sessionUpdate === 'user_message_chunk')
603
+ session.replay.userChunks++;
604
+ }
605
+ return;
606
+ }
607
+ if (session.historyGap || session.lost)
608
+ return;
381
609
  if (update.sessionUpdate === 'config_option_update') {
382
610
  // During our mutation, its response (or rollback response) is authoritative.
383
611
  if (!session.configuring && !session.lost)
@@ -401,6 +629,12 @@ export class AcpExecutor {
401
629
  if (update.sessionUpdate === 'tool_call_update')
402
630
  this.#acceptTool(active, update);
403
631
  }
632
+ #assertWorkspaceAccess(session, sessionId) {
633
+ this.#assertSession(session, sessionId);
634
+ if (session.restoring || session.historyGap || session.lost) {
635
+ throw new AcpRuntimeError('ACP filesystem access is unavailable while Session history is untrusted', 'acp_history_gap');
636
+ }
637
+ }
404
638
  #acceptTool(active, update) {
405
639
  const snapshot = active.tools.get(update.toolCallId) ?? {
406
640
  id: update.toolCallId,
@@ -516,6 +750,10 @@ export class AcpExecutor {
516
750
  await owner.dispose();
517
751
  }
518
752
  }
753
+ 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;
756
+ }
519
757
  function toolTextContent(content) {
520
758
  return content
521
759
  .flatMap((item) => item.type === 'content' && item.content.type === 'text' ? [item.content.text] : [])
@@ -549,15 +787,82 @@ function pluginStateStore(storage, executorId) {
549
787
  const key = (conversationKey) => `acp/${executorId}/${createHash('sha256').update(conversationKey).digest('hex')}`;
550
788
  return {
551
789
  async has(conversationKey) {
552
- const value = (await storage.get(key(conversationKey)))
553
- .value;
554
- return value?.version === 1;
790
+ return (await storage.get(key(conversationKey))).value !== undefined;
555
791
  },
556
792
  async mark(conversationKey, cwd) {
557
793
  await storage.set(key(conversationKey), { version: 1, cwd });
558
794
  },
795
+ async read(conversationKey) {
796
+ return (await storage.get(key(conversationKey))).value;
797
+ },
798
+ async write(conversationKey, state) {
799
+ await storage.set(key(conversationKey), state);
800
+ },
559
801
  };
560
802
  }
803
+ function decodeContinuity(value) {
804
+ if (value === undefined)
805
+ return undefined;
806
+ if (!value || typeof value !== 'object' || Array.isArray(value))
807
+ return 'invalid';
808
+ const record = value;
809
+ if (record.version === 1)
810
+ return 'legacy';
811
+ if (record.version !== 2 ||
812
+ typeof record.cwd !== 'string' ||
813
+ !isAbsolute(record.cwd) ||
814
+ typeof record.binding !== 'string' ||
815
+ !/^sha256:[a-f0-9]{64}$/u.test(record.binding) ||
816
+ !Number.isSafeInteger(record.committedPrompts) ||
817
+ record.committedPrompts < 0 ||
818
+ !['reserved', 'established', 'prompt_pending', 'committed', 'history_gap'].includes(String(record.phase)) ||
819
+ (record.sessionId !== undefined &&
820
+ (typeof record.sessionId !== 'string' ||
821
+ !record.sessionId ||
822
+ record.sessionId.length > 4096)) ||
823
+ (record.pendingTurnId !== undefined &&
824
+ (typeof record.pendingTurnId !== 'string' || !record.pendingTurnId)) ||
825
+ (record.confirmedModel !== undefined &&
826
+ (typeof record.confirmedModel !== 'string' || !record.confirmedModel)) ||
827
+ (record.phase === 'reserved' && record.sessionId !== undefined) ||
828
+ (record.phase !== 'reserved' && !record.sessionId) ||
829
+ (record.phase === 'prompt_pending' && !record.pendingTurnId) ||
830
+ (record.phase !== 'prompt_pending' && record.pendingTurnId !== undefined) ||
831
+ (record.gapEvidence !== undefined &&
832
+ (record.phase !== 'history_gap' ||
833
+ !record.gapEvidence ||
834
+ typeof record.gapEvidence !== 'object' ||
835
+ !Number.isSafeInteger(record.gapEvidence.replayedUpdates) ||
836
+ !Number.isSafeInteger(record.gapEvidence.replayedUserChunks) ||
837
+ record.gapEvidence.replayedUpdates < 0 ||
838
+ record.gapEvidence.replayedUserChunks < 0)))
839
+ return 'invalid';
840
+ return record;
841
+ }
842
+ async function launchBinding(adapterId, launch, signal) {
843
+ const paths = [launch.executable, ...(launch.requiredExecutables ?? [])];
844
+ const files = [];
845
+ for (const path of paths) {
846
+ signal.throwIfAborted();
847
+ const resolved = await checkedExecutable(path);
848
+ const hash = createHash('sha256');
849
+ for await (const chunk of createReadStream(resolved)) {
850
+ signal.throwIfAborted();
851
+ hash.update(chunk);
852
+ }
853
+ files.push({ path: resolved, digest: hash.digest('hex') });
854
+ }
855
+ return `sha256:${createHash('sha256')
856
+ .update(JSON.stringify({
857
+ adapterId,
858
+ files,
859
+ args: launch.args ?? [],
860
+ cwd: launch.cwd ?? dirname(files[0].path),
861
+ platform: process.platform,
862
+ arch: process.arch,
863
+ }))
864
+ .digest('hex')}`;
865
+ }
561
866
  function validateAdapter(adapter) {
562
867
  if (!adapter ||
563
868
  typeof adapter !== 'object' ||