@alwith-ai/dsh-agent 0.2.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/src/bridge.ts ADDED
@@ -0,0 +1,903 @@
1
+ /**
2
+ * Interactive ACP v2 bridge for DeepSeek Harness: exposes a harness agent as a
3
+ * chat backend consumable by ACP v2 hosts such as ALwith Desktop.
4
+ *
5
+ * Built on the SDK's `experimental/v2` runtime (typed method router and typed
6
+ * `session/update` notifications — `state_update` is a first-class frame).
7
+ * The dsh-facing half (agent creation, prompt settlement, approval waterfall,
8
+ * quiescing) adapts @deepseek-ai/dsh-acp (MIT, the automation-only v1 bridge).
9
+ *
10
+ * v2 contract notes:
11
+ * - turn completion is announced by an `idle` state frame carrying
12
+ * `stopReason`; the `session/prompt` response body is `_meta`-only;
13
+ * - reporting discipline mirrors alwith-cli, the authoritative v2
14
+ * implementation: `running` when the turn starts, `requires_action` while a
15
+ * client answer is pending, closing `idle` at settlement;
16
+ * - permission requests use the v2 `subject` scheme with a required `title`.
17
+ *
18
+ * MVP scope: session/new + prompt + cancel. session/resume is the next
19
+ * milestone (v2 renamed v1's session/load; it carries a client-driven
20
+ * replayFrom cursor — omitted means context-only restore, { type: "start" }
21
+ * means replay the whole conversation as session/update frames).
22
+ */
23
+
24
+ import type { Context } from "@deepseek-ai/cordis"
25
+ import { randomUUID } from "node:crypto"
26
+ import { isAbsolute } from "node:path"
27
+ import { Readable, Writable } from "node:stream"
28
+ import Schema from "@deepseek-ai/schemastery"
29
+ import { createUserMessage, errorChain } from "@deepseek-ai/dsh-llm"
30
+ import {
31
+ RequestError,
32
+ agent as createAgentApp,
33
+ ndJsonStream,
34
+ type AgentConnection,
35
+ type AgentContext,
36
+ type CancelSessionNotification,
37
+ type CloseSessionRequest,
38
+ type CloseSessionResponse,
39
+ type CompactionId,
40
+ type InitializeResponse,
41
+ type NewSessionRequest,
42
+ type NewSessionResponse,
43
+ type PromptRequest,
44
+ type PromptResponse,
45
+ type ResumeSessionRequest,
46
+ type ResumeSessionResponse,
47
+ type SessionConfigOption,
48
+ type SetSessionConfigOptionRequest,
49
+ type SetSessionConfigOptionResponse,
50
+ type MessageId,
51
+ type PlanId,
52
+ type ToolCallContent,
53
+ type ToolCallId,
54
+ type UpdateSessionNotification,
55
+ type StopReason,
56
+ type Stream,
57
+ } from "@agentclientprotocol/sdk/experimental/v2"
58
+ import type { Agent } from "@deepseek-ai/dsh-agent"
59
+ import { SessionId, type SessionEvent, type TurnEndReason } from "@deepseek-ai/dsh-session"
60
+ // Side-effect type imports: declaration-merge the approval/request waterfall
61
+ // types and the ctx.sessionPersistence key.
62
+ import type {} from "@deepseek-ai/dsh-user-approval"
63
+ // Event-map augmentation: the compaction/* session events this bridge maps.
64
+ import type {} from "@deepseek-ai/dsh-compaction"
65
+ import type {} from "@deepseek-ai/dsh-session-persistence"
66
+ import type { ContentBlock as DshContentBlock } from "@deepseek-ai/dsh-llm"
67
+ import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from "./codec.ts"
68
+
69
+ export const name = "dsh-agent"
70
+ /** The bridge creates and owns agents (llm serves background session titling); every other concern is carried by the composition. */
71
+ export const inject = ["agents", "llm"]
72
+
73
+ /** Wire protocol version; moves in lockstep with the ALwith Desktop client. */
74
+ export const ACP_PROTOCOL_VERSION = 2
75
+
76
+ /** Plugin config: the provider/model selection used for each ACP-created agent. */
77
+ export interface AcpConfig {
78
+ provider?: string
79
+ model?: string
80
+ /** Host-facing provider identity stamped into `_meta.alwith` turn metadata; defaults to `provider`. */
81
+ providerId?: string
82
+ /**
83
+ * Model for background LLM session titles (a short one-shot after the first
84
+ * turn settles). Absent means titles stay the deterministic first-prompt
85
+ * truncation — tests rely on that default.
86
+ */
87
+ titleModel?: string
88
+ /** Test-only transport override; production uses stdio. */
89
+ stream?: Stream
90
+ }
91
+
92
+ export const Config: Schema<AcpConfig> = Schema.object({
93
+ provider: Schema.string(),
94
+ model: Schema.string(),
95
+ providerId: Schema.string(),
96
+ titleModel: Schema.string(),
97
+ })
98
+
99
+ /** Per-session protocol state. */
100
+ interface SessionRecord {
101
+ agent: Agent
102
+ dispose: () => Promise<void>
103
+ /** In-flight prompt and its captured turn number for exact settlement. */
104
+ inflight: {
105
+ resolve: () => void
106
+ reject: (error: Error) => void
107
+ messageId: string
108
+ turn: number | undefined
109
+ endReason: TurnEndReason | undefined
110
+ } | undefined
111
+ /** Pending permission requests; while > 0 the reported state is requires_action. */
112
+ pendingPermissions: number
113
+ /** Last emitted state_update value, deduplicating consecutive identical frames. */
114
+ lastState: "running" | "idle" | "requires_action" | undefined
115
+ /** Whether a session_info_update title was already emitted (first prompt names the session). */
116
+ titled: boolean
117
+ /** The model this session currently runs on (config default, then set_config_option switches). */
118
+ model: string
119
+ /** In-flight model switch; prompts await it so they never drive a retiring agent. */
120
+ switching: Promise<void> | undefined
121
+ }
122
+
123
+ /** Brand a string as a v2 MessageId (the schema type is a branded string). */
124
+ function MessageId(id: string): MessageId {
125
+ return id as MessageId
126
+ }
127
+
128
+ /** Brand a string as a v2 ToolCallId. */
129
+ function ToolCallId(id: string): ToolCallId {
130
+ return id as ToolCallId
131
+ }
132
+
133
+ /** Brand a string as a v2 PlanId. */
134
+ function PlanId(id: string): PlanId {
135
+ return id as PlanId
136
+ }
137
+
138
+ /** Parse tool-call arguments for rawInput; malformed JSON stays a string rather than crashing the stream. */
139
+ function parseRawInput(argumentsJson: string): unknown {
140
+ try {
141
+ return JSON.parse(argumentsJson)
142
+ } catch {
143
+ return argumentsJson
144
+ }
145
+ }
146
+
147
+ function invalidParams(detail: string): RequestError {
148
+ return RequestError.invalidParams(undefined, detail)
149
+ }
150
+
151
+ function internalError(detail: string): RequestError {
152
+ return RequestError.internalError(undefined, detail)
153
+ }
154
+
155
+ /**
156
+ * Mount the ACP v2 server.
157
+ * @param ctx - Cordis context carrying the agent factory and session events.
158
+ * @param config - provider/model selection and optional test transport.
159
+ */
160
+ export function apply(ctx: Context, config: AcpConfig): void {
161
+ const agents = ctx.agents
162
+ const logger = ctx.logger
163
+ const sessions = new Map<SessionId, SessionRecord>()
164
+ /** In-flight resume acquisitions by id: a second prepare while the first is publishing would hit the live gate. */
165
+ const resumes = new Map<SessionId, Promise<SessionRecord>>()
166
+ let closed = false
167
+ let client: AgentContext
168
+
169
+ const ownedRecord = (agent: Agent): SessionRecord | undefined => {
170
+ const record = sessions.get(agent.session.id)
171
+ return record?.agent === agent ? record : undefined
172
+ }
173
+
174
+ const assertOpen = (): void => {
175
+ if (closed) throw internalError("the ACP bridge has been disposed")
176
+ }
177
+
178
+ const requireSession = (sessionId: string): SessionRecord => {
179
+ const record = sessions.get(SessionId(sessionId))
180
+ if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`)
181
+ return record
182
+ }
183
+
184
+ const notify = (notification: UpdateSessionNotification): void => {
185
+ void client.notify("session/update", notification).catch((error: unknown) => {
186
+ logger.warn(`acp: session/update failed: ${String(error)}`)
187
+ })
188
+ }
189
+
190
+ // Reporting discipline mirrors alwith-cli: running when the turn starts,
191
+ // requires_action while any client answer is pending (flipping back to
192
+ // running when the last one settles, deduplicated); idle only at settlement,
193
+ // always sent, carrying stopReason.
194
+ const notifyState = (record: SessionRecord, state: "running" | "requires_action"): void => {
195
+ if (record.lastState === state) return
196
+ record.lastState = state
197
+ notify({
198
+ sessionId: record.agent.session.id,
199
+ update: { sessionUpdate: "state_update", state },
200
+ })
201
+ }
202
+
203
+ /**
204
+ * ACP v2: once a prompt is accepted the agent reports where the user message
205
+ * landed in session history. The dsh message id is the id replay reports
206
+ * the same message under (`user_message_chunk` keyed by message.id).
207
+ */
208
+ const reportUserMessage = (record: SessionRecord, messageId: string, text: string): void => {
209
+ notify({
210
+ sessionId: record.agent.session.id,
211
+ update: { sessionUpdate: "user_message", messageId: MessageId(messageId), content: [{ type: "text", text }] },
212
+ })
213
+ }
214
+
215
+ const reportIdle = (record: SessionRecord, stopReason: StopReason): void => {
216
+ record.lastState = "idle"
217
+ notify({
218
+ sessionId: record.agent.session.id,
219
+ update: { sessionUpdate: "state_update", state: "idle", stopReason },
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Background LLM titling: after the first turn settles, one short generate
225
+ * upgrades the deterministic first-prompt title. Best-effort by design —
226
+ * the deterministic title is already on the wire, so a failed upgrade is
227
+ * logged and the session keeps working (this is the "expected-failure
228
+ * best-effort" category, not a swallowed error).
229
+ */
230
+ const refineTitle = async (record: SessionRecord, firstPrompt: string): Promise<void> => {
231
+ if (config.titleModel === undefined || config.provider === undefined) return
232
+ try {
233
+ const message = createUserMessage({ content: [{ type: "text", text: firstPrompt }], source: { kind: "user" } })
234
+ // Reasoning models burn budget on reasoning before any text, so the cap
235
+ // is generous and the effort drops to the model's own "low" when it
236
+ // advertises one (efforts are adapter-owned; an invented id is rejected).
237
+ const modelInfo = await ctx.llm.resolveModelInfo(config.provider, config.titleModel).catch(() => undefined)
238
+ const lowEffort = modelInfo?.reasoning?.efforts.find(effort => String(effort.id) === "low")?.id
239
+ let title = ""
240
+ for await (const chunk of ctx.llm.stream({
241
+ provider: config.provider,
242
+ model: config.titleModel,
243
+ ...(lowEffort !== undefined ? { reasoningEffort: lowEffort } : {}),
244
+ system:
245
+ "Name this conversation from the user's first message, in the same language as that message. " +
246
+ "Reply with the title only: at most six words, no quotes, no trailing punctuation.",
247
+ messages: [message],
248
+ maxTokens: 128,
249
+ })) {
250
+ if (chunk.type === "text-delta") title += chunk.text
251
+ if (chunk.type === "finish" && chunk.reason.kind !== "stop" && chunk.reason.kind !== "max-tokens") {
252
+ throw new Error(`title generation finished with ${chunk.reason.kind}`)
253
+ }
254
+ }
255
+ title = title
256
+ .trim()
257
+ .replace(/\s+/g, " ")
258
+ .replace(/^["'“”‘’「『]+|["'“”‘’」』.。!?]+$/g, "")
259
+ .slice(0, 60)
260
+ if (title.length === 0) return
261
+ // The session may have closed while the title was generating.
262
+ if (sessions.get(record.agent.session.id) !== record) return
263
+ notify({
264
+ sessionId: record.agent.session.id,
265
+ update: { sessionUpdate: "session_info_update", title },
266
+ })
267
+ } catch (error: unknown) {
268
+ logger.warn(`acp: llm session title failed: ${String(error)}`)
269
+ }
270
+ }
271
+
272
+ /** Map a tool result's model-facing blocks to v2 tool-call content (text verbatim, images as placeholders). */
273
+ const toolResultContent = (blocks: readonly DshContentBlock[]): ToolCallContent[] => {
274
+ const content: ToolCallContent[] = []
275
+ for (const block of blocks) {
276
+ if (block.type === "text" && block.text.length > 0) {
277
+ content.push({ type: "content", content: { type: "text", text: block.text } })
278
+ } else if (block.type === "image") {
279
+ content.push({ type: "content", content: { type: "text", text: `[image attachment ${block.attachment.attachmentId}]` } })
280
+ }
281
+ }
282
+ return content
283
+ }
284
+
285
+ // usage_update needs the context-window size; resolve it once per bridge
286
+ // lifetime (provider/model are fixed per composition) and skip the frame
287
+ // when the model does not declare a window.
288
+ let contextWindow: Promise<number | undefined> | undefined
289
+ const resolveContextWindow = (): Promise<number | undefined> => {
290
+ contextWindow ??= (async () => {
291
+ const llm = ctx.get("llm")
292
+ if (llm === undefined || config.provider === undefined || config.model === undefined) return undefined
293
+ try {
294
+ const info = await llm.resolveModelInfo(config.provider, config.model)
295
+ return info.context?.contextWindow
296
+ } catch (error: unknown) {
297
+ logger.warn(`acp: resolveModel failed, usage_update disabled: ${String(error)}`)
298
+ return undefined
299
+ }
300
+ })()
301
+ return contextWindow
302
+ }
303
+
304
+ const emitUsage = (record: SessionRecord, usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number } | undefined): void => {
305
+ if (usage === undefined) return
306
+ void resolveContextWindow().then(size => {
307
+ if (size === undefined) return
308
+ notify({
309
+ sessionId: record.agent.session.id,
310
+ update: {
311
+ sessionUpdate: "usage_update",
312
+ used: usage.inputTokens + (usage.cacheReadTokens ?? 0) + usage.outputTokens,
313
+ size,
314
+ },
315
+ })
316
+ })
317
+ }
318
+
319
+ /**
320
+ * `_meta.alwith` turn metadata the ALwith Desktop client reads off assistant
321
+ * and tool frames (turn_provider / turn_model / turn_timestamp); a turn
322
+ * without an assistant providerId fails the client's envelope refresh.
323
+ */
324
+ const turnMeta = (record: SessionRecord): { alwith: { providerId: string; model: string; timestamp: string } } => ({
325
+ alwith: {
326
+ providerId: config.providerId ?? config.provider ?? "dsh",
327
+ model: record.model,
328
+ timestamp: new Date().toISOString(),
329
+ },
330
+ })
331
+
332
+ /** Build the downlinked model config option from the adapter catalog; empty catalog downlinks nothing. */
333
+ const modelConfigOptions = async (record: SessionRecord): Promise<SessionConfigOption[]> => {
334
+ const llm = ctx.get("llm")
335
+ if (llm === undefined || config.provider === undefined) return []
336
+ const models = await llm.listModels(config.provider)
337
+ if (models.length === 0) return []
338
+ // v2 shape (`configId`, not v1's `id`): the SDK validates outgoing frames since 1.4
339
+ // and drops an option that does not parse — the host would see no model select.
340
+ return [
341
+ {
342
+ configId: "model",
343
+ name: "Model",
344
+ category: "model",
345
+ type: "select",
346
+ currentValue: record.model,
347
+ options: models.map(model => ({ value: model.id, name: model.name })),
348
+ },
349
+ ]
350
+ }
351
+
352
+ const settlePrompt = (record: SessionRecord): void => {
353
+ const inflight = record.inflight
354
+ if (inflight === undefined) return
355
+ record.inflight = undefined
356
+ inflight.resolve()
357
+ }
358
+
359
+ /** Live-first session acquisition for resume: reuse a bridge-owned live agent, else cold-resume from persistence. */
360
+ const acquireSession = async (sessionId: SessionId, cwd: string): Promise<SessionRecord> => {
361
+ const live = sessions.get(sessionId)
362
+ if (live !== undefined) {
363
+ const storedCwd = live.agent.session.header.cwd
364
+ if (storedCwd !== undefined && storedCwd !== cwd) {
365
+ throw invalidParams(`cwd mismatch: session was created in ${storedCwd}`)
366
+ }
367
+ return live
368
+ }
369
+ if (ctx.agents.get(sessionId) !== undefined) {
370
+ // In the sidecar composition every agent is bridge-owned; an unowned live
371
+ // agent means another frontend shares this context — refuse rather than
372
+ // adopt an agent this bridge cannot dispose.
373
+ throw internalError(`session ${sessionId} is live outside the bridge`)
374
+ }
375
+ const persistence = ctx.get("sessionPersistence")
376
+ if (persistence === undefined) {
377
+ throw internalError("session persistence is not configured; session/resume is unavailable")
378
+ }
379
+ let inspection: Awaited<ReturnType<typeof persistence.inspect>>
380
+ try {
381
+ inspection = await persistence.inspect(sessionId)
382
+ } catch (error: unknown) {
383
+ throw invalidParams(`unknown session: ${sessionId} (${errorChain(error)})`)
384
+ }
385
+ if (inspection.meta.cwd !== undefined && inspection.meta.cwd !== cwd) {
386
+ throw invalidParams(`cwd mismatch: session was created in ${inspection.meta.cwd}`)
387
+ }
388
+ const handle = await agents.resume({ resumeSessionId: sessionId, agentOptions: agentOptions(config) })
389
+ if (closed) {
390
+ await handle.dispose()
391
+ throw internalError("connection closed during session/resume")
392
+ }
393
+ const record: SessionRecord = {
394
+ agent: handle.agent,
395
+ dispose: () => handle.dispose(),
396
+ inflight: undefined,
397
+ pendingPermissions: 0,
398
+ lastState: undefined,
399
+ titled: true, // a resumed session already carries its name on the client
400
+ model: config.model ?? "",
401
+ switching: undefined,
402
+ }
403
+ sessions.set(sessionId, record)
404
+ return record
405
+ }
406
+
407
+ /**
408
+ * Replay the whole conversation as session/update frames (the replayFrom
409
+ * { type: "start" } cursor). Committed messages only: user text as
410
+ * user_message_chunk, assistant text/reasoning as message/thought chunks,
411
+ * images as placeholders — mirroring the live-stream vocabulary.
412
+ */
413
+ const replayHistory = (record: SessionRecord): void => {
414
+ const sessionId = record.agent.session.id
415
+ for (const event of record.agent.session.snapshotEvents()) {
416
+ if (event.type === "user/message") {
417
+ const message = event.data
418
+ for (const block of message.content) {
419
+ if (block.type === "text" && block.text.length > 0) {
420
+ notify({
421
+ sessionId,
422
+ update: { sessionUpdate: "user_message_chunk", messageId: MessageId(message.id), content: { type: "text", text: block.text } },
423
+ })
424
+ }
425
+ }
426
+ } else if (event.type === "assistant/message") {
427
+ const message = event.data.message
428
+ for (const block of message.content) {
429
+ if (block.type === "text" && block.text.length > 0) {
430
+ notify({
431
+ sessionId,
432
+ update: { sessionUpdate: "agent_message_chunk", messageId: MessageId(message.id), content: { type: "text", text: block.text }, _meta: turnMeta(record) },
433
+ })
434
+ } else if (block.type === "reasoning" && block.text.length > 0) {
435
+ notify({
436
+ sessionId,
437
+ update: {
438
+ sessionUpdate: "agent_thought_chunk",
439
+ messageId: MessageId(`${message.id}/thought`),
440
+ content: { type: "text", text: block.text },
441
+ },
442
+ })
443
+ } else if (block.type === "image") {
444
+ notify({
445
+ sessionId,
446
+ update: {
447
+ sessionUpdate: "agent_message_chunk",
448
+ messageId: MessageId(message.id),
449
+ content: { type: "text", text: `[image attachment ${block.attachment.attachmentId}]` },
450
+ },
451
+ })
452
+ }
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ // Token-level streaming: text-delta → agent_message_chunk, reasoning-delta →
459
+ // agent_thought_chunk. Committed assistant/message text is NOT re-emitted
460
+ // (only image placeholders), or the client would render it twice.
461
+ // Note: chunks of a retried model request have already streamed out — the
462
+ // same live-stream behavior CLI frontends exhibit.
463
+ ctx.on("session/event", (session, event: SessionEvent) => {
464
+ const record = sessions.get(session.header.id)
465
+ if (record === undefined || record.agent.session !== session) return
466
+ try {
467
+ if (event.type === "assistant/chunk") {
468
+ const chunk = event.data.chunk
469
+ // v2 ContentChunk requires messageId (chunks of one message share it; a
470
+ // change starts a new message). One dsh step is one model response, so
471
+ // a session/turn/step composite is a stable per-message identity.
472
+ const messageId = MessageId(`${record.agent.session.id}/${event.data.turn}/${event.data.step}`)
473
+ if (chunk.type === "text-delta" && chunk.text.length > 0) {
474
+ notify({
475
+ sessionId: record.agent.session.id,
476
+ update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text: chunk.text }, _meta: turnMeta(record) },
477
+ })
478
+ } else if (chunk.type === "reasoning-delta" && chunk.text.length > 0) {
479
+ notify({
480
+ sessionId: record.agent.session.id,
481
+ update: {
482
+ sessionUpdate: "agent_thought_chunk",
483
+ messageId: MessageId(`${messageId}/thought`),
484
+ content: { type: "text", text: chunk.text },
485
+ _meta: turnMeta(record),
486
+ },
487
+ })
488
+ }
489
+ } else if (event.type === "assistant/message") {
490
+ for (const block of event.data.message.content) {
491
+ if (block.type === "image") {
492
+ notify({
493
+ sessionId: record.agent.session.id,
494
+ update: {
495
+ sessionUpdate: "agent_message_chunk",
496
+ messageId: MessageId(event.data.message.id),
497
+ content: { type: "text", text: `[image attachment ${block.attachment.attachmentId}]` },
498
+ _meta: turnMeta(record),
499
+ },
500
+ })
501
+ }
502
+ }
503
+ emitUsage(record, event.data.usage)
504
+ } else if (event.type === "tool/call") {
505
+ // First frame with a standard name creates the client-side card
506
+ // (v2 dropped the tool_call variant; creation and patch share
507
+ // tool_call_update).
508
+ notify({
509
+ sessionId: record.agent.session.id,
510
+ update: {
511
+ sessionUpdate: "tool_call_update",
512
+ toolCallId: ToolCallId(event.data.callId),
513
+ name: event.data.name,
514
+ title: event.data.name,
515
+ status: "in_progress",
516
+ rawInput: parseRawInput(event.data.arguments),
517
+ _meta: turnMeta(record),
518
+ },
519
+ })
520
+ } else if (event.type === "tool/result") {
521
+ const result = event.data.message.content[0]
522
+ notify({
523
+ sessionId: record.agent.session.id,
524
+ update: {
525
+ sessionUpdate: "tool_call_update",
526
+ toolCallId: ToolCallId(result.toolCallId),
527
+ status: event.data.error !== undefined || result.isError === true ? "failed" : "completed",
528
+ content: toolResultContent(result.content),
529
+ _meta: turnMeta(record),
530
+ },
531
+ })
532
+ } else if (event.type === "compaction/start" || event.type === "compaction/summary" || event.type === "compaction/end") {
533
+ for (const update of compactionUpdates(event)) notify({ sessionId: record.agent.session.id, update })
534
+ } else if (event.type === "todo/write") {
535
+ // Whole-list snapshot; v2 item-based plans are replaced per update, so
536
+ // the shapes align one to one.
537
+ notify({
538
+ sessionId: record.agent.session.id,
539
+ update: {
540
+ sessionUpdate: "plan_update",
541
+ plan: {
542
+ type: "items",
543
+ planId: PlanId(record.agent.session.id),
544
+ entries: event.data.todos.map(todo => ({ content: todo.content, status: todo.status, priority: "medium" as const })),
545
+ },
546
+ },
547
+ })
548
+ }
549
+ } finally {
550
+ const inflight = record.inflight
551
+ if (inflight !== undefined && event.type === "turn/end" && inflight.turn === event.data.turn) {
552
+ if (event.data.reason.kind === "error") {
553
+ record.inflight = undefined
554
+ reportIdle(record, "end_turn")
555
+ inflight.reject(internalError(`turn failed: ${event.data.reason.error.message}`))
556
+ } else {
557
+ inflight.endReason = event.data.reason
558
+ }
559
+ }
560
+ }
561
+ })
562
+
563
+ ctx.on("agent/inbox/claimed", ({ agent, message, turn }) => {
564
+ const record = ownedRecord(agent)
565
+ const inflight = record?.inflight
566
+ if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
567
+ })
568
+
569
+ ctx.on("agent/error", ({ agent, turn, error }) => {
570
+ const record = ownedRecord(agent)
571
+ const inflight = record?.inflight
572
+ if (record === undefined || inflight === undefined || inflight.turn === turn) return
573
+ record.inflight = undefined
574
+ reportIdle(record, "end_turn")
575
+ inflight.reject(internalError(`turn failed: ${errorChain(error)}`))
576
+ })
577
+
578
+ // One-shot permission decisions via the v2 subject scheme (required title);
579
+ // requires_action is reported while awaiting the client answer.
580
+ ctx.on("approval/request", (request, next) => {
581
+ const record = ownedRecord(request.agent)
582
+ if (record === undefined || request.callId === undefined) return next()
583
+ const callId = request.callId
584
+ record.pendingPermissions += 1
585
+ if (record.pendingPermissions === 1) notifyState(record, "requires_action")
586
+ return client
587
+ .request("session/request_permission", {
588
+ sessionId: record.agent.session.id,
589
+ title: request.reason ?? `Allow ${request.toolName}?`,
590
+ subject: { type: "tool_call", toolCall: { toolCallId: callId } },
591
+ options: [
592
+ { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
593
+ { optionId: "reject-once", name: "Reject", kind: "reject_once" },
594
+ ],
595
+ })
596
+ .then(({ outcome }) => {
597
+ if (outcome.outcome === "cancelled") return "cancelled" as const
598
+ return outcome.outcome === "selected" && outcome.optionId === "allow-once"
599
+ ? ("allowed-once" as const)
600
+ : ("rejected" as const)
601
+ })
602
+ .finally(() => {
603
+ record.pendingPermissions -= 1
604
+ if (record.pendingPermissions === 0) notifyState(record, "running")
605
+ })
606
+ })
607
+
608
+ const app = createAgentApp()
609
+ .onConnect(connected => {
610
+ client = connected.client
611
+ })
612
+ .onRequest("initialize", (): InitializeResponse => {
613
+ return {
614
+ protocolVersion: ACP_PROTOCOL_VERSION,
615
+ info: { name: "dsh-agent", title: "ALwith dsh bridge", version: "0.1.0" },
616
+ authMethods: [],
617
+ capabilities: { session: { prompt: {} } },
618
+ }
619
+ })
620
+ .onRequest("session/new", async (context): Promise<NewSessionResponse> => {
621
+ assertOpen()
622
+ const params: NewSessionRequest = context.params
623
+ validateSessionParams(params)
624
+ const sessionId = SessionId(randomUUID())
625
+ const handle = await agents.create({
626
+ sessionId,
627
+ meta: { cwd: params.cwd },
628
+ agentOptions: agentOptions(config),
629
+ })
630
+ if (closed) {
631
+ await handle.dispose()
632
+ throw internalError("connection closed during session/new")
633
+ }
634
+ sessions.set(sessionId, {
635
+ agent: handle.agent,
636
+ dispose: () => handle.dispose(),
637
+ inflight: undefined,
638
+ pendingPermissions: 0,
639
+ lastState: undefined,
640
+ titled: false,
641
+ model: config.model ?? "",
642
+ switching: undefined,
643
+ })
644
+ const record = sessions.get(sessionId)
645
+ if (record === undefined) throw internalError("session record vanished during session/new")
646
+ return { sessionId, configOptions: await modelConfigOptions(record) }
647
+ })
648
+ .onRequest("session/resume", async (context): Promise<ResumeSessionResponse> => {
649
+ assertOpen()
650
+ const params: ResumeSessionRequest = context.params
651
+ if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
652
+ const sessionId = SessionId(params.sessionId)
653
+ let pending = resumes.get(sessionId)
654
+ if (pending === undefined) {
655
+ pending = acquireSession(sessionId, params.cwd)
656
+ resumes.set(sessionId, pending)
657
+ void pending.catch(() => {}).finally(() => resumes.delete(sessionId))
658
+ }
659
+ const acquired = await pending
660
+ // Replay is client-driven: an omitted/null cursor means context-only
661
+ // restore (the client already has the history); { type: "start" } means
662
+ // replay the whole conversation.
663
+ if (params.replayFrom !== undefined && params.replayFrom !== null) {
664
+ if (params.replayFrom.type !== "start") {
665
+ throw invalidParams(`unsupported replayFrom cursor: ${params.replayFrom.type}`)
666
+ }
667
+ replayHistory(acquired)
668
+ }
669
+ return { configOptions: await modelConfigOptions(acquired) }
670
+ })
671
+ .onRequest("session/set_config_option", async (context): Promise<SetSessionConfigOptionResponse> => {
672
+ assertOpen()
673
+ const params: SetSessionConfigOptionRequest = context.params
674
+ const { sessionId, configId } = params
675
+ const record = requireSession(sessionId)
676
+ if (configId !== "model") throw invalidParams(`unsupported config option: ${configId}`)
677
+ // The schema keeps an open `{ type: string; value: unknown }` branch for future
678
+ // value kinds, so the discriminant alone does not narrow `value`.
679
+ const value = params.type === "id" && typeof params.value === "string" ? params.value : undefined
680
+ if (value === undefined || value.length === 0) throw invalidParams("model is a select option: send { type: \"id\", value: <model id> }")
681
+ if (record.inflight !== undefined) throw invalidParams("cannot switch model while a prompt is in flight")
682
+ if (record.switching !== undefined) await record.switching
683
+ if (record.model !== value) {
684
+ // In-place options are immutable on a live dsh agent; the sanctioned
685
+ // switch is dispose + resume with new agentOptions — same machinery
686
+ // as session/resume, so history and turn numbering carry over.
687
+ const persistence = ctx.get("sessionPersistence")
688
+ if (persistence === undefined) {
689
+ throw internalError("session persistence is not configured; model switching is unavailable")
690
+ }
691
+ const previous = record.agent
692
+ const id = previous.session.id
693
+ record.switching = (async () => {
694
+ await record.dispose()
695
+ const handle = await agents.resume({
696
+ resumeSessionId: id,
697
+ agentOptions: { ...(config.provider !== undefined ? { provider: config.provider } : {}), model: value },
698
+ })
699
+ record.agent = handle.agent
700
+ record.dispose = () => handle.dispose()
701
+ record.model = value
702
+ })()
703
+ try {
704
+ await record.switching
705
+ } finally {
706
+ record.switching = undefined
707
+ }
708
+ }
709
+ const configOptions = await modelConfigOptions(record)
710
+ notify({ sessionId: record.agent.session.id, update: { sessionUpdate: "config_option_update", configOptions } })
711
+ return { configOptions }
712
+ })
713
+ .onRequest("session/prompt", async (context): Promise<PromptResponse> => {
714
+ assertOpen()
715
+ const params: PromptRequest = context.params
716
+ const record = requireSession(params.sessionId)
717
+ if (record.switching !== undefined) await record.switching
718
+ if (promptHasUnsupportedContent(params.prompt)) {
719
+ throw invalidParams("only text and resource_link prompt content is supported")
720
+ }
721
+ const text = acpPromptToText(params.prompt)
722
+ if (text.trim().length === 0) {
723
+ // Mirror alwith-cli: an empty prompt reports idle(end_turn) and returns; it is not a protocol error.
724
+ reportIdle(record, "end_turn")
725
+ return {}
726
+ }
727
+
728
+ // Bridge contract: never drive a retired agent — a loop-only reload disposes agents while bridge records survive.
729
+ if (ctx.agents.get(record.agent.id) !== record.agent) {
730
+ throw internalError("prompt was not queued: the agent was disposed outside the bridge")
731
+ }
732
+ // Deterministic first-prompt title lands immediately (the host backfills
733
+ // run-state titles from this frame); the LLM upgrade runs after settlement.
734
+ const firstPrompt = !record.titled
735
+ if (firstPrompt) {
736
+ record.titled = true
737
+ const title = text.trim().replace(/\s+/g, " ").slice(0, 60)
738
+ notify({
739
+ sessionId: record.agent.session.id,
740
+ update: { sessionUpdate: "session_info_update", title },
741
+ })
742
+ }
743
+ const message = createUserMessage({ content: [{ type: "text", text }], source: { kind: "user" } })
744
+ if (record.inflight !== undefined) {
745
+ // Another prompt while a turn is running: it enters the dsh inbox and
746
+ // is claimed by the driver at the next step boundary (approximating
747
+ // alwith-cli's mid-turn steering); completion is still announced by
748
+ // the in-flight turn's idle frame.
749
+ record.agent.followup(message)
750
+ reportUserMessage(record, message.id, text)
751
+ return {}
752
+ }
753
+ await new Promise<void>((resolve, reject) => {
754
+ const inflight: NonNullable<SessionRecord["inflight"]> = {
755
+ resolve,
756
+ reject,
757
+ messageId: message.id,
758
+ turn: undefined,
759
+ endReason: undefined,
760
+ }
761
+ record.inflight = inflight
762
+ try {
763
+ record.agent.followup(message)
764
+ reportUserMessage(record, message.id, text)
765
+ notifyState(record, "running")
766
+ } catch (error: unknown) {
767
+ record.inflight = undefined
768
+ const detail = error instanceof Error ? error.message : String(error)
769
+ throw internalError(`prompt was not queued: ${detail}`)
770
+ }
771
+ // Settlement waits for whole-agent idle: a correlated turn/end arms
772
+ // endReason first; a turnless slot (admission discarded the prompt)
773
+ // stays cancelled. Since v2 the stop reason travels on the idle state
774
+ // frame; the prompt response body is _meta-only.
775
+ void record.agent.whenIdle().then(() => {
776
+ if (record.inflight !== inflight) return
777
+ record.inflight = undefined
778
+ const end = inflight.endReason
779
+ const reason: StopReason =
780
+ end === undefined ? "cancelled" : end.kind === "max-tokens" ? "end_turn" : turnEndToStopReason(end)
781
+ reportIdle(record, reason)
782
+ inflight.resolve()
783
+ })
784
+ })
785
+ // First turn settled: upgrade the deterministic title in the background.
786
+ if (firstPrompt) void refineTitle(record, text)
787
+ return {}
788
+ })
789
+ .onRequest("session/close", async (context): Promise<CloseSessionResponse> => {
790
+ const params: CloseSessionRequest = context.params
791
+ const record = requireSession(params.sessionId)
792
+ // Baseline v2 method: stop foreground work, settle the caller, free the
793
+ // agent. The record leaves the table first so a racing frame for this
794
+ // session is dropped rather than reported on a released agent.
795
+ sessions.delete(SessionId(params.sessionId))
796
+ record.agent.cancel({ kind: "user" })
797
+ settlePrompt(record)
798
+ await record.dispose()
799
+ return {}
800
+ })
801
+ .onNotification("session/cancel", context => {
802
+ const params: CancelSessionNotification = context.params
803
+ const record = sessions.get(SessionId(params.sessionId))
804
+ if (record === undefined) return
805
+ record.agent.cancel({ kind: "user" })
806
+ reportIdle(record, "cancelled")
807
+ settlePrompt(record)
808
+ })
809
+
810
+ const stream: Stream =
811
+ config.stream ??
812
+ ndJsonStream(
813
+ Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
814
+ Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>,
815
+ )
816
+ const connection: AgentConnection = app.connect(stream)
817
+
818
+ let quiescing: Promise<void> | undefined
819
+ const quiesce = (): Promise<void> => {
820
+ if (quiescing !== undefined) return quiescing
821
+ closed = true
822
+ const records = [...sessions.values()]
823
+ sessions.clear()
824
+ for (const record of records) {
825
+ record.agent.cancel({ kind: "user" })
826
+ settlePrompt(record)
827
+ }
828
+ quiescing = (async () => {
829
+ const disposals = await Promise.allSettled(records.map(record => record.dispose()))
830
+ const failures: unknown[] = []
831
+ for (const result of disposals) {
832
+ if (result.status === "rejected") failures.push(result.reason as unknown)
833
+ }
834
+ if (failures.length > 0) {
835
+ const detail = failures.map(failure => errorChain(failure)).join("; ")
836
+ throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s): ${detail}`)
837
+ }
838
+ })()
839
+ return quiescing
840
+ }
841
+
842
+ void connection.closed
843
+ .catch((error: unknown) => {
844
+ logger.warn(`acp: connection closed with an error: ${String(error)}`)
845
+ })
846
+ .then(quiesce)
847
+ .catch((error: unknown) => {
848
+ logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
849
+ })
850
+
851
+ ctx.effect(() => quiesce, "dsh-agent.connection")
852
+ }
853
+
854
+ /**
855
+ * dsh compaction lifecycle → ACP v2 (SDK 1.4) compaction frames. `compaction/start`
856
+ * opens the bracket, each `compaction/summary` text block streams as a summary
857
+ * chunk, `compaction/end` settles it — `error` present means it failed. The
858
+ * internal `compaction/prune` bookkeeping has no client-visible counterpart.
859
+ */
860
+ export function compactionUpdates(event: SessionEvent): UpdateSessionNotification["update"][] {
861
+ if (event.type === "compaction/start") {
862
+ return [{ sessionUpdate: "compaction_update", compactionId: CompactionId(event.data.compactionId), status: "in_progress" }]
863
+ }
864
+ if (event.type === "compaction/summary") {
865
+ return event.data.summary.flatMap(block =>
866
+ block.type === "text" && block.text.length > 0
867
+ ? [{ sessionUpdate: "compaction_summary_chunk" as const, compactionId: CompactionId(event.data.compactionId), content: { type: "text" as const, text: block.text } }]
868
+ : [],
869
+ )
870
+ }
871
+ if (event.type === "compaction/end") {
872
+ const error = event.data.error
873
+ return [
874
+ error === undefined
875
+ ? { sessionUpdate: "compaction_update", compactionId: CompactionId(event.data.compactionId), status: "completed" }
876
+ : { sessionUpdate: "compaction_update", compactionId: CompactionId(event.data.compactionId), status: "failed", error },
877
+ ]
878
+ }
879
+ return []
880
+ }
881
+
882
+ /** Brand a string as a v2 CompactionId. */
883
+ function CompactionId(id: string): CompactionId {
884
+ return id as CompactionId
885
+ }
886
+
887
+ function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
888
+ return {
889
+ ...(config.provider !== undefined ? { provider: config.provider } : {}),
890
+ ...(config.model !== undefined ? { model: config.model } : {}),
891
+ }
892
+ }
893
+
894
+ /** Reject session features outside the bridge contract. */
895
+ function validateSessionParams(params: NewSessionRequest): void {
896
+ if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
897
+ if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
898
+ throw invalidParams("additionalDirectories is not supported")
899
+ }
900
+ if (params.mcpServers !== undefined && params.mcpServers.length > 0) {
901
+ throw invalidParams("mcpServers is not supported")
902
+ }
903
+ }