import { createHash } from 'node:crypto' import { mkdir, readFile, writeFile, rename } from 'node:fs/promises' import { join } from 'node:path' import { ChromeGatewayClient } from './chrome-bridge.mjs' const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value) // Reserved BEFORE the RPC: a crash/timeout is indeterminate, never safe to resend. // Journal stores only hashes and delivery state, no prompt, token, or result. export class DeliveryJournal { constructor(directory) { this.directory = directory; this.writes = Promise.resolve() } async reserve(deviceId, taskId, envelope) { if (!identifier(deviceId) || !identifier(taskId)) throw new Error('Geçersiz görev kimliği.') await mkdir(this.directory, { recursive: true }) const file = join(this.directory, `${digest([deviceId, taskId])}.json`) const hash = digest(envelope) try { await writeFile(file, JSON.stringify({ hash, state: 'unknown' }), { flag: 'wx', mode: 0o600, flush: true }) return { file, hash, fresh: true } } catch (error) { if (error.code !== 'EEXIST') throw error let previous try { previous = JSON.parse(await readFile(file, 'utf8')) } catch { return { file, hash, fresh: false, state: 'unknown' } } if (previous.hash !== hash) throw new Error('Bu görev kimliği farklı bir içerikle kullanılmış.') return { file, hash, fresh: false, state: previous.state } } } async accepted(reservation) { return this.update(reservation, 'accepted') } update(reservation, state) { const operation = this.writes.then(async () => { const previous = JSON.parse(await readFile(reservation.file, 'utf8')) if (['complete', 'error', 'interrupted'].includes(previous.state)) return const temporary = `${reservation.file}.tmp` await writeFile(temporary, JSON.stringify({ hash: reservation.hash, state }), { mode: 0o600, flush: true }) await rename(temporary, reservation.file) }) this.writes = operation.catch(() => {}) return operation } } export class MobileGatewayClient extends ChromeGatewayClient { constructor(resolveBackend, emit, { deviceId, journal, authorize, webSocket } = {}) { super(resolveBackend, emit, webSocket) this.deviceId = deviceId this.journal = journal this.authorize = authorize this.approval = null this.model = null this.chain = Promise.resolve() this.tasks = new Map() } onEvent(event) { if (['task.start', 'task.complete'].includes(event?.type)) { const task = this.tasks.get(event.payload?.task_id) if (!task || task.runtimeId !== event.session_id || task.terminal) return const state = event.type === 'task.start' ? 'running' : event.payload.status if (!['running', 'complete', 'error', 'interrupted'].includes(state)) return task.terminal = state !== 'running' task.state = state if (task.terminal) this.journal.update(task.reservation, state).catch(() => {}) this.emit({ event: event.type, taskId: task.taskId, sessionId: task.sessionId, status: state, text: typeof event.payload.text === 'string' ? event.payload.text.slice(0, 100000) : '' }) return } if (!this.selected || event?.session_id !== this.selected.runtimeId) return if (event.type === 'approval.request') { this.approval = this.approvalView(event.payload) this.emit({ event: 'approval.request', sessionId: this.selected.storedId, approval: this.approval }) return } if (event.type === 'message.complete') this.approval = null super.onEvent(event) } approvalView(payload) { if (!identifier(payload?.request_id) || typeof payload?.command !== 'string') return null // Retain full backend-redacted operation; never approve a clipped preview. if (payload.command.length > 48000) return null return { requestId: payload.request_id, command: payload.command, reason: typeof payload.reason === 'string' ? payload.reason : '', choices: (payload.choices || []).filter(x => ['once', 'deny'].includes(x)) } } handle(message) { const result = this.chain.then(() => this.perform(message)) this.chain = result.catch(() => {}) return result } async perform(message) { if (!message || !identifier(message.id)) throw new Error('Geçersiz istek.') await this.authorize() const params = message.params || {} if (message.action === 'session.open') { const list = await super.handle({ id: message.id, action: 'sessions.list' }) if (!list.sessions.some(row => row.id === params.sessionId)) throw new Error('Bu IDE konuşması erişilebilir değil.') const result = await super.handle(message) this.model = result.model const resumed = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96, source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() }) this.approval = this.approvalView(resumed.pending_approval) return { ...result, approval: this.approval } } if (message.action === 'task.submit') { const capabilities = await this.rpc('gateway.capabilities', {}) if (!capabilities.client_task_tracking) throw new Error('IDE sürümü mobil görev takibini desteklemiyor. IDE güncellemesi gerekli.') if (this.selected?.storedId !== params.sessionId || !params.model || params.model !== this.model) throw new Error('Konuşma veya iş modeli değişti. Hedefi tekrar seçin.') if (typeof params.text !== 'string' || !params.text.trim() || params.text.length > 48000) throw new Error('Görev boş olamaz ve 48.000 karakteri aşamaz.') if (!['research', 'execute'].includes(params.intent)) throw new Error('Görev türü gerekli.') // A model change by the desktop between selection and delivery fails closed. const info = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96, source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() }) if (info.info?.model !== params.model || info.session_id !== this.selected.runtimeId) throw new Error('IDE hedefi değişti. Konuşmayı yeniden seçin.') const envelope = { sessionId: params.sessionId, profile: this.backend.profile || 'default', model: params.model, text: params.text, intent: params.intent } const reservation = await this.journal.reserve(this.deviceId, params.taskId, envelope) const correlation = digest([this.deviceId, params.taskId]) if (!this.tasks.has(correlation)) this.tasks.set(correlation, { taskId: params.taskId, sessionId: params.sessionId, runtimeId: this.selected.runtimeId, reservation, terminal: false, state: reservation.state || 'unknown' }) if (!reservation.fresh) { // Recover a terminal event missed while the phone/companion was closed. // Never resubmit even if the bounded replay window no longer has it. const replay = await this.rpc('session.events.since', { session_id: this.selected.runtimeId, last_seen: 0 }) for (const frame of replay.events || []) if (frame.params?.payload?.task_id === correlation) this.onEvent(frame.params) await this.journal.writes const state = this.tasks.get(correlation).state return { taskId: params.taskId, state, duplicate: true, historyRequired: !['complete', 'error', 'interrupted'].includes(state) } } await this.authorize() await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true, client_task_id: correlation }) await this.journal.accepted(reservation) if (!this.tasks.get(correlation).terminal) this.tasks.get(correlation).state = 'accepted' return { taskId: params.taskId, state: 'accepted' } } if (message.action === 'approval.respond') { if (this.selected?.storedId !== params.sessionId || !this.approval || params.requestId !== this.approval.requestId || !['once', 'deny'].includes(params.choice) || !this.approval.choices.includes(params.choice)) throw new Error('Onay artık geçerli değil. IDE durumunu yenileyin.') const result = await this.rpc('approval.respond', { session_id: this.selected.runtimeId, request_id: params.requestId, choice: params.choice, all: false }) if (!result.resolved) throw new Error('İşlem onayı daha önce yanıtlanmış veya sona ermiş.') this.approval = null return { resolved: true } } if (!['status', 'sessions.list', 'session.history', 'message.stop'].includes(message.action)) throw new Error('Bu mobil işlem desteklenmiyor.') if (message.action === 'session.history' && this.selected?.storedId !== params.sessionId) throw new Error('Önce konuşmayı seçin.') return super.handle(message) } }