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/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
+ }