dsh-acp-enhanced 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-en.md CHANGED
@@ -44,6 +44,14 @@ over the ACP wire.
44
44
  `session/list` / `session/delete` manage the thread archive (titled, sorted by last
45
45
  activity); live title updates
46
46
 
47
+ ### Commands
48
+
49
+ - **Slash commands**: typing `/` reveals the command list (`available_commands_update`):
50
+ `/status` shows the route and telemetry, `/model` lists or switches the model, everything
51
+ else (`/compact` `/goal` `/permission` `/plan`…) runs straight through the harness
52
+ command registry — all executed **without a model turn**; unresolved slashes fall
53
+ through to the model (the `/skill-name` skill gesture)
54
+
47
55
  ### MCP
48
56
 
49
57
  - **MCP servers**: `session/new` `mcpServers` mount any MCP server (stdio + streamable
package/README.md CHANGED
@@ -38,6 +38,13 @@ ACP 线上。
38
38
  - **恢复与归档**:`session/load` 恢复历史线程(完整回放);`session/list` /
39
39
  `session/delete` 管理线程归档(带标题、按更新时间排序);标题实时推送
40
40
 
41
+ ### 命令
42
+
43
+ - **Slash 命令**:输入 `/` 即可见命令列表(`available_commands_update`):`/status`
44
+ 查看路由与遥测、`/model` 列出或切换模型,其余(`/compact` `/goal` `/permission`
45
+ `/plan`…)直通 harness 命令注册表,全部**不经过模型 turn** 即时执行;未解析的
46
+ slash 放行给模型(`/skill-name` 技能手势)
47
+
41
48
  ### MCP
42
49
 
43
50
  - **MCP servers**:`session/new` 的 `mcpServers` 挂载任意 MCP server(stdio +
package/lib/index.js CHANGED
@@ -43,7 +43,7 @@ import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason, usag
43
43
 
44
44
  export const name = 'acp-enhanced'
45
45
  /** The bridge creates and owns agents; every other concern is carried by the composition. */
46
- export const inject = ['agents', 'llm', 'approval', 'tools']
46
+ export const inject = ['agents', 'llm', 'approval', 'tools', 'commands']
47
47
 
48
48
  export const Config = Schema.object({
49
49
  /** Initial provider route for every created agent. */
@@ -121,6 +121,7 @@ export function apply(ctx, config) {
121
121
  const llm = ctx.llm
122
122
  const approval = ctx.approval
123
123
  const tools = ctx.tools
124
+ const commands = ctx.commands
124
125
  const logger = ctx.logger
125
126
  /** The user-questions service (mounted by dsh-base); absent in minimal deployments. */
126
127
  const userQuestions = ctx.get('userQuestions')
@@ -1001,6 +1002,94 @@ export function apply(ctx, config) {
1001
1002
  }
1002
1003
  }
1003
1004
 
1005
+ // ── slash commands (adapter built-ins + harness command registry) ────────
1006
+
1007
+ /** Adapter-level commands, always first in the advertised list. */
1008
+ const BUILTIN_COMMANDS = [
1009
+ { name: 'status', description: 'Show session status: model route, context usage, telemetry.' },
1010
+ { name: 'model', description: 'List the model catalog, or switch with /model <provider/model | substring>.' },
1011
+ ]
1012
+
1013
+ /**
1014
+ * Advertise the full command surface for a session as ACP
1015
+ * `available_commands_update`: adapter built-ins plus whatever the harness
1016
+ * command registry (compact/goal/permission/plan/…) serves for the agent.
1017
+ */
1018
+ async function publishCommands(record) {
1019
+ const list = [...BUILTIN_COMMANDS]
1020
+ const seen = new Set(list.map((command) => command.name))
1021
+ try {
1022
+ for (const descriptor of commands.list(record.agent)) {
1023
+ if (seen.has(descriptor.name)) continue
1024
+ seen.add(descriptor.name)
1025
+ list.push({
1026
+ name: descriptor.name,
1027
+ description: descriptor.description,
1028
+ ...descriptor.input === undefined ? {} : { input: descriptor.input },
1029
+ })
1030
+ }
1031
+ } catch (error) {
1032
+ logger.warn(`acp-enhanced: command listing failed: ${String(error)}`)
1033
+ }
1034
+ notify({
1035
+ sessionId: record.agent.session.id,
1036
+ update: { sessionUpdate: 'available_commands_update', availableCommands: list },
1037
+ })
1038
+ }
1039
+
1040
+ /** Text summary of one session: route, turns, last usage telemetry. */
1041
+ async function statusText(record) {
1042
+ const selection = record.selection.current
1043
+ const last = record.lastUsage
1044
+ const lines = [
1045
+ `route: ${selection.provider ?? '?'}/${selection.model ?? '?'}`,
1046
+ `turns: ${record.turnCount}`,
1047
+ ...last === undefined ? [] : [
1048
+ `context: ${last.used}/${last.size}`,
1049
+ `last usage: in ${last.meta.inputTokens ?? '?'} / out ${last.meta.outputTokens ?? '?'} / reasoning ${last.meta.reasoningTokens ?? '?'} tokens, cache hit ${last.meta.cacheHitRate ?? '?'}%, tps ${last.meta.tps ?? '?'}`,
1050
+ ],
1051
+ ]
1052
+ return lines.join('\n')
1053
+ }
1054
+
1055
+ /** The /model command: list the live catalog or switch by exact id/substring. */
1056
+ async function modelCommandText(record, query) {
1057
+ const catalog = await modelCatalog()
1058
+ const current = record.selection.current
1059
+ if (query.trim().length === 0) {
1060
+ const lines = catalog.flatMap((group) => group.models.map((model) => {
1061
+ const mark = `${group.id}/${model.id}` === `${current.provider}/${current.model}` ? '* ' : ' '
1062
+ return `${mark}${group.id}/${model.id}`
1063
+ }))
1064
+ return lines.length > 0 ? lines.join('\n') : 'no models available'
1065
+ }
1066
+ const needle = query.trim().toLowerCase()
1067
+ const matches = catalog.flatMap((group) => group.models.map((model) => ({ provider: group.id, model: model.id })))
1068
+ .filter((candidate) => `${candidate.provider}/${candidate.model}`.toLowerCase().includes(needle) || candidate.model.toLowerCase().includes(needle))
1069
+ if (matches.length === 0) return `no model matches "${query}"`
1070
+ if (matches.length > 1) return `ambiguous: ${matches.map((m) => `${m.provider}/${m.model}`).join(', ')}`
1071
+ await applySelection(record, { provider: matches[0].provider, model: matches[0].model })
1072
+ return `switched to ${matches[0].provider}/${matches[0].model}`
1073
+ }
1074
+
1075
+ /** Refresh every client-visible surface a command may have mutated. */
1076
+ function refreshAfterCommand(record) {
1077
+ const permission = permissionPresets()
1078
+ if (permission !== undefined) {
1079
+ notify({
1080
+ sessionId: record.agent.session.id,
1081
+ update: {
1082
+ sessionUpdate: 'current_mode_update',
1083
+ currentModeId: permission.current(record.agent.session.events),
1084
+ },
1085
+ })
1086
+ }
1087
+ broadcastConfig(record).catch((error) => {
1088
+ logger.warn(`acp-enhanced: config rebroadcast after command failed: ${String(error)}`)
1089
+ })
1090
+ publishCommands(record)
1091
+ }
1092
+
1004
1093
  // ── session records + history replay ─────────────────────────────────────
1005
1094
 
1006
1095
  /** Build the bridge-owned protocol record for a fresh or resumed agent. */
@@ -1238,6 +1327,7 @@ export function apply(ctx, config) {
1238
1327
  const record = makeRecord(handle)
1239
1328
  sessions.set(sessionId, record)
1240
1329
  await syncMcpServers(params.mcpServers, params.cwd)
1330
+ publishCommands(record)
1241
1331
  const permission = permissionPresets()
1242
1332
  return {
1243
1333
  sessionId,
@@ -1299,6 +1389,7 @@ export function apply(ctx, config) {
1299
1389
  // Zed inserts the thread before the load RPC completes; replay the
1300
1390
  // conversation history as notifications so the thread renders.
1301
1391
  await replayHistory(record)
1392
+ publishCommands(record)
1302
1393
  const permission = permissionPresets()
1303
1394
  return {
1304
1395
  ...permission === undefined ? {} : {
@@ -1329,6 +1420,44 @@ export function apply(ctx, config) {
1329
1420
  }
1330
1421
  const text = acpPromptToText(params.prompt)
1331
1422
  if (text.trim().length === 0) throw invalidParams('empty prompt')
1423
+
1424
+ // Adapter-level slash commands never reach the model: /status and
1425
+ // /model are built in, any other registered slash (compact/goal/
1426
+ // permission/plan/…) runs through the harness command registry
1427
+ // without a model turn. An unresolved slash falls through — the
1428
+ // /skill-name gesture is claimed inside the agent's next step.
1429
+ const trimmed = text.trim()
1430
+ const commandMatch = trimmed.match(/^\/(\w[\w-]*)\b/)
1431
+ const respond = (reply) => {
1432
+ notify({
1433
+ sessionId: record.agent.session.id,
1434
+ update: {
1435
+ sessionUpdate: 'agent_message_chunk',
1436
+ messageId: randomUUID(),
1437
+ content: { type: 'text', text: reply },
1438
+ },
1439
+ })
1440
+ return { stopReason: 'end_turn' }
1441
+ }
1442
+ if (commandMatch?.[1] === 'status') return respond(await statusText(record))
1443
+ if (commandMatch?.[1] === 'model') {
1444
+ return respond(await modelCommandText(record, trimmed.slice(commandMatch[0].length).trim()))
1445
+ }
1446
+ if (commandMatch !== null && commandMatch[1] !== undefined) {
1447
+ let execution
1448
+ try {
1449
+ execution = await commands.execute(record.agent, trimmed, new AbortController().signal)
1450
+ } catch (error) {
1451
+ return respond(`⚠ /${commandMatch[1]} failed: ${error.message ?? String(error)}`)
1452
+ }
1453
+ if (execution !== undefined) {
1454
+ const { result } = execution
1455
+ const reply = result.text ?? (result.kind === 'success' ? `/${commandMatch[1]} ✓` : `/${commandMatch[1]} failed`)
1456
+ refreshAfterCommand(record)
1457
+ return respond(result.kind === 'error' ? `⚠ ${reply}` : reply)
1458
+ }
1459
+ }
1460
+
1332
1461
  if (ctx.agents.get(record.agent.id) !== record.agent) {
1333
1462
  throw internalError('prompt was not queued: the agent was disposed outside the bridge')
1334
1463
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-enhanced",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Enhanced ACP server for DeepSeek Harness: block-level streaming, usage/stat telemetry (cache hit rate, token speed, input/output tokens, context length, turns, tool timing), model & reasoning-effort switching, and permission-preset control over the ACP wire (Zed-friendly)",
5
5
  "keywords": [
6
6
  "dsh",