dsh-acp-enhanced 0.1.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/LICENSE +21 -0
- package/README-en.md +336 -0
- package/README.md +290 -0
- package/cordis.patch.yml +28 -0
- package/lib/codec.js +87 -0
- package/lib/index.js +1429 -0
- package/package.json +65 -0
- package/profile/cordis.yml +138 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Agent Client Protocol server for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* A drop-in improvement over the official `@deepseek-ai/dsh-acp` automation
|
|
5
|
+
* bridge. It keeps the same session model (fresh agents per `session/new`,
|
|
6
|
+
* prompt/cancel lifecycle, one-shot permission requests) and adds the surfaces
|
|
7
|
+
* the Web GUI has but the official wire lacks:
|
|
8
|
+
*
|
|
9
|
+
* - **Block-level streaming**: `text` blocks are emitted as soon as each block
|
|
10
|
+
* commits (`block-end`), instead of waiting for the whole `assistant/message`.
|
|
11
|
+
* A retried/cancelled block is dropped before it commits, so the wire never
|
|
12
|
+
* carries torn text.
|
|
13
|
+
* - **Usage telemetry**: every provider `usage` sample is broadcast as a
|
|
14
|
+
* standard `usage_update` (used = context pressure, size = model context
|
|
15
|
+
* window), with the full breakdown in `_meta`: input/output/cache/reasoning
|
|
16
|
+
* tokens, cache hit rate, tokens-per-second, step elapsed, turn count, and
|
|
17
|
+
* tool-call stats.
|
|
18
|
+
* - **Tool visibility**: `tool_call` / `tool_call_update` notifications carry
|
|
19
|
+
* the tool name and per-call elapsed time.
|
|
20
|
+
* - **Session configuration**: `session/set_config_option` switches model,
|
|
21
|
+
* reasoning effort, and permission preset; the option set is advertised on
|
|
22
|
+
* `session/new` and re-broadcast as `config_option_update` after changes.
|
|
23
|
+
* - **Session modes**: permission presets are exposed as ACP modes
|
|
24
|
+
* (`session/set_mode`), so the client's mode UI drives the sandbox/approval
|
|
25
|
+
* preset of the session.
|
|
26
|
+
*
|
|
27
|
+
* Stdout is reserved for ACP JSON-RPC; diagnostics go to stderr only.
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-acp-enhanced
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { randomUUID } from 'node:crypto'
|
|
33
|
+
import { rm } from 'node:fs/promises'
|
|
34
|
+
import { isAbsolute, dirname } from 'node:path'
|
|
35
|
+
import { Readable, Writable } from 'node:stream'
|
|
36
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
37
|
+
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk'
|
|
38
|
+
import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
|
39
|
+
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
40
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
41
|
+
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
42
|
+
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason, usageTelemetry } from './codec.js'
|
|
43
|
+
|
|
44
|
+
export const name = 'acp-enhanced'
|
|
45
|
+
/** The bridge creates and owns agents; every other concern is carried by the composition. */
|
|
46
|
+
export const inject = ['agents', 'llm', 'approval', 'tools']
|
|
47
|
+
|
|
48
|
+
export const Config = Schema.object({
|
|
49
|
+
/** Initial provider route for every created agent. */
|
|
50
|
+
provider: Schema.string(),
|
|
51
|
+
/** Initial model for every created agent. */
|
|
52
|
+
model: Schema.string(),
|
|
53
|
+
/** When false (default), the model dropdown advertises only the configured
|
|
54
|
+
* provider's models. On a single-provider machine (chat routed through a
|
|
55
|
+
* company gateway, or through a single official key) this hides "phantom"
|
|
56
|
+
* providers — mountable-but-unroutable adapters whose models look switchable
|
|
57
|
+
* but fail to dispatch with a MISSING_CREDENTIAL error. Set true to list
|
|
58
|
+
* every served provider's models regardless. */
|
|
59
|
+
includeAllProviders: Schema.boolean().default(false),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
|
63
|
+
function invalidParams(detail) {
|
|
64
|
+
return RequestError.invalidParams(undefined, detail)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
|
|
68
|
+
function internalError(detail) {
|
|
69
|
+
return RequestError.internalError(undefined, detail)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Map a dsh tool name to the ACP ToolKind used for icons and card UX.
|
|
74
|
+
*
|
|
75
|
+
* Zed treats kind == 'execute' as a TERMINAL tool and kind == 'edit' as a
|
|
76
|
+
* diff tool, and for both it HIDES rawInput (the "Raw Input:" section). Only
|
|
77
|
+
* genuinely terminal tools (zed_terminal) should be 'execute'; local executors
|
|
78
|
+
* like bash/run_code must stay 'other' so the editor shows the exact arguments
|
|
79
|
+
* (the command) plus the output. Write/edit tools likewise stay 'other' unless
|
|
80
|
+
* the bridge also emits diff content — otherwise the card would show nothing.
|
|
81
|
+
*/
|
|
82
|
+
function toolKindFor(name) {
|
|
83
|
+
if (name === 'zed_terminal') return 'execute'
|
|
84
|
+
if (/^fs_.*read|read_text|cat|show/.test(name)) return 'read'
|
|
85
|
+
if (/search|find|grep/.test(name)) return 'search'
|
|
86
|
+
if (/fetch|http/.test(name)) return 'fetch'
|
|
87
|
+
if (/think/.test(name)) return 'think'
|
|
88
|
+
return 'other'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parse a tool's raw arguments JSON into a JSON value for ACP rawInput. */
|
|
92
|
+
function parseToolArguments(raw) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(raw)
|
|
95
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : String(raw ?? '')
|
|
96
|
+
} catch {
|
|
97
|
+
return String(raw ?? '')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Extract a bounded text preview of a dsh tool result for ACP rawOutput. */
|
|
102
|
+
function resultPreview(event) {
|
|
103
|
+
if (event.data.error !== undefined) {
|
|
104
|
+
return `[tool error: ${event.data.error.code ?? event.data.error.name ?? 'unknown'}]`
|
|
105
|
+
}
|
|
106
|
+
const parts = []
|
|
107
|
+
for (const block of event.data.message?.content ?? []) {
|
|
108
|
+
for (const inner of block?.content ?? []) {
|
|
109
|
+
if (inner?.type === 'text' && typeof inner.text === 'string') parts.push(inner.text)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const text = parts.join('\n')
|
|
113
|
+
return text.length > 0 ? text.slice(0, 12000) : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Mount the enhanced ACP server. */
|
|
117
|
+
export function apply(ctx, config) {
|
|
118
|
+
// ACP handlers execute outside this plugin's injection scope, so capture the
|
|
119
|
+
// injected services during apply rather than reading them lazily in a callback.
|
|
120
|
+
const agents = ctx.agents
|
|
121
|
+
const llm = ctx.llm
|
|
122
|
+
const approval = ctx.approval
|
|
123
|
+
const tools = ctx.tools
|
|
124
|
+
const logger = ctx.logger
|
|
125
|
+
/** The user-questions service (mounted by dsh-base); absent in minimal deployments. */
|
|
126
|
+
const userQuestions = ctx.get('userQuestions')
|
|
127
|
+
|
|
128
|
+
/** sessionId → protocol state for one bridge-owned agent. */
|
|
129
|
+
const sessions = new Map()
|
|
130
|
+
let closed = false
|
|
131
|
+
let conn
|
|
132
|
+
/** Capabilities the connected client advertised on `initialize`. */
|
|
133
|
+
let clientCaps = {}
|
|
134
|
+
/** Disposers for the client-forwarding tools currently registered. */
|
|
135
|
+
let clientToolDisposers = []
|
|
136
|
+
|
|
137
|
+
/** Resolve the permission-presets service, tolerating a lazy mount. */
|
|
138
|
+
const permissionPresets = () => ctx.get('permissionPresets')
|
|
139
|
+
|
|
140
|
+
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
|
|
141
|
+
const ownedRecord = (agent) => {
|
|
142
|
+
const record = sessions.get(agent.session.id)
|
|
143
|
+
return record?.agent === agent ? record : undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const assertOpen = () => {
|
|
147
|
+
if (closed) throw internalError('the ACP bridge has been disposed')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const requireSession = (sessionId) => {
|
|
151
|
+
const record = sessions.get(sessionId)
|
|
152
|
+
if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`)
|
|
153
|
+
return record
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Send a protocol update without letting a disconnected client fail an agent turn. */
|
|
157
|
+
const notify = (notification) => {
|
|
158
|
+
void conn.sessionUpdate(notification).catch((error) => {
|
|
159
|
+
logger.warn(`acp-enhanced: session/update failed: ${String(error)}`)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const settlePrompt = (record, reason) => {
|
|
164
|
+
const inflight = record.inflight
|
|
165
|
+
if (inflight === undefined) return
|
|
166
|
+
record.inflight = undefined
|
|
167
|
+
inflight.resolve(reason)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const rejectFromError = (inflight, reason) => {
|
|
171
|
+
inflight.reject(internalError(`turn failed: ${reason.error.message}`))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── session/event → ACP notifications ─────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
ctx.on('session/event', (session, event) => {
|
|
177
|
+
const record = sessions.get(session.header.id)
|
|
178
|
+
if (record === undefined || record.agent.session !== session) return
|
|
179
|
+
try {
|
|
180
|
+
if (process.env.ACP_DEBUG) {
|
|
181
|
+
const extra = event.type === 'turn/end' ? ` reason=${JSON.stringify(event.data.reason)}` : event.type === 'assistant/chunk' ? ` chunkType=${event.data.chunk.type}` : event.type === 'agent/inbox/spliced' ? ` hasPending=${record.agent.inbox?.hasPending}` : event.type === 'tool/call' ? ` name=${event.data.name}` : ''
|
|
182
|
+
process.stderr.write(`[acp-debug] ${event.type} turn=${event.data?.turn} step=${event.data?.step}${extra}\n`)
|
|
183
|
+
}
|
|
184
|
+
switch (event.type) {
|
|
185
|
+
case 'assistant/chunk':
|
|
186
|
+
handleChunk(record, event)
|
|
187
|
+
break
|
|
188
|
+
case 'assistant/message':
|
|
189
|
+
// Final accounting when the adapter reported usage on the message
|
|
190
|
+
// rather than as a stream chunk.
|
|
191
|
+
if (event.data.usage !== undefined) emitUsage(record, event.data.usage, event)
|
|
192
|
+
break
|
|
193
|
+
case 'turn/start': {
|
|
194
|
+
record.turnCount += 1
|
|
195
|
+
record.turnStartedAt = Date.now()
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
case 'turn/end': {
|
|
199
|
+
const elapsed = record.turnStartedAt === undefined ? 0 : Date.now() - record.turnStartedAt
|
|
200
|
+
record.turnStartedAt = undefined
|
|
201
|
+
record.lastActivityAt = Date.now()
|
|
202
|
+
publishSessionInfo(record, { updatedAt: new Date(record.lastActivityAt).toISOString() })
|
|
203
|
+
// Surface turn-level stats even when no usage sample landed this turn.
|
|
204
|
+
if (record.lastUsage !== undefined) {
|
|
205
|
+
notify({
|
|
206
|
+
sessionId: session.header.id,
|
|
207
|
+
update: {
|
|
208
|
+
sessionUpdate: 'usage_update',
|
|
209
|
+
used: record.lastUsage.used,
|
|
210
|
+
size: record.lastUsage.size,
|
|
211
|
+
_meta: {
|
|
212
|
+
...record.lastUsage.meta,
|
|
213
|
+
turnCount: record.turnCount,
|
|
214
|
+
turnMs: elapsed,
|
|
215
|
+
tools: record.toolStats.count > 0 ? {
|
|
216
|
+
count: record.toolStats.count,
|
|
217
|
+
totalMs: record.toolStats.totalMs,
|
|
218
|
+
} : undefined,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
break
|
|
224
|
+
}
|
|
225
|
+
case 'step/start':
|
|
226
|
+
record.stepStartedAt = Date.now()
|
|
227
|
+
// One ACP message per model step: all block chunks of this step share
|
|
228
|
+
// the same messageId so the client can group them.
|
|
229
|
+
record.messageId = randomUUID()
|
|
230
|
+
break
|
|
231
|
+
case 'tool/call': {
|
|
232
|
+
record.toolStats.lastCallAt = Date.now()
|
|
233
|
+
record.toolStats.lastName = event.data.name
|
|
234
|
+
notify({
|
|
235
|
+
sessionId: session.header.id,
|
|
236
|
+
update: {
|
|
237
|
+
sessionUpdate: 'tool_call',
|
|
238
|
+
toolCallId: event.data.callId,
|
|
239
|
+
title: event.data.name,
|
|
240
|
+
kind: toolKindFor(event.data.name),
|
|
241
|
+
rawInput: parseToolArguments(event.data.arguments),
|
|
242
|
+
_meta: {
|
|
243
|
+
turn: event.data.turn,
|
|
244
|
+
step: event.data.step,
|
|
245
|
+
name: event.data.name,
|
|
246
|
+
argumentsPreview: event.data.arguments.slice(0, 200),
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
case 'tool/result': {
|
|
253
|
+
const elapsed = record.toolStats.lastCallAt === undefined
|
|
254
|
+
? 0
|
|
255
|
+
: Date.now() - record.toolStats.lastCallAt
|
|
256
|
+
record.toolStats.count += 1
|
|
257
|
+
record.toolStats.totalMs += elapsed
|
|
258
|
+
record.toolStats.lastCallAt = undefined
|
|
259
|
+
// The tool call id lives on the ToolResultBlock, not on the event root.
|
|
260
|
+
const callId = event.data.message?.content?.[0]?.toolCallId ?? event.data.callId
|
|
261
|
+
const preview = resultPreview(event)
|
|
262
|
+
notify({
|
|
263
|
+
sessionId: session.header.id,
|
|
264
|
+
update: {
|
|
265
|
+
sessionUpdate: 'tool_call_update',
|
|
266
|
+
toolCallId: callId,
|
|
267
|
+
status: event.data.error === undefined ? 'completed' : 'error',
|
|
268
|
+
...preview === undefined ? {} : { rawOutput: preview },
|
|
269
|
+
_meta: {
|
|
270
|
+
turn: event.data.turn,
|
|
271
|
+
step: event.data.step,
|
|
272
|
+
elapsedMs: elapsed,
|
|
273
|
+
count: record.toolStats.count,
|
|
274
|
+
totalMs: record.toolStats.totalMs,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
break
|
|
279
|
+
}
|
|
280
|
+
case 'request/context':
|
|
281
|
+
if (event.data.contextWindow !== undefined) record.contextWindow = event.data.contextWindow
|
|
282
|
+
break
|
|
283
|
+
case 'session/title': {
|
|
284
|
+
const title = event.data.title
|
|
285
|
+
if (typeof title === 'string' && title.length > 0) {
|
|
286
|
+
record.title = title
|
|
287
|
+
publishSessionInfo(record, {
|
|
288
|
+
title,
|
|
289
|
+
updatedAt: new Date().toISOString(),
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
case 'plan/mode': {
|
|
295
|
+
// Map DSH plan mode flips onto the ACP Plan panel. DSH plan mode has
|
|
296
|
+
// no structured task list, so a single state entry marks "planning in
|
|
297
|
+
// progress" while active; leaving plan mode clears the panel. The
|
|
298
|
+
// wire shape is flat: `{ sessionUpdate: 'plan', entries: [...] }`.
|
|
299
|
+
const active = event.data.active === true
|
|
300
|
+
notify({
|
|
301
|
+
sessionId: session.header.id,
|
|
302
|
+
update: {
|
|
303
|
+
sessionUpdate: 'plan',
|
|
304
|
+
entries: active
|
|
305
|
+
? [{
|
|
306
|
+
content: 'Plan mode: the agent will propose a plan and await your review before making changes.',
|
|
307
|
+
priority: 'high',
|
|
308
|
+
status: 'in_progress',
|
|
309
|
+
}]
|
|
310
|
+
: [],
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
const inflight = record.inflight
|
|
318
|
+
if (inflight !== undefined && event.type === 'turn/end'
|
|
319
|
+
&& (inflight.turn === undefined || inflight.turn === event.data.turn)) {
|
|
320
|
+
// agent/inbox/claimed can arrive on a scope this plugin does not see;
|
|
321
|
+
// with at most one in-flight prompt per session, the newest turn/end
|
|
322
|
+
// is authoritative for the pending prompt.
|
|
323
|
+
if (event.data.reason.kind === 'error') {
|
|
324
|
+
record.inflight = undefined
|
|
325
|
+
rejectFromError(inflight, event.data.reason)
|
|
326
|
+
} else {
|
|
327
|
+
inflight.endReason = event.data.reason
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Block-level streaming: accumulate text deltas per block index and forward
|
|
335
|
+
* committed blocks immediately. A retry restarts the same index, so torn
|
|
336
|
+
* partial text never reaches the wire; the uncommitted tail of a cancelled
|
|
337
|
+
* attempt is simply dropped.
|
|
338
|
+
*/
|
|
339
|
+
function handleChunk(record, event) {
|
|
340
|
+
const chunk = event.data.chunk
|
|
341
|
+
switch (chunk.type) {
|
|
342
|
+
case 'block-start':
|
|
343
|
+
if (chunk.blockType === 'text') record.buffer[chunk.index] = ''
|
|
344
|
+
break
|
|
345
|
+
case 'text-delta':
|
|
346
|
+
if (record.buffer[chunk.index] !== undefined) record.buffer[chunk.index] += chunk.text
|
|
347
|
+
break
|
|
348
|
+
case 'block-end': {
|
|
349
|
+
const text = record.buffer[chunk.index]
|
|
350
|
+
delete record.buffer[chunk.index]
|
|
351
|
+
if (chunk.block.type === 'text' && text !== undefined && text.length > 0) {
|
|
352
|
+
notify({
|
|
353
|
+
sessionId: record.agent.session.id,
|
|
354
|
+
update: {
|
|
355
|
+
sessionUpdate: 'agent_message_chunk',
|
|
356
|
+
messageId: record.messageId,
|
|
357
|
+
content: { type: 'text', text },
|
|
358
|
+
},
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
break
|
|
362
|
+
}
|
|
363
|
+
case 'usage':
|
|
364
|
+
emitUsage(record, chunk.usage, event)
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Broadcast one usage sample as a standard usage_update with rich _meta. */
|
|
370
|
+
function emitUsage(record, usage, event) {
|
|
371
|
+
const now = Date.now()
|
|
372
|
+
const elapsedMs = record.stepStartedAt === undefined ? 0 : now - record.stepStartedAt
|
|
373
|
+
const telemetry = usageTelemetry(usage, elapsedMs)
|
|
374
|
+
const used = telemetry.contextTokens
|
|
375
|
+
const size = record.contextWindow ?? used
|
|
376
|
+
const update = {
|
|
377
|
+
sessionUpdate: 'usage_update',
|
|
378
|
+
used,
|
|
379
|
+
size,
|
|
380
|
+
_meta: {
|
|
381
|
+
...telemetry,
|
|
382
|
+
turn: event?.data?.turn,
|
|
383
|
+
step: event?.data?.step,
|
|
384
|
+
turnCount: record.turnCount,
|
|
385
|
+
tools: record.toolStats.count > 0 ? {
|
|
386
|
+
count: record.toolStats.count,
|
|
387
|
+
totalMs: record.toolStats.totalMs,
|
|
388
|
+
} : undefined,
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
record.lastUsage = { used, size, meta: update._meta }
|
|
392
|
+
notify({ sessionId: record.agent.session.id, update })
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── approval → session/request_permission ─────────────────────────────────
|
|
396
|
+
|
|
397
|
+
ctx.on('approval/request', (request, next) => {
|
|
398
|
+
const record = ownedRecord(request.agent)
|
|
399
|
+
if (record === undefined || request.callId === undefined) return next()
|
|
400
|
+
return conn.requestPermission({
|
|
401
|
+
sessionId: record.agent.session.id,
|
|
402
|
+
toolCall: { toolCallId: request.callId },
|
|
403
|
+
options: [
|
|
404
|
+
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
|
405
|
+
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
|
|
406
|
+
],
|
|
407
|
+
}).then(({ outcome }) => {
|
|
408
|
+
if (outcome.outcome === 'cancelled') return 'cancelled'
|
|
409
|
+
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
// ── configuration options (model / reasoning effort / permission preset) ──
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A provider route can register asynchronously right after boot, so a
|
|
417
|
+
* freshly spawned bridge may enumerate before the profile's provider (e.g.
|
|
418
|
+
* one that registers after a gateway/adapter section is applied) appears in
|
|
419
|
+
* `llm.listProviders()`. Without a settle, the very first `session/new`
|
|
420
|
+
* builds a model select that omits the session's own provider, so its models
|
|
421
|
+
* cannot be chosen. Wait until the expected provider is registered (or a
|
|
422
|
+
* short ceiling elapses) before building the catalog; also rebroadcast
|
|
423
|
+
* config options whenever the adapter set changes, so an editor that opened
|
|
424
|
+
* a session mid-boot catches up.
|
|
425
|
+
*/
|
|
426
|
+
function waitForProvider(provider) {
|
|
427
|
+
if (provider === undefined) return Promise.resolve()
|
|
428
|
+
if (llm.listProviders().some((entry) => entry.id === provider)) return Promise.resolve()
|
|
429
|
+
return new Promise((resolve) => {
|
|
430
|
+
let settled = false
|
|
431
|
+
const finish = () => {
|
|
432
|
+
if (settled) return
|
|
433
|
+
settled = true
|
|
434
|
+
dispose()
|
|
435
|
+
clearTimeout(timer)
|
|
436
|
+
resolve()
|
|
437
|
+
}
|
|
438
|
+
const check = () => {
|
|
439
|
+
if (llm.listProviders().some((entry) => entry.id === provider)) finish()
|
|
440
|
+
}
|
|
441
|
+
const timer = setTimeout(finish, 4000)
|
|
442
|
+
const dispose = ctx.on('llm/adapters-updated', check)
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Rebuild every open session's config options when the provider set changes. */
|
|
447
|
+
ctx.on('llm/adapters-updated', () => {
|
|
448
|
+
for (const record of sessions.values()) {
|
|
449
|
+
broadcastConfig(record).catch((error) => {
|
|
450
|
+
logger.warn(`acp-enhanced: config rebroadcast after adapter update failed: ${String(error)}`)
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
/** Model directory for the select option: provider/model entries with reasoning info. */
|
|
456
|
+
async function modelCatalog() {
|
|
457
|
+
const groups = []
|
|
458
|
+
await waitForProvider(config.provider)
|
|
459
|
+
let providers = llm.listProviders()
|
|
460
|
+
if (!config.includeAllProviders) {
|
|
461
|
+
const configured = providers.filter((entry) => entry.id === config.provider)
|
|
462
|
+
// Only advertise the configured provider's models. This machine's default
|
|
463
|
+
// route is the one that can actually dispatch; dropping the rest keeps the
|
|
464
|
+
// "phantom" cross-provider group (e.g. an official adapter mounted but
|
|
465
|
+
// without a key when chat routes through a company gateway) from appearing
|
|
466
|
+
// selectable — picking one silently broke every later prompt. Fall back to
|
|
467
|
+
// the full set only if the configured provider isn't served yet, so the
|
|
468
|
+
// dropdown is never empty during adapter boot.
|
|
469
|
+
if (configured.length > 0) providers = configured
|
|
470
|
+
}
|
|
471
|
+
for (const provider of providers) {
|
|
472
|
+
try {
|
|
473
|
+
const models = await llm.listModels(provider.id)
|
|
474
|
+
const entries = await Promise.all(models.map(async (model) => {
|
|
475
|
+
let resolved
|
|
476
|
+
try {
|
|
477
|
+
resolved = await llm.resolveModelInfo(provider.id, model.id)
|
|
478
|
+
} catch {
|
|
479
|
+
resolved = undefined
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
id: model.id,
|
|
483
|
+
name: model.name,
|
|
484
|
+
...model.description === undefined ? {} : { description: model.description },
|
|
485
|
+
...resolved?.reasoning === undefined ? {} : {
|
|
486
|
+
reasoning: {
|
|
487
|
+
efforts: resolved.reasoning.efforts.map((effort) => ({
|
|
488
|
+
id: effort.id,
|
|
489
|
+
name: effort.name,
|
|
490
|
+
...effort.description === undefined ? {} : { description: effort.description },
|
|
491
|
+
})),
|
|
492
|
+
...resolved.reasoning.defaultEffort === undefined ? {} : { defaultEffort: resolved.reasoning.defaultEffort },
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
}))
|
|
497
|
+
if (entries.length > 0) groups.push({ id: provider.id, name: provider.name, models: entries })
|
|
498
|
+
} catch {
|
|
499
|
+
// A provider that fails to enumerate contributes no models.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return groups
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Build the full config-option set for one session. */
|
|
506
|
+
async function buildConfigOptions(record) {
|
|
507
|
+
const selected = record.selection.current
|
|
508
|
+
const options = []
|
|
509
|
+
const groups = await modelCatalog()
|
|
510
|
+
// ACP grouped-select shape: each group is `{ group, name, options }`
|
|
511
|
+
// (`group` = unique id, `name` = display label). Emitting `groupName`
|
|
512
|
+
// instead made Zed skip the whole group on deserialization, so the model
|
|
513
|
+
// dropdown came back empty and models could not be selected.
|
|
514
|
+
const selectGrouped = groups.map((group) => ({
|
|
515
|
+
group: group.id,
|
|
516
|
+
name: group.name,
|
|
517
|
+
options: group.models.map((model) => ({
|
|
518
|
+
value: `${group.id}/${model.id}`,
|
|
519
|
+
name: model.name,
|
|
520
|
+
...model.description === undefined ? {} : { description: model.description },
|
|
521
|
+
})),
|
|
522
|
+
}))
|
|
523
|
+
options.push({
|
|
524
|
+
id: 'model',
|
|
525
|
+
type: 'select',
|
|
526
|
+
name: 'Model',
|
|
527
|
+
description: 'Provider/model route for this session (provider/model values).',
|
|
528
|
+
category: 'model',
|
|
529
|
+
currentValue: `${selected.provider}/${selected.model}`,
|
|
530
|
+
options: selectGrouped,
|
|
531
|
+
})
|
|
532
|
+
const modelInfo = await llm.resolveModelInfo(selected.provider, selected.model).catch(() => undefined)
|
|
533
|
+
const efforts = modelInfo?.reasoning?.efforts ?? []
|
|
534
|
+
// Only advertise the reasoning-effort option when the routed model exposes
|
|
535
|
+
// selectable efforts. A model route without reasoning metadata (e.g. a
|
|
536
|
+
// gateway that reports none) would otherwise render an empty, dead dropdown
|
|
537
|
+
// chip next to the send button.
|
|
538
|
+
if (efforts.length > 0) {
|
|
539
|
+
options.push({
|
|
540
|
+
id: 'reasoning_effort',
|
|
541
|
+
type: 'select',
|
|
542
|
+
name: 'Reasoning effort',
|
|
543
|
+
description: 'Reasoning level applied to model requests for this session.',
|
|
544
|
+
category: 'thought_level',
|
|
545
|
+
currentValue: selected.reasoningEffort ?? modelInfo?.reasoning?.defaultEffort ?? '',
|
|
546
|
+
options: efforts.map((effort) => ({
|
|
547
|
+
value: effort.id,
|
|
548
|
+
name: effort.name,
|
|
549
|
+
...effort.description === undefined ? {} : { description: effort.description },
|
|
550
|
+
})),
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
const permission = permissionPresets()
|
|
554
|
+
if (permission !== undefined) {
|
|
555
|
+
options.push({
|
|
556
|
+
id: 'permission_preset',
|
|
557
|
+
type: 'select',
|
|
558
|
+
name: 'Permission preset',
|
|
559
|
+
description: 'Sandbox mode and approval policy bundle for this session.',
|
|
560
|
+
category: 'mode',
|
|
561
|
+
currentValue: permission.current(record.agent.session.events),
|
|
562
|
+
options: permission.names.map((presetName) => {
|
|
563
|
+
const spec = permission.presets[presetName]
|
|
564
|
+
return {
|
|
565
|
+
value: presetName,
|
|
566
|
+
name: spec?.name ?? presetName,
|
|
567
|
+
...spec?.description === undefined ? {} : { description: spec.description },
|
|
568
|
+
}
|
|
569
|
+
}),
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
const planMode = ctx.get('planMode')
|
|
573
|
+
if (planMode !== undefined) {
|
|
574
|
+
options.push({
|
|
575
|
+
id: 'plan_mode',
|
|
576
|
+
type: 'boolean',
|
|
577
|
+
name: 'Plan mode',
|
|
578
|
+
description: 'Ask the agent to plan before acting and present the plan for review.',
|
|
579
|
+
category: 'plan',
|
|
580
|
+
currentValue: planMode.get(record.agent).active,
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
return options
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Resolve and apply a new model/effort selection to the session. */
|
|
587
|
+
async function applySelection(record, next) {
|
|
588
|
+
const resolved = await llm.resolveCallConfig(next)
|
|
589
|
+
const selected = {
|
|
590
|
+
provider: resolved.provider,
|
|
591
|
+
model: resolved.model,
|
|
592
|
+
...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort },
|
|
593
|
+
}
|
|
594
|
+
record.selection.current = selected
|
|
595
|
+
// Persist as the default only when the chosen route is one this bridge is
|
|
596
|
+
// wired to serve. On a single-provider machine the default route is the
|
|
597
|
+
// routable one, so switching within it always persists; a stray cross-provider
|
|
598
|
+
// pick (a manually addressed adapter that isn't configured here) is applied
|
|
599
|
+
// to this session but never recorded as the default, so it can't poison the
|
|
600
|
+
// default for every later session with a non-routable route.
|
|
601
|
+
const persistDefault = config.includeAllProviders || selected.provider === config.provider
|
|
602
|
+
if (persistDefault) {
|
|
603
|
+
try {
|
|
604
|
+
await ctx.get('agentDefaultModel')?.saveSelection?.(selected)
|
|
605
|
+
} catch (error) {
|
|
606
|
+
logger.warn(`acp-enhanced: the model switch applies to this session but was not saved as the default: ${String(error)}`)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function broadcastConfig(record) {
|
|
612
|
+
const configOptions = await buildConfigOptions(record)
|
|
613
|
+
notify({
|
|
614
|
+
sessionId: record.agent.session.id,
|
|
615
|
+
update: { sessionUpdate: 'config_option_update', configOptions },
|
|
616
|
+
})
|
|
617
|
+
return configOptions
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Apply one permission preset, mirroring the /permission command. */
|
|
621
|
+
function applyPermissionPreset(record, presetName, permission) {
|
|
622
|
+
if (!permission.names.includes(presetName)) {
|
|
623
|
+
throw invalidParams(`unknown permission preset "${presetName}" (available: ${permission.names.join(', ')})`)
|
|
624
|
+
}
|
|
625
|
+
permission.apply(record.agent.session, presetName, (policy) => {
|
|
626
|
+
approval.setPolicy(record.agent, policy)
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── client-forwarding tools (Zed fs / terminal) ──────────────────────────
|
|
631
|
+
|
|
632
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
633
|
+
|
|
634
|
+
/** The bridge-owned session record behind a tool call, or throw. */
|
|
635
|
+
function sessionIdOf(exec) {
|
|
636
|
+
const record = ownedRecord(exec.agent)
|
|
637
|
+
if (record === undefined) {
|
|
638
|
+
throw new Error('this tool is only usable inside a bridge-owned session')
|
|
639
|
+
}
|
|
640
|
+
return record.agent.session.id
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Register (or refresh) the tools that forward to the connected client's own
|
|
645
|
+
* capabilities — Zed's filesystem API and terminal. They only exist while the
|
|
646
|
+
* client advertised the matching capability, so a raw test client or a plain
|
|
647
|
+
* automation client never sees them. When the model edits project files
|
|
648
|
+
* through `zed_write_text_file`, Zed applies the change to its own buffer and
|
|
649
|
+
* surfaces it in the agent panel's "edited files" section with diff +
|
|
650
|
+
* accept/reject; `zed_terminal` runs the command in a real Zed terminal.
|
|
651
|
+
*/
|
|
652
|
+
function syncClientTools() {
|
|
653
|
+
for (const dispose of clientToolDisposers) dispose()
|
|
654
|
+
clientToolDisposers = []
|
|
655
|
+
const fsCaps = clientCaps.fs ?? {}
|
|
656
|
+
if (fsCaps.readTextFile === true) {
|
|
657
|
+
clientToolDisposers.push(tools.register(defineTool({
|
|
658
|
+
name: 'zed_read_text_file',
|
|
659
|
+
description: 'Read a file through the connected editor (Zed) instead of the local fs tool. Use for files inside the editor workspace; the editor resolves the path against its project and records the read in its agent activity log.',
|
|
660
|
+
parameters: {
|
|
661
|
+
path: { type: 'string', required: true, description: 'Absolute path to the file to read.' },
|
|
662
|
+
line: { type: 'integer', description: '1-based line to start reading from.' },
|
|
663
|
+
limit: { type: 'integer', description: 'Maximum number of lines to read.' },
|
|
664
|
+
},
|
|
665
|
+
output: {
|
|
666
|
+
schema: {
|
|
667
|
+
type: 'object', additionalProperties: false,
|
|
668
|
+
properties: { content: { type: 'string', required: true } },
|
|
669
|
+
},
|
|
670
|
+
render: (_args, value) => [{ type: 'text', text: value.content }],
|
|
671
|
+
},
|
|
672
|
+
async execute(args, exec) {
|
|
673
|
+
const sessionId = sessionIdOf(exec)
|
|
674
|
+
const response = await conn.readTextFile({
|
|
675
|
+
sessionId,
|
|
676
|
+
path: args.path,
|
|
677
|
+
...args.line === undefined ? {} : { line: args.line },
|
|
678
|
+
...args.limit === undefined ? {} : { limit: args.limit },
|
|
679
|
+
})
|
|
680
|
+
return { content: response.content }
|
|
681
|
+
},
|
|
682
|
+
})))
|
|
683
|
+
}
|
|
684
|
+
if (fsCaps.writeTextFile === true) {
|
|
685
|
+
clientToolDisposers.push(tools.register(defineTool({
|
|
686
|
+
name: 'zed_write_text_file',
|
|
687
|
+
description: 'Write a file through the connected editor (Zed) instead of the local fs write tool. Use for project files in the editor workspace: the editor applies the change to its own buffer and shows it in the agent panel\'s edited-files section with a diff you can accept or reject. The content replaces the whole file.',
|
|
688
|
+
parameters: {
|
|
689
|
+
path: { type: 'string', required: true, description: 'Absolute path to the file to write.' },
|
|
690
|
+
content: { type: 'string', required: true, description: 'The full text content to write to the file.' },
|
|
691
|
+
},
|
|
692
|
+
output: {
|
|
693
|
+
schema: {
|
|
694
|
+
type: 'object', additionalProperties: false,
|
|
695
|
+
properties: { path: { type: 'string', required: true }, bytes: { type: 'integer' } },
|
|
696
|
+
},
|
|
697
|
+
render: (_args, value) => [{ type: 'text', text: `wrote ${value.bytes ?? 0} bytes to ${value.path} (in the editor)` }],
|
|
698
|
+
},
|
|
699
|
+
async execute(args, exec) {
|
|
700
|
+
const sessionId = sessionIdOf(exec)
|
|
701
|
+
await conn.writeTextFile({ sessionId, path: args.path, content: args.content })
|
|
702
|
+
return { path: args.path, bytes: Buffer.byteLength(args.content, 'utf8') }
|
|
703
|
+
},
|
|
704
|
+
})))
|
|
705
|
+
}
|
|
706
|
+
if (clientCaps.terminal === true) {
|
|
707
|
+
clientToolDisposers.push(tools.register(defineTool({
|
|
708
|
+
name: 'zed_terminal',
|
|
709
|
+
description: 'Run a command in a terminal inside the connected editor (Zed) instead of the local bash tool. The command runs in a real editor terminal visible in the panel; output is captured until the process exits (up to 120s). Use when running commands in the editor workspace where you want the terminal visible.',
|
|
710
|
+
parameters: {
|
|
711
|
+
command: { type: 'string', required: true, description: 'The command to execute.' },
|
|
712
|
+
args: { type: 'array', items: { type: 'string' }, description: 'Optional command arguments.' },
|
|
713
|
+
cwd: { type: 'string', description: 'Working directory for the command (absolute path).' },
|
|
714
|
+
},
|
|
715
|
+
output: {
|
|
716
|
+
schema: {
|
|
717
|
+
type: 'object', additionalProperties: false,
|
|
718
|
+
properties: {
|
|
719
|
+
output: { type: 'string', required: true },
|
|
720
|
+
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
721
|
+
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
722
|
+
timedOut: { type: 'boolean', required: true },
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
render: (_args, value) => [{
|
|
726
|
+
type: 'text',
|
|
727
|
+
text: value.timedOut
|
|
728
|
+
? `timed out after 120s; last output:\n${value.output}`
|
|
729
|
+
: `exit code: ${value.exitCode ?? 'signal' + (value.signal ?? '')}\n${value.output}`,
|
|
730
|
+
}],
|
|
731
|
+
},
|
|
732
|
+
async execute(args, exec) {
|
|
733
|
+
const sessionId = sessionIdOf(exec)
|
|
734
|
+
const handle = await conn.createTerminal({
|
|
735
|
+
sessionId,
|
|
736
|
+
command: args.command,
|
|
737
|
+
...args.args === undefined || args.args.length === 0 ? {} : { args: args.args },
|
|
738
|
+
...args.cwd === undefined ? {} : { cwd: args.cwd },
|
|
739
|
+
})
|
|
740
|
+
try {
|
|
741
|
+
let output = ''
|
|
742
|
+
let exitStatus
|
|
743
|
+
const deadline = Date.now() + 120_000
|
|
744
|
+
while (Date.now() < deadline) {
|
|
745
|
+
await sleep(300)
|
|
746
|
+
const chunk = await handle.currentOutput()
|
|
747
|
+
if (chunk.output) output = chunk.output // cumulative
|
|
748
|
+
if (chunk.exitStatus !== undefined && chunk.exitStatus !== null) {
|
|
749
|
+
exitStatus = chunk.exitStatus
|
|
750
|
+
break
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (exitStatus === undefined) {
|
|
754
|
+
await handle.kill().catch(() => {})
|
|
755
|
+
const final = await handle.currentOutput().catch(() => undefined)
|
|
756
|
+
if (final?.output) output = final.output
|
|
757
|
+
return { output, exitCode: null, signal: null, timedOut: true }
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
output,
|
|
761
|
+
exitCode: exitStatus.exitCode ?? null,
|
|
762
|
+
signal: exitStatus.signal ?? null,
|
|
763
|
+
timedOut: false,
|
|
764
|
+
}
|
|
765
|
+
} finally {
|
|
766
|
+
await handle.release().catch(() => {})
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
})))
|
|
770
|
+
}
|
|
771
|
+
if (clientCaps.elicitation?.form !== undefined && clientCaps.elicitation?.form !== null && userQuestions !== undefined) {
|
|
772
|
+
// Model-facing question tool (mirrors @deepseek-ai/dsh-tool-ask-user),
|
|
773
|
+
// rendered in the editor as a form elicitation instead of a web dialog.
|
|
774
|
+
clientToolDisposers.push(tools.register(defineTool({
|
|
775
|
+
name: 'ask_user_question',
|
|
776
|
+
description: 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer. The questions render as a form inside the editor.',
|
|
777
|
+
parameters: { questions: {
|
|
778
|
+
type: 'array',
|
|
779
|
+
required: true,
|
|
780
|
+
description: 'Questions to ask the user before continuing.',
|
|
781
|
+
items: {
|
|
782
|
+
type: 'object',
|
|
783
|
+
additionalProperties: true,
|
|
784
|
+
properties: {
|
|
785
|
+
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
|
|
786
|
+
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
|
|
787
|
+
header: { type: 'string', description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".' },
|
|
788
|
+
options: {
|
|
789
|
+
type: 'array',
|
|
790
|
+
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
|
|
791
|
+
items: {
|
|
792
|
+
type: 'object', additionalProperties: true,
|
|
793
|
+
properties: {
|
|
794
|
+
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
|
|
795
|
+
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
multi_select: { type: 'boolean', description: 'Whether the user may select more than one option. Defaults to false.' },
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
} },
|
|
803
|
+
output: {
|
|
804
|
+
schema: {
|
|
805
|
+
type: 'object', additionalProperties: false,
|
|
806
|
+
properties: { answers: {
|
|
807
|
+
type: 'array', required: true,
|
|
808
|
+
items: {
|
|
809
|
+
type: 'object', additionalProperties: false,
|
|
810
|
+
properties: {
|
|
811
|
+
id: { type: 'string', required: true },
|
|
812
|
+
selected: { type: 'array', required: true, items: { type: 'string' } },
|
|
813
|
+
custom: { type: 'string' },
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
} },
|
|
817
|
+
},
|
|
818
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
819
|
+
},
|
|
820
|
+
async execute(args, exec) {
|
|
821
|
+
const answers = await userQuestions.ask({
|
|
822
|
+
questions: args.questions.map((question) => ({
|
|
823
|
+
id: question.id,
|
|
824
|
+
question: question.question,
|
|
825
|
+
...question.header === undefined ? {} : { header: question.header },
|
|
826
|
+
...question.options === undefined ? {} : { options: question.options },
|
|
827
|
+
...question.multi_select === undefined ? {} : { multiSelect: question.multi_select },
|
|
828
|
+
})),
|
|
829
|
+
...exec.agent === undefined ? {} : { agent: exec.agent },
|
|
830
|
+
signal: exec.signal,
|
|
831
|
+
})
|
|
832
|
+
return {
|
|
833
|
+
answers: answers.answers.map((answer) => ({
|
|
834
|
+
id: answer.id,
|
|
835
|
+
selected: [...answer.selected],
|
|
836
|
+
...answer.custom === undefined ? {} : { custom: answer.custom },
|
|
837
|
+
})),
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
})))
|
|
841
|
+
// The UI provider that renders those questions as an editor form.
|
|
842
|
+
clientToolDisposers.push(userQuestions.registerProvider({
|
|
843
|
+
ask: async (request) => {
|
|
844
|
+
const record = ownedRecord(request.agent)
|
|
845
|
+
if (record === undefined) {
|
|
846
|
+
throw new Error('ask_user_question is only usable inside a bridge-owned session')
|
|
847
|
+
}
|
|
848
|
+
const properties = {}
|
|
849
|
+
const required = []
|
|
850
|
+
for (const question of request.questions) {
|
|
851
|
+
required.push(question.id)
|
|
852
|
+
const labels = (question.options ?? []).map((option) => option.label)
|
|
853
|
+
if (question.multiSelect === true) {
|
|
854
|
+
properties[question.id] = {
|
|
855
|
+
type: 'array',
|
|
856
|
+
...labels.length > 0 ? { items: { type: 'string', enum: labels } } : { items: { type: 'string' } },
|
|
857
|
+
description: question.question,
|
|
858
|
+
}
|
|
859
|
+
} else if (labels.length > 0) {
|
|
860
|
+
properties[question.id] = { type: 'string', enum: labels, description: question.question }
|
|
861
|
+
} else {
|
|
862
|
+
properties[question.id] = { type: 'string', description: question.question }
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const response = await conn.unstable_createElicitation({
|
|
866
|
+
sessionId: record.agent.session.id,
|
|
867
|
+
mode: 'form',
|
|
868
|
+
message: request.questions.map((question) => question.question).join('\n'),
|
|
869
|
+
requestedSchema: { type: 'object', properties, required },
|
|
870
|
+
})
|
|
871
|
+
if (response.action !== 'accept') {
|
|
872
|
+
throw new Error(`the user ${response.action === 'decline' ? 'declined' : 'cancelled'} the question`)
|
|
873
|
+
}
|
|
874
|
+
const content = response.content ?? {}
|
|
875
|
+
return {
|
|
876
|
+
answers: request.questions.map((question) => {
|
|
877
|
+
const value = content[question.id]
|
|
878
|
+
const selected = Array.isArray(value)
|
|
879
|
+
? value.filter((entry) => typeof entry === 'string')
|
|
880
|
+
: typeof value === 'string' ? [value] : []
|
|
881
|
+
return { id: question.id, selected }
|
|
882
|
+
}),
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
}))
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ── session records + history replay ─────────────────────────────────────
|
|
890
|
+
|
|
891
|
+
/** Build the bridge-owned protocol record for a fresh or resumed agent. */
|
|
892
|
+
function makeRecord(handle) {
|
|
893
|
+
// Session-local model selection, exactly as the Web api-proxy installs it:
|
|
894
|
+
// reads fall back to the logged request header, then the default.
|
|
895
|
+
let picked
|
|
896
|
+
const selection = {
|
|
897
|
+
get current() {
|
|
898
|
+
if (picked !== undefined) return picked
|
|
899
|
+
const logged = handle.agent.session.requestHeader()?.config
|
|
900
|
+
if (logged === undefined) return ctx.get('agentDefaultModel')?.currentSelection?.() ?? {}
|
|
901
|
+
return {
|
|
902
|
+
provider: logged.provider,
|
|
903
|
+
model: logged.model,
|
|
904
|
+
...logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort },
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
set current(next) {
|
|
908
|
+
picked = next
|
|
909
|
+
},
|
|
910
|
+
assembled: undefined,
|
|
911
|
+
}
|
|
912
|
+
installModelSelection(handle.agent.ctx, selection)
|
|
913
|
+
return {
|
|
914
|
+
agent: handle.agent,
|
|
915
|
+
dispose: () => handle.dispose(),
|
|
916
|
+
inflight: undefined,
|
|
917
|
+
messageId: undefined,
|
|
918
|
+
stepStartedAt: undefined,
|
|
919
|
+
turnStartedAt: undefined,
|
|
920
|
+
turnCount: 0,
|
|
921
|
+
title: undefined,
|
|
922
|
+
lastActivityAt: undefined,
|
|
923
|
+
toolStats: { count: 0, totalMs: 0, lastCallAt: undefined, lastName: undefined },
|
|
924
|
+
buffer: {},
|
|
925
|
+
contextWindow: undefined,
|
|
926
|
+
lastUsage: undefined,
|
|
927
|
+
selection,
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Best-effort last known title of a persisted (non-live) session, read from
|
|
933
|
+
* its stored log's `session/title` events. Bounded: oversized logs are
|
|
934
|
+
* skipped rather than fully loaded.
|
|
935
|
+
*/
|
|
936
|
+
async function readStoredTitle(persistence, id) {
|
|
937
|
+
try {
|
|
938
|
+
if (persistence?.supportsRawArtifacts !== true) return undefined
|
|
939
|
+
const raw = await persistence.readRaw(id)
|
|
940
|
+
if (raw === undefined || raw.content.length > 8 * 1024 * 1024) return undefined
|
|
941
|
+
let last
|
|
942
|
+
for (const line of raw.content.split('\n')) {
|
|
943
|
+
if (line.trim().length === 0) continue
|
|
944
|
+
try {
|
|
945
|
+
const record = JSON.parse(line)
|
|
946
|
+
if (record?.type === 'session/title' && typeof record?.data?.title === 'string') {
|
|
947
|
+
last = record.data.title
|
|
948
|
+
}
|
|
949
|
+
} catch {
|
|
950
|
+
// Malformed line — skip; titles live on well-formed rows.
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return last
|
|
954
|
+
} catch {
|
|
955
|
+
return undefined
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Publish a session-metadata change (title / last activity) to the client. */
|
|
960
|
+
function publishSessionInfo(record, info) {
|
|
961
|
+
notify({
|
|
962
|
+
sessionId: record.agent.session.id,
|
|
963
|
+
update: { sessionUpdate: 'session_info_update', ...info },
|
|
964
|
+
})
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Extract plain text from a dsh message's content blocks (baseline ACP is text-only). */
|
|
968
|
+
function textFromBlocks(content) {
|
|
969
|
+
const parts = []
|
|
970
|
+
for (const block of content ?? []) {
|
|
971
|
+
if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
972
|
+
}
|
|
973
|
+
return parts.join('\n')
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Replay a resumed session's conversation to the client as ACP session
|
|
978
|
+
* notifications. Zed inserts the thread before the load RPC completes, so
|
|
979
|
+
* these find it. Only the baseline-visible surface is replayed: user and
|
|
980
|
+
* assistant text chunks plus tool calls/results; everything else (turns,
|
|
981
|
+
* steps, usage, plan flips, …) is omitted.
|
|
982
|
+
*/
|
|
983
|
+
async function replayHistory(record) {
|
|
984
|
+
const events = record.agent.session.events
|
|
985
|
+
for (const event of events) {
|
|
986
|
+
try {
|
|
987
|
+
switch (event.type) {
|
|
988
|
+
case 'user/message': {
|
|
989
|
+
// Only direct human prompts (source.kind === 'user') belong in the
|
|
990
|
+
// editor thread; synthetic injections (system reminders, skill
|
|
991
|
+
// content, cron notices, …) would clutter it.
|
|
992
|
+
if (event.data.source?.kind !== 'user') break
|
|
993
|
+
const text = textFromBlocks(event.data.content)
|
|
994
|
+
if (text.trim().length === 0) break
|
|
995
|
+
await notifyNow(record, {
|
|
996
|
+
sessionUpdate: 'user_message_chunk',
|
|
997
|
+
messageId: randomUUID(),
|
|
998
|
+
content: { type: 'text', text },
|
|
999
|
+
})
|
|
1000
|
+
break
|
|
1001
|
+
}
|
|
1002
|
+
case 'assistant/message': {
|
|
1003
|
+
const text = textFromBlocks(event.data.message.content)
|
|
1004
|
+
if (text.trim().length === 0) break
|
|
1005
|
+
await notifyNow(record, {
|
|
1006
|
+
sessionUpdate: 'agent_message_chunk',
|
|
1007
|
+
messageId: randomUUID(),
|
|
1008
|
+
content: { type: 'text', text },
|
|
1009
|
+
})
|
|
1010
|
+
break
|
|
1011
|
+
}
|
|
1012
|
+
case 'tool/call':
|
|
1013
|
+
await notifyNow(record, {
|
|
1014
|
+
sessionUpdate: 'tool_call',
|
|
1015
|
+
toolCallId: event.data.callId,
|
|
1016
|
+
title: event.data.name,
|
|
1017
|
+
kind: toolKindFor(event.data.name),
|
|
1018
|
+
rawInput: parseToolArguments(event.data.arguments),
|
|
1019
|
+
_meta: {
|
|
1020
|
+
turn: event.data.turn,
|
|
1021
|
+
step: event.data.step,
|
|
1022
|
+
name: event.data.name,
|
|
1023
|
+
argumentsPreview: String(event.data.arguments ?? '').slice(0, 200),
|
|
1024
|
+
},
|
|
1025
|
+
})
|
|
1026
|
+
break
|
|
1027
|
+
case 'tool/result': {
|
|
1028
|
+
const preview = resultPreview(event)
|
|
1029
|
+
const callId = event.data.message?.content?.[0]?.toolCallId ?? event.data.callId
|
|
1030
|
+
await notifyNow(record, {
|
|
1031
|
+
sessionUpdate: 'tool_call_update',
|
|
1032
|
+
toolCallId: callId,
|
|
1033
|
+
status: event.data.error === undefined ? 'completed' : 'error',
|
|
1034
|
+
...preview === undefined ? {} : { rawOutput: preview },
|
|
1035
|
+
_meta: {
|
|
1036
|
+
turn: event.data.turn,
|
|
1037
|
+
step: event.data.step,
|
|
1038
|
+
name: event.data.name,
|
|
1039
|
+
},
|
|
1040
|
+
})
|
|
1041
|
+
break
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
logger.warn(`acp-enhanced: history replay skipped an event: ${String(error)}`)
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Awaiting variant of notify() used by history replay (ordered delivery). */
|
|
1051
|
+
async function notifyNow(record, update) {
|
|
1052
|
+
await conn.sessionUpdate({
|
|
1053
|
+
sessionId: record.agent.session.id,
|
|
1054
|
+
update,
|
|
1055
|
+
}).catch((error) => {
|
|
1056
|
+
logger.warn(`acp-enhanced: session/update failed during history replay: ${String(error)}`)
|
|
1057
|
+
})
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// ── the Agent face ────────────────────────────────────────────────────────
|
|
1061
|
+
|
|
1062
|
+
const makeAgent = (connection) => {
|
|
1063
|
+
conn = connection
|
|
1064
|
+
return {
|
|
1065
|
+
initialize(params) {
|
|
1066
|
+
clientCaps = params?.clientCapabilities ?? {}
|
|
1067
|
+
syncClientTools()
|
|
1068
|
+
return Promise.resolve({
|
|
1069
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1070
|
+
agentInfo: { name: 'deepseek-harness-acp-enhanced', version: '0.1.0' },
|
|
1071
|
+
agentCapabilities: {
|
|
1072
|
+
loadSession: true,
|
|
1073
|
+
sessionCapabilities: { list: {}, delete: {} },
|
|
1074
|
+
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
|
1075
|
+
},
|
|
1076
|
+
authMethods: [],
|
|
1077
|
+
})
|
|
1078
|
+
},
|
|
1079
|
+
|
|
1080
|
+
authenticate() {
|
|
1081
|
+
return Promise.resolve()
|
|
1082
|
+
},
|
|
1083
|
+
|
|
1084
|
+
async newSession(params) {
|
|
1085
|
+
assertOpen()
|
|
1086
|
+
validateSessionParams(params)
|
|
1087
|
+
const sessionId = SessionId(randomUUID())
|
|
1088
|
+
const handle = await agents.create({
|
|
1089
|
+
sessionId,
|
|
1090
|
+
meta: { cwd: params.cwd },
|
|
1091
|
+
agentOptions: {
|
|
1092
|
+
...config.provider === undefined ? {} : { provider: config.provider },
|
|
1093
|
+
...config.model === undefined ? {} : { model: config.model },
|
|
1094
|
+
},
|
|
1095
|
+
})
|
|
1096
|
+
if (closed) {
|
|
1097
|
+
await handle.dispose()
|
|
1098
|
+
throw internalError('connection closed during session/new')
|
|
1099
|
+
}
|
|
1100
|
+
const record = makeRecord(handle)
|
|
1101
|
+
sessions.set(sessionId, record)
|
|
1102
|
+
const permission = permissionPresets()
|
|
1103
|
+
return {
|
|
1104
|
+
sessionId,
|
|
1105
|
+
...permission === undefined ? {} : {
|
|
1106
|
+
modes: {
|
|
1107
|
+
currentModeId: permission.current(handle.agent.session.events),
|
|
1108
|
+
availableModes: permission.names.map((presetName) => {
|
|
1109
|
+
const spec = permission.presets[presetName]
|
|
1110
|
+
return {
|
|
1111
|
+
id: presetName,
|
|
1112
|
+
name: spec?.name ?? presetName,
|
|
1113
|
+
...spec?.description === undefined ? {} : { description: spec.description },
|
|
1114
|
+
}
|
|
1115
|
+
}),
|
|
1116
|
+
},
|
|
1117
|
+
},
|
|
1118
|
+
configOptions: await buildConfigOptions(record),
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
|
|
1122
|
+
async loadSession(params) {
|
|
1123
|
+
assertOpen()
|
|
1124
|
+
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
1125
|
+
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
|
|
1126
|
+
throw invalidParams('additionalDirectories is not supported')
|
|
1127
|
+
}
|
|
1128
|
+
if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')
|
|
1129
|
+
const sessionId = SessionId(params.sessionId)
|
|
1130
|
+
const live = sessions.get(sessionId)
|
|
1131
|
+
if (live !== undefined) {
|
|
1132
|
+
// Already live on this connection: return its state without replay.
|
|
1133
|
+
const permission = permissionPresets()
|
|
1134
|
+
return {
|
|
1135
|
+
...permission === undefined ? {} : {
|
|
1136
|
+
modes: {
|
|
1137
|
+
currentModeId: permission.current(live.agent.session.events),
|
|
1138
|
+
availableModes: permission.names.map((presetName) => {
|
|
1139
|
+
const spec = permission.presets[presetName]
|
|
1140
|
+
return { id: presetName, name: spec?.name ?? presetName }
|
|
1141
|
+
}),
|
|
1142
|
+
},
|
|
1143
|
+
},
|
|
1144
|
+
configOptions: await buildConfigOptions(live),
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const handle = await agents.resume({
|
|
1148
|
+
resumeSessionId: sessionId,
|
|
1149
|
+
agentOptions: {
|
|
1150
|
+
...config.provider === undefined ? {} : { provider: config.provider },
|
|
1151
|
+
...config.model === undefined ? {} : { model: config.model },
|
|
1152
|
+
},
|
|
1153
|
+
})
|
|
1154
|
+
if (closed) {
|
|
1155
|
+
await handle.dispose()
|
|
1156
|
+
throw internalError('connection closed during session/load')
|
|
1157
|
+
}
|
|
1158
|
+
const record = makeRecord(handle)
|
|
1159
|
+
sessions.set(sessionId, record)
|
|
1160
|
+
// Zed inserts the thread before the load RPC completes; replay the
|
|
1161
|
+
// conversation history as notifications so the thread renders.
|
|
1162
|
+
await replayHistory(record)
|
|
1163
|
+
const permission = permissionPresets()
|
|
1164
|
+
return {
|
|
1165
|
+
...permission === undefined ? {} : {
|
|
1166
|
+
modes: {
|
|
1167
|
+
currentModeId: permission.current(record.agent.session.events),
|
|
1168
|
+
availableModes: permission.names.map((presetName) => {
|
|
1169
|
+
const spec = permission.presets[presetName]
|
|
1170
|
+
return {
|
|
1171
|
+
id: presetName,
|
|
1172
|
+
name: spec?.name ?? presetName,
|
|
1173
|
+
...spec?.description === undefined ? {} : { description: spec.description },
|
|
1174
|
+
}
|
|
1175
|
+
}),
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
configOptions: await buildConfigOptions(record),
|
|
1179
|
+
}
|
|
1180
|
+
},
|
|
1181
|
+
|
|
1182
|
+
async prompt(params) {
|
|
1183
|
+
assertOpen()
|
|
1184
|
+
const record = requireSession(SessionId(params.sessionId))
|
|
1185
|
+
if (record.inflight !== undefined) {
|
|
1186
|
+
throw invalidParams('a prompt is already in flight for this session')
|
|
1187
|
+
}
|
|
1188
|
+
if (promptHasUnsupportedContent(params.prompt)) {
|
|
1189
|
+
throw invalidParams('only text and resource_link prompt content is supported')
|
|
1190
|
+
}
|
|
1191
|
+
const text = acpPromptToText(params.prompt)
|
|
1192
|
+
if (text.trim().length === 0) throw invalidParams('empty prompt')
|
|
1193
|
+
if (ctx.agents.get(record.agent.id) !== record.agent) {
|
|
1194
|
+
throw internalError('prompt was not queued: the agent was disposed outside the bridge')
|
|
1195
|
+
}
|
|
1196
|
+
const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
1197
|
+
if (process.env.ACP_DEBUG) process.stderr.write(`[acp-debug] followup queued, agent phase=${record.agent.phase?.kind} inboxPending=${record.agent.inbox?.hasPending}\n`)
|
|
1198
|
+
const stopReason = await new Promise((resolve, reject) => {
|
|
1199
|
+
const inflight = {
|
|
1200
|
+
resolve,
|
|
1201
|
+
reject,
|
|
1202
|
+
messageId: message.id,
|
|
1203
|
+
turn: undefined,
|
|
1204
|
+
endReason: undefined,
|
|
1205
|
+
}
|
|
1206
|
+
record.inflight = inflight
|
|
1207
|
+
try {
|
|
1208
|
+
record.agent.followup(message)
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
record.inflight = undefined
|
|
1211
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
1212
|
+
throw internalError(`prompt was not queued: ${detail}`)
|
|
1213
|
+
}
|
|
1214
|
+
void record.agent.whenIdle().then(() => {
|
|
1215
|
+
if (record.inflight !== inflight) return
|
|
1216
|
+
record.inflight = undefined
|
|
1217
|
+
const end = inflight.endReason
|
|
1218
|
+
if (end === undefined) {
|
|
1219
|
+
inflight.resolve('cancelled')
|
|
1220
|
+
} else {
|
|
1221
|
+
inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
|
|
1222
|
+
}
|
|
1223
|
+
})
|
|
1224
|
+
})
|
|
1225
|
+
return { stopReason }
|
|
1226
|
+
},
|
|
1227
|
+
|
|
1228
|
+
cancel(params) {
|
|
1229
|
+
const record = sessions.get(SessionId(params.sessionId))
|
|
1230
|
+
if (record === undefined) return Promise.resolve()
|
|
1231
|
+
record.agent.cancel({ kind: 'user' })
|
|
1232
|
+
settlePrompt(record, 'cancelled')
|
|
1233
|
+
return Promise.resolve()
|
|
1234
|
+
},
|
|
1235
|
+
|
|
1236
|
+
async setSessionConfigOption(params) {
|
|
1237
|
+
assertOpen()
|
|
1238
|
+
const record = requireSession(SessionId(params.sessionId))
|
|
1239
|
+
// ACP serializes SessionConfigOptionValue FLATTENED into the request
|
|
1240
|
+
// (schema 1.4.0 / JS SDK 0.25.1, what Zed sends): selects arrive as
|
|
1241
|
+
// `value: '<id>'`, booleans as `type: 'boolean', value: true` at the
|
|
1242
|
+
// top level. The SDK already validates this shape, so `params.value`
|
|
1243
|
+
// is a plain string/boolean here. The unwrap below is only a safety
|
|
1244
|
+
// net for hand-rolled streams that send the older nested form.
|
|
1245
|
+
let value = params.value
|
|
1246
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value) && 'value' in value) {
|
|
1247
|
+
value = value.value
|
|
1248
|
+
}
|
|
1249
|
+
switch (params.configId) {
|
|
1250
|
+
case 'model': {
|
|
1251
|
+
if (typeof value !== 'string') throw invalidParams('model value must be a "provider/model" string')
|
|
1252
|
+
const slash = value.indexOf('/')
|
|
1253
|
+
if (slash <= 0 || slash === value.length - 1) {
|
|
1254
|
+
throw invalidParams('model value must be a "provider/model" string')
|
|
1255
|
+
}
|
|
1256
|
+
const current = record.selection.current
|
|
1257
|
+
await applySelection(record, {
|
|
1258
|
+
provider: value.slice(0, slash),
|
|
1259
|
+
model: value.slice(slash + 1),
|
|
1260
|
+
...current.reasoningEffort === undefined ? {} : { reasoningEffort: current.reasoningEffort },
|
|
1261
|
+
})
|
|
1262
|
+
break
|
|
1263
|
+
}
|
|
1264
|
+
case 'reasoning_effort': {
|
|
1265
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
1266
|
+
throw invalidParams('reasoning_effort value must be a non-empty effort id')
|
|
1267
|
+
}
|
|
1268
|
+
const current = record.selection.current
|
|
1269
|
+
await applySelection(record, {
|
|
1270
|
+
provider: current.provider,
|
|
1271
|
+
model: current.model,
|
|
1272
|
+
reasoningEffort: ReasoningEffortId(value),
|
|
1273
|
+
})
|
|
1274
|
+
break
|
|
1275
|
+
}
|
|
1276
|
+
case 'permission_preset': {
|
|
1277
|
+
if (typeof value !== 'string') throw invalidParams('permission_preset value must be a preset name')
|
|
1278
|
+
const permission = permissionPresets()
|
|
1279
|
+
if (permission === undefined) throw internalError('permission presets are not mounted')
|
|
1280
|
+
applyPermissionPreset(record, value, permission)
|
|
1281
|
+
break
|
|
1282
|
+
}
|
|
1283
|
+
case 'plan_mode': {
|
|
1284
|
+
if (typeof value !== 'boolean') throw invalidParams('plan_mode value must be a boolean')
|
|
1285
|
+
const planMode = ctx.get('planMode')
|
|
1286
|
+
if (planMode === undefined) throw internalError('plan mode is not mounted')
|
|
1287
|
+
planMode.set(record.agent, value)
|
|
1288
|
+
break
|
|
1289
|
+
}
|
|
1290
|
+
default:
|
|
1291
|
+
throw invalidParams(`unknown config option "${params.configId}" (available: model, reasoning_effort, permission_preset, plan_mode)`)
|
|
1292
|
+
}
|
|
1293
|
+
const configOptions = await broadcastConfig(record)
|
|
1294
|
+
return { configOptions }
|
|
1295
|
+
},
|
|
1296
|
+
|
|
1297
|
+
async setSessionMode(params) {
|
|
1298
|
+
assertOpen()
|
|
1299
|
+
const record = requireSession(SessionId(params.sessionId))
|
|
1300
|
+
const permission = permissionPresets()
|
|
1301
|
+
if (permission === undefined) throw internalError('session modes are unavailable: permission presets are not mounted')
|
|
1302
|
+
applyPermissionPreset(record, params.modeId, permission)
|
|
1303
|
+
notify({
|
|
1304
|
+
sessionId: record.agent.session.id,
|
|
1305
|
+
update: { sessionUpdate: 'current_mode_update', currentModeId: params.modeId },
|
|
1306
|
+
})
|
|
1307
|
+
await broadcastConfig(record)
|
|
1308
|
+
return {}
|
|
1309
|
+
},
|
|
1310
|
+
|
|
1311
|
+
async listSessions(params) {
|
|
1312
|
+
assertOpen()
|
|
1313
|
+
if (params.cwd !== undefined && params.cwd !== null && !isAbsolute(params.cwd)) {
|
|
1314
|
+
throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
1315
|
+
}
|
|
1316
|
+
const cwd = params.cwd ?? undefined
|
|
1317
|
+
const persistence = ctx.get('sessionPersistence')
|
|
1318
|
+
const headers = persistence === undefined ? [] : await persistence.list()
|
|
1319
|
+
const out = []
|
|
1320
|
+
for (const header of headers) {
|
|
1321
|
+
if (cwd !== undefined && header.cwd !== undefined && header.cwd !== cwd) continue
|
|
1322
|
+
const live = sessions.get(header.id)
|
|
1323
|
+
const title = live?.title ?? await readStoredTitle(persistence, header.id)
|
|
1324
|
+
const updatedAtMs = live?.lastActivityAt ?? header.createdAt
|
|
1325
|
+
out.push({
|
|
1326
|
+
sessionId: header.id,
|
|
1327
|
+
cwd: header.cwd ?? cwd ?? process.cwd(),
|
|
1328
|
+
...title === undefined ? {} : { title },
|
|
1329
|
+
updatedAt: new Date(updatedAtMs).toISOString(),
|
|
1330
|
+
})
|
|
1331
|
+
}
|
|
1332
|
+
out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
1333
|
+
return { sessions: out }
|
|
1334
|
+
},
|
|
1335
|
+
|
|
1336
|
+
async deleteSession(params) {
|
|
1337
|
+
assertOpen()
|
|
1338
|
+
const sessionId = SessionId(params.sessionId)
|
|
1339
|
+
const live = sessions.get(sessionId)
|
|
1340
|
+
if (live !== undefined) {
|
|
1341
|
+
sessions.delete(sessionId)
|
|
1342
|
+
try {
|
|
1343
|
+
await live.dispose()
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
logger.warn(`acp-enhanced: failed to dispose live session ${sessionId}: ${String(error)}`)
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
const persistence = ctx.get('sessionPersistence')
|
|
1349
|
+
if (persistence !== undefined) {
|
|
1350
|
+
try {
|
|
1351
|
+
const headers = await persistence.list()
|
|
1352
|
+
const header = headers.find((entry) => entry.id === sessionId)
|
|
1353
|
+
if (header !== undefined) {
|
|
1354
|
+
const location = persistence.locate(header)
|
|
1355
|
+
// The JSONL backend owns one directory per session; remove the
|
|
1356
|
+
// whole directory (log + any session-local artifacts). No official
|
|
1357
|
+
// delete API exists on the persistence surface, so this removes the
|
|
1358
|
+
// backend artifact directly.
|
|
1359
|
+
if (location?.path !== undefined) {
|
|
1360
|
+
await rm(dirname(location.path), { recursive: true, force: true })
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
logger.warn(`acp-enhanced: failed to remove persisted session ${sessionId}: ${String(error)}`)
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return {}
|
|
1368
|
+
},
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const stream = config.stream ?? ndJsonStream(
|
|
1373
|
+
Writable.toWeb(process.stdout),
|
|
1374
|
+
Readable.toWeb(process.stdin),
|
|
1375
|
+
)
|
|
1376
|
+
conn = new AgentSideConnection(makeAgent, stream)
|
|
1377
|
+
|
|
1378
|
+
let quiescing
|
|
1379
|
+
const quiesce = () => {
|
|
1380
|
+
if (quiescing !== undefined) return quiescing
|
|
1381
|
+
closed = true
|
|
1382
|
+
const records = [...sessions.values()]
|
|
1383
|
+
sessions.clear()
|
|
1384
|
+
for (const record of records) {
|
|
1385
|
+
record.agent.cancel({ kind: 'user' })
|
|
1386
|
+
settlePrompt(record, 'cancelled')
|
|
1387
|
+
}
|
|
1388
|
+
quiescing = (async () => {
|
|
1389
|
+
const subagents = ctx.get('subagents')
|
|
1390
|
+
if (subagents?.drainContinuableDescendants !== undefined) {
|
|
1391
|
+
try {
|
|
1392
|
+
await subagents.drainContinuableDescendants(records.map((record) => record.agent))
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
logger.warn(`acp-enhanced: continuable subagent teardown failed: ${String(error)}`)
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
const disposals = await Promise.allSettled(records.map((record) => record.dispose()))
|
|
1398
|
+
const failures = []
|
|
1399
|
+
for (const result of disposals) {
|
|
1400
|
+
if (result.status === 'rejected') failures.push(result.reason)
|
|
1401
|
+
}
|
|
1402
|
+
if (failures.length > 0) {
|
|
1403
|
+
const detail = failures.map((failure) => errorChain(failure)).join('; ')
|
|
1404
|
+
throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s): ${detail}`)
|
|
1405
|
+
}
|
|
1406
|
+
})()
|
|
1407
|
+
return quiescing
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
void conn.closed
|
|
1411
|
+
.catch((error) => {
|
|
1412
|
+
logger.warn(`acp-enhanced: connection closed with an error: ${String(error)}`)
|
|
1413
|
+
})
|
|
1414
|
+
.then(quiesce)
|
|
1415
|
+
.catch((error) => {
|
|
1416
|
+
logger.warn(`acp-enhanced: connection-close teardown failed: ${String(error)}`)
|
|
1417
|
+
})
|
|
1418
|
+
|
|
1419
|
+
ctx.effect(() => quiesce, 'acp-enhanced.connection')
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** Reject session features outside the automation contract. */
|
|
1423
|
+
function validateSessionParams(params) {
|
|
1424
|
+
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
|
1425
|
+
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
|
|
1426
|
+
throw invalidParams('additionalDirectories is not supported')
|
|
1427
|
+
}
|
|
1428
|
+
if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')
|
|
1429
|
+
}
|