diff --git a/package-lock.json b/package-lock.json index 50d5fcb5..e831a610 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.13.1", + "version": "0.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.13.1", + "version": "0.13.3", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -64,7 +64,6 @@ "version": "7.28.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3381,7 +3380,6 @@ "version": "7.20.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -3483,7 +3481,6 @@ "version": "22.19.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3558,7 +3555,6 @@ "integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cac": "^6.7.14", "colorette": "^2.0.20", @@ -3641,7 +3637,6 @@ "version": "6.12.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3844,6 +3839,7 @@ "version": "5.3.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -3861,6 +3857,7 @@ "version": "2.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -3881,6 +3878,7 @@ "version": "2.3.8", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -3894,12 +3892,14 @@ "node_modules/archiver-utils/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -4213,6 +4213,7 @@ "version": "4.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -4276,7 +4277,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4767,6 +4767,7 @@ "version": "4.1.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -4896,6 +4897,7 @@ "version": "1.2.2", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -4907,6 +4909,7 @@ "version": "4.0.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -5272,7 +5275,6 @@ "version": "24.13.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -5439,6 +5441,7 @@ "version": "24.13.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -5450,6 +5453,7 @@ "version": "10.1.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -5463,6 +5467,7 @@ "version": "6.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -5474,6 +5479,7 @@ "version": "2.0.1", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -6191,7 +6197,8 @@ "node_modules/fs-constants": { "version": "1.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-extra": { "version": "8.1.0", @@ -7408,7 +7415,8 @@ "node_modules/isarray": { "version": "1.0.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.6", @@ -7458,7 +7466,6 @@ "version": "1.21.7", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -7590,6 +7597,7 @@ "version": "1.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -7601,6 +7609,7 @@ "version": "2.3.8", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7614,12 +7623,14 @@ "node_modules/lazystream/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -7684,22 +7695,26 @@ "node_modules/lodash.defaults": { "version": "4.2.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.flatten": { "version": "4.4.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.sortby": { "version": "4.7.0", @@ -7711,7 +7726,8 @@ "node_modules/lodash.union": { "version": "4.6.0", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lowercase-keys": { "version": "2.0.0", @@ -8515,7 +8531,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8663,7 +8678,8 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/process-warning": { "version": "3.0.0", @@ -8912,6 +8928,7 @@ "version": "3.6.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8925,6 +8942,7 @@ "version": "1.1.3", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -9227,7 +9245,6 @@ "version": "4.52.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9451,7 +9468,6 @@ "node_modules/seroval": { "version": "1.3.2", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -9775,7 +9791,6 @@ "node_modules/solid-js": { "version": "1.9.10", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", @@ -9916,6 +9931,7 @@ "version": "1.3.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -10249,6 +10265,7 @@ "version": "2.2.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -10441,7 +10458,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10691,7 +10707,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11039,7 +11054,6 @@ "version": "5.4.21", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11524,7 +11538,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11719,7 +11732,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -12008,6 +12020,7 @@ "version": "4.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -12021,6 +12034,7 @@ "version": "3.0.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", @@ -12040,7 +12054,6 @@ "node_modules/zod": { "version": "3.25.76", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -12055,7 +12068,7 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.13.1", + "version": "0.13.3", "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", @@ -12092,7 +12105,7 @@ }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.13.1", + "version": "0.13.3", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -12134,7 +12147,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.13.1", + "version": "0.13.3", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -12142,7 +12155,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.13.1", + "version": "0.13.3", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index f87e6994..6398dd9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.13.1", + "version": "0.13.3", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", @@ -22,7 +22,7 @@ "build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app", "build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app", "typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app", - "bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version" + "bumpVersion": "node ./scripts/bump-version.js" }, "dependencies": { "7zip-bin": "^5.2.0", diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 3b06c710..f0c6f42d 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.13.1", + "minServerVersion": "0.13.3", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/.gitignore b/packages/electron-app/.gitignore index 2dfa475b..3b43e35f 100644 --- a/packages/electron-app/.gitignore +++ b/packages/electron-app/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ release/ .vite/ +electron/resources/server/ diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 3e88adf2..10e4b3c6 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" import fs from "fs" +import { requestMicrophoneAccess } from "./permissions" import type { CliProcessManager, CliStatus } from "./process-manager" let wakeLockId: number | null = null @@ -111,6 +112,11 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return { enabled: false } }) + ipcMain.handle( + "media:requestMicrophoneAccess", + async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }), + ) + ipcMain.handle( "notifications:show", async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => { diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 500bd4c9..55e3dbad 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -6,6 +6,7 @@ import { dirname, join } from "path" import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" import { setupCliIPC } from "./ipc" +import { configureMediaPermissionHandlers } from "./permissions" import { CliProcessManager } from "./process-manager" const mainFilename = fileURLToPath(import.meta.url) @@ -489,6 +490,7 @@ app.whenReady().then(() => { if (isMac) { session.defaultSession.setSpellCheckerEnabled(false) + configureMediaPermissionHandlers(getAllowedRendererOrigins) app.on("browser-window-created", (_, window) => { window.webContents.session.setSpellCheckerEnabled(false) }) diff --git a/packages/electron-app/electron/main/permissions.ts b/packages/electron-app/electron/main/permissions.ts new file mode 100644 index 00000000..28652321 --- /dev/null +++ b/packages/electron-app/electron/main/permissions.ts @@ -0,0 +1,58 @@ +import { session, systemPreferences } from "electron" + +const isMac = process.platform === "darwin" + +export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean { + if (!origin) { + return false + } + + try { + const normalized = new URL(origin).origin + return allowedOrigins.includes(normalized) + } catch { + return false + } +} + +export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) { + const isAudioMediaRequest = (permission: string, details?: unknown) => { + if (permission !== "media") { + return false + } + + const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? [] + return mediaTypes.length === 0 || mediaTypes.includes("audio") + } + + session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => { + if (!isAudioMediaRequest(permission, details)) { + return false + } + + return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()) + }) + + session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => { + if (!isAudioMediaRequest(permission, details)) { + callback(false) + return + } + + const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL() + callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())) + }) +} + +export async function requestMicrophoneAccess(): Promise { + if (!isMac) { + return true + } + + const status = systemPreferences.getMediaAccessStatus("microphone") + if (status === "granted") { + return true + } + + return systemPreferences.askForMediaAccess("microphone") +} diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 275c0170..7790be39 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -1,14 +1,17 @@ import { spawn, spawnSync, type ChildProcess } from "child_process" -import { app } from "electron" +import { app, utilityProcess, type UtilityProcess } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" import { existsSync, readFileSync } from "fs" import os from "os" import path from "path" +import { fileURLToPath } from "url" import { parse as parseYaml } from "yaml" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" const nodeRequire = createRequire(import.meta.url) +const mainFilename = fileURLToPath(import.meta.url) +const mainDirname = path.dirname(mainFilename) const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" const SESSION_COOKIE_NAME_PREFIX = "codenomad_session" @@ -39,6 +42,9 @@ interface CliEntryResolution { runnerPath?: string } +type ManagedChild = ChildProcess | UtilityProcess +type ChildLaunchMode = "spawn" | "utility" + const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" function isYamlPath(filePath: string): boolean { @@ -118,7 +124,8 @@ export declare interface CliProcessManager { } export class CliProcessManager extends EventEmitter { - private child?: ChildProcess + private child?: ManagedChild + private childLaunchMode: ChildLaunchMode = "spawn" private status: CliStatus = { state: "stopped" } private stdoutBuffer = "" private stderrBuffer = "" @@ -138,33 +145,63 @@ export class CliProcessManager extends EventEmitter { this.requestedStop = false this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) - const cliEntry = this.resolveCliEntry(options) const listeningMode = this.resolveListeningMode() const host = resolveHostForMode(listeningMode) const args = this.buildCliArgs(options, host) - console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, - ) + let child: ManagedChild - const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } - env.ELECTRON_RUN_AS_NODE = "1" + if (this.shouldUsePackagedShellSupervisor(options)) { + const runtimePath = this.resolveShellNodeCommand() + const entryPath = this.resolveBundledProdEntry() + const supervisorPath = this.resolveCliSupervisorPath() + const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env } + const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`) + const supervisorPayload = JSON.stringify({ + command: shellCommand.command, + args: shellCommand.args, + cwd: process.cwd(), + }) - const spawnDetails = supportsUserShell() - ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) - : this.buildDirectSpawn(cliEntry, args) + console.info( + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`, + ) + console.info(`[cli] utility supervisor: ${supervisorPath}`) + console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`) - const detached = process.platform !== "win32" - const child = spawn(spawnDetails.command, spawnDetails.args, { - cwd: process.cwd(), - stdio: ["ignore", "pipe", "pipe"], - env, - shell: false, - detached, - }) + child = utilityProcess.fork(supervisorPath, [supervisorPayload], { + env: shellEnv, + stdio: "pipe", + serviceName: "CodeNomad CLI Supervisor", + }) + this.childLaunchMode = "utility" + } else { + const cliEntry = this.resolveCliEntry(options) + console.info( + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, + ) - console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) - if (!child.pid) { + const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } + env.ELECTRON_RUN_AS_NODE = "1" + + const spawnDetails = supportsUserShell() + ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`) + : this.buildDirectSpawn(cliEntry, args) + + const detached = process.platform !== "win32" + child = spawn(spawnDetails.command, spawnDetails.args, { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + env, + shell: false, + detached, + }) + + console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`) + this.childLaunchMode = "spawn" + } + + if (this.childLaunchMode === "spawn" && !child.pid) { console.error("[cli] spawn failed: no pid") } @@ -179,23 +216,48 @@ export class CliProcessManager extends EventEmitter { this.handleStream(data.toString(), "stderr") }) - child.on("error", (error) => { - console.error("[cli] failed to start CLI:", error) - this.updateStatus({ state: "error", error: error.message }) - this.emit("error", error) - }) + if (this.childLaunchMode === "utility") { + const utilityChild = child as UtilityProcess - child.on("exit", (code, signal) => { - const failed = this.status.state !== "ready" - const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined - console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) - this.updateStatus({ state: failed ? "error" : "stopped", error }) - if (failed && error) { - this.emit("error", new Error(error)) - } - this.emit("exit", this.status) - this.child = undefined - }) + utilityChild.on("error", (error) => { + const message = this.describeUtilityProcessError(error) + console.error("[cli] utility supervisor failed:", error) + this.updateStatus({ state: "error", error: message }) + this.emit("error", new Error(message)) + }) + + utilityChild.on("exit", (code) => { + const failed = this.status.state !== "ready" + const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined + console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`) + this.updateStatus({ state: failed ? "error" : "stopped", error }) + if (failed && error) { + this.emit("error", new Error(error)) + } + this.emit("exit", this.status) + this.child = undefined + }) + } else { + const spawnedChild = child as ChildProcess + + spawnedChild.on("error", (error) => { + console.error("[cli] failed to start CLI:", error) + this.updateStatus({ state: "error", error: error.message }) + this.emit("error", error) + }) + + spawnedChild.on("exit", (code, signal) => { + const failed = this.status.state !== "ready" + const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined + console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`) + this.updateStatus({ state: failed ? "error" : "stopped", error }) + if (failed && error) { + this.emit("error", new Error(error)) + } + this.emit("exit", this.status) + this.child = undefined + }) + } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -222,16 +284,22 @@ export class CliProcessManager extends EventEmitter { return } + if (this.childLaunchMode === "utility") { + return this.stopUtilityChild(child as UtilityProcess) + } + + const spawnedChild = child as ChildProcess + this.requestedStop = true - const pid = child.pid + const pid = spawnedChild.pid if (!pid) { this.child = undefined this.updateStatus({ state: "stopped" }) return } - const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null + const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null const tryKillPosixGroup = (signal: NodeJS.Signals) => { try { @@ -307,7 +375,7 @@ export class CliProcessManager extends EventEmitter { sendStopSignal("SIGKILL") }, 30000) - child.on("exit", () => { + spawnedChild.on("exit", () => { clearTimeout(killTimeout) this.child = undefined console.info("[cli] CLI process exited") @@ -327,6 +395,46 @@ export class CliProcessManager extends EventEmitter { }) } + private stopUtilityChild(child: UtilityProcess): Promise { + this.requestedStop = true + + const pid = child.pid + if (!pid) { + this.child = undefined + this.updateStatus({ state: "stopped" }) + return Promise.resolve() + } + + return new Promise((resolve) => { + const killTimeout = setTimeout(() => { + console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`) + try { + process.kill(pid, "SIGKILL") + } catch { + // no-op + } + }, 30000) + + child.once("exit", () => { + clearTimeout(killTimeout) + this.child = undefined + console.info("[cli] CLI process exited") + this.updateStatus({ state: "stopped" }) + resolve() + }) + + if (child.pid === undefined) { + clearTimeout(killTimeout) + this.child = undefined + this.updateStatus({ state: "stopped" }) + resolve() + return + } + + child.kill() + }) + } + getStatus(): CliStatus { return { ...this.status } } @@ -342,14 +450,22 @@ export class CliProcessManager extends EventEmitter { private handleTimeout() { if (this.child) { const pid = this.child.pid - if (pid && process.platform !== "win32") { + if (this.childLaunchMode === "utility") { + if (pid) { + try { + process.kill(pid, "SIGKILL") + } catch { + // no-op + } + } + } else if (pid && process.platform !== "win32") { try { process.kill(-pid, "SIGKILL") } catch { - this.child.kill("SIGKILL") + ;(this.child as ChildProcess).kill("SIGKILL") } } else { - this.child.kill("SIGKILL") + ;(this.child as ChildProcess).kill("SIGKILL") } this.child = undefined } @@ -456,6 +572,10 @@ export class CliProcessManager extends EventEmitter { return parts.join(" ") } + private buildExecutableCommand(command: string, args: string[]): string { + return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ") + } + private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) { if (cliEntry.runner === "tsx") { return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] } @@ -526,4 +646,58 @@ export class CliProcessManager extends EventEmitter { } throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.") } + + private shouldUsePackagedShellSupervisor(options: StartOptions): boolean { + return !options.dev && app.isPackaged && process.platform === "darwin" + } + + private resolveCliSupervisorPath(): string { + const candidates = [ + path.join(process.resourcesPath, "cli-supervisor.cjs"), + path.join(mainDirname, "../resources/cli-supervisor.cjs"), + ] + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + + throw new Error("Unable to locate CodeNomad CLI supervisor script.") + } + + private resolveShellNodeCommand(): string { + const configured = process.env.NODE_BINARY?.trim() + return configured && configured.length > 0 ? configured : "node" + } + + private resolveBundledProdEntry(): string { + const candidates = [ + path.join(process.resourcesPath, "server", "dist", "bin.js"), + path.join(mainDirname, "../resources/server/dist/bin.js"), + ] + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + + throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.") + } + + private describeUtilityProcessError(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message + } + + if (error && typeof error === "object") { + const typed = error as { type?: unknown; location?: unknown } + if (typeof typed.type === "string") { + return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type + } + } + + return String(error) + } } diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 75bad994..06cb9cad 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -20,6 +20,7 @@ const electronAPI = { return null } }, + requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"), setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)), showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload), } diff --git a/packages/electron-app/electron/resources/cli-supervisor.cjs b/packages/electron-app/electron/resources/cli-supervisor.cjs new file mode 100644 index 00000000..3ac319e3 --- /dev/null +++ b/packages/electron-app/electron/resources/cli-supervisor.cjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +const { spawn } = require("child_process") + +const SHUTDOWN_GRACE_MS = 30_000 + +let child = null +let shutdownTimer = null + +function log(message, error) { + if (error) { + console.error(`[cli-supervisor] ${message}`, error) + return + } + console.log(`[cli-supervisor] ${message}`) +} + +function clearShutdownTimer() { + if (shutdownTimer) { + clearTimeout(shutdownTimer) + shutdownTimer = null + } +} + +function forwardStream(stream, target) { + if (!stream) return + stream.on("data", (chunk) => { + target.write(chunk) + }) +} + +function terminateChild(force) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return + } + + try { + child.kill(force ? "SIGKILL" : "SIGTERM") + } catch { + // no-op + } +} + +function requestShutdown(force = false) { + if (!child) { + process.exit(force ? 1 : 0) + return + } + + terminateChild(force) + if (force) { + process.exit(1) + return + } + + clearShutdownTimer() + shutdownTimer = setTimeout(() => { + log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`) + terminateChild(true) + }, SHUTDOWN_GRACE_MS) + shutdownTimer.unref() +} + +function installShutdownHandlers() { + process.on("SIGTERM", () => requestShutdown(false)) + process.on("SIGINT", () => requestShutdown(false)) + process.on("disconnect", () => requestShutdown(false)) + process.on("uncaughtException", (error) => { + log("uncaught exception", error) + requestShutdown(true) + }) + process.on("unhandledRejection", (error) => { + log("unhandled rejection", error) + requestShutdown(true) + }) +} + +function parsePayload() { + const raw = process.argv[2] + if (!raw) { + throw new Error("Supervisor payload is required") + } + + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== "object") { + throw new Error("Supervisor payload must be an object") + } + if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) { + throw new Error("Supervisor payload command is required") + } + if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) { + throw new Error("Supervisor payload args must be a string array") + } + + return { + command: parsed.command, + args: parsed.args, + cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(), + } +} + +function main() { + installShutdownHandlers() + + const payload = parsePayload() + log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`) + + child = spawn(payload.command, payload.args, { + cwd: payload.cwd, + env: process.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }) + + forwardStream(child.stdout, process.stdout) + forwardStream(child.stderr, process.stderr) + + child.on("error", (error) => { + log("failed to spawn shell command", error) + process.exit(1) + }) + + child.on("exit", (code, signal) => { + clearShutdownTimer() + log(`child exited code=${code ?? ""} signal=${signal ?? ""}`) + process.exitCode = typeof code === "number" ? code : signal ? 1 : 0 + process.exit() + }) +} + +main() diff --git a/packages/electron-app/electron/resources/entitlements.mac.plist b/packages/electron-app/electron/resources/entitlements.mac.plist new file mode 100644 index 00000000..53fdf0fc --- /dev/null +++ b/packages/electron-app/electron/resources/entitlements.mac.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.audio-input + + + diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 07008f33..c4307504 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.13.1", + "version": "0.13.3", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { @@ -20,6 +20,8 @@ "dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev", "dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts", + "prepare:resources": "node scripts/prepare-resources.js", + "prebuild": "npm run prepare:resources", "build": "electron-vite build", "typecheck": "tsc --noEmit -p tsconfig.json", "preview": "electron-vite preview", @@ -33,8 +35,11 @@ "build:linux-arm64": "node scripts/build.js linux-arm64", "build:linux-rpm": "node scripts/build.js linux-rpm", "build:all": "node scripts/build.js all", + "prepackage:mac": "npm run prepare:resources", "package:mac": "electron-builder --mac", + "prepackage:win": "npm run prepare:resources", "package:win": "electron-builder --win", + "prepackage:linux": "npm run prepare:resources", "package:linux": "electron-builder --linux" }, "dependencies": { @@ -82,6 +87,12 @@ } ], "mac": { + "entitlements": "electron/resources/entitlements.mac.plist", + "entitlementsInherit": "electron/resources/entitlements.mac.plist", + "extendInfo": { + "NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.", + "NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services." + }, "category": "public.app-category.developer-tools", "target": [ { diff --git a/packages/electron-app/scripts/build.js b/packages/electron-app/scripts/build.js index 4cc52ce7..636170d8 100644 --- a/packages/electron-app/scripts/build.js +++ b/packages/electron-app/scripts/build.js @@ -111,6 +111,12 @@ async function build(platform) { env: { NODE_PATH: workspaceNodeModulesPath }, }) + console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n") + await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], { + cwd: workspaceRoot, + env: { NODE_PATH: workspaceNodeModulesPath }, + }) + console.log("\n📦 Step 2/3: Building Electron app...\n") await run(npmCmd, ["run", "build"]) diff --git a/packages/electron-app/scripts/prepare-resources.js b/packages/electron-app/scripts/prepare-resources.js new file mode 100644 index 00000000..d44a4508 --- /dev/null +++ b/packages/electron-app/scripts/prepare-resources.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +import fs from "fs" +import path, { join } from "path" +import { spawnSync } from "child_process" +import { fileURLToPath } from "url" + +const __dirname = fileURLToPath(new URL(".", import.meta.url)) +const appDir = join(__dirname, "..") +const workspaceRoot = join(appDir, "..", "..") +const serverRoot = join(appDir, "..", "server") +const resourcesRoot = join(appDir, "electron", "resources") +const serverDest = join(resourcesRoot, "server") +const npmExecPath = process.env.npm_execpath +const npmNodeExecPath = process.env.npm_node_execpath + +const serverSources = ["dist", "public", "node_modules", "package.json"] +const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json") + +function log(message) { + console.log(`[prepare-resources] ${message}`) +} + +function ensureServerBuild() { + const distPath = join(serverRoot, "dist") + const publicPath = join(serverRoot, "public") + if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) { + throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.") + } +} + +function ensureServerDependencies() { + if (fs.existsSync(serverDepsMarker)) { + return + } + + log("installing production server dependencies") + const npmArgs = [ + "install", + "--omit=dev", + "--ignore-scripts", + "--workspaces=false", + "--package-lock=false", + "--install-strategy=shallow", + "--fund=false", + "--audit=false", + ] + + const env = { + ...process.env, + PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`, + npm_config_workspaces: "false", + } + + const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null + const result = npmCli + ? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env }) + : spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" }) + + if (result.status !== 0) { + if (result.error) { + throw result.error + } + throw new Error(`npm install exited with code ${result.status ?? 1}`) + } +} + +function copyServerArtifacts() { + fs.rmSync(serverDest, { recursive: true, force: true }) + fs.mkdirSync(serverDest, { recursive: true }) + + for (const name of serverSources) { + const from = join(serverRoot, name) + const to = join(serverDest, name) + if (!fs.existsSync(from)) { + throw new Error(`Missing required server artifact: ${from}`) + } + fs.cpSync(from, to, { recursive: true, dereference: true }) + log(`copied ${name} to Electron resources`) + } +} + +function stripNodeModuleBins() { + const root = 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 = 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) { + log(`removed ${removed} node_modules/.bin directories`) + } +} + +async function main() { + ensureServerBuild() + ensureServerDependencies() + copyServerArtifacts() + stripNodeModuleBins() +} + +main().catch((error) => { + console.error("[prepare-resources] failed:", error) + process.exit(1) +}) diff --git a/packages/electron-app/tsconfig.json b/packages/electron-app/tsconfig.json index af517920..0953e045 100644 --- a/packages/electron-app/tsconfig.json +++ b/packages/electron-app/tsconfig.json @@ -14,5 +14,5 @@ "noEmit": true }, "include": ["electron/**/*.ts", "electron.vite.config.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "electron/resources/server"] } diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index e02664c0..635f7716 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -4,6 +4,6 @@ "private": true, "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.3.2" + "@opencode-ai/plugin": "1.3.7" } } \ No newline at end of file diff --git a/packages/opencode-config/plugin/codenomad.ts b/packages/opencode-config/plugin/codenomad.ts index b04322d0..08515dd8 100644 --- a/packages/opencode-config/plugin/codenomad.ts +++ b/packages/opencode-config/plugin/codenomad.ts @@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin" import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" import { createBackgroundProcessTools } from "./lib/background-process" +let voiceModeEnabled = false + export async function CodeNomadPlugin(input: PluginInput) { const config = getCodeNomadConfig() const client = createCodeNomadClient(config) @@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) { pingTs: (event.properties as any)?.ts, }, }).catch(() => {}) + return + } + + if (event.type === "codenomad.voiceMode") { + voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled) } }) @@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) { tool: { ...backgroundProcessTools, }, + async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) { + if (!voiceModeEnabled) { + return + } + + output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n") + }, async event(input: { event: any }) { const opencodeEvent = input?.event if (!opencodeEvent || typeof opencodeEvent !== "object") return @@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) { }, } } + +function buildVoiceModePrompt(): string { + return [ + "Voice conversation mode is enabled.", + "Prepend your reply with a fenced code block using language `spoken`.", + "The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.", + "In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.", + "Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.", + "Do not add generic phrases about whether the user should read more.", + "Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.", + "When referring to that written detail, say `below` or `in the message` rather than `detailed section`.", + "After the `spoken` block, continue with your normal detailed response.", + "Example:", + "```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```", + ].join("\n\n") +} diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index b2d85fd4..9d0466ef 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.13.1", + "version": "0.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.13.1", + "version": "0.13.3", "dependencies": { "@fastify/cors": "^8.5.0", "@fastify/reply-from": "^9.8.0", diff --git a/packages/server/package.json b/packages/server/package.json index 71e9d2cb..fc1781f6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.13.1", + "version": "0.13.3", "description": "CodeNomad Server", "license": "MIT", "author": { diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 7bc54a13..e901e335 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -240,6 +240,10 @@ export interface SpeechSynthesisResponse { mimeType: string } +export interface VoiceModeStateResponse { + enabled: boolean +} + export type WorkspaceEventType = | "workspace.created" | "workspace.started" diff --git a/packages/server/src/clients/connection-manager.ts b/packages/server/src/clients/connection-manager.ts new file mode 100644 index 00000000..7eaa426f --- /dev/null +++ b/packages/server/src/clients/connection-manager.ts @@ -0,0 +1,128 @@ +import type { Logger } from "../logger" + +const STALE_CONNECTION_TIMEOUT_MS = 45000 +const STALE_SWEEP_INTERVAL_MS = 5000 + +export interface ClientConnectionRef { + clientId: string + connectionId: string +} + +export interface ClientConnectionRecord extends ClientConnectionRef { + key: string + connectedAt: number + lastSeenAt: number +} + +type ConnectionChangeEvent = { + type: "connected" | "disconnected" + connection: ClientConnectionRecord + reason?: string +} + +interface RegisteredConnection extends ClientConnectionRecord { + close: () => void +} + +export class ClientConnectionManager { + private readonly connections = new Map() + private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>() + private readonly sweepTimer: NodeJS.Timeout + + constructor(private readonly logger: Logger) { + this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS) + this.sweepTimer.unref?.() + } + + shutdown(): void { + clearInterval(this.sweepTimer) + for (const connection of Array.from(this.connections.values())) { + this.disconnect(connection.key, "shutdown", false) + } + } + + subscribe(listener: (event: ConnectionChangeEvent) => void): () => void { + this.subscribers.add(listener) + return () => this.subscribers.delete(listener) + } + + register(input: ClientConnectionRef & { close: () => void }): () => void { + const key = getConnectionKey(input) + const now = Date.now() + const existing = this.connections.get(key) + + if (existing) { + this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection") + this.disconnect(key, "replaced") + } + + const connection: RegisteredConnection = { + key, + clientId: input.clientId, + connectionId: input.connectionId, + connectedAt: now, + lastSeenAt: now, + close: input.close, + } + this.connections.set(key, connection) + this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected") + this.notify({ type: "connected", connection }) + return () => this.disconnect(key, "closed") + } + + pong(input: ClientConnectionRef): boolean { + const key = getConnectionKey(input) + const connection = this.connections.get(key) + if (!connection) { + this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection") + return false + } + + connection.lastSeenAt = Date.now() + return true + } + + isConnected(input: ClientConnectionRef): boolean { + return this.connections.has(getConnectionKey(input)) + } + + private sweepStaleConnections(): void { + const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS + for (const connection of Array.from(this.connections.values())) { + if (connection.lastSeenAt > cutoff) continue + this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out") + this.disconnect(connection.key, "timeout") + } + } + + private disconnect(key: string, reason: string, invokeClose = true): void { + const connection = this.connections.get(key) + if (!connection) return + this.connections.delete(key) + this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected") + + if (invokeClose) { + try { + connection.close() + } catch (error) { + this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection") + } + } + + this.notify({ type: "disconnected", connection, reason }) + } + + private notify(event: ConnectionChangeEvent): void { + for (const subscriber of this.subscribers) { + try { + subscriber(event) + } catch (error) { + this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed") + } + } + } +} + +function getConnectionKey(input: ClientConnectionRef): string { + return `${input.clientId}:${input.connectionId}` +} diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts index d2a8065d..6643e5b3 100644 --- a/packages/server/src/filesystem/browser.ts +++ b/packages/server/src/filesystem/browser.ts @@ -81,6 +81,14 @@ export class FileSystemBrowser { return { path: relativePath, absolutePath } } + writeFile(relativePath: string, contents: string): void { + if (this.unrestricted) { + throw new Error("writeFile is not available in unrestricted mode") + } + const resolved = this.toRestrictedAbsolute(relativePath) + fs.writeFileSync(resolved, contents, "utf-8") + } + readFile(relativePath: string): string { if (this.unrestricted) { throw new Error("readFile is not available in unrestricted mode") diff --git a/packages/server/src/plugins/voice-mode.ts b/packages/server/src/plugins/voice-mode.ts new file mode 100644 index 00000000..3e2f8cb5 --- /dev/null +++ b/packages/server/src/plugins/voice-mode.ts @@ -0,0 +1,96 @@ +import type { Logger } from "../logger" +import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager" +import type { PluginChannelManager } from "./channel" + +interface VoiceModeManagerOptions { + connections: ClientConnectionManager + channel: PluginChannelManager + logger: Logger +} + +export class VoiceModeManager { + private readonly enabledConnectionsByInstance = new Map>() + private readonly aggregateByInstance = new Map() + + constructor(private readonly options: VoiceModeManagerOptions) { + this.options.connections.subscribe((event) => { + if (event.type !== "disconnected") return + this.clearConnection(event.connection) + }) + } + + setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void { + if (enabled && !this.options.connections.isConnected(connection)) { + this.options.logger.debug( + { instanceId, clientId: connection.clientId, connectionId: connection.connectionId }, + "Ignoring voice mode enable for disconnected client connection", + ) + return + } + + const key = getConnectionKey(connection) + const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set() + + if (enabled) { + current.add(key) + this.enabledConnectionsByInstance.set(instanceId, current) + } else if (current.delete(key)) { + if (current.size === 0) { + this.enabledConnectionsByInstance.delete(instanceId) + } else { + this.enabledConnectionsByInstance.set(instanceId, current) + } + } + + this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection") + this.publishIfChanged(instanceId) + } + + syncInstance(instanceId: string): void { + this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId))) + } + + isEnabled(instanceId: string): boolean { + return this.aggregateByInstance.get(instanceId) === true + } + + private clearConnection(connection: ClientConnectionRef): void { + const key = getConnectionKey(connection) + for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) { + if (!enabledConnections.delete(key)) continue + if (enabledConnections.size === 0) { + this.enabledConnectionsByInstance.delete(instanceId) + } + this.publishIfChanged(instanceId) + } + } + + private publishIfChanged(instanceId: string): void { + const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0 + const previous = this.aggregateByInstance.get(instanceId) === true + if (enabled === previous) return + + if (enabled) { + this.aggregateByInstance.set(instanceId, true) + } else { + this.aggregateByInstance.delete(instanceId) + } + + this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode") + this.options.channel.send(instanceId, buildVoiceModeEvent(enabled)) + } +} + +function buildVoiceModeEvent(enabled: boolean) { + return { + type: "codenomad.voiceMode", + properties: { + enabled, + formatVersion: "v1", + }, + } +} + +function getConnectionKey(connection: ClientConnectionRef): string { + return `${connection.clientId}:${connection.connectionId}` +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 3f558cb8..61f82535 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -29,6 +29,9 @@ import type { AuthManager } from "../auth/manager" import { registerAuthRoutes } from "./routes/auth" import { sendUnauthorized, wantsHtml } from "../auth/http-auth" import type { SpeechService } from "../speech/service" +import { ClientConnectionManager } from "../clients/connection-manager" +import { PluginChannelManager } from "../plugins/channel" +import { VoiceModeManager } from "../plugins/voice-mode" interface HttpServerDeps { bindHost: string @@ -173,6 +176,13 @@ export function createHttpServer(deps: HttpServerDeps) { eventBus: deps.eventBus, logger: deps.logger.child({ component: "background-processes" }), }) + const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" })) + const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" })) + const voiceModeManager = new VoiceModeManager({ + connections: clientConnectionManager, + channel: pluginChannel, + logger: deps.logger.child({ component: "voice-mode" }), + }) registerAuthRoutes(app, { authManager: deps.authManager }) @@ -248,7 +258,12 @@ export function createHttpServer(deps: HttpServerDeps) { registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerMetaRoutes(app, { serverMeta: deps.serverMeta }) - registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger }) + registerEventRoutes(app, { + eventBus: deps.eventBus, + registerClient: registerSseClient, + logger: sseLogger, + connectionManager: clientConnectionManager, + }) registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerStorageRoutes(app, { instanceStore: deps.instanceStore, @@ -256,7 +271,13 @@ export function createHttpServer(deps: HttpServerDeps) { workspaceManager: deps.workspaceManager, }) registerSpeechRoutes(app, { speechService: deps.speechService }) - registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger }) + registerPluginRoutes(app, { + workspaceManager: deps.workspaceManager, + eventBus: deps.eventBus, + logger: proxyLogger, + channel: pluginChannel, + voiceModeManager, + }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) @@ -321,6 +342,7 @@ export function createHttpServer(deps: HttpServerDeps) { }, stop: () => { closeSseClients() + clientConnectionManager.shutdown() return app.close() }, } diff --git a/packages/server/src/server/routes/events.ts b/packages/server/src/server/routes/events.ts index e8f23298..158266e1 100644 --- a/packages/server/src/server/routes/events.ts +++ b/packages/server/src/server/routes/events.ts @@ -1,19 +1,32 @@ import { FastifyInstance } from "fastify" +import { z } from "zod" import { EventBus } from "../../events/bus" import { WorkspaceEventPayload } from "../../api-types" +import type { ClientConnectionManager } from "../../clients/connection-manager" import { Logger } from "../../logger" interface RouteDeps { eventBus: EventBus registerClient: (cleanup: () => void) => () => void logger: Logger + connectionManager: ClientConnectionManager } let nextClientId = 0 +const ConnectionQuerySchema = z.object({ + clientId: z.string().trim().min(1), + connectionId: z.string().trim().min(1), +}) + +const PongBodySchema = ConnectionQuerySchema.extend({ + pingTs: z.number().optional(), +}) + export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/events", (request, reply) => { const clientId = ++nextClientId + const connection = ConnectionQuerySchema.parse(request.query ?? {}) deps.logger.debug({ clientId }, "SSE client connected") const origin = request.headers.origin ?? "*" @@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { const unsubscribe = deps.eventBus.onEvent(send) const heartbeat = setInterval(() => { - reply.raw.write(`:hb ${Date.now()}\n\n`) + const ping = { ts: Date.now() } + reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`) }, 15000) let closed = false @@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) { } const unregister = deps.registerClient(close) + const unregisterConnection = deps.connectionManager.register({ + ...connection, + close, + }) const handleClose = () => { close() unregister() + unregisterConnection() } request.raw.on("close", handleClose) request.raw.on("error", handleClose) }) + + app.post("/api/client-connections/pong", (request, reply) => { + const body = PongBodySchema.parse(request.body ?? {}) + if (!deps.connectionManager.pong(body)) { + reply.code(404).send({ error: "Client connection not found" }) + return + } + reply.code(204).send() + }) } diff --git a/packages/server/src/server/routes/plugin.ts b/packages/server/src/server/routes/plugin.ts index 374ce545..daa7630e 100644 --- a/packages/server/src/server/routes/plugin.ts +++ b/packages/server/src/server/routes/plugin.ts @@ -1,15 +1,19 @@ import { FastifyInstance } from "fastify" import { z } from "zod" +import type { VoiceModeStateResponse } from "../../api-types" import type { WorkspaceManager } from "../../workspaces/manager" import type { EventBus } from "../../events/bus" import type { Logger } from "../../logger" import { PluginChannelManager } from "../../plugins/channel" import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers" +import { VoiceModeManager } from "../../plugins/voice-mode" interface RouteDeps { workspaceManager: WorkspaceManager eventBus: EventBus logger: Logger + channel: PluginChannelManager + voiceModeManager: VoiceModeManager } const PluginEventSchema = z.object({ @@ -17,9 +21,13 @@ const PluginEventSchema = z.object({ properties: z.record(z.unknown()).optional(), }) -export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { - const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" })) +const VoiceModeStateSchema = z.object({ + enabled: z.boolean(), + clientId: z.string().trim().min(1), + connectionId: z.string().trim().min(1), +}) +export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => { const workspace = deps.workspaceManager.get(request.params.id) if (!workspace) { @@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { reply.raw.flushHeaders?.() reply.hijack() - const registration = channel.register(request.params.id, reply) + const registration = deps.channel.register(request.params.id, reply) + deps.voiceModeManager.syncInstance(request.params.id) const heartbeat = setInterval(() => { - channel.send(request.params.id, buildPingEvent()) + deps.channel.send(request.params.id, buildPingEvent()) }, 15000) const close = () => { @@ -49,6 +58,22 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) { request.raw.on("error", close) }) + app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404).send({ error: "Workspace not found" }) + return + } + + const payload = VoiceModeStateSchema.parse(request.body ?? {}) + deps.voiceModeManager.setEnabled( + request.params.id, + { clientId: payload.clientId, connectionId: payload.connectionId }, + payload.enabled, + ) + return { enabled: payload.enabled } + }) + const handleWildcard = async (request: any, reply: any) => { const workspaceId = request.params.id as string const workspace = deps.workspaceManager.get(workspaceId) diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 1541475d..de2afd22 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({ path: z.string(), }) +const WorkspaceFileContentBodySchema = z.object({ + contents: z.string(), +}) + const WorkspaceFileSearchQuerySchema = z.object({ q: z.string().trim().min(1, "Query is required"), limit: z.coerce.number().int().positive().max(200).optional(), @@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { return handleWorkspaceError(error, reply) } }) + + app.put<{ + Params: { id: string } + Querystring: { path?: string } + }>("/api/workspaces/:id/files/content", async (request, reply) => { + try { + const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) + const body = WorkspaceFileContentBodySchema.parse(request.body ?? {}) + deps.workspaceManager.writeFile(request.params.id, query.path, body.contents) + reply.code(204) + } catch (error) { + return handleWorkspaceError(error, reply) + } + }) } diff --git a/packages/server/src/speech/providers/openai-compatible.ts b/packages/server/src/speech/providers/openai-compatible.ts index 0db8da7f..bc4cffb8 100644 --- a/packages/server/src/speech/providers/openai-compatible.ts +++ b/packages/server/src/speech/providers/openai-compatible.ts @@ -147,19 +147,49 @@ export class OpenAICompatibleSpeechProvider { } const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1")) - const response = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${settings.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: settings.ttsModel, - voice: settings.ttsVoice, - input: text, - response_format: format, - }), - }) + let response: Response + try { + response = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${settings.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: settings.ttsModel, + voice: settings.ttsVoice, + input: text, + response_format: format, + }), + }) + } catch (error) { + const detailedError = error as Error & { + cause?: unknown + code?: string + errno?: number | string + syscall?: string + address?: string + port?: number + } + this.options.logger.error( + { + err: error, + endpoint: endpoint.toString(), + baseUrl: settings.baseUrl, + model: settings.ttsModel, + voice: settings.ttsVoice, + format, + cause: detailedError.cause, + code: detailedError.code, + errno: detailedError.errno, + syscall: detailedError.syscall, + address: detailedError.address, + port: detailedError.port, + }, + "speech.synthesize fetch failed", + ) + throw error + } if (!response.ok) { const detail = await response.text() diff --git a/packages/server/src/ui/__tests__/remote-ui.test.ts b/packages/server/src/ui/__tests__/remote-ui.test.ts index e858498d..99b34900 100644 --- a/packages/server/src/ui/__tests__/remote-ui.test.ts +++ b/packages/server/src/ui/__tests__/remote-ui.test.ts @@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => { assert.equal(result.uiStaticDir, bundledDir) assert.equal(result.uiVersion, "0.8.1") }) + + it("prefers bundled when bundled and downloaded versions are equal", async () => { + const bundledDir = path.join(tempRoot, "bundled") + const configDir = path.join(tempRoot, "config") + const currentDir = path.join(configDir, "ui", "current") + + await mkdir(bundledDir, { recursive: true }) + await mkdir(currentDir, { recursive: true }) + + writeFileSync(path.join(bundledDir, "index.html"), "bundled") + writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" })) + + writeFileSync(path.join(currentDir, "index.html"), "current") + writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" })) + + const result = await resolveUi({ + serverVersion: "0.8.1", + bundledUiDir: bundledDir, + autoUpdate: false, + configDir, + logger: noopLogger, + }) + + assert.equal(result.source, "bundled") + assert.equal(result.uiStaticDir, bundledDir) + assert.equal(result.uiVersion, "0.8.1") + }) }) diff --git a/packages/server/src/ui/remote-ui.ts b/packages/server/src/ui/remote-ui.ts index 1aff87df..879c5970 100644 --- a/packages/server/src/ui/remote-ui.ts +++ b/packages/server/src/ui/remote-ui.ts @@ -250,7 +250,7 @@ async function pickBestLocalUi(args: { uiStaticDir: currentResolved, source: "downloaded", uiVersion: await readUiVersion(currentResolved), - priority: 2, + priority: 1, }) } @@ -260,7 +260,7 @@ async function pickBestLocalUi(args: { uiStaticDir: bundledResolved, source: "bundled", uiVersion: await readUiVersion(bundledResolved), - priority: 1, + priority: 2, }) } diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 602589ee..805b8f95 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -83,6 +83,12 @@ export class WorkspaceManager { } } + writeFile(workspaceId: string, relativePath: string, contents: string): void { + const workspace = this.requireWorkspace(workspaceId) + const browser = new FileSystemBrowser({ rootDir: workspace.path }) + browser.writeFile(relativePath, contents) + } + async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index a8da637c..2268df75 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -458,7 +458,7 @@ dependencies = [ [[package]] name = "codenomad-tauri" -version = "0.12.3" +version = "0.13.3" dependencies = [ "anyhow", "dirs 5.0.1", diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 67bf4578..2be47336 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.13.1", + "version": "0.13.3", "private": true, "license": "MIT", "scripts": { @@ -8,6 +8,7 @@ "dev:ui": "npm run dev --workspace @codenomad/ui", "dev:prep": "node ./scripts/dev-prep.js", "dev:bootstrap": "npm run dev:prep && npm run dev:ui", + "sync:version": "node ./scripts/sync-tauri-version.js", "prebuild": "node ./scripts/prebuild.js", "bundle:server": "npm run prebuild", "build": "tauri build" diff --git a/packages/tauri-app/scripts/prebuild.js b/packages/tauri-app/scripts/prebuild.js index a382a8d2..f74825f5 100644 --- a/packages/tauri-app/scripts/prebuild.js +++ b/packages/tauri-app/scripts/prebuild.js @@ -56,11 +56,7 @@ async function ensureMonacoAssets() { function ensureServerBuild() { const distPath = path.join(serverRoot, "dist") const publicPath = path.join(serverRoot, "public") - if (fs.existsSync(distPath) && fs.existsSync(publicPath)) { - return - } - - console.log("[prebuild] server build missing; running workspace build...") + console.log("[prebuild] rebuilding server workspace for desktop packaging...") execSync("npm --workspace @neuralnomads/codenomad run build", { cwd: workspaceRoot, stdio: "inherit", diff --git a/packages/tauri-app/scripts/sync-tauri-version.js b/packages/tauri-app/scripts/sync-tauri-version.js new file mode 100644 index 00000000..f5f8092e --- /dev/null +++ b/packages/tauri-app/scripts/sync-tauri-version.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +const fs = require("fs") +const path = require("path") + +const root = path.resolve(__dirname, "..") +const packageJsonPath = path.join(root, "package.json") +const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml") +const cargoLockPath = path.join(root, "Cargo.lock") +const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json") + +function readPackageVersion() { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) + if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { + throw new Error("Missing version in packages/tauri-app/package.json") + } + return packageJson.version +} + +function syncCargoToml(version) { + const current = fs.readFileSync(cargoTomlPath, "utf8") + const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m + const match = current.match(packageVersionPattern) + + if (!match) { + throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml") + } + + if (match[2] === version) { + return false + } + + const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`) + fs.writeFileSync(cargoTomlPath, updated) + return true +} + +function syncCargoLock(version) { + if (!fs.existsSync(cargoLockPath)) { + return false + } + + const current = fs.readFileSync(cargoLockPath, "utf8") + const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/ + const match = current.match(packageVersionPattern) + + if (!match) { + throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock") + } + + if (match[2] === version) { + return false + } + + const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`) + fs.writeFileSync(cargoLockPath, updated) + return true +} + +function syncTauriConfig(version) { + const current = fs.readFileSync(tauriConfigPath, "utf8") + const config = JSON.parse(current) + if (config.version === version) { + return false + } + + config.version = version + fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`) + return true +} + +function main() { + const version = readPackageVersion() + const changed = [] + + if (syncCargoToml(version)) { + changed.push(path.relative(root, cargoTomlPath)) + } + + if (syncCargoLock(version)) { + changed.push(path.relative(root, cargoLockPath)) + } + + if (syncTauriConfig(version)) { + changed.push(path.relative(root, tauriConfigPath)) + } + + if (changed.length === 0) { + console.log(`[sync-tauri-version] already aligned to ${version}`) + return + } + + console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`) +} + +try { + main() +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[sync-tauri-version] failed: ${message}`) + process.exit(1) +} diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 56d241ef..13a89b1f 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codenomad-tauri" -version = "0.12.3" +version = "0.13.3" edition = "2021" license = "MIT" diff --git a/packages/tauri-app/src-tauri/Info.plist b/packages/tauri-app/src-tauri/Info.plist new file mode 100644 index 00000000..dd135cbc --- /dev/null +++ b/packages/tauri-app/src-tauri/Info.plist @@ -0,0 +1,10 @@ + + + + + NSMicrophoneUsageDescription + CodeNomad needs microphone access for speech-to-text prompt input. + NSLocalNetworkUsageDescription + CodeNomad needs local network access to connect to locally hosted AI and speech services. + + diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index c8f965ff..8a1b4103 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -1,16 +1,13 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CodeNomad", - "version": "0.12.3", + "version": "0.13.3", "identifier": "ai.neuralnomads.codenomad.client", "build": { "beforeDevCommand": "npm run dev:bootstrap", "beforeBuildCommand": "npm run bundle:server", "frontendDist": "resources/ui-loading" }, - - - "app": { "withGlobalTauri": true, "windows": [ @@ -33,9 +30,13 @@ ], "security": { "assetProtocol": { - "scope": ["**"] + "scope": [ + "**" + ] }, - "capabilities": ["main-window-native-dialogs"] + "capabilities": [ + "main-window-native-dialogs" + ] } }, "bundle": { @@ -44,7 +45,17 @@ "resources/server", "resources/ui-loading" ], - "icon": ["icon.icns", "icon.ico", "icon.png"], - "targets": ["app", "appimage", "deb", "rpm", "nsis"] + "icon": [ + "icon.icns", + "icon.ico", + "icon.png" + ], + "targets": [ + "app", + "appimage", + "deb", + "rpm", + "nsis" + ] } } diff --git a/packages/ui/package.json b/packages/ui/package.json index dfd2e7e8..04e49654 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.13.1", + "version": "0.13.3", "private": true, "license": "MIT", "type": "module", diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index 7ef34570..20c8b989 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -108,15 +108,15 @@ const AlertDialog: Component = () => { open modal onOpenChange={(open) => { - if (!open) { + // Only handle dismiss if dialog is dismissible (default: true) + if (!open && payload.dismissible !== false) { dismiss(false, payload) } }} > - -
- + +
{
-
- -
- - +
+
+ + ) }} diff --git a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx index cffecf2d..4816f225 100644 --- a/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx +++ b/packages/ui/src/components/file-viewer/monaco-file-viewer.tsx @@ -9,6 +9,8 @@ interface MonacoFileViewerProps { scopeKey: string path: string content: string + onSave?: (content: string) => void + onContentChange?: (content: string) => void } export function MonacoFileViewer(props: MonacoFileViewerProps) { @@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { editor = null } + const saveContent = () => { + if (!editor || !props.onSave) return + props.onSave(editor.getValue()) + } + onMount(() => { let cancelled = false void (async () => { @@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { editor = monaco.editor.create(host, { value: "", language: "plaintext", - readOnly: true, + readOnly: false, automaticLayout: true, lineNumbers: "on", minimap: { enabled: false }, @@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) { fontSize: 13, }) + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent) + + editor.onDidChangeModelContent(() => { + if (props.onContentChange) { + props.onContentChange(editor.getValue()) + } + }) + setReady(true) })() diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 85c80059..2dcc4c1a 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -443,7 +443,7 @@ const FolderSelectionView: Component = (props) => { rel="noreferrer" class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" aria-label={t("folderSelection.links.githubStars")} - title={t("folderSelection.links.githubStars")} + title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")} onClick={(event) => { event.preventDefault() void openExternalUrl(GITHUB_URL, "folder-selection") diff --git a/packages/ui/src/components/instance-info.tsx b/packages/ui/src/components/instance-info.tsx index 31684cbe..8f9c893c 100644 --- a/packages/ui/src/components/instance-info.tsx +++ b/packages/ui/src/components/instance-info.tsx @@ -44,6 +44,7 @@ const InstanceInfo: Component = (props) => { variant: "warning", confirmLabel: t("infoView.dispose.confirm.confirmLabel"), cancelLabel: t("infoView.dispose.confirm.cancelLabel"), + dismissible: false, }) if (!confirmed) return diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 750e15f8..694b9d36 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -36,12 +36,12 @@ import { serverApi } from "../../lib/api-client" import { loadBackgroundProcesses } from "../../stores/background-processes" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { useI18n } from "../../lib/i18n" -import { getPermissionQueueLength, getQuestionQueueLength } from "../../stores/instances" +import { getPermissionQueue, getPermissionQueueLength, getQuestionQueueLength, sendPermissionResponse } from "../../stores/instances" import SessionSidebar from "./shell/SessionSidebar" import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" import RightPanel from "./shell/right-panel/RightPanel" import { useDrawerChrome } from "./shell/useDrawerChrome" -import { getSessionStatus } from "../../stores/session-status" +import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status" import { Maximize2, ShieldAlert } from "lucide-solid" import type { LayoutMode } from "./shell/types" @@ -57,6 +57,13 @@ import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure" import { useDrawerResize } from "./shell/useDrawerResize" import { useSessionCache } from "./shell/useSessionCache" import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" +import { getPermissionSessionId } from "../../types/permission" +import { + canAutoRespondPermission, + finishAutoRespondPermission, + getPermissionAutoAcceptInFlightVersion, + isPermissionAutoAcceptEnabled, +} from "../../stores/permission-auto-accept" const log = getLogger("session") @@ -97,6 +104,7 @@ const InstanceShell2: Component = (props) => { const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) + const [now, setNow] = createSignal(Date.now()) // Worktree selector manages its own dialogs. const [showSessionSearch, setShowSessionSearch] = createSignal(false) @@ -230,6 +238,12 @@ const InstanceShell2: Component = (props) => { window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString()) }) + createEffect(() => { + if (typeof window === "undefined") return + const timer = window.setInterval(() => setNow(Date.now()), 1000) + onCleanup(() => window.clearInterval(timer)) + }) + const connectionStatus = () => sseManager.getStatus(props.instance.id) const connectionStatusClass = () => { const status = connectionStatus() @@ -252,6 +266,33 @@ const InstanceShell2: Component = (props) => { return permissions + questions > 0 }) + const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id)) + + createEffect(() => { + getPermissionAutoAcceptInFlightVersion() + + for (const permission of permissionQueue()) { + const sessionId = getPermissionSessionId(permission) + if (!sessionId) continue + if (!permission?.id) continue + if (!canAutoRespondPermission(props.instance.id, sessionId, permission.id)) continue + + void sendPermissionResponse(props.instance.id, sessionId, permission.id, "once") + .catch((error) => { + log.error("Failed to auto-accept permission", error) + }) + .finally(() => { + finishAutoRespondPermission(props.instance.id, sessionId, permission.id) + }) + } + }) + + const yoloModeEnabled = createMemo(() => { + const session = activeSessionForInstance() + if (!session) return false + return isPermissionAutoAcceptEnabled(props.instance.id, session.id) + }) + const activeSessionStatusPill = createMemo(() => { const activeSessionId = activeSessionIdForInstance() if (!activeSessionId || activeSessionId === "info") return null @@ -272,17 +313,28 @@ const InstanceShell2: Component = (props) => { } const status = getSessionStatus(props.instance.id, activeSessionId) - const text = - status === "working" + const retry = getSessionRetry(props.instance.id, activeSessionId) + const text = retry + ? (() => { + const seconds = getRetrySeconds(retry.next, now()) + return seconds > 0 ? t("sessionList.status.retryingIn", { seconds: String(seconds) }) : t("sessionList.status.retrying") + })() + : status === "working" ? t("sessionList.status.working") : status === "compacting" ? t("sessionList.status.compacting") : t("sessionList.status.idle") return { - className: `session-${status}`, + className: `session-${retry ? "retrying" : status}`, text, showAlertIcon: false, + title: retry + ? t("sessionList.status.retryTooltip", { + message: retry.message, + attempt: String(retry.attempt), + }) + : undefined, } }) @@ -290,13 +342,39 @@ const InstanceShell2: Component = (props) => { const pill = activeSessionStatusPill() if (!pill) return null return ( - + {pill.showAlertIcon ?