Compare commits

...

25 Commits

Author SHA1 Message Date
Shantur Rathore
bd2a0d1bec Bump version to 0.7.2 2026-01-15 20:55:59 +00:00
Shantur Rathore
df9722cd16 fix(server): run background processes via cmd.exe on Windows 2026-01-15 20:53:13 +00:00
Shantur Rathore
dffa4907ec fix(server): validate OpenCode binary by spawning --version 2026-01-15 20:47:30 +00:00
Shantur Rathore
e567d35438 fix(server): prefer .exe/.cmd candidates on Windows 2026-01-15 20:45:14 +00:00
Shantur Rathore
62f52fc534 fix(server): spawn opencode shims via Windows shells 2026-01-15 20:43:40 +00:00
Shantur Rathore
69f221942c Bump version to 0.7.1 2026-01-15 08:39:06 +00:00
Shantur Rathore
7749225f71 fix(ui): restore pasted text expand controls\n\nFixes #67 2026-01-15 08:36:56 +00:00
Shantur Rathore
ae322c53cc fix(ui): correct Go to Session navigation across instances 2026-01-15 08:26:49 +00:00
Shantur Rathore
37da426ab4 Bump version to 0.7.0 2026-01-14 21:36:45 +00:00
Shantur Rathore
591f55bef9 fix(ui): render prompt attachments above input 2026-01-14 21:35:18 +00:00
Shantur Rathore
aabaadbe1d fix(ui): expand prompt via rows, keep placeholder padding 2026-01-14 21:28:04 +00:00
Shantur Rathore
3ab14e8de6 Merge pull request #62 from bizzkoot/feat/expand-chat-input
feat: Implement expandable chat input with double-click detection and gradient tooltip
2026-01-14 18:22:56 +00:00
Shantur Rathore
40634138bc feat(server): add authenticated remote access and desktop bootstrap
Adds cookie-based login with a bootstrap token flow for desktop apps, secures OpenCode instance traffic with per-instance Basic auth, and updates UI/plugin clients to use credentials.
2026-01-14 18:18:14 +00:00
bizzkoot
b17087b610 refactor: remove mobile-specific placeholder text for simplicity
- Remove isMobileWidth signal and updateMobileWidth resize listener
- Use same placeholder text for all devices/platforms
- "Type your message, @file, @agent, or paste images and text..."

Simplifies implementation per dev feedback - one approach for all
2026-01-13 06:48:33 +08:00
bizzkoot
71f58e7c5f refactor: simplify expand chat input to 2-state with optimized button layout
- Remove 3-state logic (normal/50%/80%) - now only normal/expanded
- Remove double-click detection and tooltips for simplicity
- Remove platform-specific behavior (same UX for Electron and web)
- Optimize button layout: reduce from 36px to 28px to fit 3 buttons
- Position expand button above history buttons in vertical stack
- Keep 15-line expanded height (360px, capped to available space)

Per upstream dev feedback to keep it simple with one approach
2026-01-13 06:45:56 +08:00
Shantur Rathore
927e4e1281 perf(ui): reduce session list churn and message block invalidation 2026-01-12 16:37:09 +00:00
bizzkoot
2e56a5e9f4 feat: implement platform-specific expand chat input with mobile optimizations
- Add platform detection (Electron vs Web) for expand behavior
  - Electron: 3-state (normal → 50% → 80%) with double-click
  - Web/Mobile: 2-state (normal → expanded) with instant single tap
- Implement fixed 15-line height for web/mobile (360px, capped)
- Add orientation-aware height calculation (landscape vs portrait)
- Remove tooltip on web/mobile, keep for Electron desktop
- Add responsive placeholder text to prevent overlap on mobile
  - Desktop: "Type your message, @file, @agent, or paste images and text..."
  - Mobile (≤640px): "Type message, @file, @agent..."
- Delete dev-docs/expand-chat-input.md per upstream feedback

Addresses PR feedback to simplify from 3-state to 2-state for web/mobile
while maintaining rich desktop experience in Electron app.
2026-01-12 20:40:19 +08:00
bizzkoot
296d07a0d6 Move expand chat input doc to dev-docs and remove empty plans folder 2026-01-12 05:24:24 +08:00
bizzkoot
0d8a844af8 feat: implement expandable chat input with double-click detection and gradient tooltip
- Add expand button with Maximize2/Minimize2 icons
- Implement 3-state height management (normal/50%/80%)
- Smart double-click detection with 300ms delay
- Height calculation based on session-view - 200px message space
- Custom CSS tooltip with dark gradient background and backdrop blur
- Send button anchored at bottom via margin-top: auto
- Smooth CSS transitions throughout
- Double-click at 80% now reduces to 50% (not normal)
- Removed all debug console.log statements
2026-01-11 21:59:28 +08:00
bizzkoot
bf9cef4cd5 docs: add expand chat input implementation plan 2026-01-11 20:17:19 +08:00
bizzkoot
9dde33aba7 style: add expand button positioning and styles 2026-01-11 20:09:13 +08:00
bizzkoot
0fefff3b0a feat: integrate ExpandButton and apply dynamic height to textarea 2026-01-11 20:07:48 +08:00
bizzkoot
1122c19648 feat: create ExpandButton component with click/double-click logic 2026-01-11 20:05:16 +08:00
bizzkoot
f06359a1fc feat: add expand state signal and height calculation helpers 2026-01-11 20:04:25 +08:00
Shantur Rathore
72f420b6f6 feat(ui): support question tool requests
Add question queue hydration, inline answering UI, and unify pending requests with permissions.
2026-01-10 09:46:23 +00:00
60 changed files with 3338 additions and 530 deletions

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.6.0",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.6.0",
"version": "0.7.2",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -1096,9 +1096,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz",
"integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==",
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
"license": "MIT"
},
"node_modules/@pinojs/redact": {
@@ -7389,7 +7389,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.6.0",
"version": "0.7.2",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -7423,7 +7423,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.6.0",
"version": "0.7.2",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -7458,18 +7458,18 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.6.0",
"version": "0.7.2",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.6.0",
"version": "0.7.2",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.1",
"@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.6.0",
"version": "0.7.2",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

View File

@@ -1,4 +1,6 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http"
import https from "node:https"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
@@ -251,6 +254,15 @@ function showLoadingScreen(force = false) {
loadLoadingScreen(mainWindow)
}
function isBootstrapTokenUrl(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
} catch {
return false
}
}
function startCliPreload(url: string) {
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
@@ -268,6 +280,13 @@ function startCliPreload(url: string) {
showLoadingScreen(true)
}
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
// consuming the token in the hidden view and then failing in the main window.
if (isBootstrapTokenUrl(url)) {
finalizeCliSwap(url)
return
}
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
@@ -308,6 +327,75 @@ function finalizeCliSwap(url: string) {
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
if (!raw) return null
const first = raw.split(";")[0] ?? ""
const index = first.indexOf("=")
if (index < 0) return null
const key = first.slice(0, index).trim()
const value = first.slice(index + 1).trim()
if (key !== name || !value) return null
try {
return decodeURIComponent(value)
} catch {
return value
}
}
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token })
const transport = target.protocol === "https:" ? https : http
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
const req = transport.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
},
(res) => {
res.resume()
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
},
)
req.on("error", reject)
req.write(body)
req.end()
})
if (result.statusCode !== 200) {
return false
}
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
if (!sessionId) {
return false
}
await session.defaultSession.cookies.set({
url: baseUrl,
name: SESSION_COOKIE_NAME,
value: sessionId,
httpOnly: true,
path: "/",
sameSite: "lax",
})
return true
}
async function startCli() {
try {
@@ -323,11 +411,53 @@ async function startCli() {
}
}
async function maybeExchangeAndNavigate(baseUrl: string) {
if (bootstrapExchangeInFlight) {
return
}
const token = pendingBootstrapToken
if (!token) {
startCliPreload(baseUrl)
return
}
bootstrapExchangeInFlight = true
try {
const ok = await exchangeBootstrapToken(baseUrl, token)
pendingBootstrapToken = null
if (!ok) {
startCliPreload(`${baseUrl}/login`)
return
}
startCliPreload(baseUrl)
} catch (error) {
console.error("[cli] bootstrap token exchange failed:", error)
pendingBootstrapToken = null
startCliPreload(`${baseUrl}/login`)
} finally {
bootstrapExchangeInFlight = false
}
}
cliManager.on("bootstrapToken", (token) => {
pendingBootstrapToken = token
const status = cliManager.getStatus()
if (status.url) {
void maybeExchangeAndNavigate(status.url)
}
})
cliManager.on("ready", (status) => {
if (!status.url) {
return
}
startCliPreload(status.url)
void maybeExchangeAndNavigate(status.url)
})
cliManager.on("status", (status) => {

View File

@@ -9,6 +9,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
const nodeRequire = createRequire(import.meta.url)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
@@ -69,6 +70,7 @@ function readListeningModeFromConfig(): ListeningMode {
export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this
on(event: "bootstrapToken", listener: (token: string) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this
@@ -79,6 +81,7 @@ export class CliProcessManager extends EventEmitter {
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
private bootstrapToken: string | null = null
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
@@ -87,6 +90,7 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.bootstrapToken = null
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
@@ -227,11 +231,22 @@ export class CliProcessManager extends EventEmitter {
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
const trimmed = line.trim()
if (!trimmed) continue
const port = this.extractPort(line)
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
if (token && !this.bootstrapToken) {
this.bootstrapToken = token
this.emit("bootstrapToken", token)
}
continue
}
console.info(`[cli][${stream}] ${trimmed}`)
this.emit("log", { stream, message: trimmed })
const port = this.extractPort(trimmed)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
@@ -271,7 +286,7 @@ export class CliProcessManager extends EventEmitter {
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0"]
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.6.0",
"version": "0.7.2",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",

View File

@@ -3,6 +3,6 @@
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.8"
"@opencode-ai/plugin": "1.1.16"
}
}

View File

@@ -1,5 +1,6 @@
import path from "path"
import { tool } from "@opencode-ai/plugin/tool"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
type BackgroundProcess = {
id: string
@@ -12,11 +13,6 @@ type BackgroundProcess = {
outputSizeBytes?: number
}
type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
type BackgroundProcessOptions = {
baseDir: string
}
@@ -27,30 +23,10 @@ type ParsedCommand = {
}
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
const requester = createCodeNomadRequester(config)
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const base = config.baseUrl.replace(/\/+$/, "")
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
const headers = normalizeHeaders(init?.headers)
if (init?.body !== undefined) {
headers["Content-Type"] = "application/json"
}
const response = await fetch(url, {
...init,
headers,
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
return requester.requestJson<T>(`/background-processes${path}`, init)
}
return {
@@ -249,13 +225,7 @@ function tokenize(input: string): string[] {
if (char === "|" || char === "&" || char === ";") {
flush()
const next = input[index + 1]
if ((char === "|" || char === "&") && next === char) {
tokens.push(char + next)
index += 1
} else {
tokens.push(char)
}
tokens.push(char)
continue
}
@@ -266,44 +236,18 @@ function tokenize(input: string): string[] {
return tokens
}
function isSeparator(token: string) {
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
function isSeparator(token: string): boolean {
return token === "|" || token === "&" || token === ";"
}
function unquote(value: string) {
if (value.length >= 2) {
const first = value[0]
const last = value[value.length - 1]
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
return value.slice(1, -1)
}
function unquote(token: string): string {
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
return token.slice(1, -1)
}
return value
return token
}
function isWithinBase(baseDir: string, target: string) {
const relative = path.relative(baseDir, target)
if (!relative) return true
return !relative.startsWith("..") && !path.isAbsolute(relative)
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
function isWithinBase(base: string, candidate: string): boolean {
const relative = path.relative(base, candidate)
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}

View File

@@ -1,74 +1,41 @@
export type PluginEvent = {
type: string
properties?: Record<string, unknown>
}
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
export type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
}
}
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
export function createCodeNomadClient(config: CodeNomadConfig) {
return {
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
}
}
const requester = createCodeNomadRequester(config)
function requireEnv(key: string): string {
const value = process.env[key]
if (!value || !value.trim()) {
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
return {
postEvent: (event: PluginEvent) =>
requester.requestVoid("/event", {
method: "POST",
body: JSON.stringify(event),
}),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
}
return value
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(event),
})
if (!response.ok) {
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
}
}
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
async function startPluginEvents(
requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void,
) {
// Fail plugin startup if we cannot establish the initial connection.
const initialBody = await connectWithRetries(url, 3)
const initialBody = await connectWithRetries(requester, 3)
// After startup, keep reconnecting; throw after 3 consecutive failures.
void consumeWithReconnect(url, onEvent, initialBody)
void consumeWithReconnect(requester, onEvent, initialBody)
}
async function connectWithRetries(url: string, maxAttempts: number) {
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
if (!response.ok || !response.body) {
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
}
return response.body
return await requester.requestSseBody("/events")
} catch (error) {
lastError = error
await delay(500 * attempt)
@@ -76,11 +43,12 @@ async function connectWithRetries(url: string, maxAttempts: number) {
}
const reason = lastError instanceof Error ? lastError.message : String(lastError)
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
const url = requester.buildUrl("/events")
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
}
async function consumeWithReconnect(
url: string,
requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void,
initialBody: ReadableStream<Uint8Array>,
) {
@@ -90,7 +58,7 @@ async function consumeWithReconnect(
while (true) {
try {
if (!body) {
body = await connectWithRetries(url, 3)
body = await connectWithRetries(requester, 3)
}
await consumeSseBody(body, onEvent)

View File

@@ -0,0 +1,124 @@
export type PluginEvent = {
type: string
properties?: Record<string, unknown>
}
export type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
}
}
export function createCodeNomadRequester(config: CodeNomadConfig) {
const baseUrl = config.baseUrl.replace(/\/+$/, "")
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
const authorization = buildInstanceAuthorizationHeader()
const buildUrl = (path: string) => {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path
}
const normalized = path.startsWith("/") ? path : `/${path}`
return `${pluginBase}${normalized}`
}
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
const output: Record<string, string> = normalizeHeaders(headers)
output.Authorization = authorization
if (hasBody) {
output["Content-Type"] = output["Content-Type"] ?? "application/json"
}
return output
}
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
const url = buildUrl(path)
const hasBody = init?.body !== undefined
const headers = buildHeaders(init?.headers, hasBody)
return fetch(url, {
...init,
headers,
})
}
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
}
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
if (!response.ok || !response.body) {
throw new Error(`SSE unavailable (${response.status})`)
}
return response.body as ReadableStream<Uint8Array>
}
return {
buildUrl,
fetch: fetchWithAuth,
requestJson,
requestVoid,
requestSseBody,
}
}
function requireEnv(key: string): string {
const value = process.env[key]
if (!value || !value.trim()) {
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
}
return value
}
function buildInstanceAuthorizationHeader(): string {
const username = requireEnv("OPENCODE_SERVER_USERNAME")
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
}

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.6.0",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.6.0",
"version": "0.7.2",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.6.0",
"version": "0.7.2",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
@@ -16,11 +16,11 @@
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
if (!existsSync(sourceDir)) {
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -0,0 +1,175 @@
import fs from "fs"
import path from "path"
import type { Logger } from "../logger"
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
export interface AuthFile {
version: 1
username: string
password: PasswordHashRecord
userProvided: boolean
updatedAt: string
}
export interface AuthStatus {
username: string
passwordUserProvided: boolean
}
export class AuthStore {
private cachedFile: AuthFile | null = null
private overrideAuth: AuthFile | null = null
private bootstrapUsername: string | null = null
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
getAuthFilePath() {
return this.authFilePath
}
load(): AuthFile | null {
if (this.overrideAuth) {
return this.overrideAuth
}
if (this.cachedFile) {
return this.cachedFile
}
try {
if (!fs.existsSync(this.authFilePath)) {
return null
}
const raw = fs.readFileSync(this.authFilePath, "utf-8")
const parsed = JSON.parse(raw) as AuthFile
if (!parsed || parsed.version !== 1) {
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
return null
}
this.cachedFile = parsed
return parsed
} catch (error) {
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
return null
}
}
ensureInitialized(params: {
username: string
password?: string
allowBootstrapWithoutPassword: boolean
}): void {
const password = params.password?.trim()
if (password) {
const now = new Date().toISOString()
const runtime: AuthFile = {
version: 1,
username: params.username,
password: hashPassword(password),
userProvided: true,
updatedAt: now,
}
this.overrideAuth = runtime
this.cachedFile = null
this.bootstrapUsername = null
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
return
}
const existing = this.load()
if (existing) {
if (existing.username !== params.username) {
// Keep existing username unless explicitly overridden later.
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
}
this.bootstrapUsername = null
return
}
if (params.allowBootstrapWithoutPassword) {
this.bootstrapUsername = params.username
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
return
}
throw new Error(
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
)
}
validateCredentials(username: string, password: string): boolean {
const auth = this.load()
if (!auth) {
return false
}
if (username !== auth.username) {
return false
}
return verifyPassword(password, auth.password)
}
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
if (this.overrideAuth) {
throw new Error(
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
)
}
const current = this.load()
if (!current) {
if (!this.bootstrapUsername) {
throw new Error("Auth is not initialized")
}
const created: AuthFile = {
version: 1,
username: this.bootstrapUsername,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(created)
this.bootstrapUsername = null
return { username: created.username, passwordUserProvided: created.userProvided }
}
const next: AuthFile = {
...current,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(next)
return { username: next.username, passwordUserProvided: next.userProvided }
}
getStatus(): AuthStatus {
const current = this.load()
if (current) {
return { username: current.username, passwordUserProvided: current.userProvided }
}
if (this.bootstrapUsername) {
return { username: this.bootstrapUsername, passwordUserProvided: false }
}
throw new Error("Auth is not initialized")
}
private persist(auth: AuthFile) {
try {
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
this.cachedFile = auth
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
} catch (error) {
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
throw error
}
}
}

View File

@@ -0,0 +1,38 @@
import type { FastifyReply, FastifyRequest } from "fastify"
export function parseCookies(header: string | undefined): Record<string, string> {
const result: Record<string, string> = {}
if (!header) return result
const parts = header.split(";")
for (const part of parts) {
const index = part.indexOf("=")
if (index < 0) continue
const key = part.slice(0, index).trim()
const value = part.slice(index + 1).trim()
if (!key) continue
result[key] = decodeURIComponent(value)
}
return result
}
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
if (!remoteAddress) return false
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
if (remoteAddress === "::ffff:127.0.0.1") return true
return false
}
export function wantsHtml(request: FastifyRequest): boolean {
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
return accept.includes("text/html") || accept.includes("application/xhtml")
}
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
reply.redirect("/login")
return
}
reply.code(401).send({ error: "Unauthorized" })
}

View File

@@ -0,0 +1,113 @@
import type { FastifyReply, FastifyRequest } from "fastify"
import path from "path"
import type { Logger } from "../logger"
import { AuthStore } from "./auth-store"
import { TokenManager } from "./token-manager"
import { SessionManager } from "./session-manager"
import { isLoopbackAddress, parseCookies } from "./http-auth"
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
export interface AuthManagerInit {
configPath: string
username: string
password?: string
generateToken: boolean
}
export class AuthManager {
private readonly authStore: AuthStore
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
const authFilePath = resolveAuthFilePath(init.configPath)
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
this.authStore.ensureInitialized({
username: init.username,
password: init.password,
allowBootstrapWithoutPassword: init.generateToken,
})
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
}
getCookieName(): string {
return this.cookieName
}
isTokenBootstrapEnabled(): boolean {
return Boolean(this.tokenManager)
}
issueBootstrapToken(): string | null {
if (!this.tokenManager) return null
return this.tokenManager.generate()
}
consumeBootstrapToken(token: string): boolean {
if (!this.tokenManager) return false
return this.tokenManager.consume(token)
}
validateLogin(username: string, password: string): boolean {
return this.authStore.validateCredentials(username, password)
}
createSession(username: string) {
return this.sessionManager.createSession(username)
}
getStatus() {
return this.authStore.getStatus()
}
setPassword(password: string) {
return this.authStore.setPassword({ password, markUserProvided: true })
}
isLoopbackRequest(request: FastifyRequest): boolean {
return isLoopbackAddress(request.socket.remoteAddress)
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
const cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
if (!session) return null
return { username: session.username, sessionId: session.id }
}
setSessionCookie(reply: FastifyReply, sessionId: string) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
}
clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
}
}
function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json")
}
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
if (options?.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
}
return parts.join("; ")
}

View File

@@ -0,0 +1,49 @@
import crypto from "crypto"
export interface PasswordHashRecord {
algorithm: "scrypt"
saltBase64: string
hashBase64: string
keyLength: number
params: {
N: number
r: number
p: number
maxmem: number
}
}
const DEFAULT_SCRYPT_PARAMS = {
N: 16384,
r: 8,
p: 1,
maxmem: 32 * 1024 * 1024,
}
export function hashPassword(password: string): PasswordHashRecord {
const salt = crypto.randomBytes(16)
const params = DEFAULT_SCRYPT_PARAMS
const keyLength = 64
const derived = crypto.scryptSync(password, salt, keyLength, params)
return {
algorithm: "scrypt",
saltBase64: salt.toString("base64"),
hashBase64: Buffer.from(derived).toString("base64"),
keyLength,
params,
}
}
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
if (record.algorithm !== "scrypt") {
return false
}
const salt = Buffer.from(record.saltBase64, "base64")
const expected = Buffer.from(record.hashBase64, "base64")
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
if (expected.length !== derived.length) {
return false
}
return crypto.timingSafeEqual(expected, Buffer.from(derived))
}

View File

@@ -0,0 +1,23 @@
import crypto from "crypto"
export interface SessionInfo {
id: string
createdAt: number
username: string
}
export class SessionManager {
private sessions = new Map<string, SessionInfo>()
createSession(username: string): SessionInfo {
const id = crypto.randomBytes(32).toString("base64url")
const info: SessionInfo = { id, createdAt: Date.now(), username }
this.sessions.set(id, info)
return info
}
getSession(id: string | undefined): SessionInfo | undefined {
if (!id) return undefined
return this.sessions.get(id)
}
}

View File

@@ -0,0 +1,32 @@
import crypto from "crypto"
export interface BootstrapToken {
token: string
createdAt: number
consumed: boolean
}
export class TokenManager {
private token: BootstrapToken | null = null
constructor(private readonly ttlMs: number) {}
generate(): string {
const token = crypto.randomBytes(32).toString("base64url")
this.token = { token, createdAt: Date.now(), consumed: false }
return token
}
consume(token: string): boolean {
if (!this.token) return false
if (this.token.consumed) return false
if (Date.now() - this.token.createdAt > this.ttlMs) return false
if (token !== this.token.token) return false
this.token.consumed = true
return true
}
peek(): string | null {
return this.token?.token ?? null
}
}

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from "child_process"
import { spawn, spawnSync, type ChildProcess } from "child_process"
import { createWriteStream, existsSync, promises as fs } from "fs"
import path from "path"
import { randomBytes } from "crypto"
@@ -60,10 +60,13 @@ export class BackgroundProcessManager {
const outputStream = createWriteStream(outputPath, { flags: "a" })
const child = spawn("bash", ["-c", command], {
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
const child = spawn(shellCommand, shellArgs, {
cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
...spawnOptions,
})
child.on("exit", () => {
@@ -274,7 +277,15 @@ export class BackgroundProcessManager {
const pid = child.pid
if (!pid) return
if (process.platform !== "win32") {
if (process.platform === "win32") {
const args = this.buildWindowsTaskkillArgs(pid, signal)
try {
spawnSync("taskkill", args, { stdio: "ignore" })
return
} catch {
// Fall back to killing the direct child.
}
} else {
try {
process.kill(-pid, signal)
return
@@ -321,6 +332,30 @@ export class BackgroundProcessManager {
}
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
if (process.platform === "win32") {
const comspec = process.env.ComSpec || "cmd.exe"
return {
shellCommand: comspec,
shellArgs: ["/d", "/s", "/c", command],
spawnOptions: { windowsVerbatimArguments: true },
}
}
// Keep bash for macOS/Linux.
return { shellCommand: "bash", shellArgs: ["-c", command] }
}
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
// Default to graceful termination (no /F), then force kill when we escalate.
const force = signal === "SIGKILL"
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
return args
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"

View File

@@ -4,10 +4,12 @@ import {
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { spawnSync } from "child_process"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
import { buildSpawnSpec } from "../workspaces/runtime"
export class BinaryRegistry {
constructor(
@@ -135,8 +137,42 @@ export class BinaryRegistry {
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check.
return { valid: true, version: record.version }
const inputPath = record.path
if (!inputPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(inputPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
private buildFallbackRecord(path: string): BinaryRecord {

View File

@@ -18,6 +18,7 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
const require = createRequire(import.meta.url)
@@ -37,6 +38,9 @@ interface CliOptions {
uiStaticDir: string
uiDevServer?: string
launch: boolean
authUsername: string
authPassword?: string
generateToken: boolean
}
const DEFAULT_PORT = 9898
@@ -63,6 +67,17 @@ function parseCliOptions(argv: string[]): CliOptions {
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
.addOption(
new Option("--username <username>", "Username for server authentication")
.env("CODENOMAD_SERVER_USERNAME")
.default(DEFAULT_AUTH_USERNAME),
)
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN")
.default(false),
)
program.parse(argv, { from: "user" })
const parsed = program.opts<{
@@ -77,6 +92,9 @@ function parseCliOptions(argv: string[]): CliOptions {
uiDir: string
uiDevServer?: string
launch?: boolean
username: string
password?: string
generateToken?: boolean
}>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
@@ -94,6 +112,9 @@ function parseCliOptions(argv: string[]): CliOptions {
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
generateToken: Boolean(parsed.generateToken),
}
}
@@ -119,7 +140,12 @@ async function main() {
const configLogger = logger.child({ component: "config" })
const eventLogger = logger.child({ component: "events" })
logger.info({ options }, "Starting CodeNomad CLI server")
const logOptions = {
...options,
authPassword: options.authPassword ? "[REDACTED]" : undefined,
}
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
const eventBus = new EventBus(eventLogger)
@@ -134,6 +160,23 @@ async function main() {
addresses: [],
}
const authManager = new AuthManager(
{
configPath: options.configPath,
username: options.authUsername,
password: options.authPassword,
generateToken: options.generateToken,
},
logger.child({ component: "auth" }),
)
if (options.generateToken) {
const token = authManager.issueBootstrapToken()
if (token) {
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
}
}
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
@@ -175,6 +218,7 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
logger,

View File

@@ -23,6 +23,9 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
interface HttpServerDeps {
host: string
@@ -34,6 +37,7 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
authManager: AuthManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -88,8 +92,34 @@ export function createHttpServer(deps: HttpServerDeps) {
done()
})
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
app.register(cors, {
origin: true,
origin: (origin, cb) => {
if (!origin) {
cb(null, true)
return
}
let selfOrigin: string | null = null
try {
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
} catch {
selfOrigin = null
}
if (selfOrigin && origin === selfOrigin) {
cb(null, true)
return
}
if (allowedDevOrigins.has(origin)) {
cb(null, true)
return
}
cb(null, false)
},
credentials: true,
})
@@ -109,6 +139,76 @@ export function createHttpServer(deps: HttpServerDeps) {
logger: deps.logger.child({ component: "background-processes" }),
})
registerAuthRoutes(app, { authManager: deps.authManager })
app.addHook("preHandler", (request, reply, done) => {
const rawUrl = request.raw.url ?? request.url
const pathname = (rawUrl.split("?")[0] ?? "").trim()
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
const publicPagePaths = new Set(["/login"])
if (deps.authManager.isTokenBootstrapEnabled()) {
publicPagePaths.add("/auth/token")
}
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
done()
return
}
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
if (pluginMatch) {
const workspaceId = pluginMatch[1]
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
const provided = Array.isArray(request.headers.authorization)
? request.headers.authorization[0]
: request.headers.authorization
if (expected && provided && provided === expected) {
done()
return
}
}
sendUnauthorized(request, reply)
return
}
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
done()
})
app.get("/", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.redirect("/login")
return
}
if (deps.uiDevServerUrl) {
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
return
}
const uiDir = deps.uiStaticDir
const indexPath = path.join(uiDir, "index.html")
if (uiDir && fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
return
}
reply.code(404).send({ message: "UI bundle missing" })
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
@@ -125,9 +225,9 @@ export function createHttpServer(deps: HttpServerDeps) {
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl)
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
} else {
setupStaticUi(app, deps.uiStaticDir)
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
}
return {
@@ -260,6 +360,7 @@ async function proxyWorkspaceRequest(args: {
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) {
@@ -267,6 +368,12 @@ async function proxyWorkspaceRequest(args: {
}
return reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
return headers
},
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
@@ -284,7 +391,7 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
function setupStaticUi(app: FastifyInstance, uiDir: string) {
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
return
@@ -310,6 +417,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
return
}
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
if (fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
} else {
@@ -318,7 +431,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
})
}
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
@@ -326,6 +439,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
reply.code(404).send({ message: "Not Found" })
return
}
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
void proxyToDevServer(request, reply, upstreamBase)
})
}

View File

@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad Login</title>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: #0b0b0f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 420px;
max-width: calc(100vw - 32px);
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 24px;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
p {
margin: 0 0 18px;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
label {
display: block;
font-size: 12px;
margin: 10px 0 6px;
color: rgba(255, 255, 255, 0.75);
}
input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #0f0f16;
color: #fff;
}
button {
width: 100%;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 0;
background: #4c6fff;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign in</h1>
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
<label for="username">Username</label>
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" value="" />
<button id="submit" type="button">Continue</button>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const $ = (id) => document.getElementById(id)
const errorEl = $("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
const hideError = () => {
errorEl.textContent = ""
errorEl.style.display = "none"
}
async function submit() {
hideError()
const username = $("username").value.trim()
const password = $("password").value
if (!username || !password) {
showError("Username and password are required.")
return
}
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Login failed (${res.status})`)
return
}
window.location.href = "/"
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
$("submit").addEventListener("click", submit)
$("password").addEventListener("keydown", (e) => {
if (e.key === "Enter") submit()
})
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: #0b0b0f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 420px;
max-width: calc(100vw - 32px);
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 24px;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Connecting…</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const token = (location.hash || "").replace(/^#/, "").trim()
const errorEl = document.getElementById("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Token exchange failed (${res.status})`)
return
}
window.location.replace("/")
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
run()
</script>
</body>
</html>

View File

@@ -0,0 +1,157 @@
import type { FastifyInstance } from "fastify"
import fs from "fs"
import { z } from "zod"
import type { AuthManager } from "../../auth/manager"
import { isLoopbackAddress } from "../../auth/http-auth"
interface RouteDeps {
authManager: AuthManager
}
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
const TokenSchema = z.object({
token: z.string().min(1),
})
const PasswordSchema = z.object({
password: z.string().min(8),
})
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
let cachedLoginTemplate: string | null = null
let cachedTokenTemplate: string | null = null
function readTemplate(url: URL, cache: string | null): string {
if (cache) return cache
const content = fs.readFileSync(url, "utf-8")
return content
}
function getLoginHtml(defaultUsername: string): string {
if (!cachedLoginTemplate) {
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
}
const escapedUsername = escapeHtml(defaultUsername)
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
}
function getTokenHtml(): string {
if (!cachedTokenTemplate) {
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
}
return cachedTokenTemplate
}
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (_request, reply) => {
const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username))
})
app.get("/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.type("text/html").send(getTokenHtml())
})
app.get("/api/auth/status", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.send({ authenticated: false })
return
}
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
})
app.post("/api/auth/login", async (request, reply) => {
const body = LoginSchema.parse(request.body ?? {})
const ok = deps.authManager.validateLogin(body.username, body.password)
if (!ok) {
reply.code(401).send({ error: "Invalid credentials" })
return
}
const session = deps.authManager.createSession(body.username)
deps.authManager.setSessionCookie(reply, session.id)
reply.send({ ok: true })
})
app.post("/api/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
const body = TokenSchema.parse(request.body ?? {})
const ok = deps.authManager.consumeBootstrapToken(body.token)
if (!ok) {
reply.code(401).send({ error: "Invalid token" })
return
}
const username = deps.authManager.getStatus().username
const session = deps.authManager.createSession(username)
deps.authManager.setSessionCookie(reply, session.id)
reply.send({ ok: true })
})
app.post("/api/auth/logout", async (_request, reply) => {
deps.authManager.clearSessionCookie(reply)
reply.send({ ok: true })
})
app.post("/api/auth/password", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.code(401).send({ error: "Unauthorized" })
return
}
const body = PasswordSchema.parse(request.body ?? {})
try {
const status = deps.authManager.setPassword(body.password)
reply.send({ ok: true, ...status })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
reply.code(409).type("text/plain").send(message)
}
})
}
function escapeHtml(value: string) {
return value.replace(/[&<>"]/g, (char) => {
switch (char) {
case "&":
return "&amp;"
case "<":
return "&lt;"
case ">":
return "&gt;"
case '"':
return "&quot;"
default:
return char
}
})
}

View File

@@ -96,8 +96,15 @@ export class InstanceEventBridge {
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const headers: Record<string, string> = { Accept: "text/event-stream" }
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
headers,
signal,
dispatcher: STREAM_AGENT,
})

View File

@@ -11,6 +11,13 @@ import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
import {
buildOpencodeBasicAuthHeader,
DEFAULT_OPENCODE_USERNAME,
generateOpencodeServerPassword,
OPENCODE_SERVER_PASSWORD_ENV,
OPENCODE_SERVER_USERNAME_ENV,
} from "./opencode-auth"
const STARTUP_STABILITY_DELAY_MS = 1500
@@ -29,6 +36,7 @@ export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
@@ -47,6 +55,10 @@ export class WorkspaceManager {
return this.workspaces.get(id)?.port
}
getInstanceAuthorizationHeader(id: string): string | undefined {
return this.opencodeAuth.get(id)?.authorization
}
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
@@ -106,11 +118,22 @@ export class WorkspaceManager {
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword()
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
if (!authorization) {
throw new Error("Failed to build OpenCode auth header")
}
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}
try {
@@ -154,6 +177,7 @@ export class WorkspaceManager {
}
this.workspaces.delete(id)
this.opencodeAuth.delete(id)
clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
@@ -174,6 +198,7 @@ export class WorkspaceManager {
}
}
this.workspaces.clear()
this.opencodeAuth.clear()
this.options.logger.info("All workspaces cleared")
}
@@ -200,13 +225,15 @@ export class WorkspaceManager {
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
const candidates = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
.filter((line) => line.length > 0)
.filter((line) => !/^INFO:/i.test(line))
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
if (candidates.length > 0) {
const resolved = this.pickBinaryCandidate(candidates)
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
@@ -219,6 +246,23 @@ export class WorkspaceManager {
return identifier
}
private pickBinaryCandidate(candidates: string[]): string {
if (process.platform !== "win32") {
return candidates[0] ?? ""
}
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
for (const ext of extensionPreference) {
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
if (match) {
return match
}
}
return candidates[0] ?? ""
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
@@ -317,7 +361,13 @@ export class WorkspaceManager {
const url = `http://127.0.0.1:${port}/project/current`
try {
const response = await fetch(url)
const headers: Record<string, string> = {}
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, { headers })
if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
@@ -408,6 +458,8 @@ export class WorkspaceManager {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return
this.opencodeAuth.delete(workspaceId)
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined

View File

@@ -0,0 +1,22 @@
import crypto from "node:crypto"
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
export function generateOpencodeServerPassword(): string {
return crypto.randomBytes(32).toString("base64url")
}
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
const username = params.username
const password = params.password
if (!username || !password) {
return undefined
}
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}

View File

@@ -5,6 +5,55 @@ import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true } as const,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {} as const,
}
}
return { command: binaryPath, args, options: {} as const }
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
redacted[key] = value
continue
}
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
}
return redacted
}
interface LaunchOptions {
workspaceId: string
folder: string
@@ -59,22 +108,25 @@ export class WorkspaceRuntime {
}
return new Promise((resolve, reject) => {
const commandLine = [options.binaryPath, ...args].join(" ")
const spec = buildSpawnSpec(options.binaryPath, args)
const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info(
{
workspaceId: options.workspaceId,
folder: options.folder,
binary: options.binaryPath,
args,
spawnCommand: spec.command,
spawnArgs: spec.args,
commandLine,
env,
env: redactEnvironment(env),
},
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, {
const child = spawn(spec.command, spec.args, {
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
...spec.options,
})
const managed: ManagedProcess = { child, requestedStop: false }

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.6.0",
"version": "0.7.2",
"private": true,
"scripts": {
"dev": "tauri dev",

View File

@@ -166,6 +166,44 @@ function copyServerArtifacts() {
}
}
function stripNodeModuleBins() {
const root = path.join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
@@ -192,4 +230,5 @@ ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()

View File

@@ -7,14 +7,15 @@ use std::collections::VecDeque;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, Url};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
@@ -31,9 +32,15 @@ fn workspace_root() -> Option<PathBuf> {
})
}
const SESSION_COOKIE_NAME: &str = "codenomad_session";
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}"));
let mut display = url.to_string();
if let Some(hash_index) = display.find('#') {
display.replace_range(hash_index + 1.., "[REDACTED]");
}
log_line(&format!("navigating main to {display}"));
if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed);
} else {
@@ -44,6 +51,85 @@ fn navigate_main(app: &AppHandle, url: &str) {
}
}
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
let cookie_kv = set_cookie.split(';').next()?.trim();
if !cookie_kv.starts_with(&prefix) {
return None;
}
let value = cookie_kv.trim_start_matches(&prefix).trim();
if value.is_empty() {
return None;
}
Some(value.to_string())
}
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80);
// This is only used for local bootstrap; we assume plain HTTP.
let mut stream = TcpStream::connect((host, port))?;
let body = format!("{{\"token\":\"{}\"}}", token);
let request = format!(
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.as_bytes().len(),
body
);
stream.write_all(request.as_bytes())?;
stream.flush()?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
let (raw_headers, _rest) = response
.split_once("\r\n\r\n")
.or_else(|| response.split_once("\n\n"))
.unwrap_or((response.as_str(), ""));
let mut lines = raw_headers.lines();
let status_line = lines.next().unwrap_or("");
if !status_line.contains(" 200 ") {
return Ok(None);
}
for line in lines {
// handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id));
}
} else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id));
}
}
}
Ok(None)
}
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
.domain(domain)
.path("/")
.http_only(true)
.same_site(tauri::webview::cookie::SameSite::Lax)
.build();
if let Some(win) = app.webview_windows().get("main") {
win.set_cookie(cookie)?;
}
Ok(())
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)]
@@ -139,6 +225,7 @@ pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
}
impl CliProcessManager {
@@ -147,6 +234,7 @@ impl CliProcessManager {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)),
}
}
@@ -154,6 +242,7 @@ impl CliProcessManager {
log_line(&format!("start requested (dev={dev})"));
self.stop()?;
self.ready.store(false, Ordering::SeqCst);
*self.bootstrap_token.lock() = None;
{
let mut status = self.status.lock();
status.state = CliState::Starting;
@@ -167,8 +256,9 @@ impl CliProcessManager {
let status_arc = self.status.clone();
let child_arc = self.child.clone();
let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone();
thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock();
locked.state = CliState::Error;
@@ -237,6 +327,7 @@ impl CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool,
) -> anyhow::Result<()> {
log_line("resolving CLI entry");
@@ -318,8 +409,10 @@ impl CliProcessManager {
let status_clone = status.clone();
let app_clone = app.clone();
let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone();
thread::spawn(move || {
let stdout = child_clone
.lock()
.as_mut()
@@ -332,10 +425,10 @@ impl CliProcessManager {
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
}
if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
}
});
@@ -407,10 +500,12 @@ impl CliProcessManager {
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop {
buffer.clear();
@@ -419,6 +514,17 @@ impl CliProcessManager {
Ok(_) => {
let line = buffer.trim_end();
if !line.is_empty() {
if line.starts_with(token_prefix) {
let token = line.trim_start_matches(token_prefix).trim();
if !token.is_empty() {
let mut guard = bootstrap_token.lock();
if guard.is_none() {
*guard = Some(token.to_string());
}
}
continue;
}
log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) {
@@ -430,7 +536,7 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
Self::mark_ready(app, status, ready, bootstrap_token, port);
continue;
}
@@ -440,13 +546,13 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
Self::mark_ready(app, status, ready, bootstrap_token, port);
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, port as u16);
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
continue;
}
}
@@ -458,16 +564,46 @@ impl CliProcessManager {
}
}
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
fn mark_ready(
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
port: u16,
) {
ready.store(true, Ordering::SeqCst);
let base_url = format!("http://127.0.0.1:{port}");
let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port);
locked.url = Some(url.clone());
locked.url = Some(base_url.clone());
locked.state = CliState::Ready;
locked.error = None;
log_line(&format!("cli ready on {url}"));
navigate_main(app, &url);
log_line(&format!("cli ready on {base_url}"));
let token = bootstrap_token.lock().take();
if let Some(token) = token {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
} else {
navigate_main(app, &base_url);
}
let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked);
}
@@ -551,6 +687,7 @@ impl CliEntry {
host.to_string(),
"--port".to_string(),
"0".to_string(),
"--generate-token".to_string(),
];
if dev {
args.push("--ui-dev-server".to_string());

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.6.0",
"version": "0.7.2",
"private": true,
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.1",
"@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View File

@@ -76,7 +76,7 @@ export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDial
setLoading(false)
})
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { type?: string; content?: string }

View File

@@ -0,0 +1,30 @@
import { Show } from "solid-js"
import { Maximize2, Minimize2 } from "lucide-solid"
interface ExpandButtonProps {
expandState: () => "normal" | "expanded"
onToggleExpand: (nextState: "normal" | "expanded") => void
}
export default function ExpandButton(props: ExpandButtonProps) {
function handleClick() {
const current = props.expandState()
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
}
return (
<button
type="button"
class="prompt-expand-button"
onClick={handleClick}
aria-label="Toggle chat input height"
>
<Show
when={props.expandState() === "normal"}
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
>
<Maximize2 class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
)
}

View File

@@ -875,7 +875,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instance.id}
sessions={allInstanceSessions()}
threads={sessionThreads()}
activeSessionId={activeSessionIdForInstance()}
onSelect={handleSessionSelect}

View File

@@ -1,4 +1,4 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { FoldVertical } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
@@ -82,8 +82,20 @@ interface TaskSessionLocation {
parentId: string | null
}
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string): TaskSessionLocation | null {
if (!sessionId) return null
if (preferredInstanceId) {
const session = sessions().get(preferredInstanceId)?.get(sessionId)
if (session) {
return {
sessionId: session.id,
instanceId: preferredInstanceId,
parentId: session.parentId ?? null,
}
}
}
const allSessions = sessions()
for (const [instanceId, sessionMap] of allSessions) {
const session = sessionMap?.get(sessionId)
@@ -235,16 +247,11 @@ export default function MessageBlock(props: MessageBlockProps) {
const index = props.messageIndex
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const info = messageInfo()
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
const infoTimestamp =
typeof infoTime.completed === "number"
? infoTime.completed
: typeof infoTime.updated === "number"
? infoTime.updated
: infoTime.created ?? 0
const infoError = (info as { error?: { name?: string } } | undefined)?.error
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
// Intentionally untracked: messageInfoVersion updates should not trigger
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const cacheSignature = [
current.id,
current.revision,
@@ -252,8 +259,6 @@ export default function MessageBlock(props: MessageBlockProps) {
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
props.showUsageMetrics() ? 1 : 0,
infoTimestamp,
infoErrorName,
].join("|")
const cachedBlock = sessionCache.messageBlocks.get(current.id)
@@ -447,7 +452,7 @@ export default function MessageBlock(props: MessageBlockProps) {
const hasToolState =
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()

View File

@@ -1,8 +1,16 @@
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { activePermissionId, getPermissionQueue } from "../stores/instances"
import { loadMessages, setActiveSession } from "../stores/sessions"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
import {
activeInterruption,
getPermissionQueue,
getQuestionQueue,
getQuestionEnqueuedAtForInstance,
setActivePermissionIdForInstance,
setActiveQuestionIdForInstance,
} from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call"
@@ -88,24 +96,72 @@ function resolveToolCallFromPermission(
return null
}
function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null {
const sessionId = getQuestionSessionId(request)
const messageId = getQuestionMessageId(request)
if (!sessionId || !messageId) return null
const store = messageStoreBus.getInstance(instanceId)
if (!store) return null
const record = store.getMessage(messageId)
if (!record) return null
const callId = getQuestionCallId(request)
if (!callId) return null
for (const partId of record.partIds) {
const partRecord = record.parts?.[partId]
const part = partRecord?.data as any
if (!part || part.type !== "tool") continue
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
if (partCallId !== callId) continue
if (typeof part.id !== "string" || part.id.length === 0) continue
return {
messageId,
sessionId,
toolPart: part as ResolvedToolCall["toolPart"],
messageVersion: record.revision,
partVersion: partRecord?.revision ?? 0,
}
}
return null
}
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const queue = createMemo(() => getPermissionQueue(props.instanceId))
const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null)
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
const orderedQueue = createMemo(() => {
const current = queue()
const activeId = activePermId()
if (!activeId) return current
const index = current.findIndex((entry) => entry.id === activeId)
if (index <= 0) return current
const active = current[index]
if (!active) return current
return [active, ...current.slice(0, index), ...current.slice(index + 1)]
type InterruptionItem =
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike }
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
const orderedQueue = createMemo<InterruptionItem[]>(() => {
const permissions = permissionQueue().map((permission) => ({
kind: "permission" as const,
id: permission.id,
sessionId: getPermissionSessionId(permission) || "",
createdAt: (permission as any)?.time?.created ?? Date.now(),
payload: permission,
}))
const questions = questionQueue().map((question) => ({
kind: "question" as const,
id: question.id,
sessionId: getQuestionSessionId(question) || "",
createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id),
payload: question,
}))
return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt)
})
const hasPermissions = createMemo(() => queue().length > 0)
const hasRequests = createMemo(() => orderedQueue().length > 0)
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
@@ -122,7 +178,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
createEffect(() => {
if (!props.isOpen) return
if (queue().length === 0) {
if (orderedQueue().length === 0) {
props.onClose()
}
})
@@ -145,7 +201,14 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
function handleGoToSession(sessionId: string) {
if (!sessionId) return
setActiveSession(props.instanceId, sessionId)
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
}
setActiveSessionFromList(props.instanceId, sessionId)
props.onClose()
}
@@ -156,10 +219,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<div class="permission-center-modal-header">
<div class="permission-center-modal-title-row">
<h2 id="permission-center-title" class="permission-center-modal-title">
Permissions
Requests
</h2>
<Show when={queue().length > 0}>
<span class="permission-center-modal-count">{queue().length}</span>
<Show when={orderedQueue().length > 0}>
<span class="permission-center-modal-count">{orderedQueue().length}</span>
</Show>
</div>
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
@@ -168,24 +231,58 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
</div>
<div class="permission-center-modal-body">
<Show when={hasPermissions()} fallback={<div class="permission-center-empty">No pending permissions.</div>}>
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
<div class="permission-center-list" role="list">
<For each={orderedQueue()}>
{(permission) => {
const sessionId = getPermissionSessionId(permission) || ""
const isActive = () => permission.id === activePermId()
const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission))
{(item) => {
const isActive = () => active()?.kind === item.kind && active()?.id === item.id
const sessionId = () => item.sessionId
const resolved = createMemo(() => {
if (item.kind === "permission") {
return resolveToolCallFromPermission(props.instanceId, item.payload)
}
return resolveToolCallFromQuestion(props.instanceId, item.payload)
})
const showFallback = () => !resolved()
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question")
const primaryTitle = () => {
if (item.kind === "permission") {
return getPermissionDisplayTitle(item.payload)
}
const first = item.payload.questions?.[0]?.question
return typeof first === "string" && first.trim().length > 0 ? first : "Question"
}
const secondaryTitle = () => {
if (item.kind === "permission") {
return getPermissionKind(item.payload)
}
const count = item.payload.questions?.length ?? 0
return count === 1 ? "1 question" : `${count} questions`
}
const handleActivate = () => {
if (item.kind === "permission") {
setActivePermissionIdForInstance(props.instanceId, item.id)
} else {
setActiveQuestionIdForInstance(props.instanceId, item.id)
}
}
return (
<div
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
role="listitem"
onClick={handleActivate}
>
<div class="permission-center-item-header">
<div class="permission-center-item-heading">
<span class="permission-center-item-kind">{getPermissionKind(permission)}</span>
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
<span class="permission-center-item-kind">{secondaryTitle()}</span>
<Show when={isActive()}>
<span class="permission-center-item-chip">Active</span>
</Show>
@@ -195,7 +292,10 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button
type="button"
class="permission-center-item-action"
onClick={() => handleGoToSession(sessionId)}
onClick={(e) => {
e.stopPropagation()
handleGoToSession(sessionId())
}}
>
Go to Session
</button>
@@ -203,10 +303,13 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
<button
type="button"
class="permission-center-item-action"
disabled={loadingSession() === sessionId}
onClick={() => handleLoadSession(sessionId)}
disabled={loadingSession() === sessionId()}
onClick={(e) => {
e.stopPropagation()
handleLoadSession(sessionId())
}}
>
{loadingSession() === sessionId ? "Loading…" : "Load Session"}
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
</button>
</Show>
</div>
@@ -217,7 +320,7 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
fallback={
<div class="permission-center-fallback">
<div class="permission-center-fallback-title">
<code>{getPermissionDisplayTitle(permission)}</code>
<code>{primaryTitle()}</code>
</div>
<div class="permission-center-fallback-hint">Load session for more information.</div>
</div>

View File

@@ -1,6 +1,6 @@
import { Show, createMemo, type Component } from "solid-js"
import { ShieldAlert } from "lucide-solid"
import { getPermissionQueueLength } from "../stores/instances"
import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances"
interface PermissionNotificationBannerProps {
instanceId: string
@@ -8,15 +8,21 @@ interface PermissionNotificationBannerProps {
}
const PermissionNotificationBanner: Component<PermissionNotificationBannerProps> = (props) => {
const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId))
const hasPermissions = createMemo(() => queueLength() > 0)
const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId))
const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId))
const queueLength = createMemo(() => permissionCount() + questionCount())
const hasRequests = createMemo(() => queueLength() > 0)
const label = createMemo(() => {
const count = queueLength()
return `${count} permission${count === 1 ? "" : "s"} pending approval`
const total = queueLength()
const parts: string[] = []
if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`)
if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`)
const detail = parts.length ? ` (${parts.join(", ")})` : ""
return `${total} pending request${total === 1 ? "" : "s"}${detail}`
})
return (
<Show when={hasPermissions()}>
<Show when={hasRequests()}>
<button
type="button"
class="permission-center-trigger"

View File

@@ -1,6 +1,7 @@
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button"
import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
@@ -46,9 +47,16 @@ export default function PromptInput(props: PromptInputProps) {
const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0)
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
const [expandState, setExpandState] = createSignal<"normal" | "expanded">("normal")
const SELECTION_INSERT_MAX_LENGTH = 2000
let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined
const getPlaceholder = () => {
if (mode() === "shell") {
return "Run a shell command (Esc to exit)..."
}
return "Type your message, @file, @agent, or paste images and text..."
}
@@ -615,7 +623,7 @@ export default function PromptInput(props: PromptInputProps) {
// Record attempted slash commands even if execution fails.
void refreshHistory()
}
try {
if (isShellMode) {
if (props.onRunShell) {
@@ -642,7 +650,7 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus()
}
}
function focusTextareaEnd() {
if (!textareaRef) return
setTimeout(() => {
@@ -652,7 +660,7 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef.focus()
}, 0)
}
function canUseHistory(force = false) {
if (force) return true
if (showPicker()) return false
@@ -660,29 +668,29 @@ export default function PromptInput(props: PromptInputProps) {
if (!textarea) return false
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
}
function selectPreviousHistory(force = false) {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(force)) return false
if (historyIndex() === -1) {
setHistoryDraft(prompt())
}
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
setHistoryIndex(newIndex)
setPrompt(entries[newIndex])
focusTextareaEnd()
return true
}
function selectNextHistory(force = false) {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(force)) return false
if (historyIndex() === -1) return false
const newIndex = historyIndex() - 1
if (newIndex >= 0) {
setHistoryIndex(newIndex)
@@ -696,12 +704,18 @@ export default function PromptInput(props: PromptInputProps) {
focusTextareaEnd()
return true
}
function handleAbort() {
if (!props.onAbortSession || !props.isSessionBusy) return
void props.onAbortSession()
}
function handleExpandToggle(nextState: "normal" | "expanded") {
setExpandState(nextState)
// Keep focus on textarea
textareaRef?.focus()
}
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
@@ -765,9 +779,9 @@ export default function PromptInput(props: PromptInputProps) {
item:
| { type: "agent"; agent: Agent }
| {
type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
}
type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
}
| { type: "command"; command: SDKCommand },
) {
if (item.type === "command") {
@@ -1018,18 +1032,18 @@ export default function PromptInput(props: PromptInputProps) {
}
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
const hasHistory = () => history().length > 0
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
const canHistoryGoNext = () => historyIndex() >= 0
const canSend = () => {
if (props.disabled) return false
const hasText = prompt().trim().length > 0
if (mode() === "shell") return hasText
return hasText || attachments().length > 0
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
const commandHint = () => ({ key: "/", text: "Commands" })
@@ -1040,7 +1054,6 @@ export default function PromptInput(props: PromptInputProps) {
return (
<div class="prompt-input-container">
<div
ref={containerRef}
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
style={
isDragging()
@@ -1067,188 +1080,92 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
<div class="flex flex-1 flex-col">
<Show when={attachments().length > 0}>
<div class="flex flex-wrap gap-1.5 border-b pb-2" style="border-color: var(--border-base);">
<For each={attachments()}>
{(attachment) => {
const isImage = attachment.mediaType.startsWith("image/")
const textValue = attachment.source.type === "text" ? attachment.source.value : undefined
const isTextAttachment = typeof textValue === "string"
return (
<div
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
title={textValue}
>
<Show
when={isImage}
fallback={
<Show
when={isTextAttachment}
fallback={
<Show
when={attachment.source.type === "agent"}
fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</Show>
}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</Show>
}
>
<img src={attachment.url} alt={attachment.filename} class="h-5 w-5 rounded object-cover" />
</Show>
<span>{isTextAttachment ? attachment.display : attachment.filename}</span>
<Show when={isTextAttachment}>
<button
onClick={() => handleExpandTextAttachment(attachment)}
class="attachment-expand"
aria-label="Expand pasted text"
title="Insert pasted text"
>
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 7h6v6H7z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4h12v12" />
</svg>
</button>
</Show>
<button
onClick={() => handleRemoveAttachment(attachment.id)}
class="attachment-remove"
aria-label="Remove attachment"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={attachment.filename} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<div class="prompt-input-field-container">
<div class="prompt-input-field">
<div class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""}`}>
<div class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}>
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
mode() === "shell"
? "Run a shell command (Esc to exit)..."
: "Type your message, @file, @agent, or paste images and text..."
}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={4}
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
<Show when={hasHistory()}>
<div class="prompt-history-top">
<button
type="button"
class="prompt-history-button"
onClick={() => selectPreviousHistory(true)}
disabled={!canHistoryGoPrevious()}
aria-label="Previous prompt"
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
placeholder={getPlaceholder()}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={expandState() === "expanded" ? 15 : 4}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
<div class="prompt-nav-buttons">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<button
type="button"
class="prompt-history-button"
onClick={() => selectPreviousHistory(true)}
disabled={!canHistoryGoPrevious()}
aria-label="Previous prompt"
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() => selectNextHistory(true)}
disabled={!canHistoryGoNext()}
aria-label="Next prompt"
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
<div class="prompt-history-bottom">
<button
type="button"
class="prompt-history-button"
onClick={() => selectNextHistory(true)}
disabled={!canHistoryGoNext()}
aria-label="Next prompt"
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span>
</Show>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
</Show>
</div>
</Show>
</Show>
</div>
</Show>
</div>
</div>
</div>
</div>
<div class="prompt-input-actions">
<button

View File

@@ -19,10 +19,16 @@ interface RemoteAccessOverlayProps {
export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
const [passwordValue, setPasswordValue] = createSignal("")
const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
@@ -38,9 +44,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const refreshMeta = async () => {
setLoading(true)
setError(null)
setPasswordError(null)
try {
const result = await serverApi.fetchServerMeta()
setMeta(result)
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult)
setAuthStatus(authResult)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@@ -108,6 +116,36 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
}
}
const handleSubmitPassword = async () => {
setPasswordError(null)
const next = passwordValue()
const confirm = passwordConfirm()
if (next.trim().length < 8) {
setPasswordError("Password must be at least 8 characters.")
return
}
if (next !== confirm) {
setPasswordError("Passwords do not match.")
return
}
setSavingPassword(true)
try {
const result = await serverApi.setServerPassword(next)
setAuthStatus({ authenticated: true, username: result.username, passwordUserProvided: result.passwordUserProvided })
setPasswordValue("")
setPasswordConfirm("")
setPasswordFormOpen(false)
} catch (err) {
setPasswordError(err instanceof Error ? err.message : String(err))
} finally {
setSavingPassword(false)
}
}
return (
<Dialog
open={props.open}
@@ -175,6 +213,87 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
</section>
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Shield class="remote-icon" />
<div>
<p class="remote-label">Server password</p>
<p class="remote-help">Remote handovers require a password. Set a memorable one to enable logins from other devices.</p>
</div>
</div>
</div>
<Show
when={authStatus() && authStatus()!.authenticated}
fallback={<div class="remote-card">Authentication status unavailable.</div>}
>
<div class="remote-card">
<p class="remote-help">Username: {authStatus()!.username ?? "codenomad"}</p>
<p class="remote-help">
{authStatus()!.passwordUserProvided
? "A password is set for remote access."
: "No memorable password is set yet. Set one to allow remote handover logins."}
</p>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
<button
class="remote-pill"
type="button"
onClick={() => {
setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null)
}}
>
{passwordFormOpen()
? "Cancel"
: authStatus()!.passwordUserProvided
? "Change password"
: "Set password"}
</button>
</div>
<Show when={passwordFormOpen()}>
<div class="selector-input-group" style={{ "margin-top": "12px" }}>
<label class="text-sm font-medium text-secondary">New password</label>
<input
class="selector-input w-full"
type="password"
value={passwordValue()}
onInput={(event) => setPasswordValue(event.currentTarget.value)}
placeholder="At least 8 characters"
/>
</div>
<div class="selector-input-group" style={{ "margin-top": "10px" }}>
<label class="text-sm font-medium text-secondary">Confirm password</label>
<input
class="selector-input w-full"
type="password"
value={passwordConfirm()}
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
/>
</div>
<Show when={passwordError()}>
{(message) => <div class="remote-error" style={{ "margin-top": "10px" }}>{message()}</div>}
</Show>
<div class="remote-actions" style={{ "justify-content": "flex-start", "margin-top": "12px" }}>
<button
class="remote-pill"
type="button"
disabled={savingPassword()}
onClick={() => void handleSubmitPassword()}
>
{savingPassword() ? "Saving…" : "Save password"}
</button>
</div>
</Show>
</div>
</Show>
</section>
<section class="remote-section">
<div class="remote-section-heading">
<div class="remote-section-title">
<Wifi class="remote-icon" />

View File

@@ -1,5 +1,5 @@
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { Session, SessionStatus } from "../types/session"
import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
@@ -14,6 +14,7 @@ import {
isSessionParentExpanded,
loading,
renameSession,
sessions as sessionStateSessions,
setActiveSessionFromList,
toggleSessionParentExpanded,
} from "../stores/sessions"
@@ -25,7 +26,6 @@ const log = getLogger("session")
interface SessionListProps {
instanceId: string
sessions: Map<string, Session>
threads: SessionThread[]
activeSessionId: string | null
onSelect: (sessionId: string) => void
@@ -58,7 +58,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const selectSession = (sessionId: string) => {
const session = props.sessions.get(sessionId)
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
@@ -132,7 +132,7 @@ const SessionList: Component<SessionListProps> = (props) => {
}
const openRenameDialog = (sessionId: string) => {
const session = props.sessions.get(sessionId)
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
if (!session) return
const label = session.title && session.title.trim() ? session.title : sessionId
setRenameTarget({ id: sessionId, title: session.title ?? "", label })
@@ -167,7 +167,7 @@ const SessionList: Component<SessionListProps> = (props) => {
expanded?: boolean
onToggleExpand?: () => void
}> = (rowProps) => {
const session = () => props.sessions.get(rowProps.sessionId)
const session = createMemo(() => sessionStateSessions().get(props.instanceId)?.get(rowProps.sessionId))
if (!session()) {
return <></>
}
@@ -175,9 +175,11 @@ const SessionList: Component<SessionListProps> = (props) => {
const title = () => session()?.title || "Untitled"
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
const statusLabel = () => formatSessionStatus(status())
const pendingPermission = () => Boolean(session()?.pendingPermission)
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
const needsPermission = () => Boolean(session()?.pendingPermission)
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${status()}`)
const statusText = () => (needsPermission() ? "Needs Permission" : needsQuestion() ? "Needs Input" : statusLabel())
return (
<div class="session-list-item group">
@@ -224,7 +226,7 @@ const SessionList: Component<SessionListProps> = (props) => {
</span>
</Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{pendingPermission() ? (
{needsInput() ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
@@ -291,7 +293,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return null
const activeSession = props.sessions.get(activeId)
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
if (!activeSession) return null
return activeSession.parentId ?? activeSession.id

View File

@@ -1,10 +1,13 @@
import { Show, createMemo, createEffect, type Component } from "solid-js"
import { Show, For, createMemo, createEffect, type Component } from "solid-js"
import { Expand } from "lucide-solid"
import type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message"
import MessageSection from "../message-section"
import { messageStoreBus } from "../../stores/message-v2/bus"
import PromptInput from "../prompt-input"
import type { Attachment as PromptAttachment } from "../../types/attachment"
import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
@@ -39,6 +42,62 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (!currentSession) return false
return getSessionBusyStatus(props.instanceId, currentSession.id)
})
const sessionNeedsInput = createMemo(() => {
const currentSession = session()
if (!currentSession) return false
return Boolean(currentSession.pendingPermission || (currentSession as any).pendingQuestion)
})
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
function handleExpandTextAttachment(attachment: PromptAttachment) {
if (attachment.source.type !== "text") return
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
const value = attachment.source.value
const match = attachment.display.match(/pasted #(\d+)/)
const placeholder = match ? `[pasted #${match[1]}]` : null
const currentText = textarea?.value ?? ""
let nextText = currentText
let selectionTarget: number | null = null
if (placeholder) {
const placeholderIndex = currentText.indexOf(placeholder)
if (placeholderIndex !== -1) {
nextText =
currentText.substring(0, placeholderIndex) +
value +
currentText.substring(placeholderIndex + placeholder.length)
selectionTarget = placeholderIndex + value.length
}
}
if (nextText === currentText) {
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
nextText = currentText.substring(0, start) + value + currentText.substring(end)
selectionTarget = start + value.length
} else {
nextText = currentText + value
}
}
if (textarea) {
textarea.value = nextText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
if (selectionTarget !== null) {
textarea.setSelectionRange(selectionTarget, selectionTarget)
}
}
removeAttachment(props.instanceId, props.sessionId, attachment.id)
}
let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined
function scheduleScrollToBottom() {
@@ -224,17 +283,52 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
onAbortSession={handleAbortSession}
registerQuoteHandler={registerQuoteHandler}
/>
<Show when={attachments().length > 0}>
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
<For each={attachments()}>
{(attachment) => {
const isText = attachment.source.type === "text"
return (
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
<span class="font-mono">{attachment.display}</span>
<Show when={isText}>
<button
type="button"
class="attachment-expand"
onClick={() => handleExpandTextAttachment(attachment)}
aria-label="Expand pasted text"
title="Insert pasted text"
>
<Expand class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="attachment-remove"
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
aria-label="Remove attachment"
>
×
</button>
</div>
)
}}
</For>
</div>
</Show>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerQuoteHandler={registerQuoteHandler}
/>
</div>
)
}}

View File

@@ -6,8 +6,9 @@ import { useTheme } from "../lib/theme"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import { useConfig } from "../stores/preferences"
import type { DiffViewMode } from "../stores/preferences"
import { sendPermissionResponse } from "../stores/instances"
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import type { TextPart, RenderCache } from "../types/message"
import { resolveToolRenderer } from "./tool-call/renderers"
import type {
@@ -239,6 +240,7 @@ export default function ToolCall(props: ToolCallProps) {
}))
const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
const activeRequest = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
const cacheVersion = createMemo(() => {
if (typeof props.partVersion === "number") {
@@ -278,6 +280,16 @@ export default function ToolCall(props: ToolCallProps) {
}
return toolCallMemo()?.pendingPermission
})
const questionState = createMemo(() => store().getQuestionState(props.messageId, toolCallIdentifier()))
const pendingQuestion = createMemo(() => {
const state = questionState()
if (state) {
return { request: state.entry.request as QuestionRequest, active: state.active }
}
return undefined
})
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
@@ -292,27 +304,45 @@ export default function ToolCall(props: ToolCallProps) {
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
const isPermissionActive = createMemo(() => {
const pending = pendingPermission()
if (!pending?.permission) return false
const active = activeRequest()
return active?.kind === "permission" && active.id === pending.permission.id
})
const isQuestionActive = createMemo(() => {
const pending = pendingQuestion()
if (!pending?.request) return false
const active = activeRequest()
return active?.kind === "question" && active.id === pending.request.id
})
const expanded = () => {
const permission = pendingPermission()
if (permission?.active) return true
if (isPermissionActive() || isQuestionActive()) return true
const override = userExpanded()
if (override !== null) return override
return defaultExpandedForTool()
}
const permissionDetails = createMemo(() => pendingPermission()?.permission)
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
const questionDetails = createMemo(() => pendingQuestion()?.request)
const activePermissionKey = createMemo(() => {
const permission = permissionDetails()
return permission && isPermissionActive() ? permission.id : ""
})
const activeQuestionKey = createMemo(() => {
const request = questionDetails()
return request && isQuestionActive() ? request.id : ""
})
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
const diagnosticsExpanded = () => {
const permission = pendingPermission()
if (permission?.active) return true
if (isPermissionActive() || isQuestionActive()) return true
const override = diagnosticsOverride()
if (override !== undefined) return override
return diagnosticsDefaultExpanded()
@@ -513,7 +543,7 @@ export default function ToolCall(props: ToolCallProps) {
})
createEffect(() => {
const activeKey = activePermissionKey()
const activeKey = activePermissionKey() || activeQuestionKey()
if (!activeKey) return
requestAnimationFrame(() => {
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
@@ -539,6 +569,81 @@ export default function ToolCall(props: ToolCallProps) {
onCleanup(() => document.removeEventListener("keydown", handler))
})
const [questionSubmitting, setQuestionSubmitting] = createSignal(false)
const [questionError, setQuestionError] = createSignal<string | null>(null)
const [questionDraftAnswers, setQuestionDraftAnswers] = createSignal<Record<string, string[][]>>({})
const [questionCustomDraft, setQuestionCustomDraft] = createSignal<Record<string, string[]>>({})
function isTextInputFocused() {
const active = document.activeElement
return (
active?.tagName === "TEXTAREA" ||
active?.tagName === "INPUT" ||
(active?.hasAttribute("contenteditable") ?? false)
)
}
async function handleQuestionSubmit() {
const request = questionDetails()
if (!request || !isQuestionActive()) {
return
}
const answers = (questionDraftAnswers()[request.id] ?? []).map((x) => (Array.isArray(x) ? x : []))
const normalized = request.questions.map((_, index) => answers[index] ?? [])
if (normalized.some((item) => (item?.length ?? 0) === 0)) {
setQuestionError("Please answer all questions before submitting.")
return
}
setQuestionSubmitting(true)
setQuestionError(null)
try {
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
await sendQuestionReply(props.instanceId, sessionId, request.id, normalized)
} catch (error) {
log.error("Failed to send question reply", error)
setQuestionError(error instanceof Error ? error.message : "Unable to reply")
} finally {
setQuestionSubmitting(false)
}
}
async function handleQuestionDismiss() {
const request = questionDetails()
if (!request || !isQuestionActive()) {
return
}
setQuestionSubmitting(true)
setQuestionError(null)
try {
const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId
await sendQuestionReject(props.instanceId, sessionId, request.id)
} catch (error) {
log.error("Failed to reject question", error)
setQuestionError(error instanceof Error ? error.message : "Unable to dismiss")
} finally {
setQuestionSubmitting(false)
}
}
createEffect(() => {
const activeKey = activeQuestionKey()
if (!activeKey) return
const handler = (event: KeyboardEvent) => {
if (isTextInputFocused()) return
if (event.key === "Enter") {
event.preventDefault()
void handleQuestionSubmit()
} else if (event.key === "Escape") {
event.preventDefault()
void handleQuestionDismiss()
}
}
document.addEventListener("keydown", handler)
onCleanup(() => document.removeEventListener("keydown", handler))
})
const statusIcon = () => {
const status = toolState()?.status || ""
@@ -563,7 +668,7 @@ export default function ToolCall(props: ToolCallProps) {
const combinedStatusClass = () => {
const base = statusClass()
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
return pendingPermission() || pendingQuestion() ? `${base} tool-call-awaiting-permission` : base
}
function toggle() {
@@ -950,6 +1055,218 @@ export default function ToolCall(props: ToolCallProps) {
)
}
const renderQuestionBlock = () => {
const state = toolState()
const request = questionDetails()
const isQuestionTool = toolName() === "question"
if (!request && !isQuestionTool) return null
const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? []
const questions = Array.isArray(questionsSource) ? questionsSource : []
if (questions.length === 0) return null
const requestId = request?.id ?? (state as any)?.input?.requestID ?? `question-${toolCallMemo()?.id ?? "unknown"}`
const active = Boolean(request && isQuestionActive())
const completedAnswers = Array.isArray((state as any)?.metadata?.answers) ? ((state as any).metadata.answers as string[][]) : undefined
const answers = completedAnswers ?? questionDraftAnswers()[requestId] ?? []
const customInputs = questionCustomDraft()[requestId] ?? []
const updateAnswer = (questionIndex: number, next: string[]) => {
if (!active) return
setQuestionDraftAnswers((prev) => {
const current = prev[requestId] ?? []
const updated = [...current]
updated[questionIndex] = next
return { ...prev, [requestId]: updated }
})
}
const updateCustom = (questionIndex: number, value: string) => {
if (!active) return
setQuestionCustomDraft((prev) => {
const current = prev[requestId] ?? []
const updated = [...current]
updated[questionIndex] = value
return { ...prev, [requestId]: updated }
})
}
const toggleOption = (questionIndex: number, label: string) => {
const info = questions[questionIndex]
const multi = info?.multiple === true
const existing = answers[questionIndex] ?? []
if (multi) {
const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label]
updateAnswer(questionIndex, next)
return
}
updateAnswer(questionIndex, [label])
}
const submitDisabled = () => {
if (!active) return true
if (questionSubmitting()) return true
return questions.some((_, index) => (answers[index]?.length ?? 0) === 0)
}
const showButtons = () => active
return (
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
<div class="tool-call-permission-header">
<span class="tool-call-permission-label">
{active ? "Question Required" : request ? "Question Queued" : "Questions"}
</span>
<span class="tool-call-permission-type">{questions.length === 1 ? "Question" : "Questions"}</span>
</div>
<div class="tool-call-permission-body">
<div class="flex flex-col gap-4">
<For each={questions}>
{(q, index) => {
const i = () => index()
const multi = () => q?.multiple === true
const selected = () => answers[i()] ?? []
const customValue = () => customInputs[i()] ?? ""
const inputType = () => (multi() ? "checkbox" : "radio")
const groupName = () => `question-${requestId}-${i()}`
return (
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs">
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
</div>
<Show when={multi()}>
<div class="text-xs text-muted">Multiple</div>
</Show>
</div>
<div class="mt-1 text-sm font-medium">{q?.question}</div>
<div class="mt-3 flex flex-col gap-1">
<For each={q?.options ?? []}>
{(opt) => {
const checked = () => selected().includes(opt.label)
return (
<label
class={`flex items-start gap-2 py-1 ${active ? "cursor-pointer" : request ? "opacity-80" : ""}`}
title={opt.description}
>
<input
type={inputType()}
name={groupName()}
checked={checked()}
disabled={!active || questionSubmitting()}
onChange={() => toggleOption(i(), opt.label)}
/>
<div class="flex flex-col">
<div class="text-sm leading-tight">{opt.label}</div>
<div class="text-xs text-muted leading-tight">{opt.description}</div>
</div>
</label>
)
}}
</For>
<Show when={active}>
<div class="mt-2 flex items-center gap-2">
<input
class="flex-1 rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
type="text"
placeholder="Type your own answer"
value={customValue()}
disabled={!active || questionSubmitting()}
onInput={(e) => updateCustom(i(), e.currentTarget.value)}
/>
<button
type="button"
class="tool-call-permission-button"
disabled={!active || questionSubmitting() || !customValue().trim()}
onClick={() => {
const value = customValue().trim()
if (!value) return
updateCustom(i(), value)
toggleOption(i(), value)
}}
>
{multi() ? "Toggle" : "Select"}
</button>
</div>
</Show>
</div>
</div>
)
}}
</For>
<Show when={showButtons()}>
<div class="tool-call-permission-actions">
<div class="tool-call-permission-buttons">
<button
type="button"
class="tool-call-permission-button"
disabled={submitDisabled()}
onClick={() => handleQuestionSubmit()}
>
Submit
</button>
<button
type="button"
class="tool-call-permission-button"
disabled={questionSubmitting()}
onClick={() => handleQuestionDismiss()}
>
Dismiss
</button>
</div>
<div class="tool-call-permission-shortcuts">
<kbd class="kbd">Enter</kbd>
<span>Submit</span>
<kbd class="kbd">Esc</kbd>
<span>Dismiss</span>
</div>
<Show when={questionError()}>
<div class="tool-call-permission-error">{questionError()}</div>
</Show>
</div>
</Show>
<Show when={!active && request}>
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
</Show>
</div>
</div>
</div>
)
}
createEffect(() => {
const request = questionDetails()
if (!request) {
setQuestionSubmitting(false)
setQuestionError(null)
return
}
setQuestionError(null)
const requestId = request.id
setQuestionDraftAnswers((prev) => {
if (prev[requestId]) return prev
const initial = request.questions.map(() => [])
return { ...prev, [requestId]: initial }
})
setQuestionCustomDraft((prev) => {
if (prev[requestId]) return prev
const initial = request.questions.map(() => "")
return { ...prev, [requestId]: initial }
})
})
const status = () => toolState()?.status || ""
onCleanup(() => {
@@ -993,6 +1310,7 @@ export default function ToolCall(props: ToolCallProps) {
{renderError()}
{renderPermissionBlock()}
{renderQuestionBlock()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">

View File

@@ -9,6 +9,7 @@ import { todoRenderer } from "./todo"
import { webfetchRenderer } from "./webfetch"
import { writeRenderer } from "./write"
import { invalidRenderer } from "./invalid"
import { questionRenderer } from "./question"
const TOOL_RENDERERS: ToolRenderer[] = [
bashRenderer,
@@ -19,6 +20,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
webfetchRenderer,
todoRenderer,
taskRenderer,
questionRenderer,
invalidRenderer,
]

View File

@@ -0,0 +1,17 @@
import type { ToolRenderer } from "../types"
export const questionRenderer: ToolRenderer = {
tools: ["question"],
getAction: () => "Awaiting answers...",
getTitle({ toolState }) {
const state = toolState()
if (!state) return "Questions"
if (state.status === "completed") return "Questions"
return "Asking questions"
},
renderBody() {
// The question tool UI is rendered by ToolCall itself so
// it can share the same layout for pending/completed.
return null
},
}

View File

@@ -45,6 +45,8 @@ export function getToolIcon(tool: string): string {
case "todowrite":
case "todoread":
return "📋"
case "question":
return "❓"
case "list":
return "📁"
case "patch":

View File

@@ -103,7 +103,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
logHttp(`${method} ${path}`)
try {
const response = await fetch(url, { ...init, headers })
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
@@ -135,6 +135,15 @@ export const serverApi = {
fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta")
},
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
},
setServerPassword(password: string): Promise<{ ok: boolean; username: string; passwordUserProvided: boolean }> {
return request<{ ok: boolean; username: string; passwordUserProvided: boolean }>("/api/auth/password", {
method: "POST",
body: JSON.stringify({ password }),
})
},
deleteWorkspace(id: string): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
},
@@ -270,7 +279,7 @@ export const serverApi = {
},
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
sseLogger.info(`Connecting to ${EVENTS_URL}`)
const source = new EventSource(EVENTS_URL)
const source = new EventSource(EVENTS_URL, { withCredentials: true } as any)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload

View File

@@ -63,6 +63,8 @@ type SSEEvent =
| EventSessionIdle
| { type: "permission.updated" | "permission.asked"; properties?: any }
| { type: "permission.replied"; properties?: any }
| { type: "question.asked"; properties?: any }
| { type: "question.replied" | "question.rejected"; properties?: any }
| EventLspUpdated
| TuiToastEvent
| BackgroundProcessUpdatedEvent
@@ -144,6 +146,13 @@ class SSEManager {
case "permission.replied":
this.onPermissionReplied?.(instanceId, event as any)
break
case "question.asked":
this.onQuestionAsked?.(instanceId, event as any)
break
case "question.replied":
case "question.rejected":
this.onQuestionAnswered?.(instanceId, event as any)
break
case "lsp.updated":
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
break
@@ -178,6 +187,8 @@ class SSEManager {
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
onPermissionUpdated?: (instanceId: string, event: any) => void
onPermissionReplied?: (instanceId: string, event: any) => void
onQuestionAsked?: (instanceId: string, event: any) => void
onQuestionAnswered?: (instanceId: string, event: any) => void
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void

View File

@@ -3,6 +3,8 @@ import type { Instance, LogEntry } from "../types/instance"
import type { LspStatus } from "@opencode-ai/sdk/v2"
import type { PermissionReply, PermissionRequestLike } from "../types/permission"
import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permission"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { getQuestionSessionId } from "../types/question"
import { requestData } from "../lib/opencode-api"
import { sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
@@ -18,10 +20,10 @@ import {
} from "./sessions"
import { fetchCommands, clearCommands } from "./commands"
import { preferences } from "./preferences"
import { setSessionPendingPermission } from "./session-state"
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus"
import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge"
import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge"
import { clearCacheForInstance } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
@@ -34,11 +36,30 @@ const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
// Permission queue management per instance
// Interruption queues (permissions + questions) per instance
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, PermissionRequestLike[]>>(new Map())
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
const permissionSessionCounts = new Map<string, Map<string, number>>()
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
const questionSessionCounts = new Map<string, Map<string, number>>()
const questionEnqueuedAt = new Map<string, number>()
function ensureQuestionEnqueuedAt(request: QuestionRequest): number {
const existing = questionEnqueuedAt.get(request.id)
if (existing) return existing
const now = Date.now()
questionEnqueuedAt.set(request.id, now)
return now
}
type InterruptionKind = "permission" | "question"
type ActiveInterruption = { kind: InterruptionKind; id: string } | null
const [activeInterruption, setActiveInterruption] = createSignal<Map<string, ActiveInterruption>>(new Map())
function syncHasInstancesFlag() {
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
setHasInstances(readyExists)
@@ -156,6 +177,38 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
}
}
async function syncPendingQuestions(instanceId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) return
try {
const remote = await requestData<QuestionRequest[]>(
instance.client.question.list(),
"question.list",
)
const remoteIds = new Set(remote.map((item) => item.id))
const local = getQuestionQueue(instanceId)
// Remove any stale local requests missing from server.
for (const entry of local) {
if (!remoteIds.has(entry.id)) {
removeQuestionFromQueue(instanceId, entry.id)
removeQuestionV2(instanceId, entry.id)
}
}
// Upsert all server-side pending questions.
for (const request of remote) {
ensureQuestionEnqueuedAt(request)
addQuestionToQueue(instanceId, request)
upsertQuestionV2(instanceId, request)
}
} catch (error) {
log.warn("Failed to sync pending questions", { instanceId, error })
}
}
async function hydrateInstanceData(instanceId: string) {
try {
await fetchSessions(instanceId)
@@ -166,6 +219,7 @@ async function hydrateInstanceData(instanceId: string) {
if (!instance?.client) return
await fetchCommands(instanceId, instance.client)
await syncPendingPermissions(instanceId)
await syncPendingQuestions(instanceId)
} catch (error) {
log.error("Failed to fetch initial data", error)
}
@@ -327,6 +381,7 @@ function removeInstance(id: string) {
removeLogContainer(id)
clearCommands(id)
clearPermissionQueue(id)
clearQuestionQueue(id)
clearInstanceMetadata(id)
if (activeInstanceId() === id) {
@@ -429,6 +484,79 @@ function getPermissionQueueLength(instanceId: string): number {
return getPermissionQueue(instanceId).length
}
function getQuestionQueue(instanceId: string): QuestionRequest[] {
const queue = questionQueues().get(instanceId)
if (!queue) {
return []
}
return queue
}
function getQuestionQueueLength(instanceId: string): number {
return getQuestionQueue(instanceId).length
}
function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string): number {
// Ensure we have a stable timestamp for sorting/ordering.
const queue = getQuestionQueue(instanceId)
const match = queue.find((q) => q.id === requestId)
if (match) {
return ensureQuestionEnqueuedAt(match)
}
return questionEnqueuedAt.get(requestId) ?? Date.now()
}
function computeActiveInterruption(instanceId: string): ActiveInterruption {
const permissions = getPermissionQueue(instanceId)
const questions = getQuestionQueue(instanceId)
const firstPermission = permissions[0]
const firstQuestion = questions[0]
if (!firstPermission && !firstQuestion) return null
if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id }
if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id }
const permTime = getPermissionCreatedAt(firstPermission)
const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER
if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id }
return { kind: "question", id: firstQuestion!.id }
}
function setActiveInterruptionForInstance(instanceId: string, nextActive: ActiveInterruption): void {
setActiveInterruption((prev) => {
const next = new Map(prev)
if (!nextActive) {
next.set(instanceId, null)
} else {
next.set(instanceId, nextActive)
}
return next
})
setActivePermissionId((prev) => {
const next = new Map(prev)
if (nextActive?.kind === "permission") {
next.set(instanceId, nextActive.id)
} else {
next.set(instanceId, null)
}
return next
})
setActiveQuestionId((prev) => {
const next = new Map(prev)
if (nextActive?.kind === "question") {
next.set(instanceId, nextActive.id)
} else {
next.set(instanceId, null)
}
return next
})
}
function recomputeActiveInterruption(instanceId: string): void {
setActiveInterruptionForInstance(instanceId, computeActiveInterruption(instanceId))
}
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
let sessionCounts = permissionSessionCounts.get(instanceId)
if (!sessionCounts) {
@@ -464,6 +592,41 @@ function clearSessionPendingCounts(instanceId: string): void {
permissionSessionCounts.delete(instanceId)
}
function incrementQuestionSessionPendingCount(instanceId: string, sessionId: string): void {
let sessionCounts = questionSessionCounts.get(instanceId)
if (!sessionCounts) {
sessionCounts = new Map()
questionSessionCounts.set(instanceId, sessionCounts)
}
const current = sessionCounts.get(sessionId) ?? 0
sessionCounts.set(sessionId, current + 1)
}
function decrementQuestionSessionPendingCount(instanceId: string, sessionId: string): number {
const sessionCounts = questionSessionCounts.get(instanceId)
if (!sessionCounts) return 0
const current = sessionCounts.get(sessionId) ?? 0
if (current <= 1) {
sessionCounts.delete(sessionId)
if (sessionCounts.size === 0) {
questionSessionCounts.delete(instanceId)
}
return 0
}
const nextValue = current - 1
sessionCounts.set(sessionId, nextValue)
return nextValue
}
function clearQuestionSessionPendingCounts(instanceId: string): void {
const sessionCounts = questionSessionCounts.get(instanceId)
if (!sessionCounts) return
for (const sessionId of sessionCounts.keys()) {
setSessionPendingQuestion(instanceId, sessionId, false)
}
questionSessionCounts.delete(instanceId)
}
function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void {
let inserted = false
@@ -485,13 +648,7 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL
return
}
setActivePermissionId((prev) => {
const next = new Map(prev)
if (!next.get(instanceId)) {
next.set(instanceId, permission.id)
}
return next
})
recomputeActiveInterruption(instanceId)
const sessionId = getPermissionSessionId(permission)
if (sessionId) {
@@ -526,15 +683,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
const updatedQueue = getPermissionQueue(instanceId)
setActivePermissionId((prev) => {
const next = new Map(prev)
const activeId = next.get(instanceId)
if (activeId === permissionId) {
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as PermissionRequestLike) : null
next.set(instanceId, nextPermission?.id ?? null)
}
return next
})
recomputeActiveInterruption(instanceId)
const removed = removedPermission
if (removed) {
@@ -558,16 +707,140 @@ function clearPermissionQueue(instanceId: string): void {
return next
})
clearSessionPendingCounts(instanceId)
recomputeActiveInterruption(instanceId)
}
function addQuestionToQueue(instanceId: string, request: QuestionRequest): void {
let inserted = false
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
setActivePermissionId((prev) => {
setQuestionQueues((prev) => {
const next = new Map(prev)
next.set(instanceId, permissionId)
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
if (queue.some((q) => q.id === request.id)) {
return next
}
ensureQuestionEnqueuedAt(request)
const updatedQueue = [...queue, request].sort((a, b) => {
return ensureQuestionEnqueuedAt(a) - ensureQuestionEnqueuedAt(b)
})
next.set(instanceId, updatedQueue)
inserted = true
return next
})
if (!inserted) {
return
}
recomputeActiveInterruption(instanceId)
const sessionId = getQuestionSessionId(request)
if (sessionId) {
incrementQuestionSessionPendingCount(instanceId, sessionId)
setSessionPendingQuestion(instanceId, sessionId, true)
}
}
function removeQuestionFromQueue(instanceId: string, requestId: string): void {
const removedSessionId = getQuestionSessionId(getQuestionQueue(instanceId).find((q) => q.id === requestId))
setQuestionQueues((prev) => {
const next = new Map(prev)
const queue = next.get(instanceId) ?? ([] as QuestionRequest[])
const filtered = queue.filter((item) => item.id !== requestId)
if (filtered.length > 0) {
next.set(instanceId, filtered)
} else {
next.delete(instanceId)
}
return next
})
questionEnqueuedAt.delete(requestId)
recomputeActiveInterruption(instanceId)
if (removedSessionId) {
const remaining = decrementQuestionSessionPendingCount(instanceId, removedSessionId)
setSessionPendingQuestion(instanceId, removedSessionId, remaining > 0)
}
}
function clearQuestionQueue(instanceId: string): void {
for (const request of getQuestionQueue(instanceId)) {
questionEnqueuedAt.delete(request.id)
}
setQuestionQueues((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
setActiveQuestionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
clearQuestionSessionPendingCounts(instanceId)
recomputeActiveInterruption(instanceId)
}
function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void {
setActiveInterruptionForInstance(instanceId, { kind: "permission", id: permissionId })
}
function setActiveQuestionIdForInstance(instanceId: string, requestId: string): void {
setActiveInterruptionForInstance(instanceId, { kind: "question", id: requestId })
}
async function sendQuestionReply(
instanceId: string,
_sessionId: string,
requestId: string,
answers: string[][],
): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) {
throw new Error("Instance not ready")
}
try {
await requestData(
instance.client.question.reply({
requestID: requestId,
answers,
}),
"question.reply",
)
removeQuestionFromQueue(instanceId, requestId)
} catch (error) {
log.error("Failed to send question reply", error)
throw error
}
}
async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance?.client) {
throw new Error("Instance not ready")
}
try {
await requestData(
instance.client.question.reject({
requestID: requestId,
}),
"question.reject",
)
removeQuestionFromQueue(instanceId, requestId)
} catch (error) {
log.error("Failed to send question reject", error)
throw error
}
}
async function sendPermissionResponse(
@@ -655,7 +928,7 @@ export {
getInstanceLogs,
isInstanceLogStreaming,
setInstanceLogStreaming,
// Permission management
// Permission + question management
permissionQueues,
activePermissionId,
getPermissionQueue,
@@ -665,6 +938,18 @@ export {
clearPermissionQueue,
sendPermissionResponse,
setActivePermissionIdForInstance,
questionQueues,
activeQuestionId,
activeInterruption,
getQuestionQueue,
getQuestionQueueLength,
getQuestionEnqueuedAtForInstance,
addQuestionToQueue,
removeQuestionFromQueue,
clearQuestionQueue,
sendQuestionReply,
sendQuestionReject,
setActiveQuestionIdForInstance,
disconnectedInstance,
acknowledgeDisconnectedInstance,
fetchLspStatus,

View File

@@ -1,5 +1,7 @@
import type { PermissionRequestLike } from "../../types/permission"
import { getPermissionCallId, getPermissionMessageId } from "../../types/permission"
import type { QuestionRequest } from "../../types/question"
import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
import type { Message, MessageInfo, ClientPart } from "../../types/message"
import type { Session } from "../../types/session"
import { messageStoreBus } from "./bus"
@@ -192,6 +194,65 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st
}
}
function extractQuestionMessageId(request: QuestionRequest): string | undefined {
return getQuestionMessageId(request)
}
function extractQuestionCallId(request: QuestionRequest): string | undefined {
return getQuestionCallId(request)
}
export function upsertQuestionV2(instanceId: string, request: QuestionRequest): void {
if (!request) return
const store = messageStoreBus.getOrCreate(instanceId)
const messageId = extractQuestionMessageId(request)
let partId: string | undefined = undefined
const callId = extractQuestionCallId(request)
if (callId) {
partId = resolvePartIdFromCallId(store, messageId, callId)
}
store.upsertQuestion({
request,
messageId,
partId,
enqueuedAt: (request as any).time?.created ?? Date.now(),
})
}
export function reconcilePendingQuestionsV2(instanceId: string, sessionId?: string): void {
const store = messageStoreBus.getOrCreate(instanceId)
const pending = store.state.questions.queue
if (!pending || pending.length === 0) return
for (const entry of pending) {
if (!entry || entry.partId) continue
const request = entry.request
if (!request) continue
const questionSessionId = request.sessionID
if (sessionId && questionSessionId && questionSessionId !== sessionId) {
continue
}
const messageId = entry.messageId ?? extractQuestionMessageId(request)
const callId = extractQuestionCallId(request)
const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId)
if (!resolvedPartId) continue
store.upsertQuestion({
...entry,
messageId,
partId: resolvedPartId,
})
}
}
export function removeQuestionV2(instanceId: string, requestId: string): void {
if (!requestId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.removeQuestion(requestId)
}
export function removePermissionV2(instanceId: string, permissionId: string): void {
if (!permissionId) return
const store = messageStoreBus.getOrCreate(instanceId)

View File

@@ -12,6 +12,7 @@ import type {
PartUpdateInput,
PendingPartEntry,
PermissionEntry,
QuestionEntry,
ReplaceMessageIdOptions,
ScrollSnapshot,
SessionRecord,
@@ -40,6 +41,11 @@ function createInitialState(instanceId: string): InstanceMessageState {
active: null,
byMessage: {},
},
questions: {
queue: [],
active: null,
byMessage: {},
},
usage: {},
scrollState: {},
latestTodos: {},
@@ -193,6 +199,9 @@ export interface InstanceMessageStore {
upsertPermission: (entry: PermissionEntry) => void
removePermission: (permissionId: string) => void
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
upsertQuestion: (entry: QuestionEntry) => void
removeQuestion: (requestId: string) => void
getQuestionState: (messageId?: string, partId?: string) => { entry: QuestionEntry; active: boolean } | null
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
@@ -757,6 +766,18 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
})
}
const questionMap = state.questions.byMessage[options.oldId]
if (questionMap) {
setState("questions", "byMessage", options.newId, questionMap)
setState("questions", (prev) => {
const next = { ...prev }
const nextByMessage = { ...next.byMessage }
delete nextByMessage[options.oldId]
next.byMessage = nextByMessage
return next
})
}
const pending = state.pendingParts[options.oldId]
if (pending) {
setState("pendingParts", options.newId, pending)
@@ -832,6 +853,60 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return { entry, active }
}
function upsertQuestion(entry: QuestionEntry) {
const messageKey = entry.messageId ?? "__global__"
const partKey = entry.partId ?? entry.request?.id ?? "__global__"
setState(
"questions",
produce((draft) => {
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
draft.byMessage[messageKey][partKey] = entry
const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id)
if (existingIndex === -1) {
draft.queue.push(entry)
} else {
draft.queue[existingIndex] = entry
}
if (!draft.active || draft.active.request.id === entry.request.id) {
draft.active = entry
}
}),
)
}
function removeQuestion(requestId: string) {
setState(
"questions",
produce((draft) => {
draft.queue = draft.queue.filter((item) => item.request.id !== requestId)
if (draft.active?.request.id === requestId) {
draft.active = draft.queue[0] ?? null
}
Object.keys(draft.byMessage).forEach((messageKey) => {
const partEntries = draft.byMessage[messageKey]
Object.keys(partEntries).forEach((partKey) => {
if (partEntries[partKey].request.id === requestId) {
delete partEntries[partKey]
}
})
if (Object.keys(partEntries).length === 0) {
delete draft.byMessage[messageKey]
}
})
}),
)
}
function getQuestionState(messageId?: string, partId?: string) {
const messageKey = messageId ?? "__global__"
const partKey = partId ?? "__global__"
const entry = state.questions.byMessage[messageKey]?.[partKey]
if (!entry) return null
const active = state.questions.active?.request.id === entry.request.id
return { entry, active }
}
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
const session = state.sessions[sessionId]
if (!session) return
@@ -873,6 +948,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return next
})
setState("questions", "byMessage", (prev) => {
const next = { ...prev }
removedIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
withUsageState(sessionId, (draft) => {
removedIds.forEach((id) => removeUsageEntry(draft, id))
})
@@ -948,6 +1031,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return next
})
setState("questions", "byMessage", (prev) => {
const next = { ...prev }
messageIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
setState("usage", (prev) => {
const next = { ...prev }
delete next[sessionId]
@@ -1012,9 +1103,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
replaceMessageId,
setMessageInfo,
getMessageInfo,
upsertPermission,
removePermission,
getPermissionState,
upsertPermission,
removePermission,
getPermissionState,
upsertQuestion,
removeQuestion,
getQuestionState,
setSessionRevert,
getSessionRevert,
rebuildUsage,

View File

@@ -1,5 +1,6 @@
import type { ClientPart } from "../../types/message"
import type { PermissionRequestLike } from "../../types/permission"
import type { QuestionRequest } from "../../types/question"
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
export type MessageRole = "user" | "assistant"
@@ -59,6 +60,19 @@ export interface InstancePermissionState {
byMessage: Record<string, Record<string, PermissionEntry>>
}
export interface QuestionEntry {
request: QuestionRequest
messageId?: string
partId?: string
enqueuedAt: number
}
export interface InstanceQuestionState {
queue: QuestionEntry[]
active: QuestionEntry | null
byMessage: Record<string, Record<string, QuestionEntry>>
}
export interface ScrollSnapshot {
scrollTop: number
atBottom: boolean
@@ -103,6 +117,7 @@ export interface InstanceMessageState {
pendingParts: Record<string, PendingPartEntry[]>
sessionRevisions: Record<string, number>
permissions: InstancePermissionState
questions: InstanceQuestionState
usage: Record<string, SessionUsageState>
scrollState: Record<string, ScrollSnapshot>
latestTodos: Record<string, LatestTodoSnapshot | undefined>

View File

@@ -27,7 +27,7 @@ import {
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info"
import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge"
import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache"
import { getLogger } from "../lib/logger"
@@ -649,7 +649,9 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
// Permissions can be hydrated before messages/tool parts exist in the store.
// After message hydration, try to attach any pending permissions to tool-call part ids.
reconcilePendingPermissionsV2(instanceId, sessionId)
reconcilePendingQuestionsV2(instanceId, sessionId)
} catch (error) {
log.error("Failed to load messages:", error)

View File

@@ -18,8 +18,17 @@ import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission"
import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission"
import { getQuestionId, getRequestIdFromQuestionReply } from "../types/question"
import type { QuestionRequest } from "../types/question"
import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
import {
instances,
addPermissionToQueue,
removePermissionFromQueue,
addQuestionToQueue,
removeQuestionFromQueue,
} from "./instances"
import { showAlertDialog } from "./alerts"
import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state"
@@ -32,9 +41,11 @@ import {
replaceMessageIdV2,
upsertMessageInfoV2,
upsertPermissionV2,
upsertQuestionV2,
removeMessagePartV2,
removeMessageV2,
removePermissionV2,
removeQuestionV2,
setSessionRevertV2,
} from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus"
@@ -102,6 +113,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<
model: existing?.model ?? fetched.model,
status: existing?.status === "compacting" ? "compacting" : fetched.status,
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
pendingQuestion: existing?.pendingQuestion ?? false,
}
instanceSessions.set(sessionId, merged)
next.set(instanceId, instanceSessions)
@@ -228,8 +240,20 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof info.id === "string" ? info.id : undefined
if (!sessionId || !messageId) return
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; completed?: number }
const nextUpdated =
typeof timeInfo.completed === "number" && timeInfo.completed > 0
? timeInfo.completed
: typeof timeInfo.updated === "number" && timeInfo.updated > 0
? timeInfo.updated
: typeof timeInfo.created === "number" && timeInfo.created > 0
? timeInfo.created
: Date.now()
withSession(instanceId, sessionId, (session) => {
session.time = { ...(session.time ?? {}), updated: Date.now() }
const currentUpdated = session.time?.updated ?? 0
if (nextUpdated <= currentUpdated) return false
session.time = { ...(session.time ?? {}), updated: nextUpdated }
})
const store = messageStoreBus.getOrCreate(instanceId)
@@ -469,12 +493,36 @@ function handlePermissionReplied(instanceId: string, event: { type: string; prop
removePermissionV2(instanceId, requestId)
}
function handleQuestionAsked(instanceId: string, event: { type: string; properties?: QuestionRequest } | any): void {
const request = event?.properties as QuestionRequest | undefined
if (!request) return
log.info(`[SSE] Question asked: ${getQuestionId(request)}`)
addQuestionToQueue(instanceId, request)
upsertQuestionV2(instanceId, request)
}
function handleQuestionAnswered(
instanceId: string,
event: { type: string; properties?: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] } | any,
): void {
const properties = event?.properties as EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | undefined
const requestId = getRequestIdFromQuestionReply(properties)
if (!requestId) return
log.info(`[SSE] Question answered: ${requestId}`)
removeQuestionFromQueue(instanceId, requestId)
removeQuestionV2(instanceId, requestId)
}
export {
handleMessagePartRemoved,
handleMessageRemoved,
handleMessageUpdate,
handlePermissionReplied,
handlePermissionUpdated,
handleQuestionAsked,
handleQuestionAnswered,
handleSessionCompacted,
handleSessionError,
handleSessionIdle,

View File

@@ -58,8 +58,8 @@ type InstanceIndicatorCounts = {
const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal<Map<string, InstanceIndicatorCounts>>(new Map())
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission">): InstanceSessionIndicatorStatus | "idle" {
if (session.pendingPermission) {
function getIndicatorBucket(session: Pick<Session, "status" | "pendingPermission" | "pendingQuestion">): InstanceSessionIndicatorStatus | "idle" {
if (session.pendingPermission || session.pendingQuestion) {
return "permission"
}
const status = session.status ?? "idle"
@@ -126,7 +126,7 @@ function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map<stri
let compacting = 0
for (const session of instanceSessions.values()) {
if (session.pendingPermission) {
if (session.pendingPermission || session.pendingQuestion) {
permission += 1
continue
}
@@ -305,6 +305,13 @@ function setSessionPendingPermission(instanceId: string, sessionId: string, pend
})
}
function setSessionPendingQuestion(instanceId: string, sessionId: string, pending: boolean): void {
withSession(instanceId, sessionId, (session) => {
if (session.pendingQuestion === pending) return false
session.pendingQuestion = pending
})
}
function setActiveSession(instanceId: string, sessionId: string): void {
setActiveSessionId((prev) => {
const next = new Map(prev)
@@ -383,9 +390,35 @@ function getSessionFamily(instanceId: string, parentId: string): Session[] {
return [parent, ...children]
}
type SessionThreadCacheEntry = {
signature: string
thread: SessionThread
}
type SessionThreadCache = {
byParentId: Map<string, SessionThreadCacheEntry>
}
const sessionThreadCache = new Map<string, SessionThreadCache>()
function getOrCreateSessionThreadCache(instanceId: string): SessionThreadCache {
let cache = sessionThreadCache.get(instanceId)
if (!cache) {
cache = { byParentId: new Map() }
sessionThreadCache.set(instanceId, cache)
}
return cache
}
function getSessionThreads(instanceId: string): SessionThread[] {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions || instanceSessions.size === 0) return []
if (!instanceSessions || instanceSessions.size === 0) {
sessionThreadCache.delete(instanceId)
return []
}
const cache = getOrCreateSessionThreadCache(instanceId)
const seenParents = new Set<string>()
const parents: Session[] = []
const childrenByParent = new Map<string, Session[]>()
@@ -409,6 +442,8 @@ function getSessionThreads(instanceId: string): SessionThread[] {
const threads: SessionThread[] = []
for (const parent of parents) {
seenParents.add(parent.id)
const children = childrenByParent.get(parent.id) ?? []
if (children.length > 1) {
children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0))
@@ -418,7 +453,23 @@ function getSessionThreads(instanceId: string): SessionThread[] {
const latestChild = children[0]?.time.updated ?? 0
const latestUpdated = Math.max(parentUpdated, latestChild)
threads.push({ parent, children, latestUpdated })
const childIds = children.map((child) => child.id).join(",")
const signature = `${parentUpdated}:${latestChild}:${childIds}`
const cached = cache.byParentId.get(parent.id)
if (cached && cached.signature === signature) {
threads.push(cached.thread)
} else {
const thread: SessionThread = { parent, children, latestUpdated }
cache.byParentId.set(parent.id, { signature, thread })
threads.push(thread)
}
}
for (const parentId of Array.from(cache.byParentId.keys())) {
if (!seenParents.has(parentId)) {
cache.byParentId.delete(parentId)
}
}
threads.sort((a, b) => {
@@ -660,6 +711,7 @@ export {
pruneDraftPrompts,
withSession,
setSessionPendingPermission,
setSessionPendingQuestion,
setSessionStatus,
setActiveSession,

View File

@@ -61,6 +61,8 @@ import {
handleMessageUpdate,
handlePermissionReplied,
handlePermissionUpdated,
handleQuestionAnswered,
handleQuestionAsked,
handleSessionCompacted,
handleSessionError,
handleSessionIdle,
@@ -81,6 +83,8 @@ sseManager.onSessionStatus = handleSessionStatus
sseManager.onTuiToast = handleTuiToast
sseManager.onPermissionUpdated = handlePermissionUpdated
sseManager.onPermissionReplied = handlePermissionReplied
sseManager.onQuestionAsked = handleQuestionAsked
sseManager.onQuestionAnswered = handleQuestionAnswered
export {
abortSession,

View File

@@ -1,4 +1,3 @@
/* Prompt input & attachment styles */
.prompt-input-container {
@apply flex flex-col border-t;
border-color: var(--border-base);
@@ -13,7 +12,7 @@
}
.prompt-input-actions {
@apply flex flex-col items-center justify-between;
@apply flex flex-col items-center;
align-self: stretch;
height: 100%;
padding: 0.5rem 0.25rem;
@@ -30,25 +29,37 @@
}
.prompt-input-field {
position: absolute;
inset: 0;
position: relative;
width: 100%;
height: 100%;
}
.prompt-input {
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
font-family: inherit;
background-color: var(--surface-base);
color: inherit;
border-color: var(--border-base);
line-height: var(--line-height-normal);
border-radius: 0;
padding-bottom: 0;
height: 100%;
min-height: 100%;
}
.prompt-input {
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
font-family: inherit;
background-color: var(--surface-base);
color: inherit;
border-color: var(--border-base);
line-height: var(--line-height-normal);
border-radius: 0;
padding-bottom: 0;
height: 100%;
min-height: 100%;
}
.prompt-input-field-container.is-expanded {
height: auto;
}
.prompt-input-field.is-expanded {
height: auto;
}
.prompt-input.is-expanded {
height: auto;
min-height: 0;
overflow-y: auto;
}
.prompt-input-overlay {
position: absolute;
@@ -69,37 +80,42 @@
color: var(--text-primary);
}
.prompt-history-top,
.prompt-history-bottom {
/* Navigation buttons container (expand, prev, next) */
.prompt-nav-buttons {
position: absolute;
right: 0.35rem;
top: 0.25rem;
right: 0.25rem;
bottom: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
justify-content: flex-start;
gap: 0.125rem;
z-index: 2;
}
.prompt-history-top {
top: 0.3rem;
}
.prompt-history-bottom {
bottom: 0.6rem;
}
.prompt-expand-button,
.prompt-history-button {
@apply w-9 h-9 flex items-center justify-center rounded-md;
@apply w-7 h-7 flex items-center justify-center rounded-md;
color: var(--text-muted);
background-color: rgba(15, 23, 42, 0.04);
transition: background-color 0.15s ease, color 0.15s ease;
padding: 0;
flex-shrink: 0;
}
.prompt-expand-button:hover:not(:disabled),
.prompt-history-button:hover:not(:disabled) {
background-color: var(--surface-secondary);
color: var(--text-primary);
}
.prompt-expand-button:active:not(:disabled) {
background-color: var(--accent-primary);
color: var(--text-inverted);
transform: scale(0.95);
}
.prompt-expand-button:disabled,
.prompt-history-button:disabled {
opacity: 0.4;
cursor: not-allowed;
@@ -176,6 +192,7 @@
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
background-color: var(--accent-primary);
color: var(--text-inverted);
margin-top: auto;
}
.send-button.shell-mode {
@@ -211,7 +228,6 @@
height: 1rem;
}
.hint {
@apply text-xs;
color: var(--text-muted);

View File

@@ -0,0 +1,34 @@
import type {
QuestionRequest,
EventQuestionReplied,
EventQuestionRejected,
} from "@opencode-ai/sdk/v2"
export type { QuestionRequest }
export function getQuestionId(question: QuestionRequest | null | undefined): string {
return question?.id ?? ""
}
export function getQuestionSessionId(question: QuestionRequest | null | undefined): string | undefined {
return question?.sessionID
}
export function getQuestionMessageId(question: QuestionRequest | null | undefined): string | undefined {
return question?.tool?.messageID
}
export function getQuestionCallId(question: QuestionRequest | null | undefined): string | undefined {
return question?.tool?.callID
}
export function getQuestionCreatedAt(question: QuestionRequest | null | undefined): number {
// v2 schema doesn't include created time; best effort for ordering.
return Date.now()
}
export function getRequestIdFromQuestionReply(
properties: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | null | undefined,
): string | undefined {
return properties?.requestID
}

View File

@@ -37,6 +37,7 @@ export interface Session
}
version: string // Include version from SDK Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
pendingQuestion?: boolean // Indicates if session is waiting on user input
status: SessionStatus // Single source of truth for session status
}