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/lib/bridge.js +741 -94
- package/lib/client.js.map +1 -1
- package/lib/impl.js +138 -16
- package/lib/index.js +25 -1
- package/lib/types/bridge.d.ts +220 -37
- package/lib/types/impl.d.ts +25 -0
- package/lib/types/protocol.d.ts +3 -2
- package/package.json +1 -1
- package/src/bridge.ts +783 -107
- package/src/impl.ts +147 -17
- package/src/index.ts +26 -3
- package/src/protocol.ts +3 -2
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
|
|
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
|
|
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 (
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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,
|
|
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):
|
|
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`
|
|
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 }
|