dsh-ntr 0.1.2 → 0.1.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/README.en.md +14 -4
- package/README.md +14 -4
- package/lib/bridge.js +116 -28
- package/lib/client/acp-ui.js +3 -1
- package/lib/client/panel.js +6 -4
- package/lib/client/session-controls.js +27 -11
- package/lib/client.js +40 -17
- package/lib/client.js.map +1 -1
- package/lib/impl.js +102 -9
- package/lib/types/bridge.d.ts +32 -6
- package/lib/types/client/acp-ui.d.ts +6 -0
- package/lib/types/client/session-controls.d.ts +2 -0
- package/lib/types/client/store.d.ts +1 -1
- package/lib/types/impl.d.ts +36 -1
- package/lib/types/protocol.d.ts +22 -8
- package/package.json +3 -2
- package/src/bridge.ts +116 -32
- package/src/client/acp-ui.tsx +13 -2
- package/src/client/panel.tsx +6 -4
- package/src/client/session-controls.tsx +29 -11
- package/src/client/store.ts +1 -1
- package/src/impl.ts +101 -10
- package/src/protocol.ts +22 -8
package/src/bridge.ts
CHANGED
|
@@ -37,7 +37,9 @@ import {
|
|
|
37
37
|
childEnv,
|
|
38
38
|
isAcpToolContinuation,
|
|
39
39
|
isUnderPath,
|
|
40
|
+
mergeCatalogShape,
|
|
40
41
|
parseCatalogOptions,
|
|
42
|
+
usableConfigOptions,
|
|
41
43
|
permissionToolKind,
|
|
42
44
|
quoteCommandToken,
|
|
43
45
|
randomUUID,
|
|
@@ -73,7 +75,7 @@ const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024
|
|
|
73
75
|
interface DisplayedChatDefaults {
|
|
74
76
|
modelId?: string
|
|
75
77
|
effort?: string
|
|
76
|
-
extraConfigs?: Record<string, string>
|
|
78
|
+
extraConfigs?: Record<string, string | boolean>
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
interface LiveSession {
|
|
@@ -351,8 +353,14 @@ export class AgentBridge {
|
|
|
351
353
|
private readonly transcriptMax: number,
|
|
352
354
|
/** Called when the connection dies so the manager can settle outstanding prompts. */
|
|
353
355
|
private readonly onConnectionLost: (bridge: AgentBridge, reason: string) => void,
|
|
354
|
-
/**
|
|
355
|
-
|
|
356
|
+
/**
|
|
357
|
+
* Observes `configOptions` from any real session so the manager's model
|
|
358
|
+
* catalog stays warm. `shapeOnly` marks a per-session answer (a
|
|
359
|
+
* `session/set_config_option` response): only the option SHAPE belongs in
|
|
360
|
+
* the agent-level catalog, never its `currentValue` — see
|
|
361
|
+
* {@link BridgeManager.absorbCatalogShape}.
|
|
362
|
+
*/
|
|
363
|
+
private readonly onCatalog?: (agentId: string, configOptions: unknown, shapeOnly?: boolean) => void,
|
|
356
364
|
/** Observes `availableCommands` from any real session so the agent's slash menu survives its sessions. */
|
|
357
365
|
private readonly onCommands?: (agentId: string, commands: NonNullable<SessionView['availableCommands']>) => void,
|
|
358
366
|
tuning?: BridgeTuning,
|
|
@@ -707,7 +715,14 @@ export class AgentBridge {
|
|
|
707
715
|
protocolVersion: PROTOCOL_VERSION,
|
|
708
716
|
// The fs handlers are implemented below behind the same workspace
|
|
709
717
|
// sandbox that gates session cwds; terminal is not implemented.
|
|
710
|
-
|
|
718
|
+
// `session.configOptions.boolean` advertises the toggle form of
|
|
719
|
+
// `configOptions`; agents that gate `type: "boolean"` entries on it
|
|
720
|
+
// would otherwise never publish them.
|
|
721
|
+
clientCapabilities: {
|
|
722
|
+
fs: { readTextFile: true, writeTextFile: true },
|
|
723
|
+
terminal: false,
|
|
724
|
+
session: { configOptions: { boolean: {} } },
|
|
725
|
+
},
|
|
711
726
|
clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: '0.1.0' },
|
|
712
727
|
}),
|
|
713
728
|
'initialize',
|
|
@@ -765,7 +780,8 @@ export class AgentBridge {
|
|
|
765
780
|
conn.agent.request(methods.agent.session.new, { cwd: resolved, mcpServers: [] }),
|
|
766
781
|
'session/new',
|
|
767
782
|
)
|
|
768
|
-
|
|
783
|
+
// An empty `configOptions` answer is a non-answer — see `usableConfigOptions`.
|
|
784
|
+
if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
|
|
769
785
|
this.onCatalog(this.def.id, result.configOptions)
|
|
770
786
|
}
|
|
771
787
|
const session: LiveSession = {
|
|
@@ -790,7 +806,7 @@ export class AgentBridge {
|
|
|
790
806
|
},
|
|
791
807
|
}
|
|
792
808
|
: {}),
|
|
793
|
-
...(result.configOptions
|
|
809
|
+
...(usableConfigOptions(result.configOptions) ? { configOptions: result.configOptions } : {}),
|
|
794
810
|
}
|
|
795
811
|
this.sessions.set(session.sessionId, session)
|
|
796
812
|
this.acpIndex.set(session.acpSessionId, session)
|
|
@@ -970,7 +986,7 @@ export class AgentBridge {
|
|
|
970
986
|
conn.agent.request(methods.agent.session.load, { sessionId: session.acpSessionId, cwd: resolved, mcpServers: [] }),
|
|
971
987
|
'session/load',
|
|
972
988
|
)
|
|
973
|
-
if (this.onCatalog !== undefined && result.configOptions
|
|
989
|
+
if (this.onCatalog !== undefined && usableConfigOptions(result.configOptions)) {
|
|
974
990
|
this.onCatalog(this.def.id, result.configOptions)
|
|
975
991
|
}
|
|
976
992
|
session.modes = result.modes !== undefined && result.modes !== null
|
|
@@ -983,7 +999,7 @@ export class AgentBridge {
|
|
|
983
999
|
})),
|
|
984
1000
|
}
|
|
985
1001
|
: session.modes
|
|
986
|
-
if (
|
|
1002
|
+
if (usableConfigOptions(result.configOptions)) session.configOptions = result.configOptions
|
|
987
1003
|
this.sessions.set(session.sessionId, session)
|
|
988
1004
|
this.released.delete(session.sessionId)
|
|
989
1005
|
// Same flip as `createSession`: a registered session makes this a chat
|
|
@@ -1077,14 +1093,26 @@ export class AgentBridge {
|
|
|
1077
1093
|
async setConfigOption(session: LiveSession, configId: string, value: string | boolean): Promise<void> {
|
|
1078
1094
|
const conn = this.conn
|
|
1079
1095
|
if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
|
|
1080
|
-
|
|
1096
|
+
// A toggle must say so on the wire: the request union discriminates on
|
|
1097
|
+
// `type`, and a bare boolean `value` is not a select value id.
|
|
1098
|
+
const params = typeof value === 'boolean'
|
|
1099
|
+
? { sessionId: session.acpSessionId, configId, value, type: 'boolean' as const }
|
|
1100
|
+
: { sessionId: session.acpSessionId, configId, value }
|
|
1101
|
+
const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, params)) as { configOptions?: unknown }
|
|
1081
1102
|
// The response carries the full authoritative option set; some agents
|
|
1082
1103
|
// (pi-acp observed) never re-notify after a client-driven set. It feeds the
|
|
1083
|
-
// SESSION snapshot
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
|
|
1104
|
+
// SESSION snapshot first — `currentValue` in a per-session answer describes
|
|
1105
|
+
// this session's pick, and promoting it to the catalog would turn the
|
|
1106
|
+
// seat's own choice into `reasoning.defaultEffort` for every other
|
|
1107
|
+
// conversation (and back onto itself next turn). The option SHAPE still
|
|
1108
|
+
// reaches the agent-level catalog (`shapeOnly`): this response is often the
|
|
1109
|
+
// only place an agent publishes how its option set changes with the model
|
|
1110
|
+
// (opencode offers `thought_level` just for models with variants), and the
|
|
1111
|
+
// composer's thought chip is driven by the catalog.
|
|
1112
|
+
if (usableConfigOptions(result.configOptions)) {
|
|
1113
|
+
session.configOptions = result.configOptions
|
|
1114
|
+
this.onCatalog?.(this.def.id, result.configOptions, true)
|
|
1115
|
+
}
|
|
1088
1116
|
this.publishMeta(session)
|
|
1089
1117
|
}
|
|
1090
1118
|
|
|
@@ -1372,7 +1400,9 @@ export class AgentBridge {
|
|
|
1372
1400
|
session.modes = { ...session.modes, currentModeId: update.currentModeId }
|
|
1373
1401
|
break
|
|
1374
1402
|
case 'config_option_update':
|
|
1375
|
-
|
|
1403
|
+
// Empty answers never overwrite the last known set — see `usableConfigOptions`.
|
|
1404
|
+
if (!usableConfigOptions(update.configOptions)) break
|
|
1405
|
+
session.configOptions = update.configOptions
|
|
1376
1406
|
this.onCatalog?.(this.def.id, session.configOptions)
|
|
1377
1407
|
break
|
|
1378
1408
|
case 'available_commands_update':
|
|
@@ -2720,7 +2750,7 @@ export class BridgeManager {
|
|
|
2720
2750
|
private makeBridge(
|
|
2721
2751
|
def: AcpAgentDef,
|
|
2722
2752
|
onConnectionLost: (bridge: AgentBridge, reason: string) => void,
|
|
2723
|
-
onCatalog?: (agentId: string, configOptions: unknown) => void,
|
|
2753
|
+
onCatalog?: (agentId: string, configOptions: unknown, shapeOnly?: boolean) => void,
|
|
2724
2754
|
onCommands?: (agentId: string, commands: NonNullable<SessionView['availableCommands']>) => void,
|
|
2725
2755
|
): AgentBridge {
|
|
2726
2756
|
return new AgentBridge(def, this.hub, this.transcriptMax, onConnectionLost, onCatalog, onCommands, this.tuning, () => this.approvalRequester, () => this.sessionWorkspaceResolver)
|
|
@@ -3173,7 +3203,7 @@ export class BridgeManager {
|
|
|
3173
3203
|
* A live session is updated immediately; a failure there is warned about and
|
|
3174
3204
|
* the pick still stands, exactly like the model/effort alignment path.
|
|
3175
3205
|
*/
|
|
3176
|
-
async setChatSessionConfig(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void> {
|
|
3206
|
+
async setChatSessionConfig(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void> {
|
|
3177
3207
|
// `agentId` is the route the composer showed — including the plugin
|
|
3178
3208
|
// default, which `chatAgentFor` cannot report before the first session.
|
|
3179
3209
|
const bound = agentId ?? this.chatAgentFor(dshSessionId)
|
|
@@ -3197,8 +3227,18 @@ export class BridgeManager {
|
|
|
3197
3227
|
if (option === undefined) {
|
|
3198
3228
|
throw new AcpError('unknown_config', `acp: agent "${bound}" publishes no extra config option "${configId}"`)
|
|
3199
3229
|
}
|
|
3200
|
-
if (
|
|
3201
|
-
|
|
3230
|
+
if (option.kind === 'boolean') {
|
|
3231
|
+
// The browser sends 'true' / 'false' from a checkbox row; the wire and
|
|
3232
|
+
// the persisted pick both use the real boolean.
|
|
3233
|
+
const toggle = typeof value === 'boolean'
|
|
3234
|
+
? value
|
|
3235
|
+
: value === 'true' ? true : value === 'false' ? false : undefined
|
|
3236
|
+
if (toggle === undefined) {
|
|
3237
|
+
throw new AcpError('bad_config', `acp: "${String(value)}" is not a boolean value of config option "${configId}"`)
|
|
3238
|
+
}
|
|
3239
|
+
value = toggle
|
|
3240
|
+
} else if (typeof value !== 'string' || !option.choices.some(choice => choice.value === value)) {
|
|
3241
|
+
throw new AcpError('bad_config', `acp: "${String(value)}" is not a value of config option "${configId}"`)
|
|
3202
3242
|
}
|
|
3203
3243
|
const current = this.chatAgentIntent.get(dshSessionId)
|
|
3204
3244
|
const extraConfigs = current?.agentId === bound ? { ...current.extraConfigs } : {}
|
|
@@ -3290,12 +3330,17 @@ export class BridgeManager {
|
|
|
3290
3330
|
})
|
|
3291
3331
|
continue
|
|
3292
3332
|
}
|
|
3293
|
-
|
|
3333
|
+
// A toggle's truth is its boolean itself; a select's is membership in
|
|
3334
|
+
// the choices the agent still offers.
|
|
3335
|
+
const usable = option.kind === 'boolean'
|
|
3336
|
+
? typeof value === 'boolean'
|
|
3337
|
+
: typeof value === 'string' && option.choices.some(entry => entry.value === value)
|
|
3338
|
+
if (!usable) {
|
|
3294
3339
|
this.hub.broadcast({
|
|
3295
3340
|
type: 'agent/notice',
|
|
3296
3341
|
agentId: session.agentId,
|
|
3297
3342
|
level: 'error',
|
|
3298
|
-
text: `acp: agent "${session.agentId}" no longer offers "${value}" for config "${configId}"; keeping its own`,
|
|
3343
|
+
text: `acp: agent "${session.agentId}" no longer offers "${String(value)}" for config "${configId}"; keeping its own`,
|
|
3299
3344
|
})
|
|
3300
3345
|
continue
|
|
3301
3346
|
}
|
|
@@ -3513,7 +3558,7 @@ export class BridgeManager {
|
|
|
3513
3558
|
if (typeof agentId !== 'string' || agentId === '') continue
|
|
3514
3559
|
const configs = extraConfigs !== null && typeof extraConfigs === 'object' && !Array.isArray(extraConfigs)
|
|
3515
3560
|
? Object.fromEntries(Object.entries(extraConfigs as Record<string, unknown>)
|
|
3516
|
-
.filter(([, value]) => typeof value === 'string' && value !== '')) as Record<string, string>
|
|
3561
|
+
.filter(([, value]) => typeof value === 'boolean' || (typeof value === 'string' && value !== ''))) as Record<string, string | boolean>
|
|
3517
3562
|
: {}
|
|
3518
3563
|
this.chatAgentIntent.set(dshSessionId, {
|
|
3519
3564
|
agentId,
|
|
@@ -3674,6 +3719,8 @@ export class BridgeManager {
|
|
|
3674
3719
|
* current `parseCatalogOptions` has now seen this agent's real answer.
|
|
3675
3720
|
*/
|
|
3676
3721
|
private absorbCatalog(agentId: string, configOptions: unknown): { catalog: AcpAgentCatalog; updatedAt: number } {
|
|
3722
|
+
// Empty / absurd answers never overwrite known state — see `usableConfigOptions`.
|
|
3723
|
+
if (!usableConfigOptions(configOptions)) return this.catalogs.get(agentId) ?? { catalog: {}, updatedAt: 0 }
|
|
3677
3724
|
const catalog = parseCatalogOptions(configOptions)
|
|
3678
3725
|
this.catalogStale.delete(agentId)
|
|
3679
3726
|
const existing = this.catalogs.get(agentId)
|
|
@@ -3700,6 +3747,29 @@ export class BridgeManager {
|
|
|
3700
3747
|
return entry
|
|
3701
3748
|
}
|
|
3702
3749
|
|
|
3750
|
+
/**
|
|
3751
|
+
* Fold a per-session option answer's SHAPE into the agent's catalog.
|
|
3752
|
+
*
|
|
3753
|
+
* The counterpart of {@link absorbCatalog} for `session/set_config_option`
|
|
3754
|
+
* responses: which options exist and which choices they offer is agent
|
|
3755
|
+
* knowledge the seat must show (the composer's thought chip only renders
|
|
3756
|
+
* when the catalog has `thought_level`, and some agents publish it only in
|
|
3757
|
+
* the answer to a model change), while a per-session `currentValue` stays
|
|
3758
|
+
* out of it. No auto-exposure and no staleness discharge either — this is
|
|
3759
|
+
* one session's answer, not the agent-level observation those marks track.
|
|
3760
|
+
*/
|
|
3761
|
+
private absorbCatalogShape(agentId: string, configOptions: unknown): void {
|
|
3762
|
+
if (!usableConfigOptions(configOptions)) return
|
|
3763
|
+
const observed = parseCatalogOptions(configOptions)
|
|
3764
|
+
const existing = this.catalogs.get(agentId)
|
|
3765
|
+
const catalog = mergeCatalogShape(existing?.catalog, observed)
|
|
3766
|
+
if (existing !== undefined && JSON.stringify(existing.catalog) === JSON.stringify(catalog)) return
|
|
3767
|
+
const entry = { catalog, updatedAt: Date.now() }
|
|
3768
|
+
this.catalogs.set(agentId, entry)
|
|
3769
|
+
this.persistCatalogs()
|
|
3770
|
+
this.hub.broadcast({ type: 'catalog/updated', agentId, updatedAt: entry.updatedAt })
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3703
3773
|
/**
|
|
3704
3774
|
* Remember the slash commands an agent declared in a real session.
|
|
3705
3775
|
*
|
|
@@ -3732,11 +3802,12 @@ export class BridgeManager {
|
|
|
3732
3802
|
* selection would resurrect a model the user deliberately unchecked every
|
|
3733
3803
|
* time the agent repeats its catalog. The first observation has no baseline
|
|
3734
3804
|
* (`before === undefined`), so it adds nothing; the user can still add the
|
|
3735
|
-
* whole list with one click in the settings card.
|
|
3805
|
+
* whole list with one click in the settings card. Discovery is ON unless the
|
|
3806
|
+
* agent definition explicitly opts out with `autoDiscoverModels: false`.
|
|
3736
3807
|
*/
|
|
3737
3808
|
private autoExposeNewModels(agentId: string, next: AcpAgentCatalog, before: AcpAgentCatalog | undefined): boolean {
|
|
3738
3809
|
const def = this.defsMap.get(agentId)
|
|
3739
|
-
if (def
|
|
3810
|
+
if (def === undefined || def.autoDiscoverModels === false || before === undefined) return false
|
|
3740
3811
|
const known = new Set((before.model?.choices ?? []).map(choice => choice.value))
|
|
3741
3812
|
const exposed = new Set(def.exposedModels ?? [])
|
|
3742
3813
|
const added = (next.model?.choices ?? [])
|
|
@@ -3756,7 +3827,7 @@ export class BridgeManager {
|
|
|
3756
3827
|
type: 'agent/exposure',
|
|
3757
3828
|
agentId,
|
|
3758
3829
|
exposedModels: [...(def.exposedModels ?? [])],
|
|
3759
|
-
autoDiscoverModels: def.autoDiscoverModels
|
|
3830
|
+
autoDiscoverModels: def.autoDiscoverModels !== false,
|
|
3760
3831
|
})
|
|
3761
3832
|
}
|
|
3762
3833
|
|
|
@@ -3932,6 +4003,7 @@ export class BridgeManager {
|
|
|
3932
4003
|
extras: extraOptions.map(option => ({
|
|
3933
4004
|
configId: option.configId,
|
|
3934
4005
|
name: option.name,
|
|
4006
|
+
...(option.kind === 'boolean' ? { kind: 'boolean' as const } : {}),
|
|
3935
4007
|
...(option.currentValue === null ? {} : { currentValue: option.currentValue }),
|
|
3936
4008
|
choices: option.choices.map(choice => ({
|
|
3937
4009
|
id: choice.value,
|
|
@@ -4142,8 +4214,9 @@ export class BridgeManager {
|
|
|
4142
4214
|
if (bridge === undefined) {
|
|
4143
4215
|
bridge = this.makeBridge(def, (b, reason) => {
|
|
4144
4216
|
this.connectionLost(b, reason)
|
|
4145
|
-
}, (observedAgentId, configOptions) => {
|
|
4146
|
-
this.
|
|
4217
|
+
}, (observedAgentId, configOptions, shapeOnly) => {
|
|
4218
|
+
if (shapeOnly) this.absorbCatalogShape(observedAgentId, configOptions)
|
|
4219
|
+
else this.absorbCatalog(observedAgentId, configOptions)
|
|
4147
4220
|
}, (observedAgentId, commands) => {
|
|
4148
4221
|
this.absorbCommands(observedAgentId, commands)
|
|
4149
4222
|
})
|
|
@@ -4277,13 +4350,14 @@ export class BridgeManager {
|
|
|
4277
4350
|
* Toggle auto-discovery of newly published models. Lightweight like
|
|
4278
4351
|
* `setExposedModels`: the def is mutated in place (live bridges share the
|
|
4279
4352
|
* object) and persisted; no process is restarted, and the current selection
|
|
4280
|
-
* is left exactly as it is.
|
|
4353
|
+
* is left exactly as it is. On is the default, so only the opt-out
|
|
4354
|
+
* (`false`) is written to the file.
|
|
4281
4355
|
*/
|
|
4282
4356
|
async setAutoDiscoverModels(agentId: string, autoDiscoverModels: boolean): Promise<void> {
|
|
4283
4357
|
const def = this.defsMap.get(agentId)
|
|
4284
4358
|
if (def === undefined) throw new AcpError('unknown_agent', `acp: agent "${agentId}" is not configured`)
|
|
4285
|
-
if (autoDiscoverModels) def.autoDiscoverModels
|
|
4286
|
-
else
|
|
4359
|
+
if (autoDiscoverModels) delete def.autoDiscoverModels
|
|
4360
|
+
else def.autoDiscoverModels = false
|
|
4287
4361
|
this.persistAgents()
|
|
4288
4362
|
this.broadcastExposure(agentId)
|
|
4289
4363
|
}
|
|
@@ -4546,9 +4620,15 @@ export class BridgeManager {
|
|
|
4546
4620
|
// `defaultEffort` only when the current value is one of the agent's levels.
|
|
4547
4621
|
const effort = this.reasoningMeta(agentId)?.defaultEffort
|
|
4548
4622
|
if (effort !== undefined) out.effort = effort
|
|
4549
|
-
const extraConfigs: Record<string, string> = {}
|
|
4623
|
+
const extraConfigs: Record<string, string | boolean> = {}
|
|
4550
4624
|
for (const option of catalog.extras ?? []) {
|
|
4551
4625
|
const value = option.currentValue
|
|
4626
|
+
if (option.kind === 'boolean') {
|
|
4627
|
+
// The catalog encodes a toggle as 'true' / 'false'; the pick stores the
|
|
4628
|
+
// real boolean, exactly like an explicit checkbox click.
|
|
4629
|
+
if (value === 'true' || value === 'false') extraConfigs[option.configId] = value === 'true'
|
|
4630
|
+
continue
|
|
4631
|
+
}
|
|
4552
4632
|
if (value !== null && value !== undefined && value !== '') extraConfigs[option.configId] = value
|
|
4553
4633
|
}
|
|
4554
4634
|
if (Object.keys(extraConfigs).length > 0) out.extraConfigs = extraConfigs
|
|
@@ -4602,7 +4682,11 @@ export class BridgeManager {
|
|
|
4602
4682
|
if (extraConfigs[configId] !== undefined) continue
|
|
4603
4683
|
const option = options.extras?.find(entry => entry.configId === configId)
|
|
4604
4684
|
if (option === undefined) continue
|
|
4605
|
-
|
|
4685
|
+
// The fresh session's own option set is the authority for BOTH forms:
|
|
4686
|
+
// a toggle must still be a toggle, a select must still offer the value.
|
|
4687
|
+
if (option.kind === 'boolean'
|
|
4688
|
+
? typeof value !== 'boolean'
|
|
4689
|
+
: typeof value !== 'string' || (option.choices.length > 0 && !option.choices.some(choice => choice.value === value))) continue
|
|
4606
4690
|
extraConfigs[configId] = value
|
|
4607
4691
|
changed = true
|
|
4608
4692
|
}
|
package/src/client/acp-ui.tsx
CHANGED
|
@@ -494,6 +494,12 @@ export interface AcpMenuItem {
|
|
|
494
494
|
disabled?: boolean
|
|
495
495
|
/** Leading status dot color (agent process state). */
|
|
496
496
|
dot?: string
|
|
497
|
+
/**
|
|
498
|
+
* Render as a checkbox row (`menuitemcheckbox`) rather than a radio row.
|
|
499
|
+
* The advanced base uses it for the agent's boolean config options: one row
|
|
500
|
+
* whose check IS the current value, whose `id` is the value a click sets.
|
|
501
|
+
*/
|
|
502
|
+
checkbox?: boolean
|
|
497
503
|
}
|
|
498
504
|
|
|
499
505
|
/** One titled block of rows. */
|
|
@@ -623,15 +629,18 @@ export function AcpMenu({ model, anchor, onPick, onClose }: AcpMenuProps) {
|
|
|
623
629
|
<button
|
|
624
630
|
key={`${section.id}/${item.id}`}
|
|
625
631
|
type="button"
|
|
626
|
-
role=
|
|
632
|
+
role={item.checkbox === true ? 'menuitemcheckbox' : 'menuitemradio'}
|
|
627
633
|
aria-checked={item.active === true}
|
|
628
634
|
className={`ntr-acp-menu-item${item.active === true ? ' is-active' : ''}`}
|
|
629
635
|
disabled={item.disabled === true}
|
|
630
636
|
onClick={() => { onPick(item.id, section.id) }}
|
|
631
637
|
>
|
|
632
638
|
{item.dot !== undefined && <span className="ntr-acp-dot" style={{ background: item.dot }} aria-hidden />}
|
|
639
|
+
{item.checkbox === true && (
|
|
640
|
+
<span className={`ntr-acp-check${item.active === true ? ' is-on' : ''}`} aria-hidden>{item.active === true ? '✓' : ''}</span>
|
|
641
|
+
)}
|
|
633
642
|
<span className="ntr-acp-menu-item-label">{item.label}</span>
|
|
634
|
-
{item.active === true && <span className="ntr-acp-tick" aria-hidden>✓</span>}
|
|
643
|
+
{item.active === true && item.checkbox !== true && <span className="ntr-acp-tick" aria-hidden>✓</span>}
|
|
635
644
|
{item.detail !== undefined && <span className="ntr-acp-menu-item-detail">{item.detail}</span>}
|
|
636
645
|
</button>
|
|
637
646
|
))}
|
|
@@ -853,6 +862,8 @@ const STYLE = `
|
|
|
853
862
|
.ntr-acp-seat-sub { color: var(--dsw-alias-text-secondary, #6b7280); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
854
863
|
.ntr-acp-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
855
864
|
.ntr-acp-tick { margin-left: auto; color: var(--dsw-alias-state-business-primary, #2563eb); }
|
|
865
|
+
.ntr-acp-check { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 13px; height: 13px; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); border-radius: 3px; font-size: 10px; line-height: 1; color: var(--dsw-alias-state-business-primary, #2563eb); }
|
|
866
|
+
.ntr-acp-check.is-on { border-color: var(--dsw-alias-state-business-primary, #2563eb); }
|
|
856
867
|
.ntr-acp-menu { position: absolute; bottom: calc(100% + 8px); left: 8px; z-index: 100; width: min(360px, 100%); max-height: min(420px, 60vh); display: flex; flex-direction: column; border: 1px solid var(--dsw-alias-border-l2, #d1d5db); border-radius: 10px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); font-size: 12px; overflow: hidden; }
|
|
857
868
|
.ntr-acp-menu-head { padding: 8px 10px 6px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
|
858
869
|
.ntr-acp-menu-title { flex: 1; }
|
package/src/client/panel.tsx
CHANGED
|
@@ -358,7 +358,7 @@ function payloadEquals(payload: AgentSavePayload, current: AcpAgentDef): boolean
|
|
|
358
358
|
if (!sameStringArray(payload.allowedCwdRoots ?? [], current.allowedCwdRoots ?? [])) return false
|
|
359
359
|
if (!sameStringMap(payload.env ?? {}, current.env ?? {})) return false
|
|
360
360
|
if (exposureKey(payload.exposedModels) !== exposureKey(current.exposedModels)) return false
|
|
361
|
-
if ((payload.autoDiscoverModels
|
|
361
|
+
if ((payload.autoDiscoverModels !== false) !== (current.autoDiscoverModels !== false)) return false
|
|
362
362
|
// An absent field means "use the host default" — which is what `current`
|
|
363
363
|
// already resolved to (see `resolvedIdleTimeoutMs`), so it is not a change.
|
|
364
364
|
if (payload.idleTimeoutMs !== undefined && payload.idleTimeoutMs !== current.idleTimeoutMs) return false
|
|
@@ -701,7 +701,7 @@ function AgentRow({
|
|
|
701
701
|
// background resyncs never clobber in-progress checkbox edits.
|
|
702
702
|
}, [serverExposureKey])
|
|
703
703
|
|
|
704
|
-
const serverAutoDiscover = detected.currentConfig?.autoDiscoverModels
|
|
704
|
+
const serverAutoDiscover = detected.currentConfig?.autoDiscoverModels !== false
|
|
705
705
|
useEffect(() => {
|
|
706
706
|
// The switch saves itself through the lightweight RPC, so the host is the
|
|
707
707
|
// truth; this keeps the form (and therefore a later full save) in step.
|
|
@@ -956,7 +956,7 @@ function initialForm(detected: DetectedAgentInfo, activeAgent: AgentView | undef
|
|
|
956
956
|
roots,
|
|
957
957
|
envRows: Object.entries(config?.env ?? {}).map(([key, value]) => ({ key, value })),
|
|
958
958
|
exposed: config?.exposedModels === undefined ? undefined : [...config.exposedModels],
|
|
959
|
-
autoDiscoverModels: config?.autoDiscoverModels
|
|
959
|
+
autoDiscoverModels: config?.autoDiscoverModels !== false,
|
|
960
960
|
// Prefer the host's resolved value: it is the one actually in effect.
|
|
961
961
|
idleStr: idleText(activeAgent?.idleTimeoutMs ?? config?.idleTimeoutMs),
|
|
962
962
|
}
|
|
@@ -1011,7 +1011,9 @@ function buildPayload(form: AgentForm, detected: DetectedAgentInfo, newCustom: b
|
|
|
1011
1011
|
...(form.npmPackage.trim() === '' ? {} : { npmPackage: form.npmPackage.trim() }),
|
|
1012
1012
|
env,
|
|
1013
1013
|
...(form.exposed === undefined ? {} : { exposedModels: form.exposed }),
|
|
1014
|
-
|
|
1014
|
+
// Auto-discovery is ON by default: only an unchecked switch records the
|
|
1015
|
+
// opt-out, otherwise a full save would silently re-enable it.
|
|
1016
|
+
...(form.autoDiscoverModels ? {} : { autoDiscoverModels: false }),
|
|
1015
1017
|
// `''` sends nothing: the host then applies its own default.
|
|
1016
1018
|
...(idleRaw === '' || !idleValid(idleRaw) ? {} : { idleTimeoutMs: Number(idleRaw) }),
|
|
1017
1019
|
}
|
|
@@ -347,6 +347,8 @@ export function describeRoute(
|
|
|
347
347
|
export interface AdvancedOption {
|
|
348
348
|
configId: string
|
|
349
349
|
name: string
|
|
350
|
+
/** `'boolean'` for an on/off toggle (`value` is `'true'` / `'false'`, no choices). */
|
|
351
|
+
kind?: 'boolean'
|
|
350
352
|
/** The conversation's pick, else the value the agent reports as current. */
|
|
351
353
|
value?: string
|
|
352
354
|
choices: readonly { id: string; name: string; description?: string }[]
|
|
@@ -370,10 +372,14 @@ export function advancedOptions(
|
|
|
370
372
|
if (catalog === undefined || extras.length === 0) return []
|
|
371
373
|
const picked = choice !== undefined && choice.agentId === catalog.agentId ? choice.extraConfigs : undefined
|
|
372
374
|
return extras.map(option => {
|
|
373
|
-
const
|
|
375
|
+
const raw = picked?.[option.configId] ?? option.currentValue
|
|
376
|
+
// A toggle's catalog value is 'true' / 'false'; a conversation pick stores
|
|
377
|
+
// the real boolean. Both collapse to the string form the menu speaks.
|
|
378
|
+
const value = typeof raw === 'boolean' ? (raw ? 'true' : 'false') : raw
|
|
374
379
|
return {
|
|
375
380
|
configId: option.configId,
|
|
376
381
|
name: option.name,
|
|
382
|
+
...(option.kind === 'boolean' ? { kind: 'boolean' as const } : {}),
|
|
377
383
|
...(value === undefined ? {} : { value }),
|
|
378
384
|
choices: option.choices,
|
|
379
385
|
}
|
|
@@ -883,16 +889,28 @@ function thoughtSection(context: MenuContext): AcpMenuSection {
|
|
|
883
889
|
* and the pick would then be read as the primary model axis.
|
|
884
890
|
*/
|
|
885
891
|
function advancedSections(context: MenuContext): AcpMenuSection[] {
|
|
886
|
-
return advancedOptions(context.catalog, context.choice).map(option =>
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
892
|
+
return advancedOptions(context.catalog, context.choice).map(option => {
|
|
893
|
+
if (option.kind === 'boolean') {
|
|
894
|
+
// A toggle is ONE checkbox row, not an On/Off pair: the row's check is
|
|
895
|
+
// the current value and the pick sends the OPPOSITE, which `onPick`
|
|
896
|
+
// forwards verbatim (the host normalizes 'true' / 'false' to a boolean).
|
|
897
|
+
const checked = option.value === 'true'
|
|
898
|
+
return {
|
|
899
|
+
id: `config:${option.configId}`,
|
|
900
|
+
items: [{ id: checked ? 'false' : 'true', label: option.name, active: checked, checkbox: true }],
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
id: `config:${option.configId}`,
|
|
905
|
+
title: option.name,
|
|
906
|
+
items: option.choices.map(choice => ({
|
|
907
|
+
id: choice.id,
|
|
908
|
+
label: choice.name,
|
|
909
|
+
...(choice.description === undefined ? {} : { detail: choice.description }),
|
|
910
|
+
active: choice.id === option.value,
|
|
911
|
+
})),
|
|
912
|
+
}
|
|
913
|
+
})
|
|
896
914
|
}
|
|
897
915
|
|
|
898
916
|
/**
|
package/src/client/store.ts
CHANGED
|
@@ -1565,7 +1565,7 @@ export class AcpStore {
|
|
|
1565
1565
|
}
|
|
1566
1566
|
|
|
1567
1567
|
/** Set one of the bound agent's EXTRA config options for one chat session. */
|
|
1568
|
-
async setChatSessionConfig(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void> {
|
|
1568
|
+
async setChatSessionConfig(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void> {
|
|
1569
1569
|
try {
|
|
1570
1570
|
await this.call('session/chat-config-set', {
|
|
1571
1571
|
dshSessionId,
|
package/src/impl.ts
CHANGED
|
@@ -226,8 +226,9 @@ export function validateAgentDef(entry: unknown, label = 'agent'): AcpAgentDef {
|
|
|
226
226
|
const exposedModels = row.exposedModels === undefined
|
|
227
227
|
? undefined
|
|
228
228
|
: normalizeExposedModels(row.exposedModels, `agent "${id}" exposedModels`, 'bad_config')
|
|
229
|
-
// Optional boolean
|
|
230
|
-
// "
|
|
229
|
+
// Optional boolean, ON by default. Only the explicit opt-out is stored:
|
|
230
|
+
// `true` and absent are the same "on" state, so the persisted file does not
|
|
231
|
+
// carry noise.
|
|
231
232
|
const autoDiscoverModels = row.autoDiscoverModels
|
|
232
233
|
if (autoDiscoverModels !== undefined && typeof autoDiscoverModels !== 'boolean') {
|
|
233
234
|
throw new AcpError('bad_config', `acp: agent "${id}" autoDiscoverModels must be a boolean`)
|
|
@@ -249,7 +250,7 @@ export function validateAgentDef(entry: unknown, label = 'agent'): AcpAgentDef {
|
|
|
249
250
|
cwd: cwd as string | undefined,
|
|
250
251
|
allowedCwdRoots: roots as string[],
|
|
251
252
|
...(exposedModels !== undefined ? { exposedModels } : {}),
|
|
252
|
-
...(autoDiscoverModels ===
|
|
253
|
+
...(autoDiscoverModels === false ? { autoDiscoverModels: false } : {}),
|
|
253
254
|
...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs: idleTimeoutMs as number }),
|
|
254
255
|
}
|
|
255
256
|
}
|
|
@@ -613,24 +614,110 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
|
|
|
613
614
|
if (configId === '') continue
|
|
614
615
|
const choices: NonNullable<AcpAgentCatalog['model']>['choices'] = []
|
|
615
616
|
collectCatalogChoices(row.options, choices)
|
|
617
|
+
// A boolean toggle has no `options` at all; its current value is a JSON
|
|
618
|
+
// boolean. It is encoded as 'true' / 'false' so the catalog stays
|
|
619
|
+
// string-typed for every existing reader, and carries `kind` so the
|
|
620
|
+
// composer renders a checkbox instead of a select.
|
|
621
|
+
const booleanKind = row.type === 'boolean'
|
|
622
|
+
|| (typeof row.currentValue === 'boolean' && choices.length === 0)
|
|
616
623
|
const option: AcpCatalogOption = {
|
|
617
624
|
configId,
|
|
618
625
|
name: typeof row.name === 'string' && row.name !== '' ? row.name : configId,
|
|
619
|
-
|
|
626
|
+
...(booleanKind ? { kind: 'boolean' as const } : {}),
|
|
627
|
+
currentValue: booleanKind
|
|
628
|
+
? row.currentValue === true || row.currentValue === 'true'
|
|
629
|
+
? 'true'
|
|
630
|
+
: row.currentValue === false || row.currentValue === 'false' ? 'false' : null
|
|
631
|
+
: typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
|
|
620
632
|
choices,
|
|
621
633
|
}
|
|
622
|
-
|
|
634
|
+
// A toggle is never a model / thought / mode SELECT: those controls pick
|
|
635
|
+
// among choices. It belongs to the advanced base, where it is a checkbox.
|
|
636
|
+
if (slot !== undefined && !booleanKind) {
|
|
623
637
|
out[slot] = option
|
|
624
638
|
continue
|
|
625
639
|
}
|
|
626
640
|
// Choice-less options have nothing to pick; they would render an empty
|
|
627
|
-
// section in the advanced menu, so they stay out of the catalog entirely
|
|
628
|
-
|
|
641
|
+
// section in the advanced menu, so they stay out of the catalog entirely —
|
|
642
|
+
// except a boolean, whose whole point is that it has no choices.
|
|
643
|
+
if (choices.length > 0 || booleanKind) extras.set(configId, option)
|
|
629
644
|
}
|
|
630
645
|
if (extras.size > 0) out.extras = [...extras.values()]
|
|
631
646
|
return out
|
|
632
647
|
}
|
|
633
648
|
|
|
649
|
+
/**
|
|
650
|
+
* Fold one per-session option answer's SHAPE into an agent-level catalog.
|
|
651
|
+
*
|
|
652
|
+
* `session/set_config_option` answers are often the ONLY place an agent
|
|
653
|
+
* publishes how its option set changes with the model — opencode offers
|
|
654
|
+
* `thought_level` (`effort`) just for models with variants and never
|
|
655
|
+
* re-notifies after a client-driven set — and the composer's thought chip is
|
|
656
|
+
* driven by the agent-level catalog. What is agent knowledge there is the
|
|
657
|
+
* SHAPE (which slots exist, which choices they offer); `currentValue` is not:
|
|
658
|
+
* in a per-session answer it is that session's pick, and promoting it would
|
|
659
|
+
* turn one conversation's choice into `reasoning.defaultEffort` for every
|
|
660
|
+
* other one. A merged slot therefore keeps the catalog's known current value
|
|
661
|
+
* while the observed choices still offer it, and `null` ("the agent's own
|
|
662
|
+
* default") otherwise. Slots the answer does not mention are left alone — a
|
|
663
|
+
* different model may still offer them.
|
|
664
|
+
*/
|
|
665
|
+
export function mergeCatalogShape(existing: AcpAgentCatalog | undefined, observed: AcpAgentCatalog): AcpAgentCatalog {
|
|
666
|
+
const out: AcpAgentCatalog = {}
|
|
667
|
+
const merge = (prev: AcpCatalogOption | undefined, next: AcpCatalogOption): AcpCatalogOption => {
|
|
668
|
+
const kept = prev?.currentValue ?? null
|
|
669
|
+
return {
|
|
670
|
+
configId: next.configId,
|
|
671
|
+
name: next.name,
|
|
672
|
+
...(next.kind === 'boolean' ? { kind: 'boolean' as const } : {}),
|
|
673
|
+
// A boolean has no choices to test `kept` against; it keeps the
|
|
674
|
+
// catalog's known default like any other slot (the answer's value is
|
|
675
|
+
// still one session's truth, not the agent's).
|
|
676
|
+
currentValue: kept !== null && (next.choices.length === 0 || next.choices.some(choice => choice.value === kept)) ? kept : null,
|
|
677
|
+
choices: next.choices,
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
for (const slot of ['model', 'thought', 'mode'] as const) {
|
|
681
|
+
const prev = existing?.[slot]
|
|
682
|
+
const next = observed[slot]
|
|
683
|
+
if (next === undefined) {
|
|
684
|
+
if (prev !== undefined) out[slot] = prev
|
|
685
|
+
continue
|
|
686
|
+
}
|
|
687
|
+
out[slot] = merge(prev, next)
|
|
688
|
+
}
|
|
689
|
+
// Extras merge by configId: observed entries refresh their shape, entries
|
|
690
|
+
// only the earlier answer knew survive (another model may still offer them).
|
|
691
|
+
const extras = new Map<string, AcpCatalogOption>()
|
|
692
|
+
for (const option of existing?.extras ?? []) extras.set(option.configId, option)
|
|
693
|
+
for (const option of observed.extras ?? []) extras.set(option.configId, merge(extras.get(option.configId), option))
|
|
694
|
+
if (extras.size > 0) out.extras = [...extras.values()]
|
|
695
|
+
return out
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Size ceiling for one `configOptions` answer to be believed. Normal answers
|
|
700
|
+
* are a few KB (a 500-model catalog is well under this); beyond it the agent is
|
|
701
|
+
* misbehaving and absorbing/persisting it would turn every notification into
|
|
702
|
+
* an unbounded write.
|
|
703
|
+
*/
|
|
704
|
+
export const MAX_CONFIG_OPTIONS_BYTES = 1 << 20
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Whether a `configOptions` answer may overwrite known state.
|
|
708
|
+
*
|
|
709
|
+
* An EMPTY (or absent) answer is a non-answer: observed agents return
|
|
710
|
+
* `configOptions: []` in intermediate states, and treating that as "this agent
|
|
711
|
+
* now has no options" would wipe a perfectly good catalog and session
|
|
712
|
+
* snapshot for a state that passes (the rule OmniTerm settles on for the same
|
|
713
|
+
* observation — empty sets never overwrite the last known state). Oversized
|
|
714
|
+
* answers are dropped for the same reason: see {@link MAX_CONFIG_OPTIONS_BYTES}.
|
|
715
|
+
*/
|
|
716
|
+
export function usableConfigOptions(configOptions: unknown): configOptions is unknown[] {
|
|
717
|
+
if (!Array.isArray(configOptions) || configOptions.length === 0) return false
|
|
718
|
+
return JSON.stringify(configOptions).length <= MAX_CONFIG_OPTIONS_BYTES
|
|
719
|
+
}
|
|
720
|
+
|
|
634
721
|
/** Flatten select `options` (plain values or `SessionConfigSelectGroup`s) into choices, deduped by value. */
|
|
635
722
|
function collectCatalogChoices(options: unknown, out: NonNullable<AcpAgentCatalog['model']>['choices']): void {
|
|
636
723
|
if (!Array.isArray(options)) return
|
|
@@ -1433,7 +1520,7 @@ export interface AcpManager {
|
|
|
1433
1520
|
/** Switch the reasoning effort for one chat session (agentId names the default-agent case). */
|
|
1434
1521
|
setChatSessionEffort?(dshSessionId: string, effort: string | undefined, agentId?: string): Promise<void>
|
|
1435
1522
|
/** Set one of the agent's EXTRA config options for one chat session. */
|
|
1436
|
-
setChatSessionConfig?(dshSessionId: string, configId: string, value: string, agentId?: string): Promise<void>
|
|
1523
|
+
setChatSessionConfig?(dshSessionId: string, configId: string, value: string | boolean, agentId?: string): Promise<void>
|
|
1437
1524
|
/**
|
|
1438
1525
|
* State of the ACP-mode agent preset in the harness's OWN roster. Read-only
|
|
1439
1526
|
* report for the settings panel: the preset is a declaration this package
|
|
@@ -1604,8 +1691,12 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
|
|
|
1604
1691
|
case 'session/chat-config-set': {
|
|
1605
1692
|
const dshSessionId = str(body, 'dshSessionId', 128)
|
|
1606
1693
|
const configId = str(body, 'configId', 128)
|
|
1607
|
-
const value =
|
|
1608
|
-
|
|
1694
|
+
const value = body.value
|
|
1695
|
+
if (typeof value === 'boolean') {
|
|
1696
|
+
await manager.setChatSessionConfig?.(dshSessionId, configId, value, optionalStr(body, 'agentId', 64))
|
|
1697
|
+
return ok({ done: true })
|
|
1698
|
+
}
|
|
1699
|
+
await manager.setChatSessionConfig?.(dshSessionId, configId, str(body, 'value', 512), optionalStr(body, 'agentId', 64))
|
|
1609
1700
|
return ok({ done: true })
|
|
1610
1701
|
}
|
|
1611
1702
|
case 'preset/status':
|