lody 0.90.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { randomUUID, createHash } from "node:crypto";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { Readable, Writable } from "node:stream";
4
4
  import { n as ndJsonStream, A as AgentSideConnection, R as RequestError, P as PROTOCOL_VERSION } from "./chunks/acp-DBqkNziA.js";
5
- import { D as DEEPSEEK_HARNESS_PERMISSION_MODES, c as DEEPSEEK_HARNESS_REASONING_OPTIONS, e as ACP_EXTENSION_DSH_VERSION, a as DEEPSEEK_HARNESS_AGENT_PRESETS } from "./chunks/profile-CPDWaQXE.js";
5
+ import { j as DEEPSEEK_HARNESS_BASE_URL_ENV, k as DEEPSEEK_HARNESS_API_KEY_ENV, b as ACP_EXTENSION_DSH_VERSION, a as DEEPSEEK_HARNESS_AGENT_PRESETS } from "./chunks/profile-DLuYzIs8.js";
6
6
  import "./chunks/schemas-Du3qLZPS.js";
7
7
  const name = "acp-extension-dsh";
8
8
  const inject = [
@@ -22,10 +22,15 @@ const MODEL_CONFIG_ID = "model";
22
22
  const MODE_CONFIG_ID = "mode";
23
23
  const REASONING_EFFORT_CONFIG_ID = "reasoning_effort";
24
24
  const AGENT_PRESET_CONFIG_ID = "agent_preset";
25
+ const PERMISSION_EVENT_TYPES = /* @__PURE__ */ new Set(["permission/preset", "sandbox/mode", "approval/policy"]);
25
26
  const MCP_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
26
27
  const MCP_TOOL_CALL_TIMEOUT_MS = 6e4;
27
28
  const MCP_SERVER_NAME_MAX_LENGTH = 32;
28
29
  const MCP_SERVER_NAME_HASH_LENGTH = 8;
30
+ const MODEL_DISCOVERY_TIMEOUT_MS = 15e3;
31
+ const MODEL_DISCOVERY_MAX_BYTES = 1024 * 1024;
32
+ const MODEL_DISCOVERY_MAX_MODELS = 1e3;
33
+ const MODEL_ID_MAX_LENGTH = 512;
29
34
  const INVALID_MCP_SERVER_NAME_CHARS = /[^A-Za-z0-9_-]/gu;
30
35
  const IMAGE_MEDIA_TYPES = [
31
36
  "image/png",
@@ -45,8 +50,6 @@ const IMAGE_ADMISSION_ERROR_CODES = /* @__PURE__ */ new Set([
45
50
  "IMAGE_TOO_MANY_PIXELS",
46
51
  "IMAGE_DIMENSION_TOO_LARGE"
47
52
  ]);
48
- const PERMISSION_MODE_IDS = new Set(DEEPSEEK_HARNESS_PERMISSION_MODES.map((mode) => mode.id));
49
- const REASONING_EFFORT_IDS = new Set(DEEPSEEK_HARNESS_REASONING_OPTIONS.map((effort) => effort.value));
50
53
  function invalidParams(detail) {
51
54
  return RequestError.invalidParams(void 0, detail);
52
55
  }
@@ -59,23 +62,19 @@ function nonEmptyString(value, fallback) {
59
62
  function resolveAdapterConfig(config) {
60
63
  const provider = nonEmptyString(config?.provider, "deepseek-official");
61
64
  const model = nonEmptyString(config?.model, "deepseek-v4-pro");
62
- const reasoningEffort = config?.reasoningEffort ?? "max";
63
- if (!REASONING_EFFORT_IDS.has(reasoningEffort)) {
64
- throw new Error(`acp-extension-dsh: unsupported reasoning effort ${JSON.stringify(reasoningEffort)}`);
65
- }
66
65
  return {
67
66
  provider,
68
67
  model,
69
- reasoningEffort,
68
+ ...config?.reasoningEffort ? { reasoningEffort: config.reasoningEffort } : {},
70
69
  ...config?.stream ? { stream: config.stream } : {}
71
70
  };
72
71
  }
73
- async function loadHarnessModels(ctx, provider) {
74
- const llm = ctx.get("llm");
75
- if (!llm)
76
- throw new Error("acp-extension-dsh: no Harness LLM catalog is mounted");
77
- const listed = await llm.listModels(provider);
78
- const models = [];
72
+ async function loadHarnessModels(llm, provider, configuredModel, endpoint) {
73
+ const listed = endpoint ? (await discoverDeepSeekModelIds(endpoint.baseUrl, endpoint.apiKey)).map((id) => ({
74
+ provider,
75
+ id
76
+ })) : await llm.listModels(provider);
77
+ const modelIds = [];
79
78
  const ids = /* @__PURE__ */ new Set();
80
79
  for (const model of listed) {
81
80
  if (model.provider !== provider || !model.id.trim())
@@ -84,12 +83,101 @@ async function loadHarnessModels(ctx, provider) {
84
83
  throw new Error(`acp-extension-dsh: duplicate model ${JSON.stringify(model.id)} for provider ${JSON.stringify(provider)}`);
85
84
  }
86
85
  ids.add(model.id);
87
- models.push({
88
- ...model,
89
- ...model.inputModalities ? { inputModalities: [...model.inputModalities] } : {}
86
+ modelIds.push(model.id);
87
+ }
88
+ if (endpoint && modelIds.length === 0) {
89
+ throw new Error("the endpoint returned no usable models");
90
+ }
91
+ if (!endpoint && !ids.has(configuredModel))
92
+ modelIds.unshift(configuredModel);
93
+ const initialModel = endpoint ? modelIds[0] : configuredModel;
94
+ return {
95
+ models: await Promise.all(modelIds.map((modelId) => resolveHarnessModel(llm, provider, modelId))),
96
+ initialModel
97
+ };
98
+ }
99
+ function modelDiscoveryUrl(baseUrl) {
100
+ const url = new URL(baseUrl);
101
+ url.pathname = `${url.pathname.replace(/\/+$/u, "")}/models`;
102
+ url.hash = "";
103
+ return url;
104
+ }
105
+ async function readLimitedResponse(response) {
106
+ const declaredLength = Number(response.headers.get("content-length"));
107
+ if (Number.isFinite(declaredLength) && declaredLength > MODEL_DISCOVERY_MAX_BYTES) {
108
+ throw new Error("model discovery response is too large");
109
+ }
110
+ if (!response.body)
111
+ return "";
112
+ const reader = response.body.getReader();
113
+ const decoder = new TextDecoder();
114
+ let text = "";
115
+ let bytes = 0;
116
+ while (true) {
117
+ const { done, value } = await reader.read();
118
+ if (done)
119
+ break;
120
+ bytes += value.byteLength;
121
+ if (bytes > MODEL_DISCOVERY_MAX_BYTES) {
122
+ await reader.cancel();
123
+ throw new Error("model discovery response is too large");
124
+ }
125
+ text += decoder.decode(value, { stream: true });
126
+ }
127
+ return text + decoder.decode();
128
+ }
129
+ async function discoverDeepSeekModelIds(baseUrl, apiKey) {
130
+ const headers = new Headers({ accept: "application/json" });
131
+ if (apiKey)
132
+ headers.set("authorization", `Bearer ${apiKey}`);
133
+ let response;
134
+ try {
135
+ response = await fetch(modelDiscoveryUrl(baseUrl), {
136
+ method: "GET",
137
+ headers,
138
+ redirect: "error",
139
+ signal: AbortSignal.timeout(MODEL_DISCOVERY_TIMEOUT_MS)
140
+ });
141
+ } catch (error) {
142
+ throw new Error(`unable to request the endpoint model list: ${errorChain(error)}`, {
143
+ cause: error
144
+ });
145
+ }
146
+ if (!response.ok) {
147
+ throw new Error(`model discovery failed with HTTP ${response.status}`);
148
+ }
149
+ let body;
150
+ try {
151
+ body = JSON.parse(await readLimitedResponse(response));
152
+ } catch (error) {
153
+ throw new Error(`model discovery returned invalid JSON: ${errorChain(error)}`, {
154
+ cause: error
90
155
  });
91
156
  }
92
- return models;
157
+ if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data) || body.data.length > MODEL_DISCOVERY_MAX_MODELS) {
158
+ throw new Error("model discovery returned an invalid OpenAI-compatible response");
159
+ }
160
+ const ids = [];
161
+ const seen = /* @__PURE__ */ new Set();
162
+ for (const entry of body.data) {
163
+ if (typeof entry !== "object" || entry === null || !("id" in entry))
164
+ continue;
165
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
166
+ if (!id || id.length > MODEL_ID_MAX_LENGTH || seen.has(id))
167
+ continue;
168
+ seen.add(id);
169
+ ids.push(id);
170
+ }
171
+ return ids;
172
+ }
173
+ async function resolveHarnessModel(llm, provider, modelId, signal) {
174
+ if (!modelId.trim())
175
+ throw invalidParams("model id must not be empty");
176
+ const model = await llm.resolveModelInfo(provider, modelId, signal);
177
+ if (model.provider !== provider || model.id !== modelId || !model.name?.trim()) {
178
+ throw new Error(`acp-extension-dsh: invalid exact model metadata for ${JSON.stringify(provider)} / ${JSON.stringify(modelId)}`);
179
+ }
180
+ return model;
93
181
  }
94
182
  async function loadMcpClientPlugin(agentContext) {
95
183
  const module = agentContext.loader.unwrapExports(await agentContext.loader.import(MCP_CLIENT_PACKAGE));
@@ -175,12 +263,9 @@ async function mountMcpServers(agentContext, servers, serverNames, cwd) {
175
263
  });
176
264
  await Promise.all(handles.map((handle) => handle.await()));
177
265
  }
178
- function cloneSelection(selection) {
179
- return { ...selection };
180
- }
181
266
  function installModelSelection(agentContext, selection) {
182
267
  agentContext.on("system-prompt/assemble", async (_assembly, _context, next) => {
183
- const selected = cloneSelection(selection.current);
268
+ const selected = { ...selection.current };
184
269
  const assembled = await next();
185
270
  selection.assembled = selected;
186
271
  return {
@@ -200,12 +285,12 @@ function installModelSelection(agentContext, selection) {
200
285
  ...withoutInheritedEffort,
201
286
  provider: selected.provider,
202
287
  model: selected.model,
203
- reasoningEffort: selected.reasoningEffort
288
+ ...selected.reasoningEffort ? { reasoningEffort: selected.reasoningEffort } : {}
204
289
  };
205
290
  });
206
291
  }
207
292
  function configOptions(record) {
208
- return [
293
+ const options = [
209
294
  {
210
295
  id: MODE_CONFIG_ID,
211
296
  name: "Permission",
@@ -213,8 +298,8 @@ function configOptions(record) {
213
298
  category: "mode",
214
299
  type: "select",
215
300
  currentValue: record.permissionMode,
216
- options: DEEPSEEK_HARNESS_PERMISSION_MODES.map((mode) => ({
217
- value: mode.id,
301
+ options: record.permissionOptions.map((mode) => ({
302
+ value: mode.value,
218
303
  name: mode.name,
219
304
  description: mode.description ?? null
220
305
  }))
@@ -242,39 +327,81 @@ function configOptions(record) {
242
327
  category: "model",
243
328
  type: "select",
244
329
  currentValue: record.selection.current.model,
245
- options: record.models.map((model) => ({
246
- value: model.id,
247
- name: model.name ?? model.id,
248
- description: model.description ?? null
330
+ options: record.models.map((model2) => ({
331
+ value: model2.id,
332
+ name: model2.name,
333
+ description: model2.description ?? null
249
334
  }))
250
- },
251
- {
335
+ }
336
+ ];
337
+ const model = record.models.find((candidate) => candidate.id === record.selection.current.model);
338
+ if (model?.reasoning && record.selection.current.reasoningEffort) {
339
+ options.push({
252
340
  id: REASONING_EFFORT_CONFIG_ID,
253
341
  name: "Reasoning effort",
254
342
  description: "How much reasoning effort the model should use",
255
343
  category: "thought_level",
256
344
  type: "select",
257
345
  currentValue: record.selection.current.reasoningEffort,
258
- options: DEEPSEEK_HARNESS_REASONING_OPTIONS.map((effort) => ({
259
- value: effort.value,
346
+ options: model.reasoning.efforts.map((effort) => ({
347
+ value: effort.id,
260
348
  name: effort.name,
261
349
  description: effort.description ?? null
262
350
  }))
263
- }
264
- ];
351
+ });
352
+ }
353
+ return options;
354
+ }
355
+ function legacyModels(record) {
356
+ return {
357
+ currentModelId: record.selection.current.model,
358
+ availableModels: record.models.flatMap((model) => [
359
+ {
360
+ modelId: model.id,
361
+ name: model.name,
362
+ description: model.description ?? null
363
+ },
364
+ ...model.reasoning?.efforts.map((effort) => ({
365
+ modelId: `${model.id}[${effort.id}]`,
366
+ name: `${model.name} (${effort.name})`,
367
+ description: model.description ?? null
368
+ })) ?? []
369
+ ])
370
+ };
265
371
  }
266
372
  function modeState(record) {
267
373
  return {
268
374
  currentModeId: record.permissionMode,
269
- availableModes: DEEPSEEK_HARNESS_PERMISSION_MODES.map((mode) => ({
270
- id: mode.id,
375
+ availableModes: record.permissionOptions.map((mode) => ({
376
+ id: mode.value,
271
377
  name: mode.name,
272
378
  description: mode.description ?? null
273
379
  }))
274
380
  };
275
381
  }
276
- function modelSupportsImages(models, modelId) {
277
- return models.some((model) => model.id === modelId && model.inputModalities?.includes("image"));
382
+ function supportsAcpImagePrompts(attachments, models) {
383
+ return attachments !== void 0 && attachments.imageLimits.mediaTypes.some((mediaType) => IMAGE_MEDIA_TYPES.includes(mediaType)) && models.some((model) => model.inputModalities?.includes("image"));
384
+ }
385
+ function resolveReasoningEffort(model, requested) {
386
+ const reasoning = model.reasoning;
387
+ if (!reasoning) {
388
+ if (requested) {
389
+ throw invalidParams(`model ${JSON.stringify(model.id)} does not support reasoning effort ${JSON.stringify(requested)}`);
390
+ }
391
+ return void 0;
392
+ }
393
+ const effort = requested ?? reasoning.defaultEffort;
394
+ if (!effort)
395
+ return void 0;
396
+ assertAllowed(effort, new Set(reasoning.efforts.map((candidate) => candidate.id)), `reasoning effort for model ${model.id}`);
397
+ return effort;
398
+ }
399
+ function permissionState(ctx, currentMode) {
400
+ const options = ctx.permissionPresets.names.map((mode) => ctx.permissionPresets.optionOf(mode));
401
+ if (!ctx.permissionPresets.names.includes(currentMode)) {
402
+ options.push(ctx.permissionPresets.optionOf(currentMode));
403
+ }
404
+ return options;
278
405
  }
279
406
  function imageMediaType(value) {
280
407
  return IMAGE_MEDIA_TYPES.includes(value) ? value : void 0;
@@ -296,7 +423,7 @@ function decodePromptImage(block) {
296
423
  function isImageAdmissionError(error) {
297
424
  return error instanceof Error && "code" in error && typeof error.code === "string" && IMAGE_ADMISSION_ERROR_CODES.has(error.code);
298
425
  }
299
- async function admitAcpPrompt(prompt, models, modelId, attachments) {
426
+ async function admitAcpPrompt(prompt, llm, selection, attachments, imagePromptEnabled, signal) {
300
427
  const images = [];
301
428
  for (const block of prompt) {
302
429
  switch (block.type) {
@@ -304,8 +431,8 @@ async function admitAcpPrompt(prompt, models, modelId, attachments) {
304
431
  case "resource_link":
305
432
  break;
306
433
  case "image":
307
- if (!modelSupportsImages(models, modelId)) {
308
- throw invalidParams(`model ${JSON.stringify(modelId)} does not support image input`);
434
+ if (!imagePromptEnabled) {
435
+ throw invalidParams("inline image prompts were not advertised by this connection");
309
436
  }
310
437
  images.push(decodePromptImage(block));
311
438
  break;
@@ -321,6 +448,17 @@ async function admitAcpPrompt(prompt, models, modelId, attachments) {
321
448
  if (images.length > 0) {
322
449
  if (!attachments)
323
450
  throw internalError("no Harness attachment store is mounted");
451
+ signal.throwIfAborted();
452
+ let model;
453
+ try {
454
+ model = await resolveHarnessModel(llm, selection.provider, selection.model, signal);
455
+ } catch (error) {
456
+ throw internalError(`unable to verify the current image model: ${errorChain(error)}`);
457
+ }
458
+ if (!model.inputModalities?.includes("image")) {
459
+ throw invalidParams(`model ${JSON.stringify(selection.model)} does not support image input`);
460
+ }
461
+ signal.throwIfAborted();
324
462
  try {
325
463
  refs = await attachments.saveImages(images);
326
464
  } catch (error) {
@@ -328,6 +466,7 @@ async function admitAcpPrompt(prompt, models, modelId, attachments) {
328
466
  throw invalidParams(error.message);
329
467
  throw internalError("unable to persist the prompt image batch");
330
468
  }
469
+ signal.throwIfAborted();
331
470
  }
332
471
  const content = [];
333
472
  let pendingText = "";
@@ -359,6 +498,28 @@ async function admitAcpPrompt(prompt, models, modelId, attachments) {
359
498
  }
360
499
  return content;
361
500
  }
501
+ async function assistantBlockToAcp(block, attachments) {
502
+ if (block.type === "text" && "text" in block) {
503
+ return block.text.length > 0 ? { type: "text", text: block.text } : void 0;
504
+ }
505
+ if (block.type !== "image" || !("attachment" in block))
506
+ return void 0;
507
+ if (!attachments)
508
+ throw new Error("cannot deliver assistant image: no attachment store is mounted");
509
+ let stored;
510
+ try {
511
+ stored = await attachments.readImage(block.attachment);
512
+ } catch (error) {
513
+ throw new Error("cannot deliver assistant image: the attachment is unavailable or corrupt", {
514
+ cause: error
515
+ });
516
+ }
517
+ return {
518
+ type: "image",
519
+ data: Buffer.from(stored.data).toString("base64"),
520
+ mimeType: stored.ref.mediaType
521
+ };
522
+ }
362
523
  function createUserMessage(id, content) {
363
524
  return Object.freeze({
364
525
  id,
@@ -367,22 +528,6 @@ function createUserMessage(id, content) {
367
528
  source: Object.freeze({ kind: "user" })
368
529
  });
369
530
  }
370
- function turnEndToStopReason(reason) {
371
- switch (reason.kind) {
372
- case "completed":
373
- return "end_turn";
374
- case "max-tokens":
375
- return "max_tokens";
376
- case "interrupted":
377
- return "cancelled";
378
- case "aborted":
379
- case "blocked":
380
- case "error":
381
- return "end_turn";
382
- default:
383
- return "end_turn";
384
- }
385
- }
386
531
  function errorChain(value) {
387
532
  const seen = /* @__PURE__ */ new Set();
388
533
  const render = (current) => {
@@ -431,11 +576,18 @@ function apply(ctx, rawConfig) {
431
576
  const activeMcpServerNames = /* @__PURE__ */ new Set();
432
577
  let closed = false;
433
578
  let conn;
434
- for (const mode of PERMISSION_MODE_IDS) {
435
- if (!ctx.permissionPresets.names.includes(mode)) {
436
- throw new Error(`acp-extension-dsh: permission preset ${JSON.stringify(mode)} is not composed`);
437
- }
579
+ let imagePromptEnabled = false;
580
+ if (ctx.permissionPresets.names.length === 0) {
581
+ throw new Error("acp-extension-dsh: no permission presets are composed");
438
582
  }
583
+ const llm = ctx.get("llm");
584
+ if (!llm)
585
+ throw new Error("acp-extension-dsh: no Harness LLM catalog is mounted");
586
+ const attachments = ctx.get("attachments");
587
+ const baseUrl = process.env[DEEPSEEK_HARNESS_BASE_URL_ENV]?.trim();
588
+ const apiKey = process.env[DEEPSEEK_HARNESS_API_KEY_ENV]?.trim();
589
+ let modelCatalog;
590
+ const loadModelCatalog = () => modelCatalog ??= loadHarnessModels(llm, config.provider, config.model, baseUrl ? { baseUrl, ...apiKey ? { apiKey } : {} } : void 0);
439
591
  const assertOpen = () => {
440
592
  if (closed)
441
593
  throw internalError("the ACP bridge has been disposed");
@@ -450,19 +602,96 @@ function apply(ctx, rawConfig) {
450
602
  const record = sessions.get(agent.session.id);
451
603
  return record?.agent === agent ? record : void 0;
452
604
  };
453
- const notify = (notification) => {
454
- void conn.sessionUpdate(notification).catch((error) => {
605
+ const notify = async (notification) => {
606
+ await conn.sessionUpdate(notification).catch((error) => {
455
607
  ctx.logger.warn(`acp-extension-dsh: session/update failed: ${String(error)}`);
456
608
  });
457
609
  };
458
- const settlePrompt = (record, reason) => {
459
- const inflight = record.inflight;
460
- if (!inflight)
610
+ const enqueueOutput = (record, work, inflight) => {
611
+ record.outputTail = record.outputTail.then(work).catch((error) => {
612
+ const failure = error instanceof Error ? error : new Error(String(error));
613
+ if (inflight)
614
+ inflight.outputError ??= failure;
615
+ ctx.logger.warn(`acp-extension-dsh: assistant output failed: ${errorChain(error)}`);
616
+ });
617
+ };
618
+ const enqueueNotification = (record, notification, inflight) => enqueueOutput(record, () => notify(notification), inflight);
619
+ const settleAfterQuiescence = (record, inflight) => {
620
+ if (inflight.settlementStarted)
461
621
  return;
462
- record.inflight = void 0;
463
- inflight.resolve(reason);
622
+ inflight.settlementStarted = true;
623
+ void (async () => {
624
+ await inflight.admissionDone;
625
+ if (inflight.messageQueued) {
626
+ await record.agent.whenIdle();
627
+ await record.outputTail;
628
+ }
629
+ if (record.inflight !== inflight)
630
+ return;
631
+ record.inflight = void 0;
632
+ if (inflight.cancelRequested) {
633
+ inflight.resolve("cancelled");
634
+ } else if (inflight.outputError) {
635
+ inflight.reject(internalError(`assistant output delivery failed: ${inflight.outputError.message}`));
636
+ } else if (inflight.agentError) {
637
+ inflight.reject(internalError(`turn failed: ${inflight.agentError.message}`));
638
+ } else if (inflight.endReason?.kind === "error") {
639
+ inflight.reject(internalError(`turn failed: ${inflight.endReason.error.message}`));
640
+ } else {
641
+ const end = inflight.endReason;
642
+ inflight.resolve(end ? end.kind === "interrupted" ? "cancelled" : "end_turn" : "cancelled");
643
+ }
644
+ })().catch((error) => {
645
+ if (record.inflight !== inflight)
646
+ return;
647
+ record.inflight = void 0;
648
+ inflight.reject(internalError(`prompt settlement failed: ${errorChain(error)}`));
649
+ });
650
+ };
651
+ const refreshPermissionState = (record) => {
652
+ const permissionMode = ctx.permissionPresets.current(record.agent.session.events);
653
+ if (permissionMode === record.permissionMode)
654
+ return false;
655
+ record.permissionMode = permissionMode;
656
+ record.permissionOptions = permissionState(ctx, permissionMode);
657
+ return true;
658
+ };
659
+ const schedulePermissionSync = (record) => {
660
+ if (record.permissionSyncQueued)
661
+ return;
662
+ record.permissionSyncQueued = true;
663
+ queueMicrotask(() => {
664
+ record.permissionSyncQueued = false;
665
+ if (sessions.get(record.agent.session.id) !== record)
666
+ return;
667
+ try {
668
+ if (!refreshPermissionState(record))
669
+ return;
670
+ enqueueNotification(record, {
671
+ sessionId: record.agent.session.id,
672
+ update: {
673
+ sessionUpdate: "current_mode_update",
674
+ currentModeId: record.permissionMode
675
+ }
676
+ });
677
+ enqueueNotification(record, {
678
+ sessionId: record.agent.session.id,
679
+ update: {
680
+ sessionUpdate: "config_option_update",
681
+ configOptions: configOptions(record)
682
+ }
683
+ });
684
+ } catch (error) {
685
+ ctx.logger.warn(`acp-extension-dsh: failed to synchronize permission state: ${errorChain(error)}`);
686
+ }
687
+ });
464
688
  };
465
689
  const disposeRecords = async (records) => {
690
+ await Promise.all(records.map(async (record) => {
691
+ await record.inflight?.admissionDone;
692
+ await record.agent.whenIdle();
693
+ await record.outputTail;
694
+ }));
466
695
  const subagents = ctx.get("subagents");
467
696
  if (subagents) {
468
697
  try {
@@ -481,9 +710,11 @@ function apply(ctx, rawConfig) {
481
710
  const record = sessions.get(session.header.id);
482
711
  if (!record || record.agent.session !== session)
483
712
  return;
713
+ if (PERMISSION_EVENT_TYPES.has(event.type))
714
+ schedulePermissionSync(record);
484
715
  try {
485
716
  if (event.type === "assistant/chunk" && event.data.chunk?.type === "reasoning-delta" && typeof event.data.chunk.text === "string" && event.data.chunk.text.length > 0) {
486
- notify({
717
+ enqueueNotification(record, {
487
718
  sessionId: record.agent.session.id,
488
719
  update: {
489
720
  sessionUpdate: "agent_thought_chunk",
@@ -491,7 +722,7 @@ function apply(ctx, rawConfig) {
491
722
  }
492
723
  });
493
724
  } else if (event.type === "assistant/chunk" && event.data.chunk?.type === "block-end" && event.data.chunk.block?.type === "reasoning") {
494
- notify({
725
+ enqueueNotification(record, {
495
726
  sessionId: record.agent.session.id,
496
727
  update: {
497
728
  sessionUpdate: "agent_thought_chunk",
@@ -499,35 +730,25 @@ function apply(ctx, rawConfig) {
499
730
  }
500
731
  });
501
732
  } else if (event.type === "assistant/message") {
502
- for (const block of event.data.message?.content ?? []) {
503
- if (block.type === "text" && "text" in block && block.text.length > 0) {
504
- notify({
505
- sessionId: record.agent.session.id,
506
- update: {
507
- sessionUpdate: "agent_message_chunk",
508
- content: { type: "text", text: block.text }
509
- }
510
- });
511
- } else if (block.type === "image" && "attachment" in block) {
512
- notify({
733
+ const inflight = record.inflight?.turn === event.data.turn ? record.inflight : void 0;
734
+ enqueueOutput(record, async () => {
735
+ for (const block of event.data.message?.content ?? []) {
736
+ const content = await assistantBlockToAcp(block, attachments);
737
+ if (!content)
738
+ continue;
739
+ await notify({
513
740
  sessionId: record.agent.session.id,
514
- update: {
515
- sessionUpdate: "agent_message_chunk",
516
- content: {
517
- type: "text",
518
- text: `[image attachment ${block.attachment.attachmentId}]`
519
- }
520
- }
741
+ update: { sessionUpdate: "agent_message_chunk", content }
521
742
  });
522
743
  }
523
- }
744
+ }, inflight);
524
745
  } else if (event.type === "compaction/start" && event.data.compactionId) {
525
746
  const activity = {
526
747
  version: 1,
527
748
  kind: "context_compaction",
528
749
  automatic: event.data.turn !== null
529
750
  };
530
- notify({
751
+ enqueueNotification(record, {
531
752
  sessionId: record.agent.session.id,
532
753
  update: {
533
754
  sessionUpdate: "tool_call",
@@ -545,7 +766,7 @@ function apply(ctx, rawConfig) {
545
766
  automatic: event.data.turn !== null,
546
767
  ...event.data.error ? { failureReason: event.data.error } : {}
547
768
  };
548
- notify({
769
+ enqueueNotification(record, {
549
770
  sessionId: record.agent.session.id,
550
771
  update: {
551
772
  sessionUpdate: "tool_call_update",
@@ -559,12 +780,7 @@ function apply(ctx, rawConfig) {
559
780
  } finally {
560
781
  const inflight = record.inflight;
561
782
  if (inflight && event.type === "turn/end" && inflight.turn === event.data.turn && event.data.reason) {
562
- if (event.data.reason.kind === "error") {
563
- record.inflight = void 0;
564
- inflight.reject(internalError(`turn failed: ${event.data.reason.error.message}`));
565
- } else {
566
- inflight.endReason = event.data.reason;
567
- }
783
+ inflight.endReason = event.data.reason;
568
784
  }
569
785
  }
570
786
  });
@@ -576,10 +792,10 @@ function apply(ctx, rawConfig) {
576
792
  ctx.on("agent/error", ({ agent, turn, error }) => {
577
793
  const record = ownedRecord(agent);
578
794
  const inflight = record?.inflight;
579
- if (!record || !inflight || inflight.turn !== void 0 && inflight.turn !== turn)
795
+ if (!record || !inflight || !inflight.messageQueued || inflight.turn !== turn)
580
796
  return;
581
- record.inflight = void 0;
582
- inflight.reject(internalError(`turn failed: ${errorChain(error)}`));
797
+ inflight.agentError = new Error(errorChain(error));
798
+ settleAfterQuiescence(record, inflight);
583
799
  });
584
800
  ctx.on("approval/request", (request, next) => {
585
801
  const record = ownedRecord(request.agent);
@@ -599,9 +815,16 @@ function apply(ctx, rawConfig) {
599
815
  });
600
816
  });
601
817
  const setPermissionMode = (record, modeId) => {
602
- assertAllowed(modeId, PERMISSION_MODE_IDS, "permission mode");
818
+ if (modeId === record.permissionMode)
819
+ return;
820
+ assertAllowed(modeId, new Set(ctx.permissionPresets.names), "permission mode");
603
821
  ctx.permissionPresets.set(record.agent.session, modeId);
604
- record.permissionMode = modeId;
822
+ const permissionMode = ctx.permissionPresets.current(record.agent.session.events);
823
+ if (permissionMode !== modeId) {
824
+ throw internalError(`permission preset ${JSON.stringify(modeId)} did not become the effective mode`);
825
+ }
826
+ record.permissionMode = permissionMode;
827
+ record.permissionOptions = permissionState(ctx, permissionMode);
605
828
  };
606
829
  const setConfigOption = (record, params) => {
607
830
  const value = requireSelectValue(params);
@@ -625,13 +848,35 @@ function apply(ctx, rawConfig) {
625
848
  throw invalidParams(`failed to select agent preset ${JSON.stringify(value)}: ${errorChain(error)}`);
626
849
  });
627
850
  } else if (params.configId === MODEL_CONFIG_ID) {
628
- assertAllowed(value, new Set(record.models.map((model) => model.id)), "model");
629
- record.selection.current = { ...record.selection.current, model: value };
851
+ return resolveHarnessModel(llm, record.selection.current.provider, value).then((model) => {
852
+ const index = record.models.findIndex((candidate) => candidate.id === value);
853
+ if (index === -1)
854
+ record.models.push(model);
855
+ else
856
+ record.models[index] = model;
857
+ const reasoningEffort = resolveReasoningEffort(model, record.selection.current.reasoningEffort && model.reasoning?.efforts.some((effort) => effort.id === record.selection.current.reasoningEffort) ? record.selection.current.reasoningEffort : void 0);
858
+ record.selection.current = {
859
+ provider: record.selection.current.provider,
860
+ model: value,
861
+ ...reasoningEffort ? { reasoningEffort } : {}
862
+ };
863
+ return { configOptions: configOptions(record) };
864
+ }).catch((error) => {
865
+ if (error instanceof RequestError)
866
+ throw error;
867
+ throw invalidParams(`failed to resolve model ${JSON.stringify(value)}: ${errorChain(error)}`);
868
+ });
630
869
  } else if (params.configId === REASONING_EFFORT_CONFIG_ID) {
631
- assertAllowed(value, REASONING_EFFORT_IDS, "reasoning effort");
870
+ const model = record.models.find((candidate) => candidate.id === record.selection.current.model);
871
+ if (!model)
872
+ throw internalError("selected model metadata is unavailable");
873
+ const reasoningEffort = resolveReasoningEffort(model, value);
874
+ if (!reasoningEffort) {
875
+ throw invalidParams(`model ${JSON.stringify(model.id)} has no reasoning selector`);
876
+ }
632
877
  record.selection.current = {
633
878
  ...record.selection.current,
634
- reasoningEffort: value
879
+ reasoningEffort
635
880
  };
636
881
  } else {
637
882
  throw invalidParams(`unknown config option: ${params.configId}`);
@@ -642,13 +887,14 @@ function apply(ctx, rawConfig) {
642
887
  conn = connection;
643
888
  return {
644
889
  async initialize(_params) {
645
- const models = await loadHarnessModels(ctx, config.provider);
890
+ const { models } = await loadModelCatalog();
891
+ imagePromptEnabled = supportsAcpImagePrompts(attachments, models);
646
892
  return {
647
893
  protocolVersion: PROTOCOL_VERSION,
648
894
  agentInfo: { name: "acp-extension-dsh", version: ACP_EXTENSION_DSH_VERSION },
649
895
  agentCapabilities: {
650
896
  promptCapabilities: {
651
- image: models.some((model) => modelSupportsImages(models, model.id)),
897
+ image: imagePromptEnabled,
652
898
  audio: false,
653
899
  embeddedContext: false
654
900
  },
@@ -665,18 +911,23 @@ function apply(ctx, rawConfig) {
665
911
  async newSession(params) {
666
912
  assertOpen();
667
913
  validateSessionParams(params);
668
- let models;
914
+ let catalog;
669
915
  try {
670
- models = await loadHarnessModels(ctx, config.provider);
916
+ catalog = await loadModelCatalog();
671
917
  } catch (error) {
672
- throw internalError(`failed to list models: ${errorChain(error)}`);
918
+ throw internalError(`failed to discover models: ${errorChain(error)}`);
673
919
  }
920
+ const { models, initialModel: initialModelId } = catalog;
674
921
  const sessionId = randomUUID();
922
+ const initialModel = models.find((model) => model.id === initialModelId);
923
+ if (!initialModel)
924
+ throw internalError("initial model metadata is unavailable");
925
+ const reasoningEffort = resolveReasoningEffort(initialModel, config.reasoningEffort);
675
926
  const selection = {
676
927
  current: {
677
928
  provider: config.provider,
678
- model: config.model,
679
- reasoningEffort: config.reasoningEffort
929
+ model: initialModelId,
930
+ ...reasoningEffort ? { reasoningEffort } : {}
680
931
  }
681
932
  };
682
933
  const agentPresetOptions = (await ctx.agentPresets.list()).filter((preset) => preset.broken === void 0);
@@ -691,7 +942,7 @@ function apply(ctx, rawConfig) {
691
942
  handle = await ctx.agents.create({
692
943
  sessionId,
693
944
  meta: { cwd: params.cwd, agentPreset: requestedPreset },
694
- agentOptions: { provider: config.provider, model: config.model },
945
+ agentOptions: { provider: config.provider, model: initialModelId },
695
946
  setup: async (agentContext) => {
696
947
  installModelSelection(agentContext, selection);
697
948
  mountedPreset = (await ctx.agentPresets.mount(agentContext, requestedPreset)).id;
@@ -716,9 +967,10 @@ function apply(ctx, rawConfig) {
716
967
  throw internalError("connection closed during session/new");
717
968
  }
718
969
  let permissionMode;
970
+ let permissionOptions;
719
971
  try {
720
972
  permissionMode = ctx.permissionPresets.current(handle.agent.session.events);
721
- assertAllowed(permissionMode, PERMISSION_MODE_IDS, "permission mode");
973
+ permissionOptions = permissionState(ctx, permissionMode);
722
974
  } catch (error) {
723
975
  await dispose();
724
976
  throw error;
@@ -728,24 +980,19 @@ function apply(ctx, rawConfig) {
728
980
  dispose,
729
981
  selection,
730
982
  permissionMode,
983
+ permissionOptions,
731
984
  agentPreset: mountedPreset,
732
985
  agentPresetOptions,
733
986
  models,
734
- started: false
987
+ started: false,
988
+ outputTail: Promise.resolve()
735
989
  };
736
990
  sessions.set(sessionId, record);
737
991
  return {
738
992
  sessionId,
739
993
  modes: modeState(record),
740
994
  configOptions: configOptions(record),
741
- models: {
742
- currentModelId: record.selection.current.model,
743
- availableModels: record.models.map((model) => ({
744
- modelId: model.id,
745
- name: model.name ?? model.id,
746
- description: model.description ?? null
747
- }))
748
- }
995
+ models: legacyModels(record)
749
996
  };
750
997
  },
751
998
  setSessionMode(params) {
@@ -763,60 +1010,92 @@ function apply(ctx, rawConfig) {
763
1010
  if (ctx.agents.get(record.agent.id) !== record.agent) {
764
1011
  throw internalError("prompt was not queued: the agent was disposed outside the bridge");
765
1012
  }
766
- const attachments = ctx.get("attachments");
767
1013
  const messageId = randomUUID();
768
1014
  let resolvePrompt;
769
1015
  let rejectPrompt;
1016
+ let finishAdmission;
770
1017
  const completion = new Promise((resolve, reject) => {
771
1018
  resolvePrompt = resolve;
772
1019
  rejectPrompt = reject;
773
1020
  });
1021
+ const admissionDone = new Promise((resolve) => {
1022
+ finishAdmission = resolve;
1023
+ });
1024
+ const admissionController = new AbortController();
774
1025
  const inflight = {
775
1026
  resolve: resolvePrompt,
776
1027
  reject: rejectPrompt,
777
- messageId
1028
+ messageQueued: false,
1029
+ admissionDone,
1030
+ admissionController,
1031
+ cancelRequested: false,
1032
+ settlementStarted: false
778
1033
  };
779
1034
  record.inflight = inflight;
1035
+ let admissionError;
780
1036
  try {
781
- const content = await admitAcpPrompt(params.prompt, record.models, record.selection.current.model, attachments);
782
- if (record.inflight !== inflight)
783
- return { stopReason: await completion };
784
- const message = createUserMessage(messageId, content);
785
- record.started = true;
786
- try {
787
- record.agent.followup(message);
788
- } catch (error) {
789
- record.inflight = void 0;
790
- record.started = false;
791
- throw internalError(`prompt was not queued: ${error instanceof Error ? error.message : String(error)}`);
1037
+ const content = await admitAcpPrompt(params.prompt, llm, record.selection.current, attachments, imagePromptEnabled, admissionController.signal);
1038
+ if (!inflight.cancelRequested) {
1039
+ if (ctx.agents.get(record.agent.id) !== record.agent) {
1040
+ throw internalError("prompt was not queued: the agent was disposed outside the bridge");
1041
+ }
1042
+ const message = createUserMessage(messageId, content);
1043
+ inflight.messageId = messageId;
1044
+ inflight.messageQueued = true;
1045
+ const wasStarted = record.started;
1046
+ try {
1047
+ record.agent.followup(message);
1048
+ record.started = true;
1049
+ } catch (error) {
1050
+ inflight.messageQueued = false;
1051
+ record.started = wasStarted;
1052
+ throw new Error(`prompt was not queued: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1053
+ }
792
1054
  }
793
- void record.agent.whenIdle().then(() => {
794
- if (record.inflight !== inflight)
795
- return;
796
- record.inflight = void 0;
797
- const end = inflight.endReason;
798
- inflight.resolve(end ? end.kind === "max-tokens" ? "end_turn" : turnEndToStopReason(end) : "cancelled");
799
- });
800
1055
  } catch (error) {
1056
+ admissionError = error;
1057
+ } finally {
1058
+ finishAdmission();
1059
+ }
1060
+ if (inflight.cancelRequested) {
1061
+ settleAfterQuiescence(record, inflight);
1062
+ return { stopReason: await completion };
1063
+ }
1064
+ if (admissionError) {
801
1065
  if (record.inflight === inflight)
802
1066
  record.inflight = void 0;
803
- throw error;
1067
+ if (admissionError instanceof RequestError)
1068
+ throw admissionError;
1069
+ throw internalError(errorChain(admissionError));
804
1070
  }
1071
+ settleAfterQuiescence(record, inflight);
805
1072
  return { stopReason: await completion };
806
1073
  },
807
1074
  cancel(params) {
808
1075
  const record = sessions.get(params.sessionId);
809
1076
  if (!record)
810
1077
  return Promise.resolve();
811
- record.agent.cancel({ kind: "user" });
812
- settlePrompt(record, "cancelled");
1078
+ const inflight = record.inflight;
1079
+ if (inflight) {
1080
+ inflight.cancelRequested = true;
1081
+ inflight.admissionController.abort(new Error("ACP prompt cancelled"));
1082
+ settleAfterQuiescence(record, inflight);
1083
+ }
1084
+ if (!inflight || inflight.messageQueued)
1085
+ record.agent.cancel({ kind: "user" });
813
1086
  return Promise.resolve();
814
1087
  },
815
1088
  async closeSession(params) {
816
1089
  const record = requireSession(params.sessionId);
817
1090
  sessions.delete(params.sessionId);
818
- record.agent.cancel({ kind: "user" });
819
- settlePrompt(record, "cancelled");
1091
+ const inflight = record.inflight;
1092
+ if (inflight) {
1093
+ inflight.cancelRequested = true;
1094
+ inflight.admissionController.abort(new Error("ACP session closed"));
1095
+ settleAfterQuiescence(record, inflight);
1096
+ }
1097
+ if (!inflight || inflight.messageQueued)
1098
+ record.agent.cancel({ kind: "user" });
820
1099
  await disposeRecords([record]);
821
1100
  }
822
1101
  };
@@ -831,8 +1110,14 @@ function apply(ctx, rawConfig) {
831
1110
  const records = [...sessions.values()];
832
1111
  sessions.clear();
833
1112
  for (const record of records) {
834
- record.agent.cancel({ kind: "user" });
835
- settlePrompt(record, "cancelled");
1113
+ const inflight = record.inflight;
1114
+ if (inflight) {
1115
+ inflight.cancelRequested = true;
1116
+ inflight.admissionController.abort(new Error("ACP bridge disposed"));
1117
+ settleAfterQuiescence(record, inflight);
1118
+ }
1119
+ if (!inflight || inflight.messageQueued)
1120
+ record.agent.cancel({ kind: "user" });
836
1121
  }
837
1122
  quiescing = (async () => {
838
1123
  await disposeRecords(records);