Compare commits

..

4 Commits

Author SHA1 Message Date
Shantur Rathore
e84adebe61 fix(server): detect OpenCode version via spawn spec 2026-02-19 07:24:14 +00:00
Shantur Rathore
d45a1ff078 Bump to v0.11.3 2026-02-18 19:59:54 +00:00
Shantur Rathore
b4121696bb fix(ui): track worktree context for question replies
Store the originating worktree slug when questions are enqueued and use
the stored worktree client when replying/rejecting from the global
permission center. This ensures question responses are sent through the
correct worktree, matching the behavior already implemented for
permissions.
2026-02-18 19:56:42 +00:00
Shantur Rathore
f75c942162 fix(ui): exclude hidden agents from pickers 2026-02-18 16:00:58 +00:00
15 changed files with 124 additions and 76 deletions

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.2", "version": "0.11.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.2", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -11985,7 +11985,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.2", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12021,7 +12021,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.2", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12062,7 +12062,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.11.2", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12070,7 +12070,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.11.2", "version": "0.11.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.2", "version": "0.11.3",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.2", "version": "0.11.3",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.2", "version": "0.11.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.2", "version": "0.11.3",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.2", "version": "0.11.3",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -1,7 +1,6 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import { spawnSync } from "child_process" import { probeBinaryVersion } from "../../workspaces/runtime"
import { buildSpawnSpec } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service" import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger" import type { Logger } from "../../logger"
@@ -15,37 +14,8 @@ const ValidateBinarySchema = z.object({
}) })
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
if (!binaryPath) { const result = probeBinaryVersion(binaryPath)
return { valid: false, error: "Missing binary path" } return { valid: result.valid, version: result.version, error: result.error }
}
const spec = buildSpawnSpec(binaryPath, ["--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) }
}
} }
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {

View File

@@ -8,7 +8,7 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { WorkspaceRuntime, ProcessExitInfo, probeBinaryVersion } from "./runtime"
import { Logger } from "../logger" import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js" import { getOpencodeConfigDir } from "../opencode-config.js"
import { import {
@@ -283,28 +283,22 @@ export class WorkspaceManager {
return undefined return undefined
} }
try { const result = probeBinaryVersion(resolvedPath)
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" }) if (result.valid) {
if (result.status === 0 && result.stdout) { if (result.version) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0) this.options.logger.debug({ binary: resolvedPath, version: result.version }, "Detected binary version")
if (line) { return result.version
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
} }
} catch (error) { if (result.reported) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version") this.options.logger.debug({ binary: resolvedPath, reported: result.reported }, "Binary reported version string")
return result.reported
}
return undefined
} }
if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to detect binary version")
}
return undefined return undefined
} }

View File

@@ -8,6 +8,8 @@ import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
export function buildSpawnSpec(binaryPath: string, args: string[]) { export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") { if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const } return { command: binaryPath, args, options: {} as const }
@@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
return { command: binaryPath, args, options: {} as const } return { command: binaryPath, args, options: {} as const }
} }
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--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 stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> { function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.11.2", "version": "0.11.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.11.2", "version": "0.11.3",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
const availableAgents = createMemo(() => { const availableAgents = createMemo(() => {
const allAgents = instanceAgents() const allAgents = instanceAgents()
if (isChildSession()) { if (isChildSession()) {
return allAgents return allAgents.filter((agent) => !agent.hidden)
} }
const filtered = allAgents.filter((agent) => agent.mode !== "subagent") const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent")
const currentAgent = allAgents.find((a) => a.name === props.currentAgent) const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) { if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
@@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
> >
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Select.Value<Agent>> <Select.Value<Agent>>
{(state) => ( {() => (
<div class="selector-trigger-label selector-trigger-label--stacked"> <div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left"> <span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })} {t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
</span> </span>
</div> </div>
)} )}

View File

@@ -287,13 +287,14 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (mode() !== "mention") return if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase() const query = props.searchQuery.toLowerCase()
const visibleAgents = props.agents.filter((agent) => !agent.hidden)
const filtered = query const filtered = query
? props.agents.filter( ? visibleAgents.filter(
(agent) => (agent) =>
agent.name.toLowerCase().includes(query) || agent.name.toLowerCase().includes(query) ||
(agent.description && agent.description.toLowerCase().includes(query)), (agent.description && agent.description.toLowerCase().includes(query)),
) )
: props.agents : visibleAgents
setFilteredAgents(filtered) setFilteredAgents(filtered)
}) })

View File

@@ -52,6 +52,8 @@ const permissionSessionCounts = new Map<string, Map<string, number>>()
const permissionWorktreeSlugByInstance = new Map<string, Map<string, string>>() const permissionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map()) const [questionQueues, setQuestionQueues] = createSignal<Map<string, QuestionRequest[]>>(new Map())
// Track which worktree a question was enqueued under (by question request id).
const questionWorktreeSlugByInstance = new Map<string, Map<string, string>>()
const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map()) const [activeQuestionId, setActiveQuestionId] = createSignal<Map<string, string | null>>(new Map())
const questionSessionCounts = new Map<string, Map<string, number>>() const questionSessionCounts = new Map<string, Map<string, number>>()
const questionEnqueuedAt = new Map<string, number>() const questionEnqueuedAt = new Map<string, number>()
@@ -877,6 +879,16 @@ function addQuestionToQueue(instanceId: string, request: QuestionRequest): void
if (sessionId) { if (sessionId) {
incrementQuestionSessionPendingCount(instanceId, sessionId) incrementQuestionSessionPendingCount(instanceId, sessionId)
setSessionPendingQuestion(instanceId, sessionId, true) setSessionPendingQuestion(instanceId, sessionId, true)
// Record the worktree slug at the time the question is enqueued.
// This is used to respond in the same worktree context even from the global permission center.
const slug = getWorktreeSlugForSession(instanceId, sessionId)
let byQuestionId = questionWorktreeSlugByInstance.get(instanceId)
if (!byQuestionId) {
byQuestionId = new Map()
questionWorktreeSlugByInstance.set(instanceId, byQuestionId)
}
byQuestionId.set(request.id, slug)
} }
} }
@@ -897,6 +909,7 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void {
}) })
questionEnqueuedAt.delete(requestId) questionEnqueuedAt.delete(requestId)
questionWorktreeSlugByInstance.get(instanceId)?.delete(requestId)
recomputeActiveInterruption(instanceId) recomputeActiveInterruption(instanceId)
if (removedSessionId) { if (removedSessionId) {
@@ -909,6 +922,7 @@ function clearQuestionQueue(instanceId: string): void {
for (const request of getQuestionQueue(instanceId)) { for (const request of getQuestionQueue(instanceId)) {
questionEnqueuedAt.delete(request.id) questionEnqueuedAt.delete(request.id)
} }
questionWorktreeSlugByInstance.delete(instanceId)
setQuestionQueues((prev) => { setQuestionQueues((prev) => {
const next = new Map(prev) const next = new Map(prev)
@@ -934,7 +948,7 @@ function setActiveQuestionIdForInstance(instanceId: string, requestId: string):
async function sendQuestionReply( async function sendQuestionReply(
instanceId: string, instanceId: string,
_sessionId: string, sessionId: string,
requestId: string, requestId: string,
answers: string[][], answers: string[][],
): Promise<void> { ): Promise<void> {
@@ -944,8 +958,13 @@ async function sendQuestionReply(
} }
try { try {
const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
const worktreeSlug = stored ?? fallback
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
await requestData( await requestData(
instance.client.question.reply({ client.question.reply({
requestID: requestId, requestID: requestId,
answers, answers,
}), }),
@@ -959,15 +978,20 @@ async function sendQuestionReply(
} }
} }
async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise<void> { async function sendQuestionReject(instanceId: string, sessionId: string, requestId: string): Promise<void> {
const instance = instances().get(instanceId) const instance = instances().get(instanceId)
if (!instance?.client) { if (!instance?.client) {
throw new Error("Instance not ready") throw new Error("Instance not ready")
} }
try { try {
const stored = questionWorktreeSlugByInstance.get(instanceId)?.get(requestId)
const fallback = sessionId ? getWorktreeSlugForSession(instanceId, sessionId) : "root"
const worktreeSlug = stored ?? fallback
const client = getOrCreateWorktreeClient(instanceId, worktreeSlug)
await requestData( await requestData(
instance.client.question.reject({ client.question.reject({
requestID: requestId, requestID: requestId,
}), }),
"question.reject", "question.reject",

View File

@@ -526,6 +526,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
name: agent.name, name: agent.name,
description: agent.description || "", description: agent.description || "",
mode: agent.mode, mode: agent.mode,
hidden: agent.hidden,
model: agent.model?.modelID model: agent.model?.modelID
? { ? {
providerId: agent.model.providerID || "", providerId: agent.model.providerID || "",

View File

@@ -68,6 +68,7 @@ export interface Agent {
name: string name: string
description: string description: string
mode: string mode: string
hidden?: boolean
model?: { model?: {
providerId: string providerId: string
modelId: string modelId: string