dsh-ntr 0.3.3 → 0.3.4

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.
package/lib/bridge.js CHANGED
@@ -12,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path';
12
12
  import { Writable, Readable } from 'node:stream';
13
13
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
15
- import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
15
+ import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, hasUnambiguousModelSelector, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, isAbsolutePath, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
16
16
  import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
17
17
  import { OUTPUT_CHARS_PER_TOKEN, TurnTpsTracker } from "./tps.js";
18
18
  /**
@@ -6287,6 +6287,11 @@ export class BridgeManager {
6287
6287
  const def = this.defsMap.get(agentId);
6288
6288
  if (def === undefined || def.autoDiscoverModels === false || before === undefined)
6289
6289
  return false;
6290
+ // A different model-slot identity is a corrected reading, not newly added
6291
+ // models. Diffing across it would expose an entire real catalog the first
6292
+ // time a parser fix replaces a mislabelled slot (Qoder CN).
6293
+ if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId)
6294
+ return false;
6290
6295
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value));
6291
6296
  const exposed = new Set(def.exposedModels ?? []);
6292
6297
  const added = (next.model?.choices ?? [])
@@ -6686,6 +6691,12 @@ export class BridgeManager {
6686
6691
  const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined;
6687
6692
  if (wanted === undefined)
6688
6693
  return undefined;
6694
+ // `category` is a UX hint, not identity: an agent may publish several
6695
+ // `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
6696
+ // and no explicit `model` id. In that case the selector is a guess, so a
6697
+ // "not offered" verdict would refuse a perfectly valid pick — fail open.
6698
+ if (!hasUnambiguousModelSelector(session.configOptions))
6699
+ return undefined;
6689
6700
  const model = parseCatalogOptions(session.configOptions).model;
6690
6701
  if (model === undefined)
6691
6702
  return undefined;
@@ -7630,6 +7641,44 @@ export class BridgeManager {
7630
7641
  });
7631
7642
  return { ok: true, reset: true };
7632
7643
  }
7644
+ /**
7645
+ * 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
7646
+ * 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
7647
+ * 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
7648
+ *
7649
+ * 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
7650
+ * 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
7651
+ *
7652
+ * - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
7653
+ * - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
7654
+ * 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
7655
+ */
7656
+ async reclaimChatPrompt(session, controller, signal) {
7657
+ if (session.closed || session.released)
7658
+ return 'ok';
7659
+ if (!controller.hasSettled && session.promptInFlight === true) {
7660
+ await this.bridge(session.agentId).cancel(session);
7661
+ controller.abortActiveSegment();
7662
+ }
7663
+ return this.waitForSettleGate(session, signal);
7664
+ }
7665
+ /** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
7666
+ waitForSettleGate(session, signal) {
7667
+ const pending = session.settled;
7668
+ if (pending === undefined)
7669
+ return Promise.resolve('ok');
7670
+ if (signal?.aborted)
7671
+ return Promise.resolve('aborted');
7672
+ return new Promise(resolve => {
7673
+ const done = (value) => {
7674
+ signal?.removeEventListener('abort', onAbort);
7675
+ resolve(value);
7676
+ };
7677
+ const onAbort = () => { done('aborted'); };
7678
+ signal?.addEventListener('abort', onAbort, { once: true });
7679
+ void pending.then(() => { done('ok'); });
7680
+ });
7681
+ }
7633
7682
  async *streamPrompt(options) {
7634
7683
  // The plugin's own conversation↔agent mapping decides who serves the turn
7635
7684
  // (see `bindChatAgent`): the driver may arrive here with the previous
@@ -7837,26 +7886,82 @@ export class BridgeManager {
7837
7886
  }
7838
7887
  // One prompt slot per session, and a turn the user aborted still occupies
7839
7888
  // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
7840
- const claim = await bridge.claimTurn(session);
7889
+ let claim = await bridge.claimTurn(session);
7841
7890
  if (!claim.ok) {
7842
- // Never roll the provisional lock back here: a failed claim means ANOTHER
7843
- // prompt holds the slot (or the agent has not settled it), and that turn
7844
- // owns the conversation's lock. Closing the session would kill a turn
7845
- // that is actively streaming — the refusal already tells the user to
7846
- // retry.
7847
- yield {
7848
- type: 'finish',
7849
- reason: {
7850
- kind: 'error',
7851
- failure: {
7852
- message: claim.reason === 'busy'
7853
- ? 'acp: this session already has a prompt in flight'
7854
- : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
7855
- code: 'turn_in_flight',
7891
+ // A STEER ("插话"): the host delivers the user's mid-turn message as the
7892
+ // NEXT step of the SAME host turn, so the conversation's own ACP prompt
7893
+ // is still on air. Refusing would fail the whole host turn. ACP forbids
7894
+ // injecting into a running prompt, so the steer's real semantic — abort
7895
+ // the current turn, then send this one — is spelled cancel + wait +
7896
+ // re-claim here.
7897
+ //
7898
+ // Only `busy` can be a steer: it means the AGENT still holds OUR prompt
7899
+ // (`promptInFlight`). `unsettled` means our side already released the
7900
+ // slot (the person pressed stop, or an earlier steer cancelled): the
7901
+ // agent has not acknowledged yet, and quietly queueing the next message
7902
+ // behind a turn the user stopped is not this path's business — that
7903
+ // stays the honest `turn_in_flight` refusal below.
7904
+ //
7905
+ // Only the conversation's OWN in-flight turn qualifies (its live stream
7906
+ // controller, on this very session). A `/acp` RPC turn has no chat
7907
+ // controller, and another conversation never reaches this session, so
7908
+ // both keep the plain refusal: nothing may be cancelled behind a
7909
+ // caller's back.
7910
+ const chatController = claim.reason === 'busy' && options.sessionId !== undefined
7911
+ ? this.turnStreamFor(options.sessionId)
7912
+ : undefined;
7913
+ if (options.sessionId !== undefined
7914
+ && chatController !== undefined
7915
+ && !chatController.stale
7916
+ && chatController.session === session) {
7917
+ const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal);
7918
+ if (reclaimed === 'aborted') {
7919
+ // The person pressed stop while the previous turn was winding down:
7920
+ // the steer is theirs to re-send, nothing goes to the agent.
7921
+ yield {
7922
+ type: 'finish',
7923
+ reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
7924
+ };
7925
+ return;
7926
+ }
7927
+ // A first turn that produced nothing rolled its provisional lock back
7928
+ // and CLOSED (and forgot) this session: the steer needs a fresh one.
7929
+ if (session.closed || session.released) {
7930
+ try {
7931
+ session = await this.getOrCreateDshSession(options.sessionId, agentId);
7932
+ }
7933
+ catch (err) {
7934
+ yield {
7935
+ type: 'finish',
7936
+ reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
7937
+ };
7938
+ return;
7939
+ }
7940
+ }
7941
+ // Same bridge: the session may have been re-created, but the
7942
+ // conversation is still served by this agent's process.
7943
+ claim = await bridge.claimTurn(session);
7944
+ }
7945
+ if (!claim.ok) {
7946
+ // Never roll the provisional lock back here: a failed claim means
7947
+ // ANOTHER prompt holds the slot (or the agent has not settled it), and
7948
+ // that turn owns the conversation's lock. Closing the session would
7949
+ // kill a turn that is actively streaming — the refusal already tells
7950
+ // the user to retry.
7951
+ yield {
7952
+ type: 'finish',
7953
+ reason: {
7954
+ kind: 'error',
7955
+ failure: {
7956
+ message: claim.reason === 'busy'
7957
+ ? 'acp: this session already has a prompt in flight'
7958
+ : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
7959
+ code: 'turn_in_flight',
7960
+ },
7856
7961
  },
7857
- },
7858
- };
7859
- return;
7962
+ };
7963
+ return;
7964
+ }
7860
7965
  }
7861
7966
  // Same-agent model/effort switches ride the SAME ACP session (context
7862
7967
  // stays continuous); switching to another agent lands in a separate one
package/lib/impl.js CHANGED
@@ -834,6 +834,42 @@ export function childEnv(parent, extra) {
834
834
  return out;
835
835
  }
836
836
  // ---- Model catalog (pure parsing of ACP `configOptions`) ----
837
+ /**
838
+ * ACP categories are free-form UX hints, not identity. Qoder CN tags
839
+ * `reasoning_effort` as `category: 'model'` (the spec means `thought_level`),
840
+ * and nothing stops an agent from reusing a category for several options.
841
+ * Slots are therefore assigned by `configId` first and category second, and a
842
+ * same-category option NEVER replaces another option's slot: whatever loses
843
+ * stays reachable through `extras` instead of silently becoming the model list.
844
+ */
845
+ const EFFORT_OPTION_HINT = /effort|thinking|thought|reason/i;
846
+ function isModelSelector(configId, name) {
847
+ return configId === 'model' || name.trim().toLowerCase() === 'model';
848
+ }
849
+ function catalogSlotFor(category, configId, name) {
850
+ const effortish = EFFORT_OPTION_HINT.test(configId) || EFFORT_OPTION_HINT.test(name);
851
+ if (category === 'model') {
852
+ // The option explicitly named `model` is the selector even when other
853
+ // model-scoped options are present.
854
+ if (isModelSelector(configId, name))
855
+ return { slot: 'model', rank: 3 };
856
+ // A model-scoped effort is mislabelled, not a model: route it to the
857
+ // thought chip when no real `thought_level` option is published.
858
+ if (effortish)
859
+ return { slot: 'thought', rank: 1 };
860
+ // An unknown model-scoped select: a last-resort selector only.
861
+ return { slot: 'model', rank: 1 };
862
+ }
863
+ if (category === 'thought_level')
864
+ return { slot: 'thought', rank: 3 };
865
+ if (category === 'mode')
866
+ return { slot: 'mode', rank: 2 };
867
+ // `model_config` is where the spec puts model-dependent knobs (effort,
868
+ // context size); only the effort-shaped ones have a dedicated control.
869
+ if (category === 'model_config' && effortish)
870
+ return { slot: 'thought', rank: 1 };
871
+ return undefined;
872
+ }
837
873
  /**
838
874
  * Extract select options from a raw ACP `configOptions` payload.
839
875
  *
@@ -848,19 +884,13 @@ export function parseCatalogOptions(configOptions) {
848
884
  const out = {};
849
885
  if (!Array.isArray(configOptions))
850
886
  return out;
851
- const extras = new Map();
887
+ const definitions = [];
888
+ const candidates = [];
852
889
  for (const raw of configOptions) {
853
890
  if (typeof raw !== 'object' || raw === null)
854
891
  continue;
855
892
  const row = raw;
856
893
  const category = typeof row.category === 'string' ? row.category : undefined;
857
- const slot = category === 'model'
858
- ? 'model'
859
- : category === 'thought_level'
860
- ? 'thought'
861
- : category === 'mode'
862
- ? 'mode'
863
- : undefined;
864
894
  const configIdRaw = row.configId ?? row.id;
865
895
  if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number')
866
896
  continue;
@@ -869,6 +899,7 @@ export function parseCatalogOptions(configOptions) {
869
899
  continue;
870
900
  const choices = [];
871
901
  collectCatalogChoices(row.options, choices);
902
+ const name = typeof row.name === 'string' && row.name !== '' ? row.name : configId;
872
903
  // A boolean toggle has no `options` at all; its current value is a JSON
873
904
  // boolean. It is encoded as 'true' / 'false' so the catalog stays
874
905
  // string-typed for every existing reader, and carries `kind` so the
@@ -877,7 +908,7 @@ export function parseCatalogOptions(configOptions) {
877
908
  || (typeof row.currentValue === 'boolean' && choices.length === 0);
878
909
  const option = {
879
910
  configId,
880
- name: typeof row.name === 'string' && row.name !== '' ? row.name : configId,
911
+ name,
881
912
  ...(booleanKind ? { kind: 'boolean' } : {}),
882
913
  currentValue: booleanKind
883
914
  ? row.currentValue === true || row.currentValue === 'true'
@@ -886,22 +917,81 @@ export function parseCatalogOptions(configOptions) {
886
917
  : typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
887
918
  choices,
888
919
  };
920
+ definitions.push(option);
889
921
  // A toggle is never a model / thought / mode SELECT: those controls pick
890
922
  // among choices. It belongs to the advanced base, where it is a checkbox.
891
- if (slot !== undefined && !booleanKind) {
892
- out[slot] = option;
923
+ if (booleanKind)
893
924
  continue;
925
+ const slot = catalogSlotFor(category, configId, name);
926
+ if (slot !== undefined)
927
+ candidates.push({ ...slot, option });
928
+ }
929
+ const winnerIds = new Set();
930
+ for (const slot of ['model', 'thought', 'mode']) {
931
+ let winner;
932
+ for (const candidate of candidates) {
933
+ if (candidate.slot !== slot)
934
+ continue;
935
+ if (winner === undefined || candidate.rank > winner.rank) {
936
+ winner = candidate;
937
+ }
938
+ else if (candidate.rank === winner.rank && candidate.option.configId === winner.option.configId) {
939
+ // The same option redefined later in one answer: the last definition
940
+ // is the current one.
941
+ winner = candidate;
942
+ }
894
943
  }
895
- // Choice-less options have nothing to pick; they would render an empty
896
- // section in the advanced menu, so they stay out of the catalog entirely —
897
- // except a boolean, whose whole point is that it has no choices.
898
- if (choices.length > 0 || booleanKind)
899
- extras.set(configId, option);
944
+ if (winner === undefined)
945
+ continue;
946
+ out[slot] = winner.option;
947
+ winnerIds.add(winner.option.configId);
948
+ }
949
+ // Choice-less options have nothing to pick; they would render an empty
950
+ // section in the advanced menu, so they stay out of the catalog entirely —
951
+ // except a boolean, whose whole point is that it has no choices. Anything
952
+ // that lost a slot race is still a real, settable option: keep it here.
953
+ const extras = new Map();
954
+ for (const option of definitions) {
955
+ if (winnerIds.has(option.configId))
956
+ continue;
957
+ if (option.choices.length > 0 || option.kind === 'boolean')
958
+ extras.set(option.configId, option);
900
959
  }
901
960
  if (extras.size > 0)
902
961
  out.extras = [...extras.values()];
903
962
  return out;
904
963
  }
964
+ /**
965
+ * Whether a payload names ONE model selector without relying on category
966
+ * alone. When an agent publishes several `category: 'model'` options and none
967
+ * is explicitly the `model` id, the category is not trustworthy enough to
968
+ * reject the user's pick — callers must fail open instead of reporting
969
+ * `model_unavailable`.
970
+ */
971
+ export function hasUnambiguousModelSelector(configOptions) {
972
+ if (!Array.isArray(configOptions))
973
+ return true;
974
+ let scoped = 0;
975
+ let explicit = false;
976
+ for (const raw of configOptions) {
977
+ if (typeof raw !== 'object' || raw === null)
978
+ continue;
979
+ const row = raw;
980
+ if (row.category !== 'model')
981
+ continue;
982
+ const configIdRaw = row.configId ?? row.id;
983
+ if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number')
984
+ continue;
985
+ const configId = String(configIdRaw);
986
+ if (configId === '')
987
+ continue;
988
+ scoped += 1;
989
+ const name = typeof row.name === 'string' ? row.name : '';
990
+ if (isModelSelector(configId, name))
991
+ explicit = true;
992
+ }
993
+ return explicit || scoped <= 1;
994
+ }
905
995
  /**
906
996
  * Fold one per-session option answer's SHAPE into an agent-level catalog.
907
997
  *
@@ -2667,6 +2667,21 @@ export declare class BridgeManager {
2667
2667
  * user is told the context is gone.
2668
2668
  */
2669
2669
  private prepareStalledSession;
2670
+ /**
2671
+ * 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
2672
+ * 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
2673
+ * 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
2674
+ *
2675
+ * 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
2676
+ * 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
2677
+ *
2678
+ * - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
2679
+ * - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
2680
+ * 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
2681
+ */
2682
+ private reclaimChatPrompt;
2683
+ /** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
2684
+ private waitForSettleGate;
2670
2685
  streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk>;
2671
2686
  }
2672
2687
  export declare class AcpLlmAdapter implements LlmAdapterLike {
@@ -248,6 +248,14 @@ export declare function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string
248
248
  * anything unusable is skipped silently.
249
249
  */
250
250
  export declare function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog;
251
+ /**
252
+ * Whether a payload names ONE model selector without relying on category
253
+ * alone. When an agent publishes several `category: 'model'` options and none
254
+ * is explicitly the `model` id, the category is not trustworthy enough to
255
+ * reject the user's pick — callers must fail open instead of reporting
256
+ * `model_unavailable`.
257
+ */
258
+ export declare function hasUnambiguousModelSelector(configOptions: unknown): boolean;
251
259
  /**
252
260
  * Fold one per-session option answer's SHAPE into an agent-level catalog.
253
261
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ntr",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent and stream its session.",
5
5
  "repository": "https://github.com/GDWhisper/dsh-ntr.git",
6
6
  "type": "module",
package/src/bridge.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  acpToolCallFromUpdate,
41
41
  buildAdapterModelId,
42
42
  childEnv,
43
+ hasUnambiguousModelSelector,
43
44
  isAcpToolContinuation,
44
45
  isUnderPath,
45
46
  mergeCatalogShape,
@@ -6970,6 +6971,10 @@ export class BridgeManager {
6970
6971
  private autoExposeNewModels(agentId: string, next: AcpAgentCatalog, before: AcpAgentCatalog | undefined): boolean {
6971
6972
  const def = this.defsMap.get(agentId)
6972
6973
  if (def === undefined || def.autoDiscoverModels === false || before === undefined) return false
6974
+ // A different model-slot identity is a corrected reading, not newly added
6975
+ // models. Diffing across it would expose an entire real catalog the first
6976
+ // time a parser fix replaces a mislabelled slot (Qoder CN).
6977
+ if (before.model !== undefined && next.model !== undefined && before.model.configId !== next.model.configId) return false
6973
6978
  const known = new Set((before.model?.choices ?? []).map(choice => choice.value))
6974
6979
  const exposed = new Set(def.exposedModels ?? [])
6975
6980
  const added = (next.model?.choices ?? [])
@@ -7360,6 +7365,11 @@ export class BridgeManager {
7360
7365
  private staleModelChoice(session: LiveSession, modelChoice: string | undefined): string | undefined {
7361
7366
  const wanted = modelChoice !== undefined && modelChoice !== '' ? modelChoice : undefined
7362
7367
  if (wanted === undefined) return undefined
7368
+ // `category` is a UX hint, not identity: an agent may publish several
7369
+ // `category: 'model'` options (Qoder CN ships `reasoning_effort` that way)
7370
+ // and no explicit `model` id. In that case the selector is a guess, so a
7371
+ // "not offered" verdict would refuse a perfectly valid pick — fail open.
7372
+ if (!hasUnambiguousModelSelector(session.configOptions)) return undefined
7363
7373
  const model = parseCatalogOptions(session.configOptions).model
7364
7374
  if (model === undefined) return undefined
7365
7375
  if (model.currentValue === wanted) return undefined
@@ -8309,6 +8319,47 @@ export class BridgeManager {
8309
8319
  return { ok: true, reset: true }
8310
8320
  }
8311
8321
 
8322
+ /**
8323
+ * 聊天插话的槽位回收:宿主把插话作为同一会话在飞回合的下一步送达,
8324
+ * 拒绝会让整条 host turn 失败。ACP 不能向运行中的 prompt 注入消息,
8325
+ * 所以按插话语义中止当前回合,等 agent 真正结算后再让调用方重发。
8326
+ *
8327
+ * 只处理 `claimTurn` 的 `busy`(agent 仍持有本会话 prompt);`unsettled`
8328
+ * 说明我们这一侧已经释放过槽位(用户按过停止),保持原样拒绝。
8329
+ *
8330
+ * - 控制器已 settle(响应已到,延后收口还没释放闸门):什么都不取消,只等闸门;
8331
+ * - agent 真在跑:走用户取消同一条管线(session/cancel + 结算权限 + cancel fallback),
8332
+ * 并立即关闭宿主这一侧的 segment,让闸门在 agent 应答/兜底后打开。
8333
+ */
8334
+ private async reclaimChatPrompt(
8335
+ session: LiveSession,
8336
+ controller: TurnStreamController,
8337
+ signal?: AbortSignal,
8338
+ ): Promise<'ok' | 'aborted'> {
8339
+ if (session.closed || session.released) return 'ok'
8340
+ if (!controller.hasSettled && session.promptInFlight === true) {
8341
+ await this.bridge(session.agentId).cancel(session)
8342
+ controller.abortActiveSegment()
8343
+ }
8344
+ return this.waitForSettleGate(session, signal)
8345
+ }
8346
+
8347
+ /** 等 agent 侧 prompt slot 真正释放;步骤被取消时立即返回 aborted。 */
8348
+ private waitForSettleGate(session: LiveSession, signal?: AbortSignal): Promise<'ok' | 'aborted'> {
8349
+ const pending = session.settled
8350
+ if (pending === undefined) return Promise.resolve('ok')
8351
+ if (signal?.aborted) return Promise.resolve('aborted')
8352
+ return new Promise(resolve => {
8353
+ const done = (value: 'ok' | 'aborted'): void => {
8354
+ signal?.removeEventListener('abort', onAbort)
8355
+ resolve(value)
8356
+ }
8357
+ const onAbort = (): void => { done('aborted') }
8358
+ signal?.addEventListener('abort', onAbort, { once: true })
8359
+ void pending.then(() => { done('ok') })
8360
+ })
8361
+ }
8362
+
8312
8363
  async *streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk> {
8313
8364
  // The plugin's own conversation↔agent mapping decides who serves the turn
8314
8365
  // (see `bindChatAgent`): the driver may arrive here with the previous
@@ -8514,26 +8565,81 @@ export class BridgeManager {
8514
8565
 
8515
8566
  // One prompt slot per session, and a turn the user aborted still occupies
8516
8567
  // the AGENT's slot until it acknowledges the cancel (see `claimTurn`).
8517
- const claim = await bridge.claimTurn(session)
8568
+ let claim = await bridge.claimTurn(session)
8518
8569
  if (!claim.ok) {
8519
- // Never roll the provisional lock back here: a failed claim means ANOTHER
8520
- // prompt holds the slot (or the agent has not settled it), and that turn
8521
- // owns the conversation's lock. Closing the session would kill a turn
8522
- // that is actively streaming — the refusal already tells the user to
8523
- // retry.
8524
- yield {
8525
- type: 'finish',
8526
- reason: {
8527
- kind: 'error',
8528
- failure: {
8529
- message: claim.reason === 'busy'
8530
- ? 'acp: this session already has a prompt in flight'
8531
- : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
8532
- code: 'turn_in_flight',
8570
+ // A STEER ("插话"): the host delivers the user's mid-turn message as the
8571
+ // NEXT step of the SAME host turn, so the conversation's own ACP prompt
8572
+ // is still on air. Refusing would fail the whole host turn. ACP forbids
8573
+ // injecting into a running prompt, so the steer's real semantic — abort
8574
+ // the current turn, then send this one — is spelled cancel + wait +
8575
+ // re-claim here.
8576
+ //
8577
+ // Only `busy` can be a steer: it means the AGENT still holds OUR prompt
8578
+ // (`promptInFlight`). `unsettled` means our side already released the
8579
+ // slot (the person pressed stop, or an earlier steer cancelled): the
8580
+ // agent has not acknowledged yet, and quietly queueing the next message
8581
+ // behind a turn the user stopped is not this path's business — that
8582
+ // stays the honest `turn_in_flight` refusal below.
8583
+ //
8584
+ // Only the conversation's OWN in-flight turn qualifies (its live stream
8585
+ // controller, on this very session). A `/acp` RPC turn has no chat
8586
+ // controller, and another conversation never reaches this session, so
8587
+ // both keep the plain refusal: nothing may be cancelled behind a
8588
+ // caller's back.
8589
+ const chatController = claim.reason === 'busy' && options.sessionId !== undefined
8590
+ ? this.turnStreamFor(options.sessionId)
8591
+ : undefined
8592
+ if (options.sessionId !== undefined
8593
+ && chatController !== undefined
8594
+ && !chatController.stale
8595
+ && chatController.session === session) {
8596
+ const reclaimed = await this.reclaimChatPrompt(session, chatController, options.signal)
8597
+ if (reclaimed === 'aborted') {
8598
+ // The person pressed stop while the previous turn was winding down:
8599
+ // the steer is theirs to re-send, nothing goes to the agent.
8600
+ yield {
8601
+ type: 'finish',
8602
+ reason: { kind: 'aborted', failure: { message: 'aborted', code: 'ABORTED' } },
8603
+ }
8604
+ return
8605
+ }
8606
+ // A first turn that produced nothing rolled its provisional lock back
8607
+ // and CLOSED (and forgot) this session: the steer needs a fresh one.
8608
+ if (session.closed || session.released) {
8609
+ try {
8610
+ session = await this.getOrCreateDshSession(options.sessionId, agentId)
8611
+ } catch (err) {
8612
+ yield {
8613
+ type: 'finish',
8614
+ reason: { kind: 'error', failure: { message: describeError(err), code: 'session_init_failed' } },
8615
+ }
8616
+ return
8617
+ }
8618
+ }
8619
+ // Same bridge: the session may have been re-created, but the
8620
+ // conversation is still served by this agent's process.
8621
+ claim = await bridge.claimTurn(session)
8622
+ }
8623
+ if (!claim.ok) {
8624
+ // Never roll the provisional lock back here: a failed claim means
8625
+ // ANOTHER prompt holds the slot (or the agent has not settled it), and
8626
+ // that turn owns the conversation's lock. Closing the session would
8627
+ // kill a turn that is actively streaming — the refusal already tells
8628
+ // the user to retry.
8629
+ yield {
8630
+ type: 'finish',
8631
+ reason: {
8632
+ kind: 'error',
8633
+ failure: {
8634
+ message: claim.reason === 'busy'
8635
+ ? 'acp: this session already has a prompt in flight'
8636
+ : 'acp: the agent is still finishing the previous (cancelled) turn; send again in a moment',
8637
+ code: 'turn_in_flight',
8638
+ },
8533
8639
  },
8534
- },
8640
+ }
8641
+ return
8535
8642
  }
8536
- return
8537
8643
  }
8538
8644
 
8539
8645
  // Same-agent model/effort switches ride the SAME ACP session (context
package/src/impl.ts CHANGED
@@ -867,6 +867,48 @@ export function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string, string
867
867
 
868
868
  // ---- Model catalog (pure parsing of ACP `configOptions`) ----
869
869
 
870
+ /**
871
+ * ACP categories are free-form UX hints, not identity. Qoder CN tags
872
+ * `reasoning_effort` as `category: 'model'` (the spec means `thought_level`),
873
+ * and nothing stops an agent from reusing a category for several options.
874
+ * Slots are therefore assigned by `configId` first and category second, and a
875
+ * same-category option NEVER replaces another option's slot: whatever loses
876
+ * stays reachable through `extras` instead of silently becoming the model list.
877
+ */
878
+ const EFFORT_OPTION_HINT = /effort|thinking|thought|reason/i
879
+
880
+ function isModelSelector(configId: string, name: string): boolean {
881
+ return configId === 'model' || name.trim().toLowerCase() === 'model'
882
+ }
883
+
884
+ type CatalogSlot = 'model' | 'thought' | 'mode'
885
+
886
+ interface CatalogSlotCandidate {
887
+ slot: CatalogSlot
888
+ rank: number
889
+ option: AcpCatalogOption
890
+ }
891
+
892
+ function catalogSlotFor(category: string | undefined, configId: string, name: string): Omit<CatalogSlotCandidate, 'option'> | undefined {
893
+ const effortish = EFFORT_OPTION_HINT.test(configId) || EFFORT_OPTION_HINT.test(name)
894
+ if (category === 'model') {
895
+ // The option explicitly named `model` is the selector even when other
896
+ // model-scoped options are present.
897
+ if (isModelSelector(configId, name)) return { slot: 'model', rank: 3 }
898
+ // A model-scoped effort is mislabelled, not a model: route it to the
899
+ // thought chip when no real `thought_level` option is published.
900
+ if (effortish) return { slot: 'thought', rank: 1 }
901
+ // An unknown model-scoped select: a last-resort selector only.
902
+ return { slot: 'model', rank: 1 }
903
+ }
904
+ if (category === 'thought_level') return { slot: 'thought', rank: 3 }
905
+ if (category === 'mode') return { slot: 'mode', rank: 2 }
906
+ // `model_config` is where the spec puts model-dependent knobs (effort,
907
+ // context size); only the effort-shaped ones have a dedicated control.
908
+ if (category === 'model_config' && effortish) return { slot: 'thought', rank: 1 }
909
+ return undefined
910
+ }
911
+
870
912
  /**
871
913
  * Extract select options from a raw ACP `configOptions` payload.
872
914
  *
@@ -880,24 +922,19 @@ export function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string, string
880
922
  export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
881
923
  const out: AcpAgentCatalog = {}
882
924
  if (!Array.isArray(configOptions)) return out
883
- const extras = new Map<string, AcpCatalogOption>()
925
+ const definitions: AcpCatalogOption[] = []
926
+ const candidates: CatalogSlotCandidate[] = []
884
927
  for (const raw of configOptions) {
885
928
  if (typeof raw !== 'object' || raw === null) continue
886
929
  const row = raw as Record<string, unknown>
887
930
  const category = typeof row.category === 'string' ? row.category : undefined
888
- const slot = category === 'model'
889
- ? 'model'
890
- : category === 'thought_level'
891
- ? 'thought'
892
- : category === 'mode'
893
- ? 'mode'
894
- : undefined
895
931
  const configIdRaw = row.configId ?? row.id
896
932
  if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number') continue
897
933
  const configId = String(configIdRaw)
898
934
  if (configId === '') continue
899
935
  const choices: NonNullable<AcpAgentCatalog['model']>['choices'] = []
900
936
  collectCatalogChoices(row.options, choices)
937
+ const name = typeof row.name === 'string' && row.name !== '' ? row.name : configId
901
938
  // A boolean toggle has no `options` at all; its current value is a JSON
902
939
  // boolean. It is encoded as 'true' / 'false' so the catalog stays
903
940
  // string-typed for every existing reader, and carries `kind` so the
@@ -906,7 +943,7 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
906
943
  || (typeof row.currentValue === 'boolean' && choices.length === 0)
907
944
  const option: AcpCatalogOption = {
908
945
  configId,
909
- name: typeof row.name === 'string' && row.name !== '' ? row.name : configId,
946
+ name,
910
947
  ...(booleanKind ? { kind: 'boolean' as const } : {}),
911
948
  currentValue: booleanKind
912
949
  ? row.currentValue === true || row.currentValue === 'true'
@@ -915,21 +952,69 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
915
952
  : typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
916
953
  choices,
917
954
  }
955
+ definitions.push(option)
918
956
  // A toggle is never a model / thought / mode SELECT: those controls pick
919
957
  // among choices. It belongs to the advanced base, where it is a checkbox.
920
- if (slot !== undefined && !booleanKind) {
921
- out[slot] = option
922
- continue
923
- }
924
- // Choice-less options have nothing to pick; they would render an empty
925
- // section in the advanced menu, so they stay out of the catalog entirely —
926
- // except a boolean, whose whole point is that it has no choices.
927
- if (choices.length > 0 || booleanKind) extras.set(configId, option)
958
+ if (booleanKind) continue
959
+ const slot = catalogSlotFor(category, configId, name)
960
+ if (slot !== undefined) candidates.push({ ...slot, option })
961
+ }
962
+ const winnerIds = new Set<string>()
963
+ for (const slot of ['model', 'thought', 'mode'] as const) {
964
+ let winner: CatalogSlotCandidate | undefined
965
+ for (const candidate of candidates) {
966
+ if (candidate.slot !== slot) continue
967
+ if (winner === undefined || candidate.rank > winner.rank) {
968
+ winner = candidate
969
+ } else if (candidate.rank === winner.rank && candidate.option.configId === winner.option.configId) {
970
+ // The same option redefined later in one answer: the last definition
971
+ // is the current one.
972
+ winner = candidate
973
+ }
974
+ }
975
+ if (winner === undefined) continue
976
+ out[slot] = winner.option
977
+ winnerIds.add(winner.option.configId)
978
+ }
979
+ // Choice-less options have nothing to pick; they would render an empty
980
+ // section in the advanced menu, so they stay out of the catalog entirely —
981
+ // except a boolean, whose whole point is that it has no choices. Anything
982
+ // that lost a slot race is still a real, settable option: keep it here.
983
+ const extras = new Map<string, AcpCatalogOption>()
984
+ for (const option of definitions) {
985
+ if (winnerIds.has(option.configId)) continue
986
+ if (option.choices.length > 0 || option.kind === 'boolean') extras.set(option.configId, option)
928
987
  }
929
988
  if (extras.size > 0) out.extras = [...extras.values()]
930
989
  return out
931
990
  }
932
991
 
992
+ /**
993
+ * Whether a payload names ONE model selector without relying on category
994
+ * alone. When an agent publishes several `category: 'model'` options and none
995
+ * is explicitly the `model` id, the category is not trustworthy enough to
996
+ * reject the user's pick — callers must fail open instead of reporting
997
+ * `model_unavailable`.
998
+ */
999
+ export function hasUnambiguousModelSelector(configOptions: unknown): boolean {
1000
+ if (!Array.isArray(configOptions)) return true
1001
+ let scoped = 0
1002
+ let explicit = false
1003
+ for (const raw of configOptions) {
1004
+ if (typeof raw !== 'object' || raw === null) continue
1005
+ const row = raw as Record<string, unknown>
1006
+ if (row.category !== 'model') continue
1007
+ const configIdRaw = row.configId ?? row.id
1008
+ if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number') continue
1009
+ const configId = String(configIdRaw)
1010
+ if (configId === '') continue
1011
+ scoped += 1
1012
+ const name = typeof row.name === 'string' ? row.name : ''
1013
+ if (isModelSelector(configId, name)) explicit = true
1014
+ }
1015
+ return explicit || scoped <= 1
1016
+ }
1017
+
933
1018
  /**
934
1019
  * Fold one per-session option answer's SHAPE into an agent-level catalog.
935
1020
  *