Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ index 84b746e..6983d4a 100644
tag.dataset.plugin = "@deepseek-ai/dsh-client-ui-agent-preset";
tag.dataset.pluginCss = tagId;
- tag.textContent = css;
+ tag.textContent = `${css}.rtSEdW_sectionActions{flex:none;align-items:center;gap:8px;margin-right:12px;display:flex}`;
+ tag.textContent = `${css}.rtSEdW_sectionHead{align-items:center;gap:16px;display:flex}.rtSEdW_sectionActions{flex:none;align-items:center;justify-content:flex-end;gap:8px;margin-left:auto;display:flex}.rtSEdW_hiddenInput{display:none}`;
document.head.appendChild(tag);
}
var AgentPresetSection_module_css_default = {
Expand Down
304 changes: 304 additions & 0 deletions src/main/mobile/lan-mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ import {

const MAX_BODY_BYTES = 64 * 1024
const PAIRING_TTL_MS = 5 * 60 * 1000
const MUX_RECONNECT_MS = 500

const RPC_ALLOWLIST = new Set([
'workspace.list',
'agentPreset.list',
'agentPreset.select',
'session.list',
'session.history',
'session.models',
'session.selectModel',
'session.create',
'session.prompt',
'session.cancel'
Expand Down Expand Up @@ -54,6 +59,33 @@ interface PendingPairing {
decision?: boolean
}

interface MobileQuestionOption {
label: string
description?: string
}

interface MobileQuestion {
id: string
question: string
detail?: string
header?: string
options?: MobileQuestionOption[]
multiSelect?: boolean
intent?: string
}

interface PendingMobileQuestion {
rpcId: string
sessionId: string
questions: MobileQuestion[]
}

interface MobileQuestionAnswer {
id: string
selected: string[]
custom?: string
}

export class LanMobileBridge {
private server?: ReturnType<typeof createServer>
private port?: number
Expand All @@ -62,7 +94,10 @@ export class LanMobileBridge {
private readonly sessions = new Map<string, MobileSession>()
private readonly suspendedSessions = new Map<string, MobileSession>()
private readonly pendingPairings = new Map<string, PendingPairing>()
private readonly pendingQuestions = new Map<string, PendingMobileQuestion>()
private readonly now: () => number
private muxAbort?: AbortController
private muxTask?: Promise<void>

constructor(private readonly options: LanMobileBridgeOptions) {
this.now = options.now ?? Date.now
Expand All @@ -73,6 +108,7 @@ export class LanMobileBridge {
if (!this.pairingToken || !this.pairingExpiresAt || this.pairingExpiresAt < this.now()) {
this.rotatePairingToken()
}
this.startMuxMonitor()
return this.snapshot()
}
this.rotatePairingToken()
Expand All @@ -87,6 +123,7 @@ export class LanMobileBridge {
this.server?.listen(this.options.port ?? 0, '0.0.0.0', resolve)
})
this.port = (this.server.address() as AddressInfo).port
this.startMuxMonitor()
return this.snapshot()
}

Expand All @@ -99,6 +136,12 @@ export class LanMobileBridge {
this.sessions.clear()
this.suspendedSessions.clear()
this.pendingPairings.clear()
this.pendingQuestions.clear()
this.muxAbort?.abort()
const muxTask = this.muxTask
this.muxAbort = undefined
this.muxTask = undefined
if (muxTask) await muxTask.catch(() => undefined)
if (!server) return
await new Promise<void>((resolve) => server.close(() => resolve()))
}
Expand Down Expand Up @@ -298,6 +341,37 @@ export class LanMobileBridge {
if (request.method === 'POST' && url.pathname === '/api/rpc') {
this.verifySameOrigin(request)
const input = JSON.parse(await readBody(request)) as { method?: unknown; payload?: unknown }
if (input.method === 'interaction.pending') {
const sessionId = requiredStringField(input.payload, 'sessionId')
const pending = [...this.pendingQuestions.values()].find(
(item) => item.sessionId === sessionId
)
return this.json(response, 200, { ok: true, value: pending ?? null })
}
if (input.method === 'interaction.answer') {
const answer = parseQuestionResponse(input.payload)
const pending = this.assertPendingQuestion(answer.rpcId, answer.sessionId)
validateQuestionAnswers(pending, answer.answers)
const result = await this.respondToQuestion(answer.rpcId, {
ok: true,
value: { sessionId: answer.sessionId, answer: { answers: answer.answers } }
})
return this.json(response, result.ok ? 200 : 400, result)
}
if (input.method === 'interaction.cancel') {
const rpcId = requiredStringField(input.payload, 'rpcId')
const sessionId = requiredStringField(input.payload, 'sessionId')
this.assertPendingQuestion(rpcId, sessionId)
const result = await this.respondToQuestion(rpcId, {
ok: false,
error: {
code: 'cancelled',
message: 'the user closed this question request',
details: {}
}
})
return this.json(response, result.ok ? 200 : 400, result)
}
if (typeof input.method !== 'string' || !RPC_ALLOWLIST.has(input.method)) {
return this.json(response, 403, { ok: false, error: 'RPC method is not available on mobile.' })
}
Expand Down Expand Up @@ -395,6 +469,136 @@ export class LanMobileBridge {
return { ok: true, value: envelope.result.value }
}

private startMuxMonitor(): void {
if (this.muxTask) return
const abort = new AbortController()
this.muxAbort = abort
this.muxTask = this.monitorMux(abort.signal).finally(() => {
if (this.muxAbort === abort) {
this.muxAbort = undefined
this.muxTask = undefined
}
})
}

private async monitorMux(signal: AbortSignal): Promise<void> {
let lastBase: string | undefined
while (!signal.aborted) {
const base = this.options.harnessUrl()
if (!base) {
this.pendingQuestions.clear()
await waitFor(MUX_RECONNECT_MS, signal)
continue
}
if (base !== lastBase) {
this.pendingQuestions.clear()
lastBase = base
}
try {
await this.consumeMux(base, signal)
} catch {
if (signal.aborted) return
this.pendingQuestions.clear()
await waitFor(MUX_RECONNECT_MS, signal)
}
}
}

private async consumeMux(base: string, signal: AbortSignal): Promise<void> {
// The network Harness exposes mux events only as a downlink WebSocket;
// ordinary GET requests intentionally return 426 with no SSE fallback.
const url = new URL('/api/events.mux', base)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(url)
let settled = false
const cleanup = (): void => {
signal.removeEventListener('abort', handleAbort)
socket.removeEventListener('open', handleOpen)
socket.removeEventListener('message', handleMessage)
socket.removeEventListener('close', handleClose)
socket.removeEventListener('error', handleError)
}
const finish = (error?: Error): void => {
if (settled) return
settled = true
cleanup()
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
socket.close()
}
if (error) reject(error)
else resolve()
}
const handleAbort = (): void => finish()
const handleOpen = (): void => this.pendingQuestions.clear()
const handleMessage = (event: MessageEvent): void => {
if (typeof event.data === 'string') this.consumeMuxEnvelope(event.data)
}
const handleClose = (): void => {
finish(signal.aborted ? undefined : new Error('Harness mux WebSocket closed.'))
}
const handleError = (): void => finish(new Error('Harness mux WebSocket failed.'))
socket.addEventListener('open', handleOpen)
socket.addEventListener('message', handleMessage)
socket.addEventListener('close', handleClose, { once: true })
socket.addEventListener('error', handleError, { once: true })
signal.addEventListener('abort', handleAbort, { once: true })
if (signal.aborted) handleAbort()
})
}

private consumeMuxEnvelope(data: string): void {
let envelope: unknown
try {
envelope = JSON.parse(data)
} catch {
return
}
if (!isRecord(envelope) || envelope.type !== 'server-request') return
const rpcId = typeof envelope.rpcId === 'string' ? envelope.rpcId : undefined
const payload = isRecord(envelope.payload) ? envelope.payload : undefined
if (!rpcId || !payload || typeof payload.type !== 'string') return
if (payload.type === 'question/requested') {
const pending = parsePendingQuestion(rpcId, payload)
if (pending) this.pendingQuestions.set(rpcId, pending)
return
}
if (payload.type === 'question/resolved' && typeof payload.questionRpcId === 'string') {
this.pendingQuestions.delete(payload.questionRpcId)
}
}

private assertPendingQuestion(rpcId: string, sessionId: string): PendingMobileQuestion {
const pending = this.pendingQuestions.get(rpcId)
if (!pending || pending.sessionId !== sessionId) {
throw new Error('This question request is no longer pending.')
}
return pending
}

private async respondToQuestion(
rpcId: string,
result: Record<string, unknown>
): Promise<{ ok: boolean; value?: unknown; error?: string }> {
const base = this.options.harnessUrl()
if (!base) return { ok: false, error: 'Harness is not ready.' }
const response = await fetch(new URL('/api/respond', base), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-response', rpcId, result }),
signal: AbortSignal.timeout(30_000)
})
if (!response.ok) return { ok: false, error: `Harness transport returned HTTP ${response.status}.` }
const receipt = (await response.json()) as { accepted?: unknown; reason?: unknown }
if (receipt.accepted !== true) {
return {
ok: false,
error: typeof receipt.reason === 'string' ? receipt.reason : 'Harness rejected the response.'
}
}
return { ok: true, value: receipt }
}

private html(response: ServerResponse, body: string): void {
response.statusCode = 200
response.setHeader('content-type', 'text/html; charset=utf-8')
Expand Down Expand Up @@ -454,3 +658,103 @@ async function readBody(request: IncomingMessage): Promise<string> {
}
return Buffer.concat(chunks).toString('utf8')
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function requiredStringField(value: unknown, field: string): string {
if (!isRecord(value) || typeof value[field] !== 'string' || !value[field]) {
throw new Error(`Invalid ${field}.`)
}
return value[field]
}

function parsePendingQuestion(
rpcId: string,
payload: Record<string, unknown>
): PendingMobileQuestion | undefined {
if (typeof payload.sessionId !== 'string' || !Array.isArray(payload.questions)) return undefined
const questions: MobileQuestion[] = []
for (const item of payload.questions.slice(0, 20)) {
if (!isRecord(item) || typeof item.id !== 'string' || typeof item.question !== 'string') continue
const question: MobileQuestion = { id: item.id, question: item.question }
if (typeof item.detail === 'string') question.detail = item.detail
if (typeof item.header === 'string') question.header = item.header
if (typeof item.multiSelect === 'boolean') question.multiSelect = item.multiSelect
if (typeof item.intent === 'string') question.intent = item.intent
if (Array.isArray(item.options)) {
question.options = item.options.slice(0, 50).flatMap((option) => {
if (!isRecord(option) || typeof option.label !== 'string') return []
return [{
label: option.label,
...(typeof option.description === 'string' ? { description: option.description } : {})
}]
})
}
questions.push(question)
}
if (!questions.length) return undefined
return { rpcId, sessionId: payload.sessionId, questions }
}

function parseQuestionResponse(value: unknown): {
rpcId: string
sessionId: string
answers: MobileQuestionAnswer[]
} {
const rpcId = requiredStringField(value, 'rpcId')
const sessionId = requiredStringField(value, 'sessionId')
if (!isRecord(value) || !Array.isArray(value.answers) || value.answers.length > 20) {
throw new Error('Invalid question answers.')
}
const answers = value.answers.map((item): MobileQuestionAnswer => {
if (!isRecord(item) || typeof item.id !== 'string' || !Array.isArray(item.selected)) {
throw new Error('Invalid question answer.')
}
const selected = item.selected.map((label) => {
if (typeof label !== 'string') throw new Error('Invalid selected option.')
return label
})
if (selected.length > 50) throw new Error('Too many selected options.')
return {
id: item.id,
selected,
...(typeof item.custom === 'string' && item.custom.trim() ? { custom: item.custom } : {})
}
})
return { rpcId, sessionId, answers }
}

function validateQuestionAnswers(
pending: PendingMobileQuestion,
answers: MobileQuestionAnswer[]
): void {
if (answers.length !== pending.questions.length) throw new Error('Every question needs an answer or skip.')
const answerById = new Map(answers.map((answer) => [answer.id, answer]))
if (answerById.size !== answers.length) throw new Error('Duplicate question answer.')
for (const question of pending.questions) {
const answer = answerById.get(question.id)
if (!answer) throw new Error('Every question needs an answer or skip.')
const allowed = new Set((question.options ?? []).map((option) => option.label))
if (answer.selected.some((label) => !allowed.has(label))) {
throw new Error('Answer contains an unknown option.')
}
if (!question.multiSelect && answer.selected.length > 1) {
throw new Error('Only one option can be selected.')
}
}
}

function waitFor(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) return resolve()
const timeout = setTimeout(done, milliseconds)
function done(): void {
clearTimeout(timeout)
signal.removeEventListener('abort', done)
resolve()
}
signal.addEventListener('abort', done, { once: true })
})
}
Loading
Loading