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
+19
View File
@@ -142,6 +142,10 @@ import type {
RemoteProjectSaveProgress,
RemoteProjectSaveRequest
} from './remote-project-candidate-contracts'
import type {
RemoteProjectRecoverySnapshot,
RemoteProjectRecoveryState
} from './remote-project-recovery-contracts'
import type {
FeedbackSubmitInput,
FeedbackSubmitResult
@@ -161,6 +165,10 @@ export type {
RemoteProjectSaveRequest,
RemoteProjectUpdateDraft
} from './remote-project-candidate-contracts'
export type {
RemoteProjectRecoverySnapshot,
RemoteProjectRecoveryState
} from './remote-project-recovery-contracts'
import type { WeixinBindingSnapshot } from './weixin-channel-contracts'
import type { RemoteChannelActivity } from './remote-channel-contracts'
import type {
@@ -177,6 +185,7 @@ import type {
RemoteEnvironmentUpdateRequest,
SshHostRemoteEnvironment,
SshHostRemovalResult,
SshHostAgentConnectionStatus,
SshHostsSnapshot,
SshDirectoryBrowseResult,
SshHostValidationRequest,
@@ -1557,6 +1566,9 @@ export type DesktopApi = {
}
sshHosts?: {
getSnapshot: () => Promise<SshHostsSnapshot>
onAgentConnectionStatus: (
listener: (status: SshHostAgentConnectionStatus) => void
) => () => void
getAgentPackageInventory: (
refresh?: boolean
) => Promise<AgentPackageInventory>
@@ -1734,6 +1746,13 @@ export type DesktopApi = {
onSaveProgress: (
listener: (progress: RemoteProjectSaveProgress) => void
) => () => void
getRecoverySnapshot: () => Promise<RemoteProjectRecoverySnapshot>
retryRecovery: (
projectId: string
) => Promise<RemoteProjectRecoveryState>
onRecoveryProgress: (
listener: (state: RemoteProjectRecoveryState) => void
) => () => void
}
}
conversations: {
+6
View File
@@ -63,6 +63,8 @@ export const ipcChannels = {
'settings:ssh-hosts:directories:browse',
sshHostsCancelDirectoryBrowse:
'settings:ssh-hosts:directories:cancel',
sshHostsAgentConnectionStatus:
'settings:ssh-hosts:agent-connection-status',
channelSettingsGet: 'settings:channels:get',
channelSettingsApply: 'settings:channels:apply',
channelSettingsTest: 'settings:channels:test',
@@ -132,6 +134,10 @@ export const ipcChannels = {
remoteProjectSave: 'projects:remote:save',
remoteProjectCancelCurrent: 'projects:remote:cancel-current',
remoteProjectSaveProgress: 'projects:remote:save-progress',
remoteProjectRecoveryGet: 'projects:remote:recovery:get',
remoteProjectRecoveryRetry: 'projects:remote:recovery:retry',
remoteProjectRecoveryProgress:
'projects:remote:recovery:progress',
conversationsList: 'conversations:list',
conversationsReplace: 'conversations:replace',
conversationsSaveLocal: 'conversations:save-local',
+114
View File
@@ -59,6 +59,120 @@ const modelNameSchema = utf8StringSchema(
'Model name cannot contain control characters'
)
const modelProfileTextSchema = (label: string) =>
utf8StringSchema(512, {
minimumBytes: 1,
label
})
.refine(
(value) => value.trim() === value,
`${label} must not have outer whitespace`
)
.refine(
(value) => !/[\0\r\n]/u.test(value),
`${label} cannot contain control characters`
)
const modelProviderBaseUrlSchema = utf8StringSchema(4_096, {
minimumBytes: 1,
label: 'Model provider base URL'
}).superRefine((value, context) => {
let url: URL
try {
url = new URL(value)
} catch {
context.addIssue({
code: 'custom',
message: 'Model provider base URL is invalid'
})
return
}
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username !== '' ||
url.password !== '' ||
url.hash !== ''
) {
context.addIssue({
code: 'custom',
message: 'Model provider base URL must be credential-free HTTP(S)'
})
}
})
/**
* A bounded prompt-scoped provider snapshot. The API key is deliberately kept
* out of ModelBridgePolicy because policy is safe to persist and log, while
* this profile must only be retained by the Agent for the active prompt.
*/
export const agentPromptModelProfileSchema = z
.object({
profileId: modelProfileTextSchema('Model profile identifier'),
modelProfileDigest: sha256DigestSchema,
provider: z.enum(['anthropic', 'openai']),
baseUrl: modelProviderBaseUrlSchema,
model: modelNameSchema,
protocol: modelBridgeModelProtocolSchema,
authentication: z.enum(['api-key', 'none']),
apiKey: utf8StringSchema(16 * 1_024, {
minimumBytes: 1,
label: 'Model provider API key'
}).optional(),
capabilities: z
.object({
imageInput: z.boolean()
})
.strict(),
limits: z
.object({
maximumOutputTokens: z.number().int().min(1).max(1_000_000),
maximumModelCalls: z.number().int().min(1).max(10_000),
maximumTotalOutputTokens: z.number().int().min(1).max(10_000_000),
requestTimeoutMilliseconds: z
.number()
.int()
.min(1)
.max(300_000)
})
.strict()
})
.strict()
.superRefine((profile, context) => {
if (
(profile.authentication === 'api-key') !==
(profile.apiKey !== undefined)
) {
context.addIssue({
code: 'custom',
path: ['apiKey'],
message: 'API-key authentication requires exactly one prompt credential'
})
}
if (
profile.provider === 'anthropic' !==
(profile.protocol === 'anthropic-messages')
) {
context.addIssue({
code: 'custom',
path: ['provider'],
message: 'Model provider does not match its wire protocol'
})
}
if (
profile.limits.maximumOutputTokens >
profile.limits.maximumTotalOutputTokens
) {
context.addIssue({
code: 'custom',
path: ['limits', 'maximumOutputTokens'],
message: 'Per-call output limit exceeds the prompt total'
})
}
})
export type AgentPromptModelProfile = z.infer<
typeof agentPromptModelProfileSchema
>
export const modelBridgePolicySchema = z
.object({
protocol: modelBridgeModelProtocolSchema,
+116
View File
@@ -0,0 +1,116 @@
export type TextModelRequestPolicy = {
protocol:
| 'anthropic-messages'
| 'openai-chat-completions'
| 'openai-responses'
model: string
supportsImageInput: boolean
}
export class ModelRequestPolicyError extends Error {
constructor() {
super('Model request does not match the selected text model policy')
this.name = 'ModelRequestPolicyError'
}
}
export function assertTextModelRequestPolicy(
policy: TextModelRequestPolicy,
value: unknown
): asserts value is Record<string, unknown> {
if (!isRecord(value) || value.model !== policy.model) {
throw new ModelRequestPolicyError()
}
if (!policy.supportsImageInput && containsImageInput(value)) {
throw new ModelRequestPolicyError()
}
if (
value.background === true ||
value.store === true ||
value.web_search_options !== undefined ||
value.mcp_servers !== undefined ||
value.conversation !== undefined ||
value.previous_response_id !== undefined ||
(value.service_tier !== undefined &&
value.service_tier !== 'auto' &&
value.service_tier !== 'default') ||
(Array.isArray(value.modalities) &&
value.modalities.some((modality) => modality !== 'text'))
) {
throw new ModelRequestPolicyError()
}
if (
Array.isArray(value.include) &&
value.include.some(
(item) =>
typeof item !== 'string' ||
/(?:web_search|file_search|computer|code_interpreter|mcp|image_generation)/iu.test(
item
)
)
) {
throw new ModelRequestPolicyError()
}
if (value.tools === undefined) {
return
}
if (!Array.isArray(value.tools)) {
throw new ModelRequestPolicyError()
}
for (const tool of value.tools) {
if (!isRecord(tool)) {
throw new ModelRequestPolicyError()
}
if (policy.protocol === 'anthropic-messages') {
if (tool.type !== undefined && tool.type !== 'custom') {
throw new ModelRequestPolicyError()
}
} else if (tool.type !== 'function') {
throw new ModelRequestPolicyError()
}
}
}
function containsImageInput(value: unknown): boolean {
const maximumNodes = 100_000
const pending: unknown[] = [value]
let visited = 0
while (pending.length > 0) {
visited += 1
if (visited > maximumNodes) {
throw new ModelRequestPolicyError()
}
const current = pending.pop()
if (Array.isArray(current)) {
if (visited + pending.length + current.length > maximumNodes) {
throw new ModelRequestPolicyError()
}
pending.push(...current)
continue
}
if (!isRecord(current)) {
continue
}
if (
current.type === 'image' ||
current.type === 'image_url' ||
current.type === 'input_image'
) {
return true
}
const values = Object.values(current)
if (visited + pending.length + values.length > maximumNodes) {
throw new ModelRequestPolicyError()
}
pending.push(...values)
}
return false
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
)
}
+102
View File
@@ -0,0 +1,102 @@
export type BoundedResponseOptions = {
maxBytes: number
missingBodyMessage?: string
tooLargeMessage: string
truncatedMessage?: string
}
export class BoundedResponseTooLargeError extends Error {
constructor(message: string) {
super(message)
this.name = 'BoundedResponseTooLargeError'
}
}
export class BoundedResponseTruncatedError extends Error {
constructor(message: string) {
super(message)
this.name = 'BoundedResponseTruncatedError'
}
}
export async function readBoundedResponseBytes(
response: Response,
options: BoundedResponseOptions
): Promise<Uint8Array> {
const declaredLength = response.headers.get('content-length')
let parsedLength: number | undefined
if (declaredLength !== null) {
parsedLength = Number(declaredLength)
if (
!Number.isSafeInteger(parsedLength) ||
parsedLength < 0 ||
parsedLength > options.maxBytes
) {
await response.body?.cancel().catch(() => undefined)
throw new BoundedResponseTooLargeError(
options.tooLargeMessage
)
}
}
if (!response.body) {
if (options.missingBodyMessage) {
throw new Error(options.missingBodyMessage)
}
if (
options.truncatedMessage !== undefined &&
parsedLength !== undefined &&
parsedLength !== 0
) {
throw new BoundedResponseTruncatedError(
options.truncatedMessage
)
}
return new Uint8Array()
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let completed = false
let total = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
completed = true
break
}
total += value.byteLength
if (total > options.maxBytes) {
throw new BoundedResponseTooLargeError(
options.tooLargeMessage
)
}
chunks.push(value)
}
} finally {
if (!completed) {
await reader.cancel().catch(() => undefined)
}
reader.releaseLock()
}
if (
options.truncatedMessage !== undefined &&
parsedLength !== undefined &&
response.headers.get('content-encoding') === null &&
total !== parsedLength
) {
throw new BoundedResponseTruncatedError(
options.truncatedMessage
)
}
return Buffer.concat(chunks, total)
}
export async function readBoundedResponseText(
response: Response,
options: BoundedResponseOptions
): Promise<string> {
return Buffer.from(
await readBoundedResponseBytes(response, options)
).toString('utf8')
}
+174
View File
@@ -11,9 +11,11 @@ import {
} from './agent-protocol/contracts'
import { digestCanonicalOperation } from './agent-protocol/canonical'
import {
agentPromptModelProfileSchema,
MODEL_BRIDGE_PROTOCOL,
modelBridgePolicySchema
} from './model-bridge-contracts'
import { jsonValueSchema } from './agent-protocol/control-contracts'
export const UNBOUNDED_REMOTE_PROMPT_DEADLINE =
'9999-12-31T23:59:59.999Z'
@@ -160,6 +162,13 @@ const remotePromptOperationIdentityFields = {
runtimeBundleDigest: sha256DigestSchema,
runtimeAdapterDigest: sha256DigestSchema,
modelBridge: remotePromptModelBridgeSchema.optional(),
modelProfile: agentPromptModelProfileSchema.optional(),
promptSequence: z
.number()
.int()
.nonnegative()
.max(Number.MAX_SAFE_INTEGER)
.default(0),
deadlineAt: z.string().datetime({ offset: true }),
budget: remotePromptOperationBudgetSchema
} as const
@@ -172,6 +181,171 @@ export type RemotePromptOperationPreparation = z.infer<
typeof remotePromptOperationPreparationSchema
>
export const REMOTE_SEMANTIC_TRANSCRIPT_LIMITS = {
maximumEventsPerPage: 256,
maximumPageBytes: 768 * 1024,
maximumEventsPerPrompt: 10_000,
maximumEventBytes: 512 * 1024,
maximumTranscriptBytes: 16 * 1024 * 1024,
maximumPromptBlocks: 256,
maximumPromptBytes: 16 * 1024 * 1024
} as const
const remotePromptContentBlockSchema = z.discriminatedUnion('type', [
z
.object({
type: z.literal('text'),
text: utf8StringSchema(
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumPromptBytes,
{ label: 'Remote prompt text' }
)
})
.strict(),
z
.object({
type: z.literal('image'),
data: z
.string()
.min(1)
.max(
Math.ceil(
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumPromptBytes / 3
) * 4
)
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u),
mimeType: z
.string()
.regex(/^image\/[a-z0-9.+-]+$/u)
.max(128)
})
.strict()
])
export const remoteOwnedPromptStartRequestSchema = z
.object({
bindingId: agentIdentifierSchema,
operationId: agentIdentifierSchema,
requestId: agentIdentifierSchema,
acpSessionId: agentIdentifierSchema.optional(),
prompt: z
.array(remotePromptContentBlockSchema)
.min(1)
.max(REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumPromptBlocks)
})
.strict()
.superRefine((request, context) => {
if (request.operationId !== request.requestId) {
context.addIssue({
code: 'custom',
path: ['requestId'],
message: 'Prompt operation and request identities must match'
})
}
const bytes = utf8ByteLength(JSON.stringify(request.prompt))
if (bytes > REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumPromptBytes) {
context.addIssue({
code: 'custom',
path: ['prompt'],
message: 'Remote prompt exceeds its total byte limit'
})
}
})
export const remoteOwnedPromptStateSchema = z.enum([
'starting',
'running',
'completed',
'failed',
'cancelled',
'outcome-unknown'
])
export const remoteOwnedPromptStartResultSchema = z
.object({
bindingId: agentIdentifierSchema,
operationId: agentIdentifierSchema,
requestId: agentIdentifierSchema,
sessionId: agentIdentifierSchema,
state: remoteOwnedPromptStateSchema,
latestSemanticSequence: agentSequenceSchema
})
.strict()
export const remoteOwnedPromptAttachRequestSchema = z
.object({
bindingId: agentIdentifierSchema,
operationId: agentIdentifierSchema,
requestId: agentIdentifierSchema
})
.strict()
export const remoteSemanticTranscriptEventSchema = z
.object({
sequence: positiveAgentSequenceSchema,
kind: z.enum([
'session-update',
'permission-decision',
'prompt-terminal'
]),
payload: jsonValueSchema,
createdAt: z.number().int().nonnegative().safe()
})
.strict()
export const remoteSemanticTranscriptPageRequestSchema = z
.object({
bindingId: agentIdentifierSchema,
operationId: agentIdentifierSchema,
afterSequence: agentSequenceSchema,
limit: z
.number()
.int()
.min(1)
.max(REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventsPerPage)
})
.strict()
export const remoteSemanticTranscriptPageResultSchema = z
.object({
bindingId: agentIdentifierSchema,
operationId: agentIdentifierSchema,
events: z
.array(remoteSemanticTranscriptEventSchema)
.max(REMOTE_SEMANTIC_TRANSCRIPT_LIMITS.maximumEventsPerPage),
latestSequence: agentSequenceSchema,
acknowledgedSequence: agentSequenceSchema,
state: remoteOwnedPromptStateSchema,
sessionId: agentIdentifierSchema.optional(),
hasMore: z.boolean()
})
.strict()
export const remoteSemanticTranscriptAckRequestSchema = z
.object({
bindingId: agentIdentifierSchema,
operationId: agentIdentifierSchema,
acknowledgedSequence: agentSequenceSchema
})
.strict()
export const remoteSemanticTranscriptAckResultSchema = z
.object({
bindingId: agentIdentifierSchema,
operationId: agentIdentifierSchema,
acknowledgedSequence: agentSequenceSchema
})
.strict()
export type RemoteOwnedPromptStartRequest = z.infer<
typeof remoteOwnedPromptStartRequestSchema
>
export type RemoteOwnedPromptStartResult = z.infer<
typeof remoteOwnedPromptStartResultSchema
>
export type RemoteSemanticTranscriptEvent = z.infer<
typeof remoteSemanticTranscriptEventSchema
>
export const remotePromptOperationAcceptanceSchema = z
.object({
bindingId: agentIdentifierSchema,
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import {
REMOTE_PROJECT_RECOVERY_LIMITS,
remoteProjectRecoveryStateSchema
} from './remote-project-recovery-contracts'
const projectId = '00000000-0000-4000-8000-000000000101'
const requestId = '00000000-0000-4000-8000-000000000201'
describe('remote project recovery contracts', () => {
it('accepts decimal cursor progress', () => {
expect(
remoteProjectRecoveryStateSchema.parse({
projectId,
requestId,
stage: 'cursor',
current: '125'
})
).toEqual({
projectId,
requestId,
stage: 'cursor',
current: '125'
})
})
it('rejects extra fields', () => {
expect(
remoteProjectRecoveryStateSchema.safeParse({
projectId,
requestId,
stage: 'agent',
detail: 'not part of the renderer contract'
}).success
).toBe(false)
expect(
remoteProjectRecoveryStateSchema.safeParse({
projectId,
requestId,
stage: 'cursor',
current: '3',
total: '2'
}).success
).toBe(false)
})
it('bounds failure messages and requires retryability', () => {
expect(
remoteProjectRecoveryStateSchema.safeParse({
projectId,
requestId,
stage: 'failed',
message: 'x'.repeat(
REMOTE_PROJECT_RECOVERY_LIMITS.maximumFailureMessageLength + 1
),
retryable: true
}).success
).toBe(false)
expect(
remoteProjectRecoveryStateSchema.safeParse({
projectId,
requestId,
stage: 'failed',
message: 'Host unavailable'
}).success
).toBe(false)
})
})
@@ -0,0 +1,88 @@
import { z } from 'zod'
import { assistantIdSchema } from './assistant-contracts'
import { agentSequenceSchema } from './agent-protocol/contracts'
export const REMOTE_PROJECT_RECOVERY_LIMITS = {
maximumFailureMessageLength: 1_000
} as const
export const remoteProjectRecoveryRequestIdSchema = z.string().uuid()
const recoveryIdentityFields = {
projectId: assistantIdSchema,
requestId: remoteProjectRecoveryRequestIdSchema
} as const
const recoveryStageStateSchema = z.discriminatedUnion('stage', [
z
.object({
...recoveryIdentityFields,
stage: z.literal('network')
})
.strict(),
z
.object({
...recoveryIdentityFields,
stage: z.literal('agent')
})
.strict(),
z
.object({
...recoveryIdentityFields,
stage: z.literal('runtime')
})
.strict(),
z
.object({
...recoveryIdentityFields,
stage: z.literal('cursor'),
current: agentSequenceSchema
})
.strict()
])
export const remoteProjectRecoveryStateSchema = z.union([
recoveryStageStateSchema,
z
.object({
...recoveryIdentityFields,
stage: z.literal('completed')
})
.strict(),
z
.object({
...recoveryIdentityFields,
stage: z.literal('failed'),
message: z
.string()
.trim()
.min(1)
.max(
REMOTE_PROJECT_RECOVERY_LIMITS.maximumFailureMessageLength
),
retryable: z.boolean()
})
.strict()
])
export const remoteProjectRecoverySnapshotSchema = z
.object({
recoveries: z.array(remoteProjectRecoveryStateSchema).max(100)
})
.strict()
export const remoteProjectRecoveryRetryRequestSchema = z
.object({
projectId: assistantIdSchema
})
.strict()
export type RemoteProjectRecoveryState = z.infer<
typeof remoteProjectRecoveryStateSchema
>
export type RemoteProjectRecoverySnapshot = z.infer<
typeof remoteProjectRecoverySnapshotSchema
>
export type RemoteProjectRecoveryRetryRequest = z.infer<
typeof remoteProjectRecoveryRetryRequestSchema
>
+17
View File
@@ -5,6 +5,7 @@ import {
sshDirectoryBrowseRequestSchema,
sshDirectoryBrowseResultSchema,
sshHostCreateInputSchema,
sshHostAgentConnectionStatusSchema,
sshHostDraftInspectionRequestSchema,
remoteEnvironmentUpdateProgressSchema,
remoteEnvironmentUpdateRequestSchema,
@@ -13,6 +14,22 @@ import {
} from './ssh-host-contracts'
describe('SSH host contracts', () => {
it('accepts only bounded Host Agent connection states', () => {
const status = {
hostId: '00000000-0000-4000-8000-000000000101',
state: 'ready'
} as const
expect(
sshHostAgentConnectionStatusSchema.parse(status)
).toEqual(status)
expect(() =>
sshHostAgentConnectionStatusSchema.parse({
...status,
state: 'unknown'
})
).toThrow()
})
it('normalizes bounded display fields and preserves password bytes', () => {
expect(
sshHostCreateInputSchema.parse({
+26
View File
@@ -203,10 +203,36 @@ export type SshHost = {
updatedAt: string
}
export const sshHostAgentConnectionStateSchema = z.enum([
'disconnected',
'connecting',
'ready',
'error'
])
export type SshHostAgentConnectionState = z.infer<
typeof sshHostAgentConnectionStateSchema
>
export const sshHostAgentConnectionStatusSchema = z
.object({
hostId: sshHostIdSchema,
state: sshHostAgentConnectionStateSchema
})
.strict()
export type SshHostAgentConnectionStatus = z.infer<
typeof sshHostAgentConnectionStatusSchema
>
export type SshHostsSnapshot = {
hosts: SshHost[]
secureStorageAvailable: boolean
projectReferences?: Record<string, SshHostProjectReference[]>
agentConnectionStatusByHostId?: Record<
string,
SshHostAgentConnectionState
>
}
export type SshHostProjectReference = {