dsh-ntr 0.3.3 → 0.3.5

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/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
  *
@@ -2580,6 +2665,51 @@ export interface SessionLogLike {
2580
2665
  header?: { agentPreset?: unknown }
2581
2666
  }
2582
2667
 
2668
+ /**
2669
+ * Structural view of a LIVE host session that can take catalog appends. The
2670
+ * host's `Session.append` validates the payload before committing; an older
2671
+ * host without the `subagent/catalog` event accepts the method but throws,
2672
+ * which the helper below treats as "not supported".
2673
+ */
2674
+ export interface SessionCatalogLike extends SessionLogLike {
2675
+ append?: (type: string, data: Record<string, unknown>) => unknown
2676
+ }
2677
+
2678
+ /**
2679
+ * Record one ACP-side worker in the host's parent-owned subagent catalog as an
2680
+ * EXTERNAL execution. Display-only: external entries have no child session, no
2681
+ * activity/timing and no continuation, and `list_agents` omits them. The
2682
+ * catalog is append-only, so a replayed/resumed dispatch must never add the
2683
+ * same `childId` twice. Returns whether a new fact was appended.
2684
+ */
2685
+ export function appendExternalSubagentCatalog(
2686
+ session: SessionCatalogLike | undefined,
2687
+ childId: string,
2688
+ label: string,
2689
+ now = Date.now(),
2690
+ ): boolean {
2691
+ if (childId === '' || session?.append === undefined) return false
2692
+ const events = session.events
2693
+ if (Array.isArray(events)) {
2694
+ for (let index = events.length - 1; index >= 0; index -= 1) {
2695
+ const event = events[index] as { type?: unknown; data?: { childId?: unknown } } | undefined
2696
+ if (event?.type === 'subagent/catalog' && event.data?.childId === childId) return false
2697
+ }
2698
+ }
2699
+ try {
2700
+ session.append('subagent/catalog', {
2701
+ version: 2,
2702
+ childId,
2703
+ childCreatedAt: now,
2704
+ mode: 'external',
2705
+ label,
2706
+ })
2707
+ return true
2708
+ } catch {
2709
+ return false
2710
+ }
2711
+ }
2712
+
2583
2713
  /**
2584
2714
  * The agent preset a session actually runs.
2585
2715
  *
package/src/index.ts CHANGED
@@ -9,9 +9,9 @@ import type { Context } from '@deepseek-ai/cordis'
9
9
  import type {} from '@deepseek-ai/dsh-host-webserver'
10
10
  import type { ConnectionRpcHandler, HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
11
11
  import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager, PLUGIN_VERSION } from './bridge.ts'
12
- import { EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from './impl.ts'
12
+ import { appendExternalSubagentCatalog, EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from './impl.ts'
13
13
  import type { AcpEvent, ApprovalDeadlineKey } from './protocol.ts'
14
- import type { RosterPresetRow, SessionLogLike } from './impl.ts'
14
+ import type { RosterPresetRow, SessionCatalogLike } from './impl.ts'
15
15
  import { UpdateService } from './update.ts'
16
16
  import type { UpdateManagerLike } from './update.ts'
17
17
 
@@ -39,7 +39,7 @@ interface ChannelRegisteringConnection extends HostConnectionHandle {
39
39
 
40
40
  /** Structural view of the host session store (`@deepseek-ai/dsh-session`'s `ctx.sessions`). */
41
41
  interface SessionsLike {
42
- get(sessionId: string): SessionLogLike | undefined
42
+ get(sessionId: string): SessionCatalogLike | undefined
43
43
  }
44
44
 
45
45
  /**
@@ -260,6 +260,27 @@ export function apply(ctx: Context, config?: unknown): void {
260
260
  }
261
261
  }
262
262
 
263
+ /**
264
+ * Optional host integration: record detached ACP workers as EXTERNAL
265
+ * children in the parent chat session's subagent catalog. Display-only — the
266
+ * host renders them as read-only "external task" rows with no child
267
+ * session, activity or continuation — and is skipped silently on hosts
268
+ * without the catalog event.
269
+ */
270
+ const wireSubagentCatalog = (): void => {
271
+ const sessions = (typeof ctx.get === 'function' ? ctx.get('sessions') : undefined) as SessionsLike | undefined
272
+ if (sessions === undefined) return
273
+ manager.setSubagentCatalogReporter((dshSessionId, childId, label) => {
274
+ let session: SessionCatalogLike | undefined
275
+ try {
276
+ session = sessions.get(dshSessionId)
277
+ } catch {
278
+ return
279
+ }
280
+ appendExternalSubagentCatalog(session, childId, label)
281
+ })
282
+ }
283
+
263
284
  wireWorkspace()
264
285
  if (typeof ctx.inject === 'function') {
265
286
  // Late arrival for the workspace collaborators: a deployment missing one
@@ -271,9 +292,11 @@ export function apply(ctx: Context, config?: unknown): void {
271
292
  // The ACP-preset verdict only needs the session store (live) and the
272
293
  // persistence store (cold); the sandbox knobs are NOT its dependencies.
273
294
  wireAcpMode()
295
+ wireSubagentCatalog()
274
296
  if (typeof ctx.inject === 'function') {
275
297
  ctx.inject(['sessions'], () => {
276
298
  wireAcpMode()
299
+ wireSubagentCatalog()
277
300
  })
278
301
  }
279
302
 
package/src/protocol.ts CHANGED
@@ -346,9 +346,10 @@ export interface AgentView {
346
346
 
347
347
  /**
348
348
  * Why the host released a session's agent process. The browser turns this into
349
- * one runtime-log line per cause; `idle` stays silent (expected lifecycle).
349
+ * one runtime-log line per cause; `idle` and `shutdown` stay silent (both are
350
+ * expected lifecycle transitions, not faults).
350
351
  */
351
- export type AcpReleaseCause = 'idle' | 'workspace' | 'resume-failed' | 'stalled' | 'died'
352
+ export type AcpReleaseCause = 'idle' | 'workspace' | 'resume-failed' | 'stalled' | 'died' | 'shutdown'
352
353
 
353
354
  export type AcpEvent =
354
355
  | { type: 'agent/status'; agentId: string; state: AgentProcessState; error?: string; agent?: AgentView }