GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
The desktop runtime still pinned the rc.6 Harness family. It now uses the coherent rc.8 package set, adapts the revised image-attachment limits and error contract, and derives release checks from the lockfile. The managed SSH Agent package remains unchanged. Release note: 内置 DeepSeek Harness 已升级至 0.1.0-rc.8,并适配新版图片附件限制;托管 SSH Agent 包不受影响。
1429 lines
39 KiB
TypeScript
1429 lines
39 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { isAbsolute } from 'node:path'
|
|
import {
|
|
AgentSideConnection,
|
|
PROTOCOL_VERSION,
|
|
RequestError,
|
|
type Agent,
|
|
type AgentSideConnection as AcpAgentConnection,
|
|
type ContentBlock as AcpContentBlock,
|
|
type Stream
|
|
} from '@agentclientprotocol/sdk'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
|
import {
|
|
AttachmentError,
|
|
isImageAdmissionError,
|
|
type ImageAttachmentRef,
|
|
type SaveImageAttachment
|
|
} from '@deepseek-ai/dsh-attachment'
|
|
import {
|
|
CredentialProvider,
|
|
type CredentialInfo,
|
|
type CredentialRef,
|
|
type ResolvedCredential
|
|
} from '@deepseek-ai/dsh-credentials'
|
|
import {
|
|
createUserMessage,
|
|
errorChain,
|
|
type ContentBlock as HarnessContentBlock,
|
|
type TokenUsage
|
|
} from '@deepseek-ai/dsh-llm'
|
|
import {
|
|
SessionId,
|
|
type SessionEvent
|
|
} from '@deepseek-ai/dsh-session'
|
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
|
|
import {
|
|
GOODBUDDY_CONTROL_PROTOCOL_VERSION,
|
|
GOODBUDDY_CREDENTIAL,
|
|
GOODBUDDY_EVENT,
|
|
GOODBUDDY_HANDSHAKE,
|
|
GOODBUDDY_NATIVE_SNAPSHOT,
|
|
GOODBUDDY_PREPARE,
|
|
GOODBUDDY_RELEASE,
|
|
GOODBUDDY_SHUTDOWN,
|
|
GOODBUDDY_TOOLS_CALL,
|
|
GOODBUDDY_TOOLS_LIST
|
|
} from './deepseek-harness-protocol'
|
|
import { GoodBuddyHarnessAttachmentStore } from './goodbuddy-harness-attachment-store'
|
|
export {
|
|
GOODBUDDY_CONTROL_PROTOCOL_VERSION,
|
|
GOODBUDDY_CREDENTIAL,
|
|
GOODBUDDY_EVENT,
|
|
GOODBUDDY_HANDSHAKE,
|
|
GOODBUDDY_NATIVE_SNAPSHOT,
|
|
GOODBUDDY_RELEASE,
|
|
GOODBUDDY_SHUTDOWN,
|
|
GOODBUDDY_TOOLS_CALL,
|
|
GOODBUDDY_TOOLS_LIST,
|
|
GOODBUDDY_PREPARE
|
|
} from './deepseek-harness-protocol'
|
|
|
|
const DEFAULT_MAX_EVENT_CHARACTERS = 64 * 1024
|
|
const DEFAULT_MAX_REQUEST_CHARACTERS = 4 * 1024 * 1024
|
|
export const GOODBUDDY_HARNESS_MAX_STEP_TOKENS = 16 * 1024
|
|
const DELTA_BATCH_CHARACTERS = 4 * 1024
|
|
const DELTA_BATCH_INTERVAL_MS = 100
|
|
const MAX_SUMMARY_CHARACTERS = 4_000
|
|
const MAX_MCP_PROXY_RESULT_BYTES = 256 * 1024
|
|
const MAX_NATIVE_SKILLS = 200
|
|
const MAX_NATIVE_TOOLS = 200
|
|
const NATIVE_SNAPSHOT_TIMEOUT_MS = 2_000
|
|
const MAIN_WEB_TOOL_NAMES = new Set(['web_search', 'web_fetch'])
|
|
const GOODBUDDY_EXECUTION_GUIDANCE = [
|
|
'GoodBuddy work mode rules:',
|
|
'- In Execute mode, act through the available tools instead of writing a long implementation plan.',
|
|
'- Inspect only what is needed, then create or update the requested workspace files promptly.',
|
|
'- Work in small verifiable steps and use tool results as the source of truth.',
|
|
'- Keep reasoning concise. Do not narrate code that can be written and checked with tools.',
|
|
'- In Ask mode, remain read-only and do not attempt mutations.'
|
|
].join('\n')
|
|
|
|
export type GoodBuddyWorkMode = 'ask' | 'execute'
|
|
|
|
export type GoodBuddyHarnessCapabilities = {
|
|
controlProtocolVersion: 1
|
|
harnessVersion: string
|
|
acpProtocolVersion: number
|
|
supports: {
|
|
cancellation: true
|
|
sessionRelease: true
|
|
reasoningEvents: boolean
|
|
toolEvents: boolean
|
|
usageEvents: boolean
|
|
credentialResolution: true
|
|
}
|
|
execution: {
|
|
mode: 'host'
|
|
}
|
|
}
|
|
|
|
export type GoodBuddyHarnessControlConfig = {
|
|
provider: string
|
|
model: string
|
|
supportsImageInput?: boolean
|
|
workspace: string
|
|
harnessVersion: string
|
|
execution: GoodBuddyHarnessCapabilities['execution']
|
|
credentialRefs: readonly string[]
|
|
skills: readonly {
|
|
name: string
|
|
description: string
|
|
content: string
|
|
directory: string
|
|
}[]
|
|
trustedAskToolDefinitions?: ReadonlyMap<string, ToolDefinition>
|
|
stream?: Stream
|
|
maxEventCharacters?: number
|
|
maxRequestCharacters?: number
|
|
}
|
|
|
|
type Preparation = {
|
|
requestId: string
|
|
mode: GoodBuddyWorkMode
|
|
}
|
|
|
|
type OwnedSession = {
|
|
handle: AgentHandle
|
|
attachmentRefs: ImageAttachmentRef[]
|
|
preparation?: Preparation
|
|
proxyTools: Map<
|
|
string,
|
|
{
|
|
definition: ToolDefinition
|
|
dispose: () => void
|
|
}
|
|
>
|
|
askToolDefinitions: Map<string, ToolDefinition>
|
|
inflight?: {
|
|
requestId: string
|
|
messageId: string
|
|
mode: GoodBuddyWorkMode
|
|
turn?: number
|
|
endReason?: string
|
|
turnError?: unknown
|
|
resolve: (reason: string) => void
|
|
reject: (error: unknown) => void
|
|
emittedCharacters: number
|
|
eventTail: Promise<void>
|
|
eventError?: unknown
|
|
pendingDelta?: {
|
|
type: 'text' | 'reasoning'
|
|
delta: string
|
|
}
|
|
pendingDeltaTimer?: ReturnType<typeof setTimeout>
|
|
}
|
|
}
|
|
|
|
type ProxyToolDefinition = {
|
|
name: string
|
|
description: string
|
|
inputSchema: Record<string, unknown>
|
|
}
|
|
|
|
function validateProxyToolResult(
|
|
value: unknown
|
|
): Record<string, unknown> {
|
|
if (
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value)
|
|
) {
|
|
throw new Error('GoodBuddy MCP tool result is invalid')
|
|
}
|
|
const serialized = JSON.stringify(value)
|
|
if (
|
|
Buffer.byteLength(serialized, 'utf8') >
|
|
MAX_MCP_PROXY_RESULT_BYTES
|
|
) {
|
|
throw new Error(
|
|
'GoodBuddy MCP tool result exceeds safety limit'
|
|
)
|
|
}
|
|
const content = (value as Record<string, unknown>).content
|
|
if (
|
|
!Array.isArray(content) ||
|
|
content.length > 100 ||
|
|
content.some(
|
|
(part) =>
|
|
!part ||
|
|
typeof part !== 'object' ||
|
|
Array.isArray(part) ||
|
|
(part as Record<string, unknown>).type !== 'text' ||
|
|
typeof (part as Record<string, unknown>).text !== 'string'
|
|
)
|
|
) {
|
|
throw new Error('GoodBuddy MCP tool result is invalid')
|
|
}
|
|
return value as Record<string, unknown>
|
|
}
|
|
|
|
function parseProxyToolCatalog(
|
|
value: unknown
|
|
): ProxyToolDefinition[] {
|
|
if (!Array.isArray(value) || value.length > 100) {
|
|
throw new Error('GoodBuddy MCP tool catalog is invalid')
|
|
}
|
|
const names = new Set<string>()
|
|
return value.map((candidate) => {
|
|
if (
|
|
!candidate ||
|
|
typeof candidate !== 'object' ||
|
|
Array.isArray(candidate)
|
|
) {
|
|
throw new Error('GoodBuddy MCP tool definition is invalid')
|
|
}
|
|
const tool = candidate as Record<string, unknown>
|
|
if (
|
|
typeof tool.name !== 'string' ||
|
|
!/^[a-zA-Z0-9_-]{1,64}$/u.test(tool.name) ||
|
|
names.has(tool.name) ||
|
|
typeof tool.description !== 'string' ||
|
|
tool.description.length > 1_000 ||
|
|
!tool.inputSchema ||
|
|
typeof tool.inputSchema !== 'object' ||
|
|
Array.isArray(tool.inputSchema)
|
|
) {
|
|
throw new Error('GoodBuddy MCP tool definition is invalid')
|
|
}
|
|
names.add(tool.name)
|
|
return {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
inputSchema: tool.inputSchema as Record<string, unknown>
|
|
}
|
|
})
|
|
}
|
|
|
|
type CredentialResolver = (
|
|
ref: string
|
|
) => Promise<string | undefined>
|
|
|
|
/**
|
|
* Memory-only credential provider. It deliberately has no writable operation
|
|
* and can resolve only references registered by the trusted host.
|
|
*/
|
|
export class GoodBuddyCredentialProvider extends CredentialProvider {
|
|
private resolver?: CredentialResolver
|
|
|
|
constructor(
|
|
ctx: Context,
|
|
private readonly allowedRefs: ReadonlySet<string>
|
|
) {
|
|
super(ctx)
|
|
}
|
|
|
|
bind(resolver: CredentialResolver): void {
|
|
if (this.resolver) {
|
|
throw new Error('GoodBuddy credential resolver is already bound')
|
|
}
|
|
this.resolver = resolver
|
|
}
|
|
|
|
async resolve(
|
|
ref: CredentialRef
|
|
): Promise<ResolvedCredential | undefined> {
|
|
if (!this.allowedRefs.has(ref) || !this.resolver) {
|
|
return undefined
|
|
}
|
|
const value = await this.resolver(ref)
|
|
return value ? { value, source: 'goodbuddy-main' } : undefined
|
|
}
|
|
|
|
async describe(ref: CredentialRef): Promise<CredentialInfo> {
|
|
const available =
|
|
this.allowedRefs.has(ref) && this.resolver !== undefined
|
|
return {
|
|
configured: available,
|
|
source: available ? 'goodbuddy-main' : undefined,
|
|
writable: false
|
|
}
|
|
}
|
|
|
|
async set(ref: CredentialRef, value: string): Promise<void> {
|
|
void ref
|
|
void value
|
|
throw new Error('GoodBuddy Harness credentials are read-only')
|
|
}
|
|
|
|
async unset(ref: CredentialRef): Promise<void> {
|
|
void ref
|
|
throw new Error('GoodBuddy Harness credentials are read-only')
|
|
}
|
|
}
|
|
|
|
export function createBoundedAcpStream(
|
|
stream: Stream,
|
|
maxFrameBytes: number
|
|
): Stream {
|
|
if (
|
|
!Number.isSafeInteger(maxFrameBytes) ||
|
|
maxFrameBytes < 1
|
|
) {
|
|
throw new TypeError('maxFrameBytes must be a positive integer')
|
|
}
|
|
return {
|
|
readable: stream.readable.pipeThrough(
|
|
new TransformStream({
|
|
transform(message, controller) {
|
|
if (
|
|
Buffer.byteLength(JSON.stringify(message), 'utf8') >
|
|
maxFrameBytes
|
|
) {
|
|
throw new Error(
|
|
`ACP input frame exceeds ${maxFrameBytes} bytes`
|
|
)
|
|
}
|
|
controller.enqueue(message)
|
|
}
|
|
})
|
|
),
|
|
writable: new WritableStream({
|
|
async write(message) {
|
|
if (
|
|
Buffer.byteLength(JSON.stringify(message), 'utf8') >
|
|
maxFrameBytes
|
|
) {
|
|
throw new Error(
|
|
`ACP output frame exceeds ${maxFrameBytes} bytes`
|
|
)
|
|
}
|
|
const writer = stream.writable.getWriter()
|
|
try {
|
|
await writer.write(message)
|
|
} finally {
|
|
writer.releaseLock()
|
|
}
|
|
},
|
|
async close() {
|
|
const writer = stream.writable.getWriter()
|
|
try {
|
|
await writer.close()
|
|
} finally {
|
|
writer.releaseLock()
|
|
}
|
|
},
|
|
async abort(reason) {
|
|
await stream.writable.abort(reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
function requiredString(
|
|
params: Record<string, unknown>,
|
|
key: string,
|
|
maxLength = 512
|
|
): string {
|
|
const value = params[key]
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length === 0 ||
|
|
value.length > maxLength
|
|
) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`${key} must be a non-empty bounded string`
|
|
)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function boundedJson(value: unknown): string | undefined {
|
|
if (value === undefined) {
|
|
return undefined
|
|
}
|
|
try {
|
|
return (
|
|
typeof value === 'string'
|
|
? value
|
|
: JSON.stringify(value)
|
|
).slice(0, MAX_SUMMARY_CHARACTERS)
|
|
} catch {
|
|
return '[unserializable]'
|
|
}
|
|
}
|
|
|
|
function promptText(prompt: readonly AcpContentBlock[]): string {
|
|
const text: string[] = []
|
|
for (const block of prompt) {
|
|
switch (block.type) {
|
|
case 'text':
|
|
text.push(block.text)
|
|
break
|
|
case 'image':
|
|
case 'resource_link':
|
|
break
|
|
case 'audio':
|
|
case 'resource':
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`unsupported ACP prompt content: ${block.type}`
|
|
)
|
|
default:
|
|
block satisfies never
|
|
}
|
|
}
|
|
return text.join('')
|
|
}
|
|
|
|
function turnReason(event: SessionEvent): string | undefined {
|
|
if (event.type !== 'turn/end') {
|
|
return undefined
|
|
}
|
|
switch (event.data.reason.kind) {
|
|
case 'aborted':
|
|
case 'interrupted':
|
|
return 'cancelled'
|
|
case 'max-tokens':
|
|
return 'max_tokens'
|
|
default:
|
|
return 'end_turn'
|
|
}
|
|
}
|
|
|
|
export class GoodBuddyHarnessControlPlane {
|
|
private readonly sessions = new Map<string, OwnedSession>()
|
|
private readonly allowedCredentialRefs: ReadonlySet<string>
|
|
private readonly maxEventCharacters: number
|
|
private readonly maxRequestCharacters: number
|
|
private connection?: AcpAgentConnection
|
|
private credentialProvider?: GoodBuddyCredentialProvider
|
|
private handshaken = false
|
|
private observing = false
|
|
private closed = false
|
|
private disposing?: Promise<void>
|
|
|
|
constructor(
|
|
private readonly ctx: Context,
|
|
private readonly config: GoodBuddyHarnessControlConfig
|
|
) {
|
|
this.allowedCredentialRefs = new Set(config.credentialRefs)
|
|
this.maxEventCharacters =
|
|
config.maxEventCharacters ?? DEFAULT_MAX_EVENT_CHARACTERS
|
|
this.maxRequestCharacters =
|
|
config.maxRequestCharacters ??
|
|
DEFAULT_MAX_REQUEST_CHARACTERS
|
|
}
|
|
|
|
bindCredentialProvider(
|
|
provider: GoodBuddyCredentialProvider
|
|
): void {
|
|
if (this.connection) {
|
|
throw new Error(
|
|
'Credential provider must be bound before bridge start'
|
|
)
|
|
}
|
|
this.credentialProvider = provider
|
|
}
|
|
|
|
start(): AcpAgentConnection {
|
|
if (this.connection) {
|
|
throw new Error(
|
|
'GoodBuddy Harness control plane is already started'
|
|
)
|
|
}
|
|
if (!this.config.stream) {
|
|
throw new Error(
|
|
'GoodBuddy Harness control plane requires its internal Host transport'
|
|
)
|
|
}
|
|
const connection = new AgentSideConnection(
|
|
() => this.createAgentApi(),
|
|
this.config.stream
|
|
)
|
|
this.connection = connection
|
|
this.credentialProvider?.bind(async (ref) => {
|
|
if (!this.handshaken || !this.allowedCredentialRefs.has(ref)) {
|
|
return undefined
|
|
}
|
|
const response = await connection.extMethod(
|
|
GOODBUDDY_CREDENTIAL,
|
|
{ ref }
|
|
)
|
|
const value = response.value
|
|
return typeof value === 'string' && value.length > 0
|
|
? value
|
|
: undefined
|
|
})
|
|
void connection.closed.finally(() => this.dispose())
|
|
return connection
|
|
}
|
|
|
|
private capabilities(): GoodBuddyHarnessCapabilities {
|
|
return {
|
|
controlProtocolVersion: GOODBUDDY_CONTROL_PROTOCOL_VERSION,
|
|
harnessVersion: this.config.harnessVersion,
|
|
acpProtocolVersion: PROTOCOL_VERSION,
|
|
supports: {
|
|
cancellation: true,
|
|
sessionRelease: true,
|
|
reasoningEvents: true,
|
|
toolEvents: true,
|
|
usageEvents: true,
|
|
credentialResolution: true
|
|
},
|
|
execution: this.config.execution
|
|
}
|
|
}
|
|
|
|
private requireSession(sessionId: string): OwnedSession {
|
|
const session = this.sessions.get(sessionId)
|
|
if (!session) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
`unknown session: ${sessionId}`
|
|
)
|
|
}
|
|
return session
|
|
}
|
|
|
|
private async sendEvent(
|
|
sessionId: string,
|
|
event: Record<string, unknown>
|
|
): Promise<void> {
|
|
const record = this.sessions.get(sessionId)
|
|
const inflight = record?.inflight
|
|
if (!inflight || !this.connection) {
|
|
return
|
|
}
|
|
const params = {
|
|
sessionId,
|
|
requestId: inflight.requestId,
|
|
...event
|
|
}
|
|
const text = JSON.stringify(params)
|
|
if (text.length > this.maxEventCharacters) {
|
|
throw new Error(
|
|
'GoodBuddy Harness control event exceeds safety limit'
|
|
)
|
|
}
|
|
inflight.emittedCharacters += text.length
|
|
if (
|
|
inflight.emittedCharacters > this.maxRequestCharacters
|
|
) {
|
|
record.handle.agent.cancel({ kind: 'user' })
|
|
throw new Error(
|
|
'GoodBuddy Harness control request output exceeds safety limit'
|
|
)
|
|
}
|
|
await this.connection.extNotification(GOODBUDDY_EVENT, params)
|
|
}
|
|
|
|
private enqueueEvent(
|
|
sessionId: string,
|
|
event: Record<string, unknown>
|
|
): void {
|
|
const record = this.sessions.get(sessionId)
|
|
const inflight = record?.inflight
|
|
if (!record || !inflight) {
|
|
return
|
|
}
|
|
const previous = inflight.eventTail
|
|
const queued = previous.then(() =>
|
|
this.sendEvent(sessionId, event)
|
|
)
|
|
inflight.eventTail = queued.catch((error: unknown) => {
|
|
inflight.eventError ??= error
|
|
record.handle.agent.cancel({ kind: 'user' })
|
|
})
|
|
}
|
|
|
|
private flushPendingDelta(sessionId: string): void {
|
|
const inflight = this.sessions.get(sessionId)?.inflight
|
|
const pending = inflight?.pendingDelta
|
|
if (!inflight || !pending) {
|
|
return
|
|
}
|
|
if (inflight.pendingDeltaTimer) {
|
|
clearTimeout(inflight.pendingDeltaTimer)
|
|
inflight.pendingDeltaTimer = undefined
|
|
}
|
|
inflight.pendingDelta = undefined
|
|
this.enqueueEvent(sessionId, pending)
|
|
}
|
|
|
|
private maxDeltaBatchCharacters(
|
|
sessionId: string,
|
|
type: 'text' | 'reasoning'
|
|
): number {
|
|
const inflight = this.sessions.get(sessionId)?.inflight
|
|
if (!inflight) {
|
|
return 0
|
|
}
|
|
const envelopeLength = JSON.stringify({
|
|
sessionId,
|
|
requestId: inflight.requestId,
|
|
type,
|
|
delta: ''
|
|
}).length
|
|
return Math.min(
|
|
DELTA_BATCH_CHARACTERS,
|
|
this.maxEventCharacters - envelopeLength
|
|
)
|
|
}
|
|
|
|
private queueDelta(
|
|
sessionId: string,
|
|
type: 'text' | 'reasoning',
|
|
delta: string
|
|
): void {
|
|
const inflight = this.sessions.get(sessionId)?.inflight
|
|
if (!inflight || !delta) {
|
|
return
|
|
}
|
|
const batchLimit = this.maxDeltaBatchCharacters(
|
|
sessionId,
|
|
type
|
|
)
|
|
if (batchLimit < 1) {
|
|
this.failEventStream(
|
|
sessionId,
|
|
new Error(
|
|
'GoodBuddy Harness control event exceeds safety limit'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
let remaining = delta
|
|
while (remaining) {
|
|
if (
|
|
inflight.pendingDelta &&
|
|
inflight.pendingDelta.type !== type
|
|
) {
|
|
this.flushPendingDelta(sessionId)
|
|
}
|
|
inflight.pendingDelta ??= { type, delta: '' }
|
|
const available =
|
|
batchLimit - inflight.pendingDelta.delta.length
|
|
if (available <= 0) {
|
|
this.flushPendingDelta(sessionId)
|
|
continue
|
|
}
|
|
inflight.pendingDelta.delta += remaining.slice(0, available)
|
|
remaining = remaining.slice(available)
|
|
if (inflight.pendingDelta.delta.length >= batchLimit) {
|
|
this.flushPendingDelta(sessionId)
|
|
}
|
|
}
|
|
if (
|
|
inflight.pendingDelta &&
|
|
!inflight.pendingDeltaTimer
|
|
) {
|
|
inflight.pendingDeltaTimer = setTimeout(() => {
|
|
this.flushPendingDelta(sessionId)
|
|
}, DELTA_BATCH_INTERVAL_MS)
|
|
}
|
|
}
|
|
|
|
private queueEvent(
|
|
sessionId: string,
|
|
event: Record<string, unknown>
|
|
): void {
|
|
this.flushPendingDelta(sessionId)
|
|
this.enqueueEvent(sessionId, event)
|
|
}
|
|
|
|
private failEventStream(sessionId: string, error: unknown): void {
|
|
const record = this.sessions.get(sessionId)
|
|
const inflight = record?.inflight
|
|
if (!record || !inflight) {
|
|
return
|
|
}
|
|
inflight.eventError ??= error
|
|
record.handle.agent.cancel({ kind: 'user' })
|
|
}
|
|
|
|
private observeSessions(): void {
|
|
if (this.observing) {
|
|
return
|
|
}
|
|
this.observing = true
|
|
this.ctx.on('tools/execute', async (exec, next) => {
|
|
const sessionId = exec.agent?.session.id
|
|
const record = sessionId
|
|
? this.sessions.get(sessionId)
|
|
: undefined
|
|
if (
|
|
record &&
|
|
record.handle.agent === exec.agent &&
|
|
record.inflight?.mode === 'ask'
|
|
) {
|
|
const registeredDefinition =
|
|
record.askToolDefinitions.get(exec.name)
|
|
const executingDefinition =
|
|
record.handle.agent.ctx.tools.get(
|
|
exec.name,
|
|
exec.agent
|
|
)
|
|
if (
|
|
!registeredDefinition ||
|
|
executingDefinition !== registeredDefinition
|
|
) {
|
|
throw new Error(
|
|
`Ask 模式不允许执行非只读工具:${exec.name}`
|
|
)
|
|
}
|
|
}
|
|
return next()
|
|
})
|
|
this.ctx.on(
|
|
'session/event',
|
|
(session, event: SessionEvent) => {
|
|
const record = this.sessions.get(session.header.id)
|
|
if (!record || record.handle.agent.session !== session) {
|
|
return
|
|
}
|
|
const inflight = record.inflight
|
|
if (!inflight) {
|
|
return
|
|
}
|
|
if (event.type === 'assistant/chunk') {
|
|
const chunk = event.data.chunk
|
|
if (
|
|
chunk.type === 'text-delta' ||
|
|
chunk.type === 'reasoning-delta'
|
|
) {
|
|
this.queueDelta(
|
|
session.header.id,
|
|
chunk.type === 'text-delta'
|
|
? 'text'
|
|
: 'reasoning',
|
|
chunk.text
|
|
)
|
|
} else if (chunk.type === 'usage') {
|
|
this.queueUsage(session.header.id, chunk.usage)
|
|
}
|
|
} else if (event.type === 'tool/call') {
|
|
this.queueEvent(session.header.id, {
|
|
type: 'tool',
|
|
callId: event.data.callId,
|
|
name: event.data.name,
|
|
state: 'pending',
|
|
input: event.data.arguments.slice(
|
|
0,
|
|
MAX_SUMMARY_CHARACTERS
|
|
)
|
|
})
|
|
} else if (event.type === 'tool/result') {
|
|
const toolResult = event.data.message.content.find(
|
|
(content) => content.type === 'tool-result'
|
|
)
|
|
this.queueEvent(session.header.id, {
|
|
type: 'tool',
|
|
callId: toolResult?.toolCallId ?? 'unknown-tool-call',
|
|
name: 'tool',
|
|
state:
|
|
event.data.error || toolResult?.isError === true
|
|
? 'failed'
|
|
: 'completed',
|
|
output: boundedJson(event.data.message.content)
|
|
})
|
|
}
|
|
if (
|
|
event.type === 'turn/end' &&
|
|
inflight.turn === event.data.turn
|
|
) {
|
|
inflight.endReason = turnReason(event)
|
|
}
|
|
}
|
|
)
|
|
this.ctx.on(
|
|
'agent/inbox/claimed',
|
|
({ agent, message, turn }) => {
|
|
const record = this.sessions.get(agent.session.id)
|
|
if (
|
|
record?.handle.agent === agent &&
|
|
record.inflight?.messageId === message.id
|
|
) {
|
|
record.inflight.turn = turn
|
|
}
|
|
}
|
|
)
|
|
this.ctx.on(
|
|
'agent/error',
|
|
({ agent, error }) => {
|
|
const record = this.sessions.get(agent.session.id)
|
|
if (record?.handle.agent === agent) {
|
|
if (record.inflight) {
|
|
record.inflight.turnError = error
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private queueUsage(sessionId: string, usage: TokenUsage): void {
|
|
this.flushPendingDelta(sessionId)
|
|
this.enqueueEvent(sessionId, {
|
|
type: 'model-usage',
|
|
callId: randomUUID(),
|
|
provider: this.config.provider,
|
|
model: this.config.model,
|
|
inputTokens: usage.inputTokens,
|
|
outputTokens: usage.outputTokens,
|
|
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
cacheWriteTokens: usage.cacheWriteTokens ?? 0
|
|
})
|
|
}
|
|
|
|
private proxyToolDefinition(
|
|
sessionId: string,
|
|
tool: ProxyToolDefinition
|
|
): ToolDefinition {
|
|
return {
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: tool.inputSchema,
|
|
output: {
|
|
schema: {
|
|
type: 'object',
|
|
properties: {
|
|
content: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
additionalProperties: true
|
|
}
|
|
}
|
|
},
|
|
required: ['content'],
|
|
additionalProperties: false
|
|
},
|
|
render(_args, result) {
|
|
const content = (
|
|
result as {
|
|
content: Array<{
|
|
type?: unknown
|
|
text?: unknown
|
|
}>
|
|
}
|
|
).content
|
|
return content
|
|
.filter(
|
|
(
|
|
part
|
|
): part is {
|
|
type: 'text'
|
|
text: string
|
|
} =>
|
|
part.type === 'text' &&
|
|
typeof part.text === 'string'
|
|
)
|
|
.map((part) => ({
|
|
type: 'text' as const,
|
|
text: part.text
|
|
}))
|
|
}
|
|
},
|
|
execute: async (argumentsValue) => {
|
|
const result = await this.connection!.extMethod(
|
|
GOODBUDDY_TOOLS_CALL,
|
|
{
|
|
sessionId,
|
|
name: tool.name,
|
|
arguments: argumentsValue
|
|
}
|
|
)
|
|
return validateProxyToolResult(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private async refreshProxyTools(
|
|
sessionId: string,
|
|
record: OwnedSession
|
|
): Promise<void> {
|
|
const response = await this.connection!.extMethod(
|
|
GOODBUDDY_TOOLS_LIST,
|
|
{ sessionId }
|
|
)
|
|
const tools = parseProxyToolCatalog(response.tools)
|
|
const nextNames = new Set(tools.map((tool) => tool.name))
|
|
for (const [name, registration] of record.proxyTools) {
|
|
if (!nextNames.has(name)) {
|
|
registration.dispose()
|
|
record.proxyTools.delete(name)
|
|
record.askToolDefinitions.delete(name)
|
|
}
|
|
}
|
|
for (const tool of tools) {
|
|
if (!record.proxyTools.has(tool.name)) {
|
|
const definition = this.proxyToolDefinition(sessionId, tool)
|
|
const dispose =
|
|
record.handle.agent.ctx.tools.register(definition)
|
|
record.proxyTools.set(tool.name, {
|
|
definition,
|
|
dispose
|
|
})
|
|
if (MAIN_WEB_TOOL_NAMES.has(tool.name)) {
|
|
record.askToolDefinitions.set(tool.name, definition)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async nativeSnapshot(): Promise<Record<string, unknown>> {
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(
|
|
() =>
|
|
controller.abort(
|
|
new Error('DeepSeek Harness native inventory timed out')
|
|
),
|
|
NATIVE_SNAPSHOT_TIMEOUT_MS
|
|
)
|
|
try {
|
|
const skills = await Promise.race([
|
|
this.ctx.skills.list({
|
|
cwd: this.config.workspace,
|
|
signal: controller.signal
|
|
}),
|
|
new Promise<never>((_resolve, reject) => {
|
|
controller.signal.addEventListener(
|
|
'abort',
|
|
() => reject(controller.signal.reason),
|
|
{ once: true }
|
|
)
|
|
})
|
|
])
|
|
let toolsSupported = true
|
|
let tools: Array<{
|
|
id: string
|
|
name: string
|
|
description?: string
|
|
}> = []
|
|
try {
|
|
tools = this.ctx.tools
|
|
.schemas()
|
|
.slice(0, MAX_NATIVE_TOOLS)
|
|
.flatMap((tool) => {
|
|
const name = tool.name.trim().slice(0, 128)
|
|
if (!name) {
|
|
return []
|
|
}
|
|
const description = tool.description.trim().slice(0, 2_000)
|
|
return [
|
|
{
|
|
id: name,
|
|
name: name.slice(0, 200),
|
|
...(description ? { description } : {})
|
|
}
|
|
]
|
|
})
|
|
} catch {
|
|
toolsSupported = false
|
|
}
|
|
return {
|
|
tools,
|
|
toolsSupported,
|
|
skills: skills.slice(0, MAX_NATIVE_SKILLS).map((skill) => ({
|
|
id: skill.name.slice(0, 128),
|
|
name: skill.name.slice(0, 200),
|
|
description: skill.description.slice(0, 2_000),
|
|
source: skill.source.slice(0, 128),
|
|
provider: skill.provider.slice(0, 128)
|
|
}))
|
|
}
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
private attachmentStore(): GoodBuddyHarnessAttachmentStore {
|
|
const store = this.ctx.get('attachments')
|
|
if (!(store instanceof GoodBuddyHarnessAttachmentStore)) {
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
'Harness image attachment service is unavailable'
|
|
)
|
|
}
|
|
return store
|
|
}
|
|
|
|
private releaseAttachments(refs: readonly ImageAttachmentRef[]): void {
|
|
if (refs.length === 0) {
|
|
return
|
|
}
|
|
const store = this.attachmentStore()
|
|
for (const ref of refs) {
|
|
store.releaseImage(ref)
|
|
}
|
|
}
|
|
|
|
private async storePromptImages(
|
|
prompt: readonly AcpContentBlock[]
|
|
): Promise<ImageAttachmentRef[]> {
|
|
const imageBlocks = prompt.filter(
|
|
(
|
|
block
|
|
): block is Extract<AcpContentBlock, { type: 'image' }> =>
|
|
block.type === 'image'
|
|
)
|
|
if (imageBlocks.length === 0) {
|
|
return []
|
|
}
|
|
if (!this.config.supportsImageInput) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'the selected model does not accept image input'
|
|
)
|
|
}
|
|
const store = this.attachmentStore()
|
|
if (imageBlocks.length > store.imageLimits.maxImagesPerMessage) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'too many images in one prompt'
|
|
)
|
|
}
|
|
const inputs: SaveImageAttachment[] = []
|
|
let totalBytes = 0
|
|
for (const block of imageBlocks) {
|
|
if (
|
|
block.uri != null ||
|
|
!store.imageLimits.mediaTypes.includes(
|
|
block.mimeType as SaveImageAttachment['mediaType']
|
|
) ||
|
|
block.data.length === 0 ||
|
|
block.data.length % 4 !== 0 ||
|
|
block.data.length >
|
|
Math.ceil(store.imageLimits.maxImageBytes / 3) * 4 ||
|
|
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
|
block.data
|
|
)
|
|
) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'invalid inline image prompt content'
|
|
)
|
|
}
|
|
const data = Buffer.from(block.data, 'base64')
|
|
totalBytes += data.byteLength
|
|
if (
|
|
data.byteLength === 0 ||
|
|
data.byteLength > store.imageLimits.maxImageBytes ||
|
|
totalBytes > store.imageLimits.maxMessageImageBytes ||
|
|
data.toString('base64') !== block.data
|
|
) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'invalid inline image prompt content'
|
|
)
|
|
}
|
|
inputs.push({
|
|
data,
|
|
mediaType: block.mimeType as SaveImageAttachment['mediaType']
|
|
})
|
|
}
|
|
try {
|
|
return await store.saveImages(inputs)
|
|
} catch (error) {
|
|
if (error instanceof AttachmentError) {
|
|
if (isImageAdmissionError(error)) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'invalid inline image prompt content'
|
|
)
|
|
}
|
|
if (error.code === 'ATTACHMENT_WRITE_FAILED') {
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
'Harness image attachment service is unavailable'
|
|
)
|
|
}
|
|
}
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
'Harness image attachment storage failed'
|
|
)
|
|
}
|
|
}
|
|
|
|
private createAgentApi(): Agent {
|
|
this.observeSessions()
|
|
return {
|
|
initialize: async () => ({
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
agentInfo: {
|
|
name: 'goodbuddy-deepseek-harness',
|
|
version: this.config.harnessVersion
|
|
},
|
|
agentCapabilities: {
|
|
promptCapabilities: {
|
|
image: this.config.supportsImageInput === true,
|
|
audio: false,
|
|
embeddedContext: false
|
|
},
|
|
sessionCapabilities: {
|
|
close: {}
|
|
}
|
|
},
|
|
authMethods: []
|
|
}),
|
|
authenticate: async () => undefined,
|
|
newSession: async (params) => {
|
|
if (this.closed) {
|
|
throw RequestError.internalError(
|
|
undefined,
|
|
'bridge is shutting down'
|
|
)
|
|
}
|
|
if (
|
|
!isAbsolute(params.cwd) ||
|
|
params.cwd !== this.config.workspace ||
|
|
params.mcpServers.length > 0
|
|
) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'the controlled workspace and no MCP servers are required'
|
|
)
|
|
}
|
|
const sessionId = SessionId(randomUUID())
|
|
let genuineSkillDefinition: ToolDefinition | undefined
|
|
const handle = await this.ctx.agents.create({
|
|
sessionId,
|
|
meta: { cwd: params.cwd },
|
|
agentOptions: {
|
|
provider: this.config.provider,
|
|
model: this.config.model,
|
|
maxTokens: GOODBUDDY_HARNESS_MAX_STEP_TOKENS
|
|
},
|
|
setup: async (agentCtx) => {
|
|
agentCtx.systemPrompt.section({
|
|
name: 'goodbuddy:controlled-execution',
|
|
order: 50,
|
|
text: GOODBUDDY_EXECUTION_GUIDANCE
|
|
})
|
|
const skillRegistrations = agentCtx.inject(
|
|
['skills'],
|
|
(skillCtx) => {
|
|
for (const skill of this.config.skills) {
|
|
skillCtx.skills.register({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
content: skill.content,
|
|
source: 'bundled',
|
|
resourceBase: {
|
|
kind: 'directory',
|
|
path: skill.directory
|
|
},
|
|
invocation: {
|
|
modelInvocable: true,
|
|
userInvocable: true
|
|
}
|
|
})
|
|
}
|
|
}
|
|
)
|
|
await agentCtx.plugin(ToolSkill)
|
|
genuineSkillDefinition =
|
|
agentCtx.tools.get('skill')
|
|
await skillRegistrations
|
|
}
|
|
})
|
|
const askToolDefinitions = new Map(
|
|
this.config.trustedAskToolDefinitions ?? []
|
|
)
|
|
if (genuineSkillDefinition) {
|
|
askToolDefinitions.set(
|
|
'skill',
|
|
genuineSkillDefinition
|
|
)
|
|
}
|
|
this.sessions.set(sessionId, {
|
|
handle,
|
|
attachmentRefs: [],
|
|
proxyTools: new Map(),
|
|
askToolDefinitions
|
|
})
|
|
return {
|
|
sessionId,
|
|
modes: {
|
|
currentModeId: 'ask',
|
|
availableModes: [
|
|
{
|
|
id: 'ask',
|
|
name: 'Ask',
|
|
description: 'Read-only'
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
prompt: async (params) => {
|
|
const record = this.requireSession(params.sessionId)
|
|
if (record.inflight) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'a prompt is already in flight'
|
|
)
|
|
}
|
|
const preparation = record.preparation
|
|
record.preparation = undefined
|
|
if (!preparation) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'a single-use goodbuddy/session/prepare is required'
|
|
)
|
|
}
|
|
await this.refreshProxyTools(params.sessionId, record)
|
|
const text = promptText(params.prompt)
|
|
if (!text.trim()) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'empty prompt'
|
|
)
|
|
}
|
|
const attachmentRefs = await this.storePromptImages(
|
|
params.prompt
|
|
)
|
|
let message: ReturnType<typeof createUserMessage>
|
|
try {
|
|
const content: HarnessContentBlock[] = [
|
|
{ type: 'text', text },
|
|
...attachmentRefs.map((attachment) => ({
|
|
type: 'image' as const,
|
|
attachment
|
|
}))
|
|
]
|
|
message = createUserMessage({
|
|
content,
|
|
source: { kind: 'user' }
|
|
})
|
|
} catch (error) {
|
|
this.releaseAttachments(attachmentRefs)
|
|
throw error
|
|
}
|
|
const stopReason = await new Promise<string>(
|
|
(resolve, reject) => {
|
|
record.inflight = {
|
|
requestId: preparation.requestId,
|
|
messageId: message.id,
|
|
mode: preparation.mode,
|
|
resolve,
|
|
reject,
|
|
emittedCharacters: 0,
|
|
eventTail: Promise.resolve()
|
|
}
|
|
try {
|
|
record.handle.agent.followup(message)
|
|
} catch (error) {
|
|
record.inflight = undefined
|
|
this.releaseAttachments(attachmentRefs)
|
|
reject(error)
|
|
return
|
|
}
|
|
record.attachmentRefs.push(...attachmentRefs)
|
|
void record.handle.agent.whenIdle().then(() => {
|
|
const current = record.inflight
|
|
if (current?.messageId !== message.id) {
|
|
return
|
|
}
|
|
this.flushPendingDelta(params.sessionId)
|
|
void current.eventTail.then(() => {
|
|
if (record.inflight === current) {
|
|
record.inflight = undefined
|
|
}
|
|
if (current.eventError) {
|
|
current.reject(current.eventError)
|
|
return
|
|
}
|
|
if (current.turnError) {
|
|
const details = errorChain(
|
|
current.turnError
|
|
).slice(0, MAX_SUMMARY_CHARACTERS)
|
|
current.reject(
|
|
RequestError.internalError(
|
|
{ details },
|
|
`DeepSeek Harness turn failed: ${details}`
|
|
)
|
|
)
|
|
return
|
|
}
|
|
current.resolve(current.endReason ?? 'cancelled')
|
|
})
|
|
}, (error) => {
|
|
if (record.inflight?.messageId === message.id) {
|
|
record.inflight = undefined
|
|
}
|
|
reject(error)
|
|
})
|
|
}
|
|
)
|
|
return {
|
|
stopReason:
|
|
stopReason === 'cancelled'
|
|
? 'cancelled'
|
|
: stopReason === 'max_tokens'
|
|
? 'max_tokens'
|
|
: 'end_turn'
|
|
}
|
|
},
|
|
cancel: async ({ sessionId }) => {
|
|
const record = this.sessions.get(sessionId)
|
|
record?.handle.agent.cancel({ kind: 'user' })
|
|
if (record?.inflight) {
|
|
const inflight = record.inflight
|
|
if (inflight.pendingDeltaTimer) {
|
|
clearTimeout(inflight.pendingDeltaTimer)
|
|
}
|
|
record.inflight = undefined
|
|
inflight.resolve('cancelled')
|
|
}
|
|
},
|
|
closeSession: async ({ sessionId }) => {
|
|
await this.releaseSession(sessionId)
|
|
},
|
|
extMethod: (method, params) =>
|
|
this.extensionMethod(method, params),
|
|
extNotification: async () => {
|
|
throw RequestError.methodNotFound('extension notification')
|
|
}
|
|
}
|
|
}
|
|
|
|
async extensionMethod(
|
|
method: string,
|
|
params: Record<string, unknown>
|
|
): Promise<Record<string, unknown>> {
|
|
if (method === GOODBUDDY_HANDSHAKE) {
|
|
const version = params.controlProtocolVersion
|
|
if (version !== GOODBUDDY_CONTROL_PROTOCOL_VERSION) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'incompatible GoodBuddy Harness control protocol'
|
|
)
|
|
}
|
|
this.handshaken = true
|
|
return this.capabilities() as unknown as Record<
|
|
string,
|
|
unknown
|
|
>
|
|
}
|
|
if (!this.handshaken) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'GoodBuddy handshake is required'
|
|
)
|
|
}
|
|
if (method === GOODBUDDY_PREPARE) {
|
|
const sessionId = requiredString(params, 'sessionId')
|
|
const requestId = requiredString(params, 'requestId')
|
|
const mode = params.mode
|
|
if (mode !== 'ask' && mode !== 'execute') {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'mode must be ask or execute'
|
|
)
|
|
}
|
|
const record = this.requireSession(sessionId)
|
|
if (record.inflight || record.preparation) {
|
|
throw RequestError.invalidParams(
|
|
undefined,
|
|
'session is already prepared or running'
|
|
)
|
|
}
|
|
record.preparation = { requestId, mode }
|
|
return { prepared: true }
|
|
}
|
|
if (method === GOODBUDDY_RELEASE) {
|
|
await this.releaseSession(
|
|
requiredString(params, 'sessionId')
|
|
)
|
|
return { released: true }
|
|
}
|
|
if (method === GOODBUDDY_NATIVE_SNAPSHOT) {
|
|
return this.nativeSnapshot()
|
|
}
|
|
if (method === GOODBUDDY_SHUTDOWN) {
|
|
await this.dispose()
|
|
return { shutdown: true }
|
|
}
|
|
throw RequestError.methodNotFound(method)
|
|
}
|
|
|
|
async releaseSession(sessionId: string): Promise<void> {
|
|
const record = this.sessions.get(sessionId)
|
|
if (!record) {
|
|
return
|
|
}
|
|
this.sessions.delete(sessionId)
|
|
record.handle.agent.cancel({ kind: 'user' })
|
|
if (record.inflight) {
|
|
if (record.inflight.pendingDeltaTimer) {
|
|
clearTimeout(record.inflight.pendingDeltaTimer)
|
|
}
|
|
record.inflight.resolve('cancelled')
|
|
record.inflight = undefined
|
|
}
|
|
try {
|
|
await record.handle.dispose()
|
|
} finally {
|
|
this.releaseAttachments(record.attachmentRefs)
|
|
}
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
this.disposing ??= (async () => {
|
|
this.closed = true
|
|
const sessions = [...this.sessions.entries()]
|
|
this.sessions.clear()
|
|
for (const [, record] of sessions) {
|
|
record.handle.agent.cancel({ kind: 'disposed' })
|
|
if (record.inflight?.pendingDeltaTimer) {
|
|
clearTimeout(record.inflight.pendingDeltaTimer)
|
|
}
|
|
record.inflight?.resolve('cancelled')
|
|
}
|
|
await Promise.allSettled(
|
|
sessions.map(([, record]) => record.handle.dispose())
|
|
)
|
|
for (const [, record] of sessions) {
|
|
this.releaseAttachments(record.attachmentRefs)
|
|
}
|
|
})()
|
|
await this.disposing
|
|
}
|
|
}
|