feat: keep managed SSH prompts running on Hosts

Managed SSH prompts previously depended on Desktop-owned provider and transcript state, so accepted work could stop or become uncertain when the Desktop, network, or power disappeared. The Agent now owns accepted OpenCode prompt execution, model and tool rounds, cancellation, and the durable semantic transcript, while Desktop remains authoritative for permanent projects, conversations, tasks, and messages.

Recovered work attaches to the original conversation without replay. The project switcher also groups managed SSH projects by saved Host and shows live Agent connectivity beside each group without opening a connection merely to read status. Remote project subtitles now show only the path, and the Agent lock advances to 0.11.14 with Runtime ACP v4.

Release note: 托管 SSH 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
This commit is contained in:
mesalogo
2026-08-31 11:04:41 +08:00
parent f6f8d5e053
commit 561de29f00
75 changed files with 13439 additions and 1327 deletions
+654
View File
@@ -0,0 +1,654 @@
import { createHash } from 'node:crypto'
import type { DatabaseSync } from 'node:sqlite'
import type { AgentPromptModelProfile } from '../shared/model-bridge-contracts'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../shared/remote-model-gateway-contracts'
import {
assertTextModelRequestPolicy,
ModelRequestPolicyError
} from '../shared/model-request-policy'
import {
BoundedResponseTruncatedError,
BoundedResponseTooLargeError,
readBoundedResponseBytes
} from '../shared/node/bounded-response'
import { openPrivateSqliteDatabase } from '../shared/node/private-sqlite-database'
const RESPONSE_HEADERS = [
'content-type',
'openai-request-id',
'request-id',
'x-request-id'
] as const
type CallRow = {
call_id: string
binding_id: string
operation_id: string
prompt_sequence: number
round_index: number
profile_digest: string
request_digest: string
status: 'dispatched' | 'completed' | 'outcome-unknown'
response_delivered: number
}
export class AgentModelGatewayError extends Error {
constructor(
readonly code:
| 'already-dispatched'
| 'cancelled'
| 'conflict'
| 'outcome-unknown'
| 'policy'
| 'response-too-large'
| 'timeout',
message: string
) {
super(message)
this.name = 'AgentModelGatewayError'
}
}
export class AgentModelCallLedger {
readonly #database: DatabaseSync
readonly #maximumRetainedTerminalCalls: number
#closed = false
constructor(
path: string,
options: { maximumRetainedTerminalCalls?: number } = {}
) {
this.#maximumRetainedTerminalCalls =
options.maximumRetainedTerminalCalls ?? 10_000
if (
!Number.isSafeInteger(this.#maximumRetainedTerminalCalls) ||
this.#maximumRetainedTerminalCalls < 1
) {
throw new AgentModelGatewayError(
'conflict',
'Model call ledger retention limit is invalid'
)
}
this.#database = openPrivateSqliteDatabase(path)
this.#database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS agent_model_calls (
call_id TEXT PRIMARY KEY,
binding_id TEXT NOT NULL,
operation_id TEXT NOT NULL,
prompt_sequence INTEGER NOT NULL CHECK (prompt_sequence >= 0),
round_index INTEGER NOT NULL CHECK (round_index >= 0),
profile_digest TEXT NOT NULL,
request_digest TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('dispatched', 'completed', 'outcome-unknown')
),
response_delivered INTEGER NOT NULL DEFAULT 0 CHECK (
response_delivered IN (0, 1)
),
terminal_code TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (binding_id, operation_id, round_index)
) STRICT;
CREATE INDEX IF NOT EXISTS agent_model_calls_dispatched
ON agent_model_calls(status) WHERE status = 'dispatched';
CREATE INDEX IF NOT EXISTS agent_model_calls_terminal_retention
ON agent_model_calls(updated_at DESC)
WHERE status <> 'dispatched';
`)
// A provider dispatch that survived an Agent restart cannot be retried.
this.#database.exec(`
UPDATE agent_model_calls
SET status = 'outcome-unknown', terminal_code = 'agent-restarted',
updated_at = CAST(unixepoch('subsec') * 1000 AS INTEGER)
WHERE status = 'dispatched'
`)
this.#pruneTerminalCalls()
}
#pruneTerminalCalls(protectedCallId?: string): void {
if (protectedCallId === undefined) {
this.#database.prepare(`
DELETE FROM agent_model_calls
WHERE call_id IN (
SELECT call_id
FROM agent_model_calls
WHERE status <> 'dispatched'
ORDER BY updated_at DESC, created_at DESC, call_id DESC
LIMIT -1 OFFSET ?
);
`).run(this.#maximumRetainedTerminalCalls)
return
}
this.#database.prepare(`
DELETE FROM agent_model_calls
WHERE call_id IN (
SELECT call_id
FROM agent_model_calls
WHERE status <> 'dispatched' AND call_id <> ?
ORDER BY updated_at DESC, created_at DESC, call_id DESC
LIMIT -1 OFFSET ?
);
`).run(
protectedCallId,
this.#maximumRetainedTerminalCalls - 1
)
}
claim(input: {
callId: string
bindingId: string
operationId: string
promptSequence: number
roundIndex: number
profileDigest: string
requestDigest: string
}): void {
this.#assertOpen()
const existing = this.get(input.callId)
if (existing !== undefined) {
if (
existing.binding_id !== input.bindingId ||
existing.operation_id !== input.operationId ||
existing.prompt_sequence !== input.promptSequence ||
existing.round_index !== input.roundIndex ||
existing.profile_digest !== input.profileDigest ||
existing.request_digest !== input.requestDigest
) {
throw new AgentModelGatewayError(
'conflict',
'Stable model call identity conflicts with prior evidence'
)
}
throw new AgentModelGatewayError(
existing.status === 'outcome-unknown'
? 'outcome-unknown'
: 'already-dispatched',
'Stable model call was already dispatched'
)
}
const previous = this.#database.prepare(`
SELECT status, response_delivered
FROM agent_model_calls
WHERE binding_id = ? AND operation_id = ?
ORDER BY round_index DESC LIMIT 1
`).get(input.bindingId, input.operationId) as
| { status: string; response_delivered: number }
| undefined
if (
input.roundIndex !== (previous === undefined ? 0 : this.#highestRound(
input.bindingId,
input.operationId
) + 1) ||
(previous !== undefined &&
(previous.status !== 'completed' ||
previous.response_delivered !== 1))
) {
throw new AgentModelGatewayError(
'conflict',
'Model call rounds must be contiguous and delivery-acknowledged'
)
}
const now = Date.now()
this.#database.prepare(`
INSERT INTO agent_model_calls (
call_id, binding_id, operation_id, prompt_sequence, round_index,
profile_digest, request_digest, status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?)
`).run(
input.callId,
input.bindingId,
input.operationId,
input.promptSequence,
input.roundIndex,
input.profileDigest,
input.requestDigest,
now,
now
)
}
complete(callId: string): void {
this.#transition(callId, 'completed', null)
}
outcomeUnknown(callId: string, code: string): void {
this.#transition(callId, 'outcome-unknown', code)
}
delivered(callId: string): void {
this.#assertOpen()
const result = this.#database.prepare(`
UPDATE agent_model_calls
SET response_delivered = 1, updated_at = ?
WHERE call_id = ? AND status = 'completed'
AND response_delivered = 0
`).run(Date.now(), callId)
if (
Number(result.changes) !== 1 &&
this.get(callId)?.response_delivered !== 1
) {
throw new AgentModelGatewayError(
'conflict',
'Only a completed model response can be acknowledged'
)
}
}
get(callId: string): CallRow | undefined {
this.#assertOpen()
return this.#database.prepare(`
SELECT call_id, binding_id, operation_id, prompt_sequence, round_index,
profile_digest, request_digest, status, response_delivered
FROM agent_model_calls WHERE call_id = ?
`).get(callId) as CallRow | undefined
}
close(): void {
if (!this.#closed) {
this.#database.close()
this.#closed = true
}
}
#transition(
callId: string,
status: 'completed' | 'outcome-unknown',
code: string | null
): void {
this.#assertOpen()
const result = this.#database.prepare(`
UPDATE agent_model_calls
SET status = ?, terminal_code = ?, updated_at = ?
WHERE call_id = ? AND status = 'dispatched'
`).run(status, code, Date.now(), callId)
const changed = Number(result.changes) === 1
if (!changed && this.get(callId)?.status !== status) {
throw new AgentModelGatewayError(
'conflict',
'Model call terminal state conflicts with prior evidence'
)
}
if (changed) {
this.#pruneTerminalCalls(callId)
}
}
#highestRound(bindingId: string, operationId: string): number {
const row = this.#database.prepare(`
SELECT MAX(round_index) AS value FROM agent_model_calls
WHERE binding_id = ? AND operation_id = ?
`).get(bindingId, operationId) as { value: number }
return row.value
}
#assertOpen(): void {
if (this.#closed) {
throw new AgentModelGatewayError(
'conflict',
'Model call ledger is closed'
)
}
}
}
export type AgentModelGatewayContext = {
bindingId: string
operationId: string
promptSequence: number
roundIndex: number
profileDigest: string
profile: AgentPromptModelProfile
}
export class AgentModelGateway {
readonly #ledger: AgentModelCallLedger
readonly #fetch: typeof fetch
readonly #promptOutputTokens = new Map<string, number>()
constructor(options: {
ledger: AgentModelCallLedger
fetcher?: typeof fetch
}) {
this.#ledger = options.ledger
this.#fetch = options.fetcher ?? fetch
}
async dispatch(
context: AgentModelGatewayContext,
requestInput: RemoteModelGatewayRequest,
signal: AbortSignal
): Promise<{
response: RemoteModelGatewayResponse
acknowledgeDelivery(): Promise<void>
failDelivery(): void
}> {
signal.throwIfAborted()
const request = remoteModelGatewayRequestSchema.parse(requestInput)
const promptKey = `${context.bindingId}\0${context.operationId}`
const usedOutputTokens = this.#promptOutputTokens.get(promptKey) ?? 0
const remainingOutputTokens =
context.profile.limits.maximumTotalOutputTokens - usedOutputTokens
if (remainingOutputTokens < 1) {
throw new AgentModelGatewayError(
'policy',
'Prompt total output token limit is exhausted'
)
}
const reservedOutputTokens = Math.min(
context.profile.limits.maximumOutputTokens,
remainingOutputTokens
)
const prepared = prepareProviderRequest(
context.profile,
request,
reservedOutputTokens
)
const requestDigest = sha256(prepared.canonicalRequest)
const callId = createAgentModelCallId(context)
this.#ledger.claim({
callId,
bindingId: context.bindingId,
operationId: context.operationId,
promptSequence: context.promptSequence,
roundIndex: context.roundIndex,
profileDigest: context.profileDigest,
requestDigest
})
const timeout = new AbortController()
const timer = setTimeout(
() => timeout.abort(new Error('provider timeout')),
context.profile.limits.requestTimeoutMilliseconds
)
timer.unref?.()
let response: Response
try {
response = await this.#fetch(prepared.url, {
method: 'POST',
redirect: 'error',
headers: prepared.headers,
body: prepared.body,
signal: AbortSignal.any([signal, timeout.signal])
})
} catch {
clearTimeout(timer)
const code = timeout.signal.aborted
? 'timeout'
: signal.aborted
? 'cancelled'
: 'outcome-unknown'
this.#ledger.outcomeUnknown(callId, code)
throw new AgentModelGatewayError(
code,
'Provider dispatch outcome is unknown'
)
}
let bytes: Uint8Array
try {
bytes = await readBoundedResponseBytes(response, {
maxBytes: REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
tooLargeMessage:
'Provider response exceeds the configured byte limit',
truncatedMessage: 'Provider response body was truncated'
})
} catch (error) {
clearTimeout(timer)
const code =
error instanceof BoundedResponseTooLargeError
? 'response-too-large'
: 'outcome-unknown'
this.#ledger.outcomeUnknown(callId, code)
throw error instanceof AgentModelGatewayError
? error
: new AgentModelGatewayError(
code,
error instanceof BoundedResponseTruncatedError
? error.message
: code === 'response-too-large'
? (error as Error).message
: 'Provider response could not be read completely'
)
}
clearTimeout(timer)
if (signal.aborted || timeout.signal.aborted) {
const code = timeout.signal.aborted ? 'timeout' : 'cancelled'
this.#ledger.outcomeUnknown(callId, code)
throw new AgentModelGatewayError(
code,
'Provider response arrived after cancellation'
)
}
const headers: Record<string, string> = {}
for (const name of RESPONSE_HEADERS) {
const value = response.headers.get(name)
if (
value !== null &&
Buffer.byteLength(value, 'utf8') <=
REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes &&
!/[\r\n]/u.test(value)
) {
headers[name] = value
}
}
const bridgeResponse = remoteModelGatewayResponseSchema.parse({
status: response.status,
headers,
bodyBase64: Buffer.from(bytes).toString('base64')
})
this.#promptOutputTokens.set(
promptKey,
usedOutputTokens +
Math.min(
reservedOutputTokens,
responseOutputTokens(bytes) ?? reservedOutputTokens
)
)
this.#ledger.complete(callId)
let finalized = false
return {
response: bridgeResponse,
acknowledgeDelivery: async () => {
if (finalized) {
throw new AgentModelGatewayError(
'conflict',
'Provider response delivery was already finalized'
)
}
finalized = true
this.#ledger.delivered(callId)
},
failDelivery: () => {
finalized = true
// The completed response cannot be reconstructed from the metadata-only
// ledger, so an unacknowledged handoff is intentionally non-retryable.
}
}
}
finalizePrompt(bindingId: string, operationId: string): void {
this.#promptOutputTokens.delete(`${bindingId}\0${operationId}`)
}
}
export function createAgentModelCallId(
context: Pick<
AgentModelGatewayContext,
'bindingId' | 'operationId' | 'roundIndex'
>
): string {
return `model-call-${createHash('sha256')
.update(
JSON.stringify({
bindingId: context.bindingId,
operationId: context.operationId,
roundIndex: context.roundIndex
})
)
.digest('hex')}`
}
function prepareProviderRequest(
profile: AgentPromptModelProfile,
request: RemoteModelGatewayRequest,
maximumOutputTokens: number
): {
url: URL
headers: Record<string, string>
body: Uint8Array
canonicalRequest: string
} {
if (!pathMatches(profile.protocol, request.path)) {
throw new AgentModelGatewayError(
'policy',
'Provider path does not match the prompt profile'
)
}
const inputBody = Buffer.from(request.bodyBase64, 'base64')
let json: unknown
try {
json = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(inputBody)
)
} catch {
throw new AgentModelGatewayError(
'policy',
'Provider request body is not UTF-8 JSON'
)
}
try {
assertTextModelRequestPolicy(
{
protocol: profile.protocol,
model: profile.model,
supportsImageInput: profile.capabilities.imageInput
},
json
)
} catch (error) {
if (!(error instanceof ModelRequestPolicyError)) {
throw error
}
throw new AgentModelGatewayError(
'policy',
'Provider request does not match the prompt profile'
)
}
const normalized = { ...(json as Record<string, unknown>) }
const outputField =
profile.protocol === 'openai-responses'
? 'max_output_tokens'
: 'max_tokens'
const requestedOutput = normalized[outputField]
if (
requestedOutput !== undefined &&
(
!Number.isSafeInteger(requestedOutput) ||
(requestedOutput as number) < 1
)
) {
throw new AgentModelGatewayError(
'policy',
'Provider request has an invalid output token limit'
)
}
normalized[outputField] =
requestedOutput === undefined
? maximumOutputTokens
: Math.min(requestedOutput as number, maximumOutputTokens)
const body = Buffer.from(JSON.stringify(normalized), 'utf8')
const url = providerUrl(profile)
const headers = { ...request.headers }
if (profile.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
if (profile.authentication === 'api-key') {
headers['x-api-key'] = profile.apiKey!
}
} else if (profile.authentication === 'api-key') {
headers.authorization = `Bearer ${profile.apiKey!}`
}
return {
url,
headers,
body,
canonicalRequest: JSON.stringify({
profileId: profile.profileId,
provider: profile.provider,
protocol: profile.protocol,
model: profile.model,
url: url.toString(),
request: {
path: request.path,
headers: Object.fromEntries(
Object.entries(request.headers).sort(([left], [right]) =>
left.localeCompare(right)
)
),
bodyBase64: body.toString('base64')
}
})
}
}
function responseOutputTokens(body: Uint8Array): number | undefined {
let value: unknown
try {
value = JSON.parse(new TextDecoder().decode(body))
} catch {
return undefined
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return undefined
}
const usage = (value as { usage?: unknown }).usage
if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) {
return undefined
}
const record = usage as Record<string, unknown>
const tokens = record.output_tokens ?? record.completion_tokens
return typeof tokens === 'number' &&
Number.isSafeInteger(tokens) &&
tokens >= 0
? tokens
: undefined
}
function providerUrl(profile: AgentPromptModelProfile): URL {
const url = new URL(profile.baseUrl)
const path = url.pathname.replace(/\/+$/u, '')
if (profile.protocol === 'anthropic-messages') {
url.pathname = `${path.endsWith('/v1') ? path : `${path}/v1`}/messages`
} else if (profile.protocol === 'openai-chat-completions') {
url.pathname = `${path}/chat/completions`
} else {
url.pathname = `${path}/responses`
}
url.hash = ''
return url
}
function pathMatches(
protocol: AgentPromptModelProfile['protocol'],
path: string
): boolean {
if (protocol === 'anthropic-messages') {
return path === '/v1/messages'
}
if (protocol === 'openai-chat-completions') {
return path === '/chat/completions' || path === '/v1/chat/completions'
}
return path === '/responses' || path === '/v1/responses'
}
function sha256(value: string | Uint8Array): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`
}