feat: Samsung lock screen face widget, voice assistant services, PiP mode and gitignore installers

Add Samsung proprietary Face Widget (lock screen/AOD) with terminal status display.
Add voice interaction services (AgentVoiceInteractionService, RecognitionService) for
digital assistant registration. Add PiP mode with voice/expand actions. Add session-state
proxy, voice transcript routes, window controls component. Ignore installers/ directory.
This commit is contained in:
2026-02-23 20:52:11 -06:00
parent e1aa8b1bdb
commit 65303df96a
35 changed files with 2640 additions and 484 deletions

View File

@@ -28,6 +28,8 @@ import {
handleHooksApprovalRespond, handleHooksApprovalRespondPlan,
handleHooksApprovalIgnore, handleHooksApprovalList
} from './hooks-approval'
import { handleSessionStateProxy } from './session-state-proxy'
import { handleVoiceTranscript } from './voice-transcript'
export async function handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url)
@@ -343,6 +345,17 @@ export async function handleRequest(req: Request): Promise<Response> {
return handleTranscriptDebugRaw(transcriptDebugRawMatch[1], url)
}
// Voice Transcript (Android voice assistant sends transcribed text here)
if (path === '/api/voice-transcript') {
const res = await handleVoiceTranscript(req)
if (res) return res
}
// Session State (proxy to terminal server for external clients like Android widget)
if (path === '/api/session-state' && req.method === 'GET') {
return handleSessionStateProxy(url)
}
// Hooks Approval (long-poll for permission/plan decisions)
if (path === '/api/hooks-approval') {
if (req.method === 'GET') {

View File

@@ -0,0 +1,26 @@
import { jsonResponse, errorResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config'
/**
* Proxy GET /api/session-state → terminal server.
* Returns session-state + terminal-registry combined,
* so external clients (Android widget) get everything in one call.
*/
export async function handleSessionStateProxy(url: URL): Promise<Response> {
try {
const [stateResp, registryResp] = await Promise.all([
fetch(`http://localhost:${PORT_TERMINAL}/session-state`),
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
])
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
const registryData = registryResp.ok ? await registryResp.json() : { registry: [] }
return jsonResponse({
agents: stateData.agents ?? {},
registry: registryData.registry ?? []
})
} catch (e: any) {
return errorResponse(`Failed to reach terminal server: ${e.message}`, 502)
}
}

View File

@@ -0,0 +1,105 @@
import { jsonResponse } from '../utils/cors'
import { PORT_TERMINAL } from '../config'
export async function handleVoiceTranscript(req: Request): Promise<Response | null> {
if (req.method !== 'POST') return null
try {
const body = await req.json() as { text?: string; timestamp?: string; source?: string }
const { text, timestamp, source } = body
if (!text) {
return jsonResponse({ error: 'Missing "text" field' }, 400)
}
const ts = timestamp || new Date().toISOString()
const src = source || 'android-voice'
console.log(`\n🎙 [VOICE TRANSCRIPT] ────────────────────────`)
console.log(` Source: ${src}`)
console.log(` Time: ${ts}`)
console.log(` Text: "${text}"`)
// Find first alive terminal and send the text as input
const result = await sendToFirstTerminal(text)
console.log(` Terminal: ${result.terminal || 'none found'}`)
console.log(` Status: ${result.sent ? 'sent ✓' : result.error || 'no terminal'}`)
console.log(` ──────────────────────────────────────────────\n`)
return jsonResponse({
ok: true,
received: text,
timestamp: ts,
source: src,
sentToTerminal: result.sent,
terminal: result.terminal || null,
ephemeralSessionId: result.ephemeralSessionId || null
})
} catch (e: any) {
console.error('[voice-transcript] Parse error:', e.message)
return jsonResponse({ error: 'Invalid JSON body' }, 400)
}
}
async function sendToFirstTerminal(text: string): Promise<{ sent: boolean; terminal?: string; ephemeralSessionId?: string; error?: string }> {
try {
// Fetch terminal registry to find alive terminals
const res = await fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
if (!res.ok) {
return { sent: false, error: `registry fetch failed: ${res.status}` }
}
const registry = await res.json() as Array<{
ephemeralSessionId: string
agent: string
label: string
alive: boolean
}>
// Find first alive terminal
const target = registry.find(t => t.alive)
if (!target) {
return { sent: false, error: 'no alive terminals' }
}
// Connect via WebSocket and send the text as input
const wsUrl = `ws://localhost:${PORT_TERMINAL}/ws/terminal?session=${target.ephemeralSessionId}`
return new Promise((resolve) => {
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
try { ws.close() } catch {}
resolve({ sent: false, terminal: target.ephemeralSessionId, error: 'ws timeout' })
}, 5000)
ws.onopen = () => {
// Send the transcribed text
ws.send(JSON.stringify({ type: 'input', data: text }))
// Send Enter after a short delay
setTimeout(() => {
ws.send(JSON.stringify({ type: 'input', data: '\r' }))
// Close after sending
setTimeout(() => {
clearTimeout(timeout)
ws.close()
resolve({
sent: true,
terminal: `${target.ephemeralSessionId} (${target.agent})`,
ephemeralSessionId: target.ephemeralSessionId
})
}, 150)
}, 80)
}
ws.onerror = (err) => {
clearTimeout(timeout)
resolve({ sent: false, terminal: target.ephemeralSessionId, error: 'ws connection error' })
}
})
} catch (e: any) {
return { sent: false, error: e.message }
}
}