@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/LICENSE +21 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/THIRD_PARTY_NOTICES.md +32 -0
- package/THIRD_PARTY_NOTICES.zh.md +30 -0
- package/package.json +94 -0
- package/skills/cordis-plugin-development/SKILL.md +420 -0
- package/src/bridge.ts +903 -0
- package/src/codec.ts +55 -0
- package/src/compose.ts +60 -0
- package/src/main.ts +107 -0
- package/src/oauth.ts +138 -0
- package/src/plugins-cli.ts +200 -0
- package/src/plugins.ts +747 -0
- package/src/sessions-cli.ts +78 -0
- package/src/vendor/anchored-tool-bootstrap.d.mts +19 -0
- package/src/vendor/anchored-tool-bootstrap.mjs +495 -0
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
|
+
}
|