diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index abd22635..61c84eb4 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; +import * as DesktopAppActivation from "./DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; @@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { @@ -210,6 +212,10 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + yield* appActivation.start.pipe( + Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), + Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), + ); // Bring up the WSL backend if the user previously enabled it. The // primary is already starting; reconcile fires off the WSL register // in parallel rather than blocking primary readiness on a possibly diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts new file mode 100644 index 00000000..f60e05b6 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -0,0 +1,144 @@ +// @effect-diagnostics nodeBuiltinImport:off -- This adapter test binds a real local socket or Windows named pipe and verifies its cleanup. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + ProjectId, + ThreadId, + type DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@helmcode/contracts"; +import { resolveDesktopAppControlAddress } from "@helmcode/shared/desktopAppControl"; +import { HostProcessPlatform, HostProcessUserId } from "@helmcode/shared/hostProcess"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { afterEach, describe, expect } from "vite-plus/test"; + +import { startDesktopAppControlServer } from "./DesktopAppActivation.ts"; + +const openServers: Array<{ close: () => Promise }> = []; + +afterEach(async () => { + await Promise.all(openServers.splice(0).map((server) => server.close())); +}); + +function makeTarget(stateDir: string, platform: NodeJS.Platform, userId: number | undefined) { + return resolveDesktopAppControlAddress({ + stateDir, + platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: NodePath.join, + }); +} + +function request(requestId: string, platform: NodeJS.Platform): DesktopAppActivationRequest { + return { + version: 1, + requestId, + type: "open-workspace", + workspaceRoot: NodePath.join(NodeOS.tmpdir(), "project"), + platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux", + }; +} + +function exchange(address: string, payload: DesktopAppActivationRequest) { + return new Promise((resolve, reject) => { + const socket = NodeNet.createConnection(address); + socket.setEncoding("utf8"); + let buffer = ""; + socket.once("error", reject); + socket.once("connect", () => socket.write(`${JSON.stringify(payload)}\n`)); + socket.on("data", (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + socket.destroy(); + resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse); + }); + }); +} + +describe("desktop app control server", () => { + it.effect("roundtrips a request and removes its socket on shutdown", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "helmcode-app-control-test-"), + ); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const received: DesktopAppActivationRequest[] = []; + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => { + received.push(input); + return { + version: 1, + requestId: input.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }; + }, + cancel: () => undefined, + }); + openServers.push(server); + + const response = await exchange(target.address, request("request-1", platform)); + + expect(received).toHaveLength(1); + expect(response).toMatchObject({ ok: true, requestId: "request-1" }); + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + if (target.directory !== null) { + await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" }); + } + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); + + it.effect("cancels a queued request when the client disconnects", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "helmcode-app-cancel-test-"), + ); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + let resolveCanceled: (requestId: string) => void = () => undefined; + const canceled = new Promise((resolve) => { + resolveCanceled = resolve; + }); + const server = await startDesktopAppControlServer({ + ...target, + userId, + handle: () => new Promise(() => undefined), + cancel: resolveCanceled, + }); + openServers.push(server); + const socket = NodeNet.createConnection(target.address); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.once("connect", () => { + socket.write(`${JSON.stringify(request("request-canceled", platform))}\n`, () => { + socket.destroy(); + resolve(); + }); + }); + }); + + await expect(canceled).resolves.toBe("request-canceled"); + await server.close(); + openServers.splice(openServers.indexOf(server), 1); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); +}); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts new file mode 100644 index 00000000..2c6872d2 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -0,0 +1,306 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@helmcode/contracts"; +import { resolveDesktopAppControlAddress } from "@helmcode/shared/desktopAppControl"; +import { HostProcessUserId } from "@helmcode/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import type * as Electron from "electron"; + +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +const MAX_REQUEST_BYTES = 64 * 1024; +const REQUEST_TIMEOUT_MS = 15_000; +const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest); + +export class DesktopAppActivationStartError extends Schema.TaggedErrorClass()( + "DesktopAppActivationStartError", + { + address: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not start the desktop app control socket at ${this.address}.`; + } +} + +interface RunningControlServer { + readonly close: () => Promise; +} + +function invalidResponse(requestId: string, message: string): DesktopAppActivationResponse { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: false, + code: "invalid-request", + message, + }; +} + +function requestIdFromUnknown(value: unknown): string { + if ( + typeof value === "object" && + value !== null && + "requestId" in value && + typeof value.requestId === "string" && + value.requestId.trim().length > 0 + ) { + return value.requestId; + } + return "invalid-request"; +} + +async function prepareUnixSocket(input: { + readonly address: string; + readonly directory: string; + readonly userId: number | undefined; +}): Promise { + await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 }); + const stat = await NodeFSP.lstat(input.directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${input.directory} is not a directory.`); + } + if (input.userId !== undefined && stat.uid !== input.userId) { + throw new Error(`${input.directory} is owned by another user.`); + } + await NodeFSP.chmod(input.directory, 0o700); + await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); +} + +export async function startDesktopAppControlServer(input: { + readonly address: string; + readonly directory: string | null; + readonly userId: number | undefined; + readonly handle: (request: DesktopAppActivationRequest) => Promise; + readonly cancel: (requestId: string) => void; +}): Promise { + if (input.directory !== null) { + await prepareUnixSocket({ + address: input.address, + directory: input.directory, + userId: input.userId, + }); + } + + const sockets = new Set(); + const server = NodeNet.createServer((socket) => { + sockets.add(socket); + socket.setEncoding("utf8"); + let buffer = ""; + let handled = false; + let responseSent = false; + let activeRequestId: string | null = null; + + socket.setTimeout(5_000, () => socket.destroy()); + + const finish = (response: DesktopAppActivationResponse) => { + responseSent = true; + if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`); + }; + + socket.on("data", (chunk) => { + if (handled) return; + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) { + handled = true; + finish(invalidResponse("invalid-request", "The desktop app request is too large.")); + return; + } + + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + handled = true; + socket.setTimeout(0); + const line = buffer.slice(0, newline); + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + finish(invalidResponse("invalid-request", "The desktop app request is not valid JSON.")); + return; + } + + if (!isDesktopAppActivationRequest(parsed)) { + finish( + invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + ); + return; + } + activeRequestId = parsed.requestId; + void input.handle(parsed).then(finish, () => { + finish( + invalidResponse(parsed.requestId, "Helm Code could not process the desktop app request."), + ); + }); + }); + socket.on("error", () => socket.destroy()); + socket.on("close", () => { + sockets.delete(socket); + if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId); + }); + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.removeListener("listening", onListening); + reject(error); + }; + const onListening = () => { + server.removeListener("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(input.address); + }); + + try { + if (input.directory !== null) { + await NodeFSP.chmod(input.address, 0o600); + } + } catch (error) { + await new Promise((resolve) => server.close(() => resolve())); + throw error; + } + + let closed = false; + return { + close: async () => { + if (closed) return; + closed = true; + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + server.removeAllListeners(); + if (input.directory !== null) { + await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }, + }; +} + +export class DesktopAppActivation extends Context.Service< + DesktopAppActivation, + { + readonly start: Effect.Effect; + readonly setRendererReady: (ready: boolean) => Effect.Effect; + readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect; + } +>()("@helmcode/desktop/app/DesktopAppActivation") {} + +const { logWarning } = makeComponentLogger("desktop-app-activation"); + +export const make = Effect.gen(function* () { + const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const path = yield* Path.Path; + const userId = yield* HostProcessUserId; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const address = resolveDesktopAppControlAddress({ + stateDir: path.resolve(desktopEnvironment.stateDir), + platform: desktopEnvironment.platform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }); + let registeredWebContents: Electron.WebContents | null = null; + let detachRendererListeners: (() => void) | null = null; + + const broker = new DesktopAppActivationBroker({ + requestTimeoutMs: REQUEST_TIMEOUT_MS, + activate: () => { + void runPromise( + desktopWindow.activate.pipe( + Effect.catchCause((cause) => logWarning("failed to focus the desktop window", { cause })), + ), + ); + }, + }); + + const clearRegisteredRenderer = () => { + detachRendererListeners?.(); + detachRendererListeners = null; + registeredWebContents = null; + broker.clearRenderer(); + }; + + return DesktopAppActivation.of({ + start: Effect.acquireRelease( + Effect.tryPromise({ + try: () => + startDesktopAppControlServer({ + ...address, + userId, + handle: (request) => broker.request(request), + cancel: (requestId) => broker.cancel(requestId), + }), + catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }), + }), + (server) => + Effect.promise(() => server.close()).pipe( + Effect.catchCause((cause) => + logWarning("failed to close the desktop app control socket", { cause }), + ), + Effect.ensuring(Effect.sync(() => broker.close())), + ), + ).pipe(Effect.asVoid), + setRendererReady: Effect.fn("DesktopAppActivation.setRendererReady")(function* (ready) { + if (!ready) { + clearRegisteredRenderer(); + return; + } + const main = yield* electronWindow.main; + if (Option.isNone(main)) return; + const webContents = main.value.webContents; + if (webContents.isDestroyed()) return; + + if (registeredWebContents !== webContents) { + clearRegisteredRenderer(); + registeredWebContents = webContents; + const onUnavailable = () => clearRegisteredRenderer(); + const onNavigation = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) clearRegisteredRenderer(); + }; + webContents.on("did-start-navigation", onNavigation); + webContents.once("destroyed", onUnavailable); + detachRendererListeners = () => { + webContents.removeListener("did-start-navigation", onNavigation); + webContents.removeListener("destroyed", onUnavailable); + }; + } + + broker.registerRenderer((request) => { + webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request); + }); + }), + complete: (response) => Effect.sync(() => broker.complete(response)), + }); +}); + +export const layer = Layer.effect(DesktopAppActivation, make); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.test.ts b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts new file mode 100644 index 00000000..62870b51 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivationBroker.test.ts @@ -0,0 +1,130 @@ +import { ProjectId, ThreadId, type DesktopAppActivationRequest } from "@helmcode/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts"; + +const request: DesktopAppActivationRequest = { + version: 1, + requestId: "request-1", + type: "open-workspace", + workspaceRoot: "/workspace/project", + platform: "linux", +}; + +describe("DesktopAppActivationBroker", () => { + it("focuses immediately and waits for renderer readiness", async () => { + const activate = vi.fn(); + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate }); + + const response = broker.request(request); + expect(activate).toHaveBeenCalledOnce(); + expect(send).not.toHaveBeenCalled(); + + broker.registerRenderer(send); + expect(send).toHaveBeenCalledWith(request); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true, projectId: "project-1" }); + broker.close(); + }); + + it("fails an in-flight request when the renderer goes away", async () => { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(vi.fn()); + + const response = broker.request(request); + broker.clearRenderer(); + + await expect(response).resolves.toMatchObject({ + ok: false, + code: "renderer-unavailable", + }); + broker.close(); + }); + + it("queues requests after unsubscribe until a new renderer registers", async () => { + const previousSend = vi.fn(); + const nextSend = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(previousSend); + broker.clearRenderer(); + + const response = broker.request(request); + expect(previousSend).not.toHaveBeenCalled(); + expect(nextSend).not.toHaveBeenCalled(); + + broker.registerRenderer(nextSend); + expect(nextSend).toHaveBeenCalledWith(request); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(response).resolves.toMatchObject({ ok: true }); + broker.close(); + }); + + it("removes a queued request when its CLI connection closes", async () => { + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + + const response = broker.request(request); + broker.cancel(request.requestId); + broker.registerRenderer(send); + + await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" }); + expect(send).not.toHaveBeenCalled(); + broker.close(); + }); + + it("never sends a canceled request that was queued behind another request", async () => { + const send = vi.fn(); + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + broker.registerRenderer(send); + const secondRequest = { ...request, requestId: "request-2" }; + + const firstResponse = broker.request(request); + const secondResponse = broker.request(secondRequest); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenLastCalledWith(request); + + broker.cancel(secondRequest.requestId); + broker.complete({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }); + + await expect(firstResponse).resolves.toMatchObject({ ok: true }); + await expect(secondResponse).resolves.toMatchObject({ ok: false }); + expect(send).toHaveBeenCalledTimes(1); + broker.close(); + }); + + it("times out a request without polling", async () => { + vi.useFakeTimers(); + try { + const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() }); + const response = broker.request(request); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(response).resolves.toMatchObject({ ok: false, code: "request-timeout" }); + broker.close(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopAppActivationBroker.ts b/apps/desktop/src/app/DesktopAppActivationBroker.ts new file mode 100644 index 00000000..12b18226 --- /dev/null +++ b/apps/desktop/src/app/DesktopAppActivationBroker.ts @@ -0,0 +1,146 @@ +// @effect-diagnostics globalTimers:off -- This protocol broker owns cancellable request deadlines outside the Effect runtime. +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + type DesktopAppActivationFailure, + type DesktopAppActivationRequest, + type DesktopAppActivationResponse, +} from "@helmcode/contracts"; + +interface PendingActivation { + readonly request: DesktopAppActivationRequest; + readonly resolve: (response: DesktopAppActivationResponse) => void; + readonly timeout: ReturnType; + dispatched: boolean; +} + +type RendererSender = (request: DesktopAppActivationRequest) => void; + +function failure( + requestId: string, + code: DesktopAppActivationFailure["code"], + message: string, +): DesktopAppActivationFailure { + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId, + ok: false, + code, + message, + }; +} + +/** Holds CLI requests until the real desktop renderer is ready to handle them. */ +export class DesktopAppActivationBroker { + readonly #pending = new Map(); + readonly #requestTimeoutMs: number; + readonly #activate: () => void; + #renderer: RendererSender | null = null; + #closed = false; + + constructor(input: { readonly requestTimeoutMs: number; readonly activate: () => void }) { + this.#requestTimeoutMs = input.requestTimeoutMs; + this.#activate = input.activate; + } + + request(request: DesktopAppActivationRequest): Promise { + if (this.#closed) { + return Promise.resolve( + failure(request.requestId, "renderer-unavailable", "Helm Code is shutting down."), + ); + } + if (this.#pending.has(request.requestId)) { + return Promise.resolve( + failure(request.requestId, "invalid-request", "The request id is already in use."), + ); + } + + const response = new Promise((resolve) => { + const timeout = setTimeout(() => { + this.#settle( + failure( + request.requestId, + "request-timeout", + "The desktop app did not finish opening the project in time.", + ), + ); + }, this.#requestTimeoutMs); + this.#pending.set(request.requestId, { + request, + resolve, + timeout, + dispatched: false, + }); + }); + + this.#activate(); + this.#flush(); + return response; + } + + registerRenderer(send: RendererSender): void { + this.#renderer = send; + this.#flush(); + } + + clearRenderer(): void { + this.#renderer = null; + for (const pending of this.#pending.values()) { + if (pending.dispatched) { + this.#settle( + failure( + pending.request.requestId, + "renderer-unavailable", + "The Helm Code window closed before it opened the project.", + ), + ); + } + } + } + + complete(response: DesktopAppActivationResponse): void { + this.#settle(response); + } + + cancel(requestId: string): void { + this.#settle( + failure(requestId, "renderer-unavailable", "The command closed before Helm Code was ready."), + ); + } + + close(): void { + this.#closed = true; + this.#renderer = null; + for (const pending of this.#pending.values()) { + this.#settle( + failure(pending.request.requestId, "renderer-unavailable", "Helm Code is shutting down."), + ); + } + } + + #flush(): void { + const renderer = this.#renderer; + if (renderer === null) return; + if ([...this.#pending.values()].some((pending) => pending.dispatched)) return; + + for (const pending of this.#pending.values()) { + if (pending.dispatched) continue; + try { + pending.dispatched = true; + renderer(pending.request); + } catch { + pending.dispatched = false; + this.#renderer = null; + } + return; + } + } + + #settle(response: DesktopAppActivationResponse): void { + const pending = this.#pending.get(response.requestId); + if (!pending) return; + clearTimeout(pending.timeout); + this.#pending.delete(response.requestId); + pending.resolve(response); + this.#flush(); + } +} diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index d2d95b48..2eb39142 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -44,12 +44,16 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* PreviewIpc.installPreviewEventForwarding(); + yield* ipc.handle(AppActivationIpc.setReady); + yield* ipc.handle(AppActivationIpc.complete); + yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 6b763088..e53bb971 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,9 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; +export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready"; +export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete"; +export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/appActivation.ts b/apps/desktop/src/ipc/methods/appActivation.ts new file mode 100644 index 00000000..012c6004 --- /dev/null +++ b/apps/desktop/src/ipc/methods/appActivation.ts @@ -0,0 +1,27 @@ +import { DesktopAppActivationResponse } from "@helmcode/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopAppActivation from "../../app/DesktopAppActivation.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const setReady = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.appActivation.setReady")(function* (ready) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + yield* activation.setRendererReady(ready); + }), +}); + +export const complete = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, + payload: DesktopAppActivationResponse, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.appActivation.complete")(function* (response) { + const activation = yield* DesktopAppActivation.DesktopAppActivation; + yield* activation.complete(response); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index aa569ec1..880c22e9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -32,6 +32,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -156,6 +157,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( Layer.provideMerge(desktopPreviewLayer), ); +const desktopAppActivationLayer = DesktopAppActivation.layer.pipe( + Layer.provide(desktopWindowLayer), +); + // Pool layer instantiates the backend factory once for the Windows // primary instance and exposes it via pool.primary. Consumers go through // the pool now; the legacy DesktopBackendManager service is gone. The @@ -182,6 +187,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, + desktopAppActivationLayer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index ec5de8fe..e2e366c4 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -163,6 +163,25 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + appActivation: { + setReady: (ready) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready), + complete: (response) => + ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, response), + onRequest: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener( + IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, + wrappedListener, + ); + }; + }, + }, preview: { createTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, { tabId }), closeTab: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, { tabId }), diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 3ef23dfe..995f15ed 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -320,7 +320,7 @@ describe("DesktopShellEnvironment", () => { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", }) - : envOutput({ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }); + : envOutput({ PATH: 'C:\\Custom\\Bin;C:";C:\\Windows\\System32' }); }, }); @@ -337,6 +337,7 @@ describe("DesktopShellEnvironment", () => { "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", "C:\\Custom\\Bin", + "C:", ].join(";"), ); assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm"); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 00d3e362..b3955690 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -151,6 +151,9 @@ const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { return platform === "win32" ? normalized.toLowerCase() : normalized; }; +const sanitizePathEntry = (entry: string, platform: NodeJS.Platform) => + platform === "win32" ? entry.replaceAll('"', "") : entry; + const mergePaths = ( platform: NodeJS.Platform, values: ReadonlyArray>, @@ -163,14 +166,14 @@ const mergePaths = ( if (Option.isNone(value)) continue; for (const entry of value.value.split(delimiter)) { - const trimmed = entry.trim(); - if (trimmed.length === 0) continue; + const sanitized = sanitizePathEntry(entry.trim(), platform); + if (sanitized.length === 0) continue; - const key = pathComparisonKey(trimmed, platform); + const key = pathComparisonKey(sanitized, platform); if (key.length === 0 || seen.has(key)) continue; seen.add(key); - entries.push(trimmed); + entries.push(sanitized); } } diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 014f0620..c10f7a13 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -8,6 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@helmcode/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; +import { appCommand } from "./cli/app.ts"; import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -52,6 +53,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.withSubcommands([ startCommand, serveCommand, + appCommand, pairCommand, authCommand, projectCommand, diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts new file mode 100644 index 00000000..d4885481 --- /dev/null +++ b/apps/server/src/cli/app.test.ts @@ -0,0 +1,307 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The integration fixture binds the same platform socket or named pipe as the CLI. +import * as NodeFSP from "node:fs/promises"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import type { DesktopAppActivationRequest } from "@helmcode/contracts"; +import { resolveDesktopAppControlAddress } from "@helmcode/shared/desktopAppControl"; +import { + HostProcessPlatform, + HostProcessUserId, + HostProcessWorkingDirectory, +} from "@helmcode/shared/hostProcess"; +import * as NetService from "@helmcode/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Command } from "effect/unstable/cli"; +import { afterEach, describe, expect, vi } from "vite-plus/test"; + +import { makeCli } from "../bin.ts"; + +vi.mock("node:os", async (importOriginal) => { + const os = await importOriginal(); + return { ...os, homedir: vi.fn(os.homedir) }; +}); + +afterEach(() => vi.mocked(NodeOS.homedir).mockReset()); + +const runCli = (args: ReadonlyArray, env: Record = {}) => + Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NetService.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + ), + ), + ); + +const pathExists = (path: string) => + Effect.promise(() => + NodeFSP.stat(path).then( + () => true, + () => false, + ), + ); + +async function startFakeDesktop(input: { + readonly baseDir: string; + readonly stateSubdirectory?: "userdata" | "dev"; + readonly platform: NodeJS.Platform; + readonly userId: number | undefined; + readonly reply?: (request: DesktopAppActivationRequest) => unknown; +}) { + const target = resolveDesktopAppControlAddress({ + stateDir: NodePath.join(input.baseDir, input.stateSubdirectory ?? "userdata"), + platform: input.platform, + tempDir: NodeOS.tmpdir(), + userId: input.userId, + joinPath: NodePath.join, + }); + if (target.directory !== null) { + await NodeFSP.mkdir(target.directory, { recursive: true, mode: 0o700 }); + await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + + const received: DesktopAppActivationRequest[] = []; + const server = NodeNet.createServer((socket) => { + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk; + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + const request = JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationRequest; + received.push(request); + const response = input.reply + ? input.reply(request) + : { + version: 1, + requestId: request.requestId, + ok: true, + projectId: "project-1", + threadId: `thread-${received.length}`, + }; + socket.end(`${JSON.stringify(response)}\n`); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(target.address, resolve); + }); + + return { + received, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (target.directory !== null) { + await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } + }, + }; +} + +const fakeDesktop = Effect.fn(function* ( + input: Omit[0], "platform" | "userId">, +) { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + return yield* Effect.acquireRelease( + Effect.promise(() => startFakeDesktop({ ...input, platform, userId })), + (server) => Effect.promise(() => server.close()), + ); +}); + +const withTempDirectory = ( + prefix: string, + use: (root: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), prefix))), + use, + (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })), + ); + +describe("helmcode app", () => { + it.effect("rejects SSH before it tries to reach a desktop app", () => + withTempDirectory("helmcode-app-ssh-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-helmcode-home"); + const error = yield* runCli(["app", "--base-dir", baseDir], { + SSH_CONNECTION: "client server", + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "DesktopAppSshUnsupportedError", + message: + "`helmcode app` only controls a desktop app on the same machine. It cannot run over SSH.", + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("rejects unsupported platforms without creating state", () => + withTempDirectory("helmcode-app-platform-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-helmcode-home"); + const error = yield* runCli(["app", "--base-dir", baseDir]).pipe( + Effect.provideService(HostProcessPlatform, "freebsd"), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "DesktopAppPlatformUnsupportedError", + platform: "freebsd", + message: "`helmcode app` is not supported on freebsd.", + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("does not create state when only a server or no desktop app is running", () => + withTempDirectory("helmcode-app-missing-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "missing-helmcode-home"); + const error = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "DesktopAppUnreachableError", + candidateAddresses: [expect.any(String)], + workspaceRoot: yield* HostProcessWorkingDirectory, + message: expect.stringContaining("Could not reach the Helm Code desktop app."), + cause: { code: "ENOENT" }, + }); + expect(yield* pathExists(baseDir)).toBe(false); + }), + ), + ); + + it.effect("uses HELMCODE_HOME or --base-dir and sends the default or explicit path", () => + withTempDirectory("helmcode-app-command-test-", (root) => + Effect.gen(function* () { + const baseDir = NodePath.join(root, "helmcode-home"); + const explicitPath = NodePath.join(root, "project"); + const platform = yield* HostProcessPlatform; + const workingDirectory = yield* HostProcessWorkingDirectory; + const desktop = yield* fakeDesktop({ baseDir }); + + yield* runCli(["app"], { HELMCODE_HOME: baseDir }); + yield* runCli(["app", explicitPath, "--base-dir", baseDir]); + + expect(desktop.received.map((request) => request.workspaceRoot)).toEqual([ + workingDirectory, + explicitPath, + ]); + expect(desktop.received.every((request) => request.platform === platform)).toBe(true); + }).pipe(Effect.scoped), + ), + ); + + it.effect("prefers the installed desktop app when a dev desktop is also running", () => + withTempDirectory("helmcode-app-preferred-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, ".helmcode"); + const desktop = yield* fakeDesktop({ baseDir }); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + yield* runCli(["app"]); + + expect(desktop.received).toHaveLength(1); + expect(development.received).toHaveLength(0); + }).pipe(Effect.scoped), + ), + ); + + it.effect("finds the dev desktop when the default desktop socket is absent", () => + withTempDirectory("helmcode-app-dev-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, ".helmcode"); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + yield* runCli(["app"]); + yield* runCli(["app"], { HELMCODE_HOME: " " }); + + expect(development.received).toHaveLength(2); + expect(yield* pathExists(baseDir)).toBe(false); + }).pipe(Effect.scoped), + ), + ); + + it.effect("never searches a dev state directory for an explicit Helm Code home", () => + withTempDirectory("helmcode-app-explicit-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, ".helmcode"); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + const flagError = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip); + const envError = yield* runCli(["app"], { HELMCODE_HOME: baseDir }).pipe(Effect.flip); + + expect(flagError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); + expect(envError).toMatchObject({ _tag: "DesktopAppUnreachableError" }); + expect(development.received).toHaveLength(0); + }).pipe(Effect.scoped), + ), + ); + + for (const responseKind of ["failure", "invalid"] as const) { + it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () => + withTempDirectory("helmcode-app-response-test-", (root) => + Effect.gen(function* () { + vi.mocked(NodeOS.homedir).mockReturnValue(root); + const baseDir = NodePath.join(root, ".helmcode"); + const desktop = yield* fakeDesktop({ + baseDir, + reply: (request) => + responseKind === "failure" + ? { + version: 1, + requestId: request.requestId, + ok: false, + code: "project-create-failed", + message: "The project path is not available.", + } + : { invalid: true }, + }); + const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" }); + + const error = yield* runCli(["app"]).pipe(Effect.flip); + + expect(desktop.received).toHaveLength(1); + expect(development.received).toHaveLength(0); + if (responseKind === "failure") { + expect(error).toMatchObject({ + _tag: "DesktopAppRequestFailedError", + code: "project-create-failed", + requestId: desktop.received[0]?.requestId, + workspaceRoot: yield* HostProcessWorkingDirectory, + message: expect.stringContaining("project-create-failed"), + cause: { + ok: false, + code: "project-create-failed", + message: "The project path is not available.", + }, + }); + } else { + expect(error).toMatchObject({ + _tag: "DesktopAppUnreachableError", + cause: { message: "The desktop app response is invalid." }, + }); + } + }).pipe(Effect.scoped), + ), + ); + } +}); diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts new file mode 100644 index 00000000..9f7cd1df --- /dev/null +++ b/apps/server/src/cli/app.ts @@ -0,0 +1,266 @@ +// @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path. +import * as NodeCrypto from "node:crypto"; +import * as NodeNet from "node:net"; +import * as NodeOS from "node:os"; + +import { + DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + DesktopAppActivationErrorCode, + DesktopAppActivationResponse, + type DesktopAppActivationPlatform, + type DesktopAppActivationRequest, +} from "@helmcode/contracts"; +import { resolveDesktopAppControlAddress } from "@helmcode/shared/desktopAppControl"; +import { + HostProcessPlatform, + HostProcessUserId, + HostProcessWorkingDirectory, +} from "@helmcode/shared/hostProcess"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command } from "effect/unstable/cli"; + +import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag } from "./config.ts"; + +const CLI_RESPONSE_TIMEOUT_MS = 17_000; +const MAX_RESPONSE_BYTES = 64 * 1024; +const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse); + +export class DesktopAppSshUnsupportedError extends Schema.TaggedErrorClass()( + "DesktopAppSshUnsupportedError", + {}, +) { + override get message(): string { + return "`helmcode app` only controls a desktop app on the same machine. It cannot run over SSH."; + } +} + +export class DesktopAppPlatformUnsupportedError extends Schema.TaggedErrorClass()( + "DesktopAppPlatformUnsupportedError", + { platform: Schema.String }, +) { + override get message(): string { + return `\`helmcode app\` is not supported on ${this.platform}.`; + } +} + +export class DesktopAppUnreachableError extends Schema.TaggedErrorClass()( + "DesktopAppUnreachableError", + { + candidateAddresses: Schema.Array(Schema.String), + requestId: Schema.String, + workspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not reach the Helm Code desktop app. Start or update the desktop app on this machine, then run `helmcode app` again. A running Helm Code server is not enough."; + } +} + +export class DesktopAppRequestFailedError extends Schema.TaggedErrorClass()( + "DesktopAppRequestFailedError", + { + code: DesktopAppActivationErrorCode, + requestId: Schema.String, + workspaceRoot: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Helm Code could not open ${this.workspaceRoot} (${this.code}).`; + } +} + +function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppActivationPlatform { + return platform === "darwin" || platform === "linux" || platform === "win32"; +} + +export function sendDesktopAppActivationRequest(input: { + readonly address: string; + readonly fallbackAddress?: string; + readonly request: DesktopAppActivationRequest; + readonly timeoutMs?: number; +}): Promise { + const totalTimeoutMs = input.timeoutMs ?? CLI_RESPONSE_TIMEOUT_MS; + const startedAt = performance.now(); + return new Promise((resolve, reject) => { + const socket = NodeNet.createConnection(input.address); + socket.setEncoding("utf8"); + let buffer = ""; + let settled = false; + let connected = false; + + const finish = ( + result: + | { readonly type: "success"; readonly response: DesktopAppActivationResponse } + | { readonly type: "failure"; readonly error: Error }, + ) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.destroy(); + if (result.type === "success") resolve(result.response); + else reject(result.error); + }; + + const timeout = setTimeout(() => { + finish({ + type: "failure", + error: new Error("The desktop app did not respond in time."), + }); + }, totalTimeoutMs); + + socket.once("connect", () => { + connected = true; + socket.write(`${JSON.stringify(input.request)}\n`); + }); + socket.on("data", (chunk) => { + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_RESPONSE_BYTES) { + finish({ type: "failure", error: new Error("The desktop app response is too large.") }); + return; + } + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + + let parsed: unknown; + try { + parsed = JSON.parse(buffer.slice(0, newline)); + } catch { + finish({ + type: "failure", + error: new Error("The desktop app response is not valid JSON."), + }); + return; + } + if (!isDesktopAppActivationResponse(parsed)) { + finish({ type: "failure", error: new Error("The desktop app response is invalid.") }); + return; + } + if (parsed.requestId !== input.request.requestId) { + finish({ + type: "failure", + error: new Error("The desktop app response did not match this request."), + }); + return; + } + finish({ type: "success", response: parsed }); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + if ( + !settled && + !connected && + input.fallbackAddress !== undefined && + (error.code === "ENOENT" || error.code === "ECONNREFUSED") + ) { + settled = true; + clearTimeout(timeout); + socket.destroy(); + resolve( + sendDesktopAppActivationRequest({ + address: input.fallbackAddress, + request: input.request, + timeoutMs: Math.max(0, totalTimeoutMs - (performance.now() - startedAt)), + }), + ); + return; + } + finish({ type: "failure", error }); + }); + socket.once("end", () => { + finish({ type: "failure", error: new Error("The desktop app closed the connection.") }); + }); + }); +} + +const appEnvironment = Config.all({ + helmcodeHome: Config.string("HELMCODE_HOME").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option), + sshTty: Config.string("SSH_TTY").pipe(Config.option), +}); + +const runAppCommand = Effect.fn("cli.app")(function* (flags: { + readonly baseDir: Option.Option; + readonly workspaceRoot: Option.Option; +}) { + const environment = yield* appEnvironment; + const hostPlatform = yield* HostProcessPlatform; + if (Option.isSome(environment.sshConnection) || Option.isSome(environment.sshTty)) { + return yield* new DesktopAppSshUnsupportedError({}); + } + if (!isDesktopPlatform(hostPlatform)) { + return yield* new DesktopAppPlatformUnsupportedError({ platform: hostPlatform }); + } + + const path = yield* Path.Path; + const configuredBaseDir = Option.getOrUndefined(flags.baseDir) ?? environment.helmcodeHome; + const baseDir = yield* resolveBaseDir(configuredBaseDir); + const allowDevFallback = Option.isNone(flags.baseDir) && !environment.helmcodeHome?.trim(); + const rawWorkspaceRoot = + Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcessWorkingDirectory); + const workspaceRoot = path.resolve(yield* expandHomePath(rawWorkspaceRoot)); + const userId = yield* HostProcessUserId; + const resolveAddress = (stateSubdirectory: "userdata" | "dev") => + resolveDesktopAppControlAddress({ + stateDir: path.join(baseDir, stateSubdirectory), + platform: hostPlatform, + tempDir: NodeOS.tmpdir(), + userId, + joinPath: path.join, + }).address; + const request: DesktopAppActivationRequest = { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId: NodeCrypto.randomUUID(), + type: "open-workspace", + workspaceRoot, + platform: hostPlatform, + }; + const address = resolveAddress("userdata"); + const fallbackAddress = allowDevFallback ? resolveAddress("dev") : undefined; + + const response = yield* Effect.tryPromise({ + try: () => + sendDesktopAppActivationRequest({ + address, + ...(fallbackAddress === undefined ? {} : { fallbackAddress }), + request, + }), + catch: (cause) => + new DesktopAppUnreachableError({ + candidateAddresses: fallbackAddress === undefined ? [address] : [address, fallbackAddress], + requestId: request.requestId, + workspaceRoot, + cause, + }), + }); + if (!response.ok) { + return yield* new DesktopAppRequestFailedError({ + code: response.code, + requestId: response.requestId, + workspaceRoot, + cause: response, + }); + } + + yield* Console.log(`Opened ${workspaceRoot} in Helm Code.`); +}); + +export const appCommand = Command.make("app", { + baseDir: baseDirFlag, + workspaceRoot: Argument.string("path").pipe( + Argument.withDescription("Project directory. Default: current directory."), + Argument.optional, + ), +}).pipe( + Command.withDescription("Open a project in the running Helm Code desktop app."), + Command.withHandler(runAppCommand), +); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 2bb5cbd1..700e97ad 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1787,18 +1787,26 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("preserves repository conventions style when recent history is empty", () => + it.effect("includes local agent instructions when recent history is empty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("helmcode-git-manager-"); yield* runGit(repoDir, ["init", "--initial-branch=main"]); yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); yield* runGit(repoDir, ["config", "user.name", "Test User"]); + const agentInstructions = "Use lowercase source control text."; + const claudeInstructions = "Keep pull request bodies brief."; + NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), agentInstructions); + NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), claudeInstructions); NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); yield* runGit(repoDir, ["add", "README.md"]); let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; const { manager } = yield* makeManager({ serverSettings: { + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, sourceControlWritingStyle: { mode: "repo_conventions" as const, }, @@ -1817,15 +1825,63 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(generatedPolicy).toEqual({ kind: "repo_conventions", - commitInstructions: - "Follow the repository's established commit message style when examples are available.", - changeRequestInstructions: - "Follow the repository's established change request title and body style when examples are available.", + commitInstructions: `Follow the repository's established commit message style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`, + changeRequestInstructions: `Follow the repository's established change request title and body style when examples are available.\n\nLocal AGENTS.md:\n${agentInstructions}\n\nLocal CLAUDE.md:\n${claudeInstructions}`, inferRepositoryConventions: true, }); }), ); + it.effect("keeps CLAUDE.md instructions when AGENTS.md is near the truncation limit", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("helmcode-git-manager-"); + yield* runGit(repoDir, ["init", "--initial-branch=main"]); + yield* runGit(repoDir, ["config", "user.email", "test@example.com"]); + yield* runGit(repoDir, ["config", "user.name", "Test User"]); + // Near the 20,000-byte per-file cap in readRepositoryInstructions, so + // this is the largest AGENTS.md the reader will still return in full. + const agentInstructions = "A".repeat(19_000); + const claudeInstructions = "Keep pull request bodies brief."; + NodeFS.writeFileSync(NodePath.join(repoDir, "AGENTS.md"), agentInstructions); + NodeFS.writeFileSync(NodePath.join(repoDir, "CLAUDE.md"), claudeInstructions); + NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\n"); + yield* runGit(repoDir, ["add", "README.md"]); + let generatedPolicy: TextGeneration.CommitMessageGenerationInput["policy"] = undefined; + + const { manager } = yield* makeManager({ + serverSettings: { + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + sourceControlWritingStyle: { + mode: "repo_conventions" as const, + }, + }, + textGeneration: { + generateCommitMessage: (input) => { + generatedPolicy = input.policy; + return Effect.succeed({ subject: "Create initial commit", body: "" }); + }, + }, + }); + yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + // A near-limit AGENTS.md must not silently swallow the CLAUDE.md + // section that follows it once the combined text hits the shared + // downstream policyInstruction truncation limit. + expect(generatedPolicy).toMatchObject({ + commitInstructions: expect.stringContaining(`Local CLAUDE.md:\n${claudeInstructions}`), + }); + expect(generatedPolicy).toMatchObject({ + commitInstructions: expect.stringContaining("Local AGENTS.md:\n"), + }); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("helmcode-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4c368f44..40895a9d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -601,9 +601,24 @@ export const make = Effect.gen(function* () { const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; + const readRepositoryInstructions = (cwd: string, fileName: string) => + Effect.gen(function* () { + const root = yield* fileSystem.realPath(cwd); + const instructionPath = yield* fileSystem.realPath(path.join(root, fileName)); + if (!instructionPath.startsWith(`${root}${path.sep}`)) { + return ""; + } + const info = yield* fileSystem.stat(instructionPath); + if (info.type !== "File" || info.size > FileSystem.Size(20_000)) { + return ""; + } + return (yield* fileSystem.readFileString(instructionPath)).trim(); + }).pipe(Effect.orElseSucceed(() => "")); const readRecentCommitSubjects = (cwd: string) => gitCore @@ -622,26 +637,51 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => []), ); - const resolveStylePolicy = (cwd: string, style: SourceControlWritingStyleSettings) => + const resolveStylePolicy = (cwd: string, settings: SourceControlTextGenerationSettings) => Effect.gen(function* () { - switch (style.mode) { + switch (settings.style.mode) { case "conventional_commits": return conventionalCommitsTextGenerationPolicy; case "custom": return customTextGenerationPolicy( - style.customInstructions + settings.style.customInstructions ? { - commitInstructions: style.customInstructions, - changeRequestInstructions: style.customInstructions, + commitInstructions: settings.style.customInstructions, + changeRequestInstructions: settings.style.customInstructions, } : {}, ); case "repo_conventions": { const subjects = yield* readRecentCommitSubjects(cwd); - if (subjects.length === 0) { + const agentInstructions = yield* readRepositoryInstructions(cwd, "AGENTS.md"); + const isClaudeWriter = + settings.modelSelection.instanceId === "claudeAgent" || + (yield* providerRegistry.getProviders).some( + (provider) => + provider.instanceId === settings.modelSelection.instanceId && + provider.driver === "claudeAgent", + ); + const claudeInstructions = isClaudeWriter + ? yield* readRepositoryInstructions(cwd, "CLAUDE.md") + : ""; + // Each source gets its own bounded budget so a near-limit AGENTS.md + // can never truncate away the CLAUDE.md section that follows it — + // the two are joined before the shared downstream 20,000-char + // policyInstruction limit sees the combined text. + const examples = [ + ...(subjects.length > 0 + ? [["Recent commit subjects from this repository:", ...subjects].join("\n")] + : []), + ...(agentInstructions + ? [`Local AGENTS.md:\n${limitContext(agentInstructions, 8_000)}`] + : []), + ...(claudeInstructions + ? [`Local CLAUDE.md:\n${limitContext(claudeInstructions, 8_000)}`] + : []), + ].join("\n\n"); + if (!examples) { return repositoryConventionsTextGenerationPolicy; } - const examples = ["Recent commit subjects from this repository:", ...subjects].join("\n"); return { ...repositoryConventionsTextGenerationPolicy, commitInstructions: `${repositoryConventionsTextGenerationPolicy.commitInstructions}\n\n${examples}`, @@ -843,9 +883,6 @@ export const make = Effect.gen(function* () { ), ), ); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); @@ -1500,7 +1537,7 @@ export const make = Effect.gen(function* () { }; } - const policy = yield* resolveStylePolicy(input.cwd, input.settings.style); + const policy = yield* resolveStylePolicy(input.cwd, input.settings); const generated = yield* textGeneration .generateCommitMessage({ @@ -1686,7 +1723,7 @@ export const make = Effect.gen(function* () { }); const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); - const policy = yield* resolveStylePolicy(cwd, settings.style); + const policy = yield* resolveStylePolicy(cwd, settings); const changeRequestTemplate = settings.style.followChangeRequestTemplates && provider.kind === "github" ? Option.getOrUndefined(yield* detectPrTemplate(cwd, baseRangeRef, gitCore.execute)) diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts index 0c4841e8..b4c52848 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts @@ -2,6 +2,62 @@ import { describe, expect, it } from "vite-plus/test"; import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; describe("ThreadBackgroundLiveness", () => { + it("does not let status-free progress or metadata restart an idle task", () => { + const liveness = ThreadBackgroundLiveness.make(); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: "idle", + kind: "updated", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "progress", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "task", + taskType: undefined, + status: undefined, + kind: "updated", + }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); + + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: "completed", + kind: "completed", + }); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "completed-task", + taskType: undefined, + status: undefined, + kind: "updated", + }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); + }); + it("agents present as working; monitors as monitoring; agents win", () => { const liveness = ThreadBackgroundLiveness.make(); const threadId = "t-live-1"; diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts index a9e90d7b..b45a6e36 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts @@ -130,6 +130,18 @@ export function make(): ThreadBackgroundLivenessService["Service"] { return; } + // Status-free progress and metadata updates are not restarts. A delayed + // row after idle must not put the task back in the live set (#7128). + if ((input.kind === "progress" || input.kind === "updated") && input.status === undefined) { + const existing = stateByThreadId.get(input.threadId); + const stillLive = + existing !== undefined && + (existing.agents.has(input.taskId) || existing.monitors.has(input.taskId)); + if (!stillLive) { + return; + } + } + drop(input.threadId, input.taskId); const state = stateFor(input.threadId); const bucket = diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 43643fef..fecf5807 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -1,10 +1,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { TestClock } from "effect/testing"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; @@ -49,6 +51,65 @@ const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) => it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { describe("resolvePath", () => { + it.effect("serves repeated resolves from cache instead of re-walking candidates", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "public/favicon.svg", "public"); + + const resolved = yield* resolver.resolvePath(cwd); + expect(resolved).toBe(path.join(cwd, "public", "favicon.svg")); + + // `favicon.svg` outranks `public/favicon.svg`, so a resolver that walked + // the candidate list again would switch to it. Staying on the original + // answer is only possible from cache. + yield* writeTextFile(cwd, "favicon.svg", "root"); + + for (const _attempt of [1, 2, 3]) { + expect(yield* resolver.resolvePath(cwd)).toBe(resolved); + } + + yield* TestClock.adjust(Duration.minutes(11)); + + expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg")); + expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("falls back at once when a cached favicon is deleted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + + expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); + + yield* fileSystem.remove(path.join(cwd, "favicon.svg")).pipe(Effect.orDie); + + // Still inside the positive TTL: the cached path must not be served. + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("re-probes for a favicon added after a miss once the negative TTL expires", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* writeTextFile(cwd, "favicon.svg", "favicon"); + expect(yield* resolver.resolvePath(cwd)).toBeNull(); + + yield* TestClock.adjust(Duration.minutes(2)); + + expect(yield* resolver.resolvePath(cwd)).not.toBeNull(); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("prefers well-known favicon files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 801cb41f..2fdeb89e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -6,8 +6,11 @@ * * @module ProjectFaviconResolver */ +import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -18,6 +21,30 @@ import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as HelmCodeProjectFileLoader from "./HelmCodeProjectFileLoader.ts"; +// Resolution walks up to 12 well-known paths plus 7 source files, so a miss +// costs ~20 filesystem probes. AssetAccess resolves on every project-favicon +// asset URL, and a project's icon does not move, so the answer is cached. +const FAVICON_CACHE_CAPACITY = 512; +const FAVICON_POSITIVE_CACHE_TTL = Duration.minutes(10); +const FAVICON_NEGATIVE_CACHE_TTL = Duration.minutes(1); + +function faviconCacheKey(cwd: string, faviconPath?: string): string { + return `${faviconPath ?? ""}\0${cwd}`; +} + +function parseFaviconCacheKey(key: string): { + readonly cwd: string; + readonly faviconPath?: string; +} { + const separatorIndex = key.indexOf("\0"); + if (separatorIndex === -1) { + return { cwd: key }; + } + const faviconPath = key.slice(0, separatorIndex); + const cwd = key.slice(separatorIndex + 1); + return faviconPath.length === 0 ? { cwd } : { cwd, faviconPath }; +} + // Well-known favicon paths checked in order. const FAVICON_CANDIDATES = [ "favicon.svg", @@ -175,9 +202,10 @@ export const make = Effect.gen(function* () { return null; }); - const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( - "ProjectFaviconResolver.resolvePath", - )(function* (cwd, faviconPath) { + const resolvePathUncached = Effect.fn("ProjectFaviconResolver.resolvePathUncached")(function* ( + cwd: string, + faviconPath?: string, + ): Effect.fn.Return { const projectCwd = yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( Effect.mapError( (cause) => @@ -260,6 +288,52 @@ export const make = Effect.gen(function* () { return null; }); + const faviconCache = yield* Cache.makeWith( + (key) => { + const { cwd, faviconPath } = parseFaviconCacheKey(key); + return resolvePathUncached(cwd, faviconPath); + }, + { + capacity: FAVICON_CACHE_CAPACITY, + timeToLive: Exit.match({ + onSuccess: (value: string | null) => + value === null ? FAVICON_NEGATIVE_CACHE_TTL : FAVICON_POSITIVE_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( + "ProjectFaviconResolver.resolvePath", + )(function* (cwd, faviconPath) { + const key = faviconCacheKey(cwd, faviconPath); + const cached = yield* Cache.get(faviconCache, key); + if (cached === null) { + return null; + } + + // A hit still confirms the file with one stat rather than the ~20 probes a + // full walk costs, so a deleted icon falls back at once instead of after + // the TTL. + const stats = yield* optionOnNotFound(fileSystem.stat(cached)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "stat-candidate", + workspaceRoot: cwd, + absolutePath: cached, + cause, + }), + ), + ); + if (Option.isSome(stats) && stats.value.type === "File") { + return cached; + } + + yield* Cache.invalidate(faviconCache, key); + return yield* Cache.get(faviconCache, key); + }); + return ProjectFaviconResolver.of({ resolvePath }); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9d7b903f..c0de7a71 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -512,6 +512,181 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("carries child model metadata through every task event", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 10)).pipe( + Effect.forkChild, + ); + + const cases = [ + ["collabAgent/started", {}], + ["collabAgent/activity", { activityKind: "started" }], + ["collabAgent/turnStarted", {}], + ["collabAgent/turnCompleted", { turn: { status: "completed" } }], + ["collabAgent/statusChanged", { status: { type: "active", activeFlags: [] } }], + ["collabAgent/tokenUsage", { tokenUsage: { total: { totalTokens: 42 } } }], + ["collabAgent/item", { item: { type: "commandExecution", command: "pwd" } }], + ["collabAgent/closed", {}], + ["collabAgent/metadataUpdated", {}], + ] as const; + + for (const [index, [method, extra]] of cases.entries()) { + yield* runtime.emit({ + id: asEventId(`evt-child-model-${index}`), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + agentThreadId: "child-model", + agentPath: "/root/model-check", + model: " gpt-5.6-sol ", + effort: " high ", + ...extra, + }, + }); + } + yield* runtime.emit({ + id: asEventId("evt-child-model-blank"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "collabAgent/metadataUpdated", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + agentThreadId: "child-model", + model: " ", + effort: "", + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => event.type), + [ + "task.started", + "task.started", + "task.updated", + "task.updated", + "task.updated", + "task.progress", + "task.progress", + "task.updated", + "task.updated", + "task.updated", + ], + ); + for (const event of events.slice(0, -1)) { + const payload = event.payload as Record; + NodeAssert.equal(payload.model, "gpt-5.6-sol"); + NodeAssert.equal(payload.effort, "high"); + } + + const metadataPayload = events[8]?.payload as Record; + NodeAssert.equal("status" in metadataPayload, false); + const blankMetadataPayload = events[9]?.payload as Record; + NodeAssert.equal("status" in blankMetadataPayload, false); + NodeAssert.equal("model" in blankMetadataPayload, false); + NodeAssert.equal("effort" in blankMetadataPayload, false); + }), + ); + + it.effect("does not fabricate a role on a metadata update with no role signal", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 1)).pipe( + Effect.forkChild, + ); + + // No agentPath and no explicit role: fillMetadata downstream overwrites + // an existing real role whenever this field is present, so a + // synthesized "general-purpose" here would stomp it. + yield* runtime.emit({ + id: asEventId("evt-metadata-no-role-signal"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "collabAgent/metadataUpdated", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + agentThreadId: "child-no-role-signal", + model: "gpt-5.6-sol", + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const payload = events[0]?.payload as Record; + NodeAssert.equal(payload.model, "gpt-5.6-sol"); + NodeAssert.equal("role" in payload, false); + }), + ); + + it.effect("does not reactivate an idle child after a parent interaction", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( + Effect.forkChild, + ); + + const childEvent = (id: string, method: string, payload: Record) => ({ + id: asEventId(id), + kind: "notification" as const, + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload, + }); + + yield* runtime.emit( + childEvent("evt-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + }), + ); + yield* runtime.emit( + childEvent("evt-child-idle", "collabAgent/turnCompleted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + turn: { status: "completed" }, + }), + ); + yield* runtime.emit( + childEvent("evt-child-interacted", "collabAgent/activity", { + agentThreadId: "child-1", + agentPath: "/root/audit", + activityKind: "interacted", + }), + ); + yield* runtime.emit( + childEvent("evt-other-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-2", + agentPath: "/root/other", + }), + ); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => + event.type === "task.updated" + ? { taskId: event.payload.taskId, status: event.payload.status } + : { type: event.type }, + ), + [ + { taskId: "child-1", status: "running" }, + { taskId: "child-1", status: "idle" }, + { taskId: "child-2", status: "running" }, + ], + ); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0b5f9254..dc9fbf8d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -522,22 +522,31 @@ function mapCollabAgentEvent( const agentPath = typeof payload.agentPath === "string" ? payload.agentPath : undefined; const pathLeaf = agentPath?.split("/").findLast((segment) => segment.length > 0); const nickname = typeof payload.nickname === "string" ? payload.nickname : undefined; - const role = - (typeof payload.role === "string" ? payload.role : undefined) ?? pathLeaf ?? "general-purpose"; + // Undefined when this event carries no real role signal — distinct from + // `role` below, which always has a value for events (started, activity) + // that need one to seed a brand-new row. + const explicitRole = typeof payload.role === "string" ? payload.role : pathLeaf; + const role = explicitRole ?? "general-purpose"; // A bare thread id is not a name. Omitting the title lets the client fold // keep the real one from task.started instead of clobbering it (probe // finding: progress rows renamed math_one to its UUID). const knownName = nickname ?? pathLeaf; const title = knownName ?? agentThreadId; + const model = typeof payload.model === "string" ? payload.model.trim() : ""; + const effort = typeof payload.effort === "string" ? payload.effort.trim() : ""; // Identity repeated on every status patch so rows are self-describing when // the start row ages out of activity retention (review finding: a - // reconstructed agent had a UUID name and no role/path). - const statusLinkage = { - role, + // reconstructed agent had a UUID name and no role/path). Kept separate from + // `role` so a metadata-only update can omit it entirely when this event + // carries no real role signal. + const identityWithoutRole = { ...(knownName ? { title: knownName } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), ...(agentPath ? { agentPath } : {}), timelineBypass: true, } as const; + const linkage = { role, ...identityWithoutRole } as const; switch (event.method) { case "collabAgent/started": @@ -549,12 +558,26 @@ function mapCollabAgentEvent( taskId, description: title, title, - role, - ...(agentPath ? { agentPath } : {}), + ...linkage, ...(typeof payload.parentThreadId === "string" ? { parentAgentId: payload.parentThreadId } : {}), - timelineBypass: true, + }, + }, + ]; + case "collabAgent/metadataUpdated": + return [ + { + ...base, + type: "task.updated", + // Metadata-only updates must not fabricate a role: fillMetadata + // downstream overwrites any existing role whenever this field is + // present, so a synthesized "general-purpose" here would stomp a + // real role a prior event already set. + payload: { + taskId, + ...identityWithoutRole, + ...(explicitRole ? { role: explicitRole } : {}), }, }, ]; @@ -565,7 +588,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "interrupted", ...statusLinkage }, + payload: { taskId, status: "interrupted", ...linkage }, }, ]; } @@ -582,28 +605,21 @@ function mapCollabAgentEvent( taskId, description: title, title, - role, - ...(agentPath ? { agentPath } : {}), - timelineBypass: true, + ...linkage, }, }, ]; } - // interacted → the child is (again) actively driven. - return [ - { - ...base, - type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, - }, - ]; + // Reading a child's result also emits "interacted" after its turn is idle. + // Only the child's turn or thread lifecycle can prove it resumed work. + return []; } case "collabAgent/turnStarted": return [ { ...base, type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, + payload: { taskId, status: "running", ...linkage }, }, ]; case "collabAgent/turnCompleted": { @@ -623,7 +639,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status, ...statusLinkage }, + payload: { taskId, status, ...linkage }, }, ]; } @@ -639,7 +655,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "failed", ...statusLinkage }, + payload: { taskId, status: "failed", ...linkage }, }, ]; } @@ -652,7 +668,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage }, + payload: { taskId, status: waiting ? "waiting" : "running", ...linkage }, }, ]; } @@ -661,7 +677,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "idle", ...statusLinkage }, + payload: { taskId, status: "idle", ...linkage }, }, ]; } @@ -708,9 +724,8 @@ function mapCollabAgentEvent( payload: { taskId, description: title, - ...(knownName ? { title: knownName } : {}), + ...linkage, typedUsage, - timelineBypass: true, }, }, ]; @@ -740,9 +755,8 @@ function mapCollabAgentEvent( payload: { taskId, description: title, - ...(knownName ? { title: knownName } : {}), + ...linkage, summary, - timelineBypass: true, }, }, ]; @@ -752,7 +766,7 @@ function mapCollabAgentEvent( { ...base, type: "task.updated", - payload: { taskId, status: "interrupted", ...statusLinkage }, + payload: { taskId, status: "interrupted", ...linkage }, }, ]; default: diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 1f225a00..4a93a292 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -70,10 +70,319 @@ function buildScript() { }; } +function capturedStartedActivity(childId = CHILD_A) { + const captured = wireFixture.notifications.find((entry) => { + const item = (entry.params as { item?: { type?: string; kind?: string } }).item; + return item?.type === "subAgentActivity" && item.kind === "started"; + }); + assert.isDefined(captured); + return { + ...captured, + params: { + ...captured.params, + item: { + ...captured.params.item, + agentThreadId: childId, + agentPath: "/root/model-check", + }, + }, + }; +} + +function capturedSpawnedThread(childId = CHILD_A) { + const captured = wireFixture.notifications.find((entry) => entry.method === "thread/started"); + assert.isDefined(captured); + return { + ...captured, + params: { + thread: { + ...captured.params.thread, + id: childId, + sessionId: childId, + parentThreadId: ROOT, + agentNickname: "model-check", + agentRole: "verifier", + source: { + subAgent: { + thread_spawn: { + agent_nickname: "model-check", + agent_path: "/root/model-check", + agent_role: "verifier", + depth: 1, + parent_thread_id: ROOT, + }, + }, + }, + }, + }, + }; +} + +function childSettings(threadId: string, model: string, effort: string) { + return { + method: "thread/settings/updated", + params: { + threadId, + threadSettings: { + approvalPolicy: "on-request", + approvalsReviewer: "auto_review", + collaborationMode: { mode: "default", settings: { model } }, + cwd: "/workspace/repo", + effort, + model, + modelProvider: "openai", + sandboxPolicy: { type: "dangerFullAccess" }, + }, + }, + }; +} + +function readRecordedRequests() { + return NodeFS.readFileSync(`${scriptPath}.requests`, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { method: string; params: Record }); +} + const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); describe("CodexSessionRuntime collab integration", () => { + it.effect("looks up child model metadata once after activity registration", () => + Effect.gen(function* () { + const script = { + rootThreadId: ROOT, + recordRequests: true, + notifications: [ + capturedStartedActivity(), + capturedStartedActivity(), + { + ...capturedStartedActivity(CHILD_B), + params: { + ...capturedStartedActivity(CHILD_B).params, + item: { ...capturedStartedActivity(CHILD_B).params.item, kind: "interacted" }, + }, + }, + { method: "thread/closed", params: { threadId: CHILD_B } }, + capturedSpawnedThread(ROOT), + ], + childResumeSnapshots: { + [CHILD_A]: { model: "gpt-5.6-luna", reasoningEffort: "low" }, + }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-model-activity"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, HELMCODE_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const metadataFiber = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.method === "collabAgent/metadataUpdated" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, + ), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + const session = yield* runtime.start(); + assert.equal(session.model, "gpt-5.6-sol"); + yield* runtime.sendTurn({ input: "start one child" }); + const metadataEvents = Array.from(yield* Fiber.join(metadataFiber)); + assert.deepInclude(metadataEvents[0]?.payload, { + agentThreadId: CHILD_A, + model: "gpt-5.6-luna", + effort: "low", + }); + assert.deepEqual(readRecordedRequests(), [ + { + method: "thread/resume", + params: { threadId: CHILD_A, excludeTurns: true }, + }, + ]); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps child settings and reroutes newer than the resume snapshot", () => + Effect.gen(function* () { + const statusChanged = wireFixture.notifications.find( + (entry) => + entry.method === "thread/status/changed" && + (entry.params as { threadId?: string }).threadId === CHILD_A, + ); + assert.isDefined(statusChanged); + const script = { + rootThreadId: ROOT, + recordRequests: true, + notifications: [ + childSettings(CHILD_A, "child-before", "medium"), + capturedSpawnedThread(), + childSettings(CHILD_A, "child-after", "high"), + { + method: "model/rerouted", + params: { + threadId: CHILD_A, + turnId: `${CHILD_A}-turn`, + fromModel: "child-after", + toModel: "child-rerouted", + reason: "highRiskCyberActivity", + }, + }, + { + method: "model/rerouted", + params: { + threadId: ROOT, + turnId: `${ROOT}-turn`, + fromModel: "gpt-5.6-sol", + toModel: "root-rerouted", + reason: "highRiskCyberActivity", + }, + }, + ], + childResumeSnapshots: { + [CHILD_A]: { + model: "stale-snapshot", + reasoningEffort: "low", + notifications: [statusChanged], + }, + }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-collab-model-spawn"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, HELMCODE_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.method === "collabAgent/statusChanged" && + (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, + ), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "start one spawned child" }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const started = events.find((event) => event.method === "collabAgent/started"); + assert.deepInclude(started?.payload, { + agentThreadId: CHILD_A, + model: "child-before", + effort: "medium", + }); + const childStatus = events.find((event) => event.method === "collabAgent/statusChanged"); + assert.deepInclude(childStatus?.payload, { + agentThreadId: CHILD_A, + model: "child-rerouted", + effort: "high", + }); + assert.isTrue( + events.some( + (event) => + event.method === "model/rerouted" && + (event.payload as { threadId?: string }).threadId === ROOT, + ), + "the root reroute must stay on the parent path", + ); + assert.isFalse( + events.some( + (event) => + (event.method === "thread/settings/updated" || event.method === "model/rerouted") && + (event.payload as { threadId?: string }).threadId === CHILD_A, + ), + "child metadata notifications must not leak to the parent path", + ); + assert.equal(readRecordedRequests().length, 1); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("does not delay the parent turn when the child lookup fails", () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + for (const [name, childSnapshot] of [ + ["hang", { hang: true }], + ["error", { error: "child unavailable" }], + ] as const) { + yield* Effect.gen(function* () { + const marker = `lookup-${name}`; + const script = { + rootThreadId: ROOT, + recordRequests: true, + resumeRequestMarker: marker, + notifications: [capturedStartedActivity()], + childResumeSnapshots: { [CHILD_A]: childSnapshot }, + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make(`thread-collab-model-${name}`), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, HELMCODE_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.method === "serverRequest/resolved" && + (event.payload as { requestId?: string }).requestId === marker, + ), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "finish without child metadata" }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.isTrue(events.some((event) => event.method === "turn/completed")); + assert.equal(readRecordedRequests().length, 1); + + yield* runtime.close; + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }).pipe(Effect.scoped); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => Effect.gen(function* () { // @effect-diagnostics-next-line preferSchemaOverJson:off diff --git a/apps/server/src/provider/Layers/CodexCollabWire.test.ts b/apps/server/src/provider/Layers/CodexCollabWire.test.ts index 50e5e819..363c1560 100644 --- a/apps/server/src/provider/Layers/CodexCollabWire.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabWire.test.ts @@ -119,6 +119,8 @@ describe("routeCodexChildNotification", () => { "turn/completed", "thread/status/changed", "thread/tokenUsage/updated", + "thread/settings/updated", + "model/rerouted", "item/started", "item/completed", "thread/closed", @@ -159,6 +161,8 @@ describe("routeCodexChildNotification", () => { "turn/completed", "turn/plan/updated", "item/plan/delta", + "thread/settings/updated", + "model/rerouted", ]) { assert.notEqual( routeCodexChildNotification(method), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 1dbc56dd..a8257063 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -85,6 +85,12 @@ const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartP const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( CodexTurnStartParamsWithCollaborationMode, ); +const CodexChildResumeMetadata = Schema.Struct({ + thread: Schema.Struct({ id: Schema.String }), + model: Schema.String, + reasoningEffort: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +const decodeCodexChildResumeMetadata = Schema.decodeUnknownEffect(CodexChildResumeMetadata); export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; @@ -505,7 +511,9 @@ function readNotificationThreadId(notification: CodexServerNotification): string case "thread/unarchived": case "thread/closed": case "thread/name/updated": + case "thread/settings/updated": case "thread/tokenUsage/updated": + case "model/rerouted": case "turn/started": case "hook/started": case "turn/completed": @@ -635,6 +643,35 @@ interface CollabChildAgentState { readonly spawnTurnId: TurnId | undefined; } +interface CollabChildMetadataState { + readonly model: string | undefined; + readonly effort: string | undefined; + readonly lookupStarted: boolean; + readonly closed: boolean; +} + +function collabChildIdentity( + child: CollabChildAgentState, + metadata: CollabChildMetadataState | undefined, +) { + return { + agentThreadId: child.agentThreadId, + ...(child.nickname ? { nickname: child.nickname } : {}), + ...(child.role ? { role: child.role } : {}), + ...(child.agentPath ? { agentPath: child.agentPath } : {}), + ...(metadata?.model ? { model: metadata.model } : {}), + ...(metadata?.effort ? { effort: metadata.effort } : {}), + }; +} + +function nonEmptyMetadataValue(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function readThreadSpawnSource(thread: { readonly source: unknown }): | { nickname: string | undefined; @@ -700,7 +737,9 @@ function shouldSuppressChildConversationNotification( method === "thread/closed" || method === "thread/compacted" || method === "thread/name/updated" || + method === "thread/settings/updated" || method === "thread/tokenUsage/updated" || + method === "model/rerouted" || method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated" || @@ -731,6 +770,8 @@ const CHILD_AGENT_EVENT_METHODS: ReadonlySet = new Set([ "turn/completed", "thread/status/changed", "thread/tokenUsage/updated", + "thread/settings/updated", + "model/rerouted", "item/started", "item/completed", "thread/closed", @@ -749,7 +790,6 @@ const CHILD_CHATTER_METHODS: ReadonlySet = new Set([ "turn/plan/updated", "turn/diff/updated", "thread/name/updated", - "thread/settings/updated", "rawResponseItem/completed", // Child-owned thread lifecycle: the parent adapter maps these onto the // PARENT thread (archived/compacted state), so a child compacting would @@ -857,6 +897,7 @@ export const makeCodexSessionRuntime = ( const pendingUserInputsRef = yield* Ref.make(new Map()); const collabReceiverTurnsRef = yield* Ref.make(new Map()); const collabChildAgentsRef = yield* Ref.make(new Map()); + const collabChildMetadataRef = yield* Ref.make(new Map()); /** Child provider-thread id → its currently running provider turn id. */ const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const closedRef = yield* Ref.make(false); @@ -954,6 +995,133 @@ export const makeCodexSessionRuntime = ( message, }); + const updateCollabChildMetadata = ( + agentThreadId: string, + update: { readonly model?: string; readonly effort?: string }, + overwriteKnown: boolean, + ) => + Ref.modify(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + const model = + update.model && (overwriteKnown || !previous.model) ? update.model : previous.model; + const effort = + update.effort && (overwriteKnown || !previous.effort) ? update.effort : previous.effort; + const changed = model !== previous.model || effort !== previous.effort; + if (!changed) { + return [false, current] as const; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, model, effort }); + return [true, next] as const; + }); + + const markCollabChildClosed = (agentThreadId: string) => + Ref.update(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + if (previous.closed) { + return current; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, closed: true }); + return next; + }); + + const markCollabChildOpen = (agentThreadId: string) => + Ref.update(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId); + if (!previous?.closed) { + return current; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, closed: false }); + return next; + }); + + const emitCollabChildMetadataUpdated = Effect.fn( + "CodexSessionRuntime.emitCollabChildMetadataUpdated", + )(function* (agentThreadId: string) { + const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId); + if (!child || metadata?.closed) { + return; + } + yield* emitEvent({ + kind: "notification", + threadId: options.threadId, + ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), + method: "collabAgent/metadataUpdated", + payload: collabChildIdentity(child, metadata), + }); + }); + + const startCollabChildMetadataLookup = Effect.fn( + "CodexSessionRuntime.startCollabChildMetadataLookup", + )(function* (agentThreadId: string) { + const shouldStart = yield* Ref.modify(collabChildMetadataRef, (current) => { + const previous = current.get(agentThreadId) ?? { + model: undefined, + effort: undefined, + lookupStarted: false, + closed: false, + }; + if (previous.lookupStarted || previous.closed) { + return [false, current] as const; + } + const next = new Map(current); + next.set(agentThreadId, { ...previous, lookupStarted: true }); + return [true, next] as const; + }); + if (!shouldStart) { + return; + } + + // The child is already loaded. This rejoins it without starting a turn, + // and excludeTurns avoids loading or replaying its history. + yield* client.raw + .request("thread/resume", { threadId: agentThreadId, excludeTurns: true }) + .pipe( + Effect.flatMap(decodeCodexChildResumeMetadata), + Effect.timeout("5 seconds"), + Effect.flatMap((response) => + Effect.gen(function* () { + if (response.thread.id !== agentThreadId) { + return; + } + const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId); + if (!child || metadata?.closed) { + return; + } + const model = nonEmptyMetadataValue(response.model); + const effort = nonEmptyMetadataValue(response.reasoningEffort); + const changed = yield* updateCollabChildMetadata( + agentThreadId, + { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }, + false, + ); + if (changed) { + yield* emitCollabChildMetadataUpdated(agentThreadId); + } + }), + ), + Effect.catch(() => Effect.void), + Effect.forkIn(runtimeScope), + ); + }); + const settlePendingApprovals = (decision: ProviderApprovalDecision) => Ref.get(pendingApprovalsRef).pipe( Effect.flatMap((pendingApprovals) => @@ -994,6 +1162,10 @@ export const makeCodexSessionRuntime = ( if (!spawn) { return false; } + const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + if (thread.id === rootProviderThreadId) { + return false; + } // Merge with any subAgentActivity registration that got here // first. spawnTurnId is REGISTRATION-time-only on both paths: for // an already-known child we keep its value (set or unset) — a @@ -1020,20 +1192,19 @@ export const makeCodexSessionRuntime = ( next.set(thread.id, state); return next; }); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(thread.id); yield* emitEvent({ kind: "notification", threadId: options.threadId, method: "collabAgent/started", ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), payload: { - agentThreadId: state.agentThreadId, - ...(state.nickname ? { nickname: state.nickname } : {}), - ...(state.role ? { role: state.role } : {}), - ...(state.agentPath ? { agentPath: state.agentPath } : {}), + ...collabChildIdentity(state, metadata), ...(state.depth !== undefined ? { depth: state.depth } : {}), ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), }, }); + yield* startCollabChildMetadataLookup(thread.id); return true; } @@ -1083,17 +1254,22 @@ export const makeCodexSessionRuntime = ( return next; }); const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(item.agentThreadId); yield* emitEvent({ kind: "notification", threadId: options.threadId, method: "collabAgent/activity", ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), payload: { - agentThreadId: item.agentThreadId, - agentPath: item.agentPath, + ...(registeredChild + ? collabChildIdentity(registeredChild, metadata) + : { agentThreadId: item.agentThreadId, agentPath: item.agentPath }), activityKind: item.kind, }, }); + if (item.kind === "started") { + yield* startCollabChildMetadataLookup(item.agentThreadId); + } return true; } @@ -1109,19 +1285,45 @@ export const makeCodexSessionRuntime = ( if (providerConversationId === interceptRootId) { return false; } + + if ( + interceptRootId !== undefined && + (notification.method === "thread/settings/updated" || + notification.method === "model/rerouted") + ) { + const model = nonEmptyMetadataValue( + notification.method === "thread/settings/updated" + ? notification.params.threadSettings.model + : notification.params.toModel, + ); + const effort = + notification.method === "thread/settings/updated" + ? nonEmptyMetadataValue(notification.params.threadSettings.effort) + : undefined; + const changed = yield* updateCollabChildMetadata( + providerConversationId, + { + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }, + true, + ); + if (changed && (yield* Ref.get(collabChildAgentsRef)).has(providerConversationId)) { + yield* emitCollabChildMetadataUpdated(providerConversationId); + } + return true; + } + const children = yield* Ref.get(collabChildAgentsRef); const child = children.get(providerConversationId); if (!child) { return false; } - const childIdentity = { - agentThreadId: child.agentThreadId, - ...(child.nickname ? { nickname: child.nickname } : {}), - ...(child.role ? { role: child.role } : {}), - ...(child.agentPath ? { agentPath: child.agentPath } : {}), - }; + const metadata = (yield* Ref.get(collabChildMetadataRef)).get(child.agentThreadId); + const childIdentity = collabChildIdentity(child, metadata); switch (notification.method) { case "turn/started": { + yield* markCollabChildOpen(child.agentThreadId); const childTurnId = typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" ? ((notification.params as { turn: { id: string } }).turn.id as string) @@ -1205,6 +1407,7 @@ export const makeCodexSessionRuntime = ( next.delete(child.agentThreadId); return next; }); + yield* markCollabChildClosed(child.agentThreadId); yield* emitEvent({ kind: "notification", threadId: options.threadId, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 6914320d..95ee6b41 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -271,7 +271,11 @@ const hasMetricSnapshot = ( Object.entries(attributes).every(([key, value]) => snapshot.attributes?.[key] === value), ); -function makeProviderServiceLayer() { +function makeProviderServiceLayer( + input: { + readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + } = {}, +) { const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER); const cursor = makeFakeCodexAdapter(CURSOR_DRIVER); @@ -288,7 +292,10 @@ function makeProviderServiceLayer() { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( Layer.provide(SqlitePersistenceMemory), ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const directoryLayer = + input.directory === undefined + ? ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)) + : Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, input.directory); const layer = it.layer( Layer.mergeAll( @@ -1959,3 +1966,50 @@ validation.layer("ProviderServiceLive validation", (it) => { }), ); }); + +const activeSessionThreadId = asThreadId("thread-active-session"); +const historicalSessionThreadId = asThreadId("thread-historical-session"); +const listThreadIds = vi.fn(() => + Effect.succeed([activeSessionThreadId, historicalSessionThreadId]), +); +const getBinding = vi.fn((threadId: ThreadId) => + Effect.succeed( + Option.some({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + }), + ), +); +const boundedListing = makeProviderServiceLayer({ + directory: { + upsert: () => Effect.void, + getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), + getBinding, + listThreadIds, + listBindings: () => Effect.die("ProviderService.listSessions does not use listBindings"), + }, +}); + +boundedListing.layer("ProviderServiceLive session listing", (it) => { + it.effect("looks up bindings for active sessions without scanning historical threads", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + yield* boundedListing.codex.startSession({ + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId: activeSessionThreadId, + cwd: "/tmp/project-active-session", + runtimeMode: "full-access", + }); + listThreadIds.mockClear(); + getBinding.mockClear(); + + const sessions = yield* provider.listSessions(); + + assert.equal(sessions.length, 1); + assert.equal(listThreadIds.mock.calls.length, 0); + assert.deepEqual(getBinding.mock.calls, [[activeSessionThreadId]]); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index e4a28985..b3f5ed7d 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -945,21 +945,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); const activeSessions = sessionsByProvider.flatMap((sessions) => sessions); - const persistedBindings = yield* directory.listThreadIds().pipe( - Effect.flatMap((threadIds) => - Effect.forEach( - threadIds, - (threadId) => - directory - .getBinding(threadId) - .pipe( - Effect.orElseSucceed(() => - Option.none(), - ), - ), - { concurrency: "unbounded" }, - ), - ), + // Only live adapter sessions appear in this response. Resolving every + // historical binding here makes each call scale with the full thread + // history instead of the active session set. + const persistedBindings = yield* Effect.forEach( + [...new Set(activeSessions.map((session) => session.threadId))], + (threadId) => + directory + .getBinding(threadId) + .pipe( + Effect.orElseSucceed(() => + Option.none(), + ), + ), + { concurrency: "unbounded" }, + ).pipe( Effect.orElseSucceed( () => [] as Array>, ), diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 886d9146..1cc14e58 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -39,7 +39,55 @@ rl.on("line", (line) => { }); return; } - if (method === "thread/start" || method === "thread/resume") { + if (method === "thread/start") { + write({ id, result: fixture.responses.threadStart }); + return; + } + if (method === "thread/resume") { + if (script.recordRequests) { + NodeFS.appendFileSync( + `${process.env.HELMCODE_CODEX_COLLAB_SCRIPT}.requests`, + `${JSON.stringify({ method, params: message.params })}\n`, + ); + } + const threadId = message.params?.threadId; + const childSnapshot = script.childResumeSnapshots?.[threadId]; + if (script.resumeRequestMarker) { + write({ + jsonrpc: "2.0", + method: "serverRequest/resolved", + params: { + threadId: script.rootThreadId, + requestId: script.resumeRequestMarker, + }, + }); + } + if (childSnapshot?.hang) { + return; + } + if (childSnapshot?.error) { + write({ id, error: { code: -32000, message: childSnapshot.error } }); + return; + } + if (childSnapshot) { + write({ + id, + result: { + ...fixture.responses.threadStart, + model: childSnapshot.model, + reasoningEffort: childSnapshot.reasoningEffort, + thread: { + ...fixture.responses.threadStart.thread, + id: threadId, + sessionId: threadId, + }, + }, + }); + for (const notification of childSnapshot.notifications ?? []) { + write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); + } + return; + } write({ id, result: fixture.responses.threadStart }); return; } diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 78f9b481..fe4834c7 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -16,7 +16,7 @@ const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; function policyInstruction(instruction: string | undefined): ReadonlyArray { const trimmed = instruction?.trim(); - return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : []; + return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 20_000)] : []; } // --------------------------------------------------------------------------- diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts new file mode 100644 index 00000000..2ea27375 --- /dev/null +++ b/apps/server/src/usage/usagePricing.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; + +const rate = (input: number, cacheRead?: number) => ({ + input_cost_per_token: input, + output_cost_per_token: input * 5, + ...(cacheRead === undefined ? {} : { cache_read_input_token_cost: cacheRead }), +}); + +describe("usage pricing", () => { + it("keeps the existing model-name normalization contract", () => { + expect(normalizeModelName(" Anthropic/Claude-Opus-5 ")).toBe("claude-opus-5"); + }); + + it("keeps the canonical Fable rate separate from DeepInfra in either order", () => { + const canonical = ["claude-fable-5", rate(1e-5, 1e-6)] as const; + const deepInfra = ["deepinfra/anthropic/claude-fable-5", rate(1e-5)] as const; + + for (const entries of [ + [canonical, deepInfra], + [deepInfra, canonical], + ]) { + const table = parseRateTable(Object.fromEntries(entries)); + + expect(lookupRate(table, "claude-fable-5")?.cacheReadCostPerToken).toBe(1e-6); + expect(lookupRate(table, "deepinfra/anthropic/claude-fable-5")?.cacheReadCostPerToken).toBe( + 1e-5, + ); + expect(lookupRate(table, "other/claude-fable-5")).toBeNull(); + } + }); + + it("adds a bare alias when every qualified entry has the same rate", () => { + const table = parseRateTable({ + "provider-a/example-model": rate(1), + "provider-b/example-model": rate(1), + }); + + expect(lookupRate(table, "example-model")).toEqual( + lookupRate(table, "provider-a/example-model"), + ); + }); + + it("leaves an ambiguous bare name unpriced", () => { + const table = parseRateTable({ + "provider-a/example-model": rate(1), + "provider-b/example-model": rate(3), + }); + + expect(lookupRate(table, "provider-a/example-model")?.inputCostPerToken).toBe(1); + expect(lookupRate(table, "provider-b/example-model")?.inputCostPerToken).toBe(3); + expect(lookupRate(table, "example-model")).toBeNull(); + }); +}); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 53f9a75b..ef5407ac 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -44,6 +44,9 @@ function finiteNumber(value: unknown): number | null { * Entries without both an input and an output rate are dropped: a half-priced * model would silently under-report cost, which is worse than reporting the * model as unpriced. + * + * Entries keep their full normalized key; a bare name is aliased only when no + * canonical entry exists and every qualified entry has the same rate. */ export function parseRateTable(document: unknown): RateTable { const table = new Map(); @@ -56,7 +59,9 @@ export function parseRateTable(document: unknown): RateTable { const output = finiteNumber(entry.output_cost_per_token); if (input === null || output === null) continue; - table.set(normalizeModelName(name), { + const key = normalizeRateKey(name); + if (key.length === 0) continue; + table.set(key, { inputCostPerToken: input, outputCostPerToken: output, // Anthropic bills cache reads at a discount and cache writes at a @@ -66,20 +71,52 @@ export function parseRateTable(document: unknown): RateTable { cacheCreationCostPerToken: finiteNumber(entry.cache_creation_input_token_cost) ?? input, }); } + + // `null` marks a bare name claimed at conflicting rates: no alias for it. + const aliasCandidates = new Map(); + for (const [key, rate] of table) { + const alias = bareModelName(key); + if (alias.length === 0 || alias === key || table.has(alias)) continue; + const held = aliasCandidates.get(alias); + if (held === undefined) { + aliasCandidates.set(alias, rate); + } else if (held !== null && !sameRate(held, rate)) { + aliasCandidates.set(alias, null); + } + } + for (const [alias, rate] of aliasCandidates) { + if (rate !== null) table.set(alias, rate); + } + return table; } +function sameRate(a: ModelRate, b: ModelRate): boolean { + return ( + a.inputCostPerToken === b.inputCostPerToken && + a.outputCostPerToken === b.outputCostPerToken && + a.cacheReadCostPerToken === b.cacheReadCostPerToken && + a.cacheCreationCostPerToken === b.cacheCreationCostPerToken + ); +} + +function normalizeRateKey(model: string): string { + return model.trim().toLowerCase(); +} + /** * Canonicalises a model name for lookup. * - * Strips a `provider/` prefix (LiteLLM publishes both `claude-opus-5` and - * `anthropic/claude-opus-5`) and lowercases, since transcripts are inconsistent - * about casing. + * Strips a `provider/` prefix and lowercases, since transcripts are + * inconsistent about casing. */ export function normalizeModelName(model: string): string { - const trimmed = model.trim().toLowerCase(); - const slash = trimmed.lastIndexOf("/"); - return slash === -1 ? trimmed : trimmed.slice(slash + 1); + return bareModelName(normalizeRateKey(model)); +} + +function bareModelName(key: string): string { + const slash = key.lastIndexOf("/"); + return slash === -1 ? key : key.slice(slash + 1); } /** @@ -99,9 +136,10 @@ const UNPRICEABLE_MODELS = new Set([ ]); export function lookupRate(table: RateTable, model: string): ModelRate | null { - const normalized = normalizeModelName(model); - if (normalized.length === 0 || UNPRICEABLE_MODELS.has(normalized)) return null; - return table.get(normalized) ?? null; + const key = normalizeRateKey(model); + const bareName = bareModelName(key); + if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; + return table.get(key) ?? null; } export interface PricedUsage { diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index d581e106..5f6ab61b 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -157,7 +157,7 @@ describe("scan cache round trip", () => { it("rejects the whole cache when an intern table holds a non-string", () => { // models: [1] would pass the undefined guard, put a number in a record's - // model, and crash normalizeModelName at aggregate time. + // model, and crash lookupRate at aggregate time. const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); const poisoned = { ...encoded, models: [1] }; diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 735c74c3..fffe12de 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -153,7 +153,7 @@ export function decodeScanCache(document: unknown): ScanCache { // The intern tables must be all strings: a numeric entry would pass the // undefined guard below, land in a record's model, and crash the aggregate - // at normalizeModelName. A corrupt table rejects the whole cache. + // at lookupRate. A corrupt table rejects the whole cache. if (!root.models.every((value) => typeof value === "string")) return cache; if (!root.sessions.every((value) => typeof value === "string")) return cache; const models = root.models as readonly string[]; diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index bf97f315..074de86c 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1677,6 +1677,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const clicked = await api.contextMenu.show( [ + { id: "project-settings", label: "Project settings" }, buildTargetedItem("rename", "Rename"), buildTargetedItem("grouping", "Group into..."), buildTargetedItem("copy-path", "Copy Path"), @@ -1694,16 +1695,29 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + if (clicked === "project-settings") { + if (isMobile) setOpenMobile(false); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: project.projectKey }, + }); + return; + } + await actionHandlers.get(clicked)?.(); })(); }, [ copyPathToClipboard, handleRemoveProject, + isMobile, openProjectGroupingDialog, openProjectRenameDialog, project.groupedProjectCount, project.memberProjects, + project.projectKey, + router, + setOpenMobile, suppressProjectClickForContextMenuRef, ], ); @@ -2146,11 +2160,21 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "project-settings", label: "Project settings" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], position, ); + if (clicked === "project-settings") { + if (isMobile) setOpenMobile(false); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: project.projectKey }, + }); + return; + } + if (clicked === "new-thread-on-branch") { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -2233,9 +2257,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard, deleteThread, handleNewThread, + isMobile, markThreadUnread, memberProjectByScopedKey, + project.projectKey, project.workspaceRoot, + router, + setOpenMobile, startThreadRename, ], ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4f88e4e6..cae1447a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1752,6 +1752,8 @@ export default function Sidebar() { () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); + const projectGroupsRef = useRef(projectGroups); + projectGroupsRef.current = projectGroups; const serverProviders = useAtomValue(primaryServerProvidersAtom); const providerEntryByInstanceId = useMemo( () => @@ -1880,11 +1882,8 @@ export default function Sidebar() { clearSelection(); }, [clearSelection, projectScopeKey]); - const handleProjectSettings = useCallback( - (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { - event.preventDefault(); - event.stopPropagation(); - setProjectScopeMenuOpen(false); + const openProjectSettings = useCallback( + (projectGroup: SidebarProjectSnapshot) => { if (isMobile) { setOpenMobile(false); } @@ -1895,6 +1894,15 @@ export default function Sidebar() { }, [isMobile, router, setOpenMobile], ); + const handleProjectSettings = useCallback( + (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { + event.preventDefault(); + event.stopPropagation(); + setProjectScopeMenuOpen(false); + openProjectSettings(projectGroup); + }, + [openProjectSettings], + ); // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot @@ -2982,6 +2990,17 @@ export default function Sidebar() { return; } switch (clicked.value) { + case "project-settings": { + const projectGroup = projectGroupsRef.current.find((group) => + group.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === thread.environmentId && + projectRef.projectId === thread.projectId, + ), + ); + if (projectGroup) openProjectSettings(projectGroup); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -3111,6 +3130,7 @@ export default function Sidebar() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + openProjectSettings, projectCwdByKey, serverConfigs, startThreadRename, diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx new file mode 100644 index 00000000..ff1caa37 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -0,0 +1,105 @@ +import { squashAtomCommandFailure } from "@helmcode/client-runtime/state/runtime"; +import type { DesktopAppActivationRequest } from "@helmcode/contracts"; +import { useEffect, useEffectEvent, useRef } from "react"; + +import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; +import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; +import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; +import { newProjectId } from "../../lib/utils"; +import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { readProjects, waitForProject } from "../../state/entities"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { environmentShell } from "../../state/shell"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function DesktopAppActivationCoordinator() { + const primaryEnvironment = usePrimaryEnvironment(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const openThread = useNewThreadHandler(); + const queueRef = useRef(Promise.resolve()); + const activation = window.desktopBridge?.appActivation; + const shell = useEnvironmentQuery( + primaryEnvironment === null + ? null + : environmentShell.stateAtom(primaryEnvironment.environmentId), + ); + const ready = + activation !== undefined && + primaryEnvironment?.connection.phase === "connected" && + primaryEnvironment.serverConfig !== null && + shell.data?.snapshot._tag === "Some"; + + const processRequest = useEffectEvent(async (request: DesktopAppActivationRequest) => + handleDesktopAppActivationRequest(request, { + getTarget: () => { + if ( + primaryEnvironment?.connection.phase !== "connected" || + primaryEnvironment.serverConfig === null + ) { + return null; + } + return { + environmentId: primaryEnvironment.environmentId, + platform: primaryEnvironment.serverConfig.environment.platform.os, + }; + }, + findProject: (environmentId, workspaceRoot) => + findProjectByPath( + readProjects().filter((project) => project.environmentId === environmentId), + workspaceRoot, + ) ?? null, + createProject: async (environmentId, workspaceRoot) => { + const projectId = newProjectId(); + const providers = + primaryEnvironment?.environmentId === environmentId + ? (primaryEnvironment.serverConfig?.providers ?? []) + : []; + const result = await createProject({ + environmentId, + input: { + projectId, + title: inferProjectTitleFromPath(workspaceRoot), + workspaceRoot, + createWorkspaceRootIfMissing: false, + defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + throw error instanceof Error ? error : new Error("Helm Code could not add the project."); + } + return projectId; + }, + waitForProject: async (projectRef) => { + await waitForProject(projectRef); + }, + openThread: (projectRef) => openThread(projectRef), + }), + ); + + useEffect(() => { + if (!ready || activation === undefined) return; + + let subscribed = true; + const unsubscribe = activation.onRequest((request) => { + queueRef.current = queueRef.current.then(async () => { + const response = await processRequest(request); + await activation.complete(response); + }); + queueRef.current = queueRef.current.catch(() => undefined); + }); + // Skip readiness if React runs cleanup before this subscription can receive requests. + queueMicrotask(() => { + if (subscribed) void activation.setReady(true).catch(() => undefined); + }); + return () => { + subscribed = false; + void activation.setReady(false).catch(() => undefined); + unsubscribe(); + }; + }, [activation, ready]); + + return null; +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index fa8698f2..ad0d54b6 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -3,9 +3,9 @@ import type { ContextMenuOpenContext as TreeContextMenuOpenContext, } from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@helmcode/contracts"; -import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; +import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@helmcode/shared/composerTrigger"; -import { RotateCw } from "lucide-react"; +import { ChevronsDownUpIcon, ChevronsUpDownIcon, RotateCw } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { Button } from "~/components/ui/button"; @@ -20,6 +20,7 @@ import { readLocalApi } from "~/localApi"; import { HELMCODE_PIERRE_ICONS } from "~/pierre-icons"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBrowserPanelProps { @@ -116,6 +117,10 @@ export default function FileBrowserPanel({ ); const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); + const directoryPaths = useMemo( + () => entries.filter((entry) => entry.kind === "directory").map(treePath), + [entries], + ); const previousTreePathsRef = useRef([]); const syncingSelectionRef = useRef(false); const treeSelectionPathRef = useRef(null); @@ -247,6 +252,12 @@ export default function FileBrowserPanel({ unsafeCSS: TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); + const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => + areAllDirectoriesExpanded(currentModel, directoryPaths), + ); + const toggleAllDirectories = () => { + setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded); + }; const handleSearchValueChange = (value: string) => { if (value.trim().length === 0) { search.close(); @@ -359,6 +370,32 @@ export default function FileBrowserPanel({ onValueChange={handleSearchValueChange} onClose={search.close} /> + {directoryPaths.length > 0 ? ( + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null} {entriesQuery.error && entriesQuery.data === null ? (
{entriesQuery.error}
diff --git a/apps/web/src/components/files/fileTreeExpansion.test.ts b/apps/web/src/components/files/fileTreeExpansion.test.ts new file mode 100644 index 00000000..1fba6957 --- /dev/null +++ b/apps/web/src/components/files/fileTreeExpansion.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "@effect/vitest"; + +import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; + +type FakeDirectoryItem = { + isDirectory: () => true; + isExpanded: () => boolean; + expand: () => void; + collapse: () => void; +}; + +function makeModel(expanded: Record) { + const items = new Map(); + return { + getItem: (path: string) => { + const existing = items.get(path); + if (existing !== undefined) return existing; + const item: FakeDirectoryItem = { + isDirectory: () => true, + isExpanded: () => expanded[path] ?? false, + expand: () => { + expanded[path] = true; + }, + collapse: () => { + expanded[path] = false; + }, + }; + items.set(path, item); + return item; + }, + }; +} + +describe("file tree expansion", () => { + it("requires at least one directory and detects whether all are expanded", () => { + const model = makeModel({ "src/": true, "test/": true }); + expect(areAllDirectoriesExpanded(model, [])).toBe(false); + expect(areAllDirectoriesExpanded(model, ["src/", "test/"])).toBe(true); + expect( + areAllDirectoriesExpanded(makeModel({ "src/": true, "test/": false }), ["src/", "test/"]), + ).toBe(false); + }); + + it("expands and collapses every directory", () => { + const expanded = { "src/": true, "test/": false }; + const model = makeModel(expanded); + setAllDirectoriesExpanded(model, ["src/", "test/"], true); + expect(expanded).toEqual({ "src/": true, "test/": true }); + setAllDirectoriesExpanded(model, ["src/", "test/"], false); + expect(expanded).toEqual({ "src/": false, "test/": false }); + }); + + it("skips directories already at the requested state", () => { + const model = makeModel({ "src/": true }); + const item = model.getItem("src/"); + const collapse = vi.spyOn(item, "collapse"); + setAllDirectoriesExpanded(model, ["src/"], true); + expect(collapse).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/files/fileTreeExpansion.ts b/apps/web/src/components/files/fileTreeExpansion.ts new file mode 100644 index 00000000..221e62b6 --- /dev/null +++ b/apps/web/src/components/files/fileTreeExpansion.ts @@ -0,0 +1,55 @@ +export interface FileTreeExpansionModel { + getItem(path: string): unknown; +} + +type DirectoryHandle = { + isDirectory(): boolean; + isExpanded(): boolean; + expand(): void; + collapse(): void; +}; + +function asDirectoryHandle(item: unknown): DirectoryHandle | null { + if ( + typeof item !== "object" || + item === null || + !("isDirectory" in item) || + typeof item.isDirectory !== "function" || + !item.isDirectory() || + !("isExpanded" in item) || + typeof item.isExpanded !== "function" || + !("expand" in item) || + typeof item.expand !== "function" || + !("collapse" in item) || + typeof item.collapse !== "function" + ) { + return null; + } + return item as DirectoryHandle; +} + +export function areAllDirectoriesExpanded( + model: FileTreeExpansionModel, + directoryPaths: readonly string[], +): boolean { + return ( + directoryPaths.length > 0 && + directoryPaths.every((path) => { + const item = asDirectoryHandle(model.getItem(path)); + return item !== null && item.isExpanded(); + }) + ); +} + +export function setAllDirectoriesExpanded( + model: FileTreeExpansionModel, + directoryPaths: readonly string[], + expanded: boolean, +): void { + for (const path of directoryPaths) { + const item = asDirectoryHandle(model.getItem(path)); + if (item === null || item.isExpanded() === expanded) continue; + if (expanded) item.expand(); + else item.collapse(); + } +} diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 76a813c1..59b3aefa 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -172,6 +172,7 @@ export function ThemeEditorPanel({ const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< Record >({ light: false, dark: false }); + const [shouldRegenerateGuidedColors, setShouldRegenerateGuidedColors] = useState(false); const [error, setError] = useState(null); const [isMinimized, setIsMinimized] = useState(false); const [roleQuery, setRoleQuery] = useState(""); @@ -265,6 +266,10 @@ export function ThemeEditorPanel({ // regenerate when the guided editor produced it. setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); setSimpleColorsDirtyByAppearance({ light: false, dark: false }); + // An unmanaged palette needs conversion when the user opts into the + // guided editor. Merely revealing Advanced for a managed/default draft + // must stay read-only until a color changes. + setShouldRegenerateGuidedColors(sourceTheme !== null && sourceTheme.managed !== true); setColorsByAppearance(nextColors); setSelectedRole(null); setUsageCount(null); @@ -359,6 +364,7 @@ export function ThemeEditorPanel({ [activeAppearance]: true, })); } + if (isAdvanced) setShouldRegenerateGuidedColors(true); }, [activeAppearance, isAdvanced], ); @@ -587,6 +593,7 @@ export function ThemeEditorPanel({ if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) { setSelectedRole(null); } + if (!shouldRegenerateGuidedColors) return; // Regenerate every appearance the theme will save, not just the visible // one, so the palettes shown after toggling match what gets saved. @@ -606,8 +613,9 @@ export function ThemeEditorPanel({ } return next; }); + setShouldRegenerateGuidedColors(false); }, - [activeAppearance, editingTheme, selectedRole], + [activeAppearance, editingTheme, selectedRole, shouldRegenerateGuidedColors], ); const handleSubmit = () => { diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e..43f35042 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -26,7 +26,24 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual([ + "rename", + "mark-unread", + "copy-path", + "copy-thread-id", + "project-settings", + "delete", + ]); + }); + + it("places project settings right before delete", () => { + const items = buildThreadActionMenuItems(baseState); + const deleteIndex = items.findIndex((item) => item.id === "delete"); + expect(items[deleteIndex - 1]).toMatchObject({ + id: "project-settings", + label: "Project settings", + icon: "settings", + }); }); it("includes branch items only for threads with a branch", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 6c57bac4..82755953 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -8,6 +8,7 @@ import type { SnoozePreset } from "@helmcode/client-runtime/state/thread-settled */ export type ThreadActionMenuId = | "new-thread-on-branch" + | "project-settings" | "pin" | "unpin" | "settle" @@ -102,6 +103,7 @@ export function buildThreadActionMenuItems( { id: "copy-path", label: "Copy path", icon: "copy" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + { id: "project-settings", label: "Project settings", icon: "settings" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 9a149f42..2c149ee8 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -17,6 +17,80 @@ const ICON_PATHS: Record = {}, +): DesktopAppActivationDependencies { + return { + getTarget: () => ({ environmentId, platform: "linux" }), + findProject: () => ({ + id: existingProjectId, + environmentId, + workspaceRoot: request.workspaceRoot, + }), + createProject: vi.fn(async () => createdProjectId), + waitForProject: vi.fn(async () => undefined), + openThread: vi.fn(async () => ({ threadId })), + ...overrides, + }; +} + +describe("desktop app activation", () => { + it("reuses an existing project and opens a new thread", async () => { + const deps = dependencies(); + + const response = await handleDesktopAppActivationRequest(request, deps); + + expect(deps.createProject).not.toHaveBeenCalled(); + expect(deps.openThread).toHaveBeenCalledWith({ environmentId, projectId: existingProjectId }); + expect(response).toEqual({ + version: 1, + requestId: request.requestId, + ok: true, + projectId: existingProjectId, + threadId, + }); + }); + + it("waits for a created project before it opens the thread", async () => { + const order: string[] = []; + const deps = dependencies({ + findProject: () => null, + createProject: vi.fn(async () => { + order.push("create"); + return createdProjectId; + }), + waitForProject: vi.fn(async () => { + order.push("project-event"); + }), + openThread: vi.fn(async () => { + order.push("open-thread"); + return { threadId }; + }), + }); + + const response = await handleDesktopAppActivationRequest(request, deps); + + expect(order).toEqual(["create", "project-event", "open-thread"]); + expect(response).toMatchObject({ ok: true, projectId: createdProjectId }); + }); + + it("rejects a Windows path when the primary environment is WSL", async () => { + const response = await handleDesktopAppActivationRequest( + { ...request, platform: "win32" }, + dependencies({ getTarget: () => ({ environmentId, platform: "linux" }) }), + ); + + expect(response).toMatchObject({ ok: false, code: "platform-mismatch" }); + }); + + it("returns a project error without opening a thread", async () => { + const openThread = vi.fn(async () => ({ threadId })); + const response = await handleDesktopAppActivationRequest( + request, + dependencies({ + findProject: () => null, + createProject: vi.fn(async () => { + throw new Error("Project path is not available."); + }), + openThread, + }), + ); + + expect(response).toMatchObject({ + ok: false, + code: "project-create-failed", + message: "Project path is not available.", + }); + expect(openThread).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/desktopAppActivation.ts b/apps/web/src/desktopAppActivation.ts new file mode 100644 index 00000000..bf8930ba --- /dev/null +++ b/apps/web/src/desktopAppActivation.ts @@ -0,0 +1,120 @@ +import { DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION } from "@helmcode/contracts"; +import type { + DesktopAppActivationFailure, + DesktopAppActivationRequest, + DesktopAppActivationResponse, + EnvironmentId, + ExecutionEnvironmentPlatformOs, + ProjectId, + ScopedProjectRef, + ThreadId, +} from "@helmcode/contracts"; + +export interface DesktopAppActivationProject { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; +} + +export interface DesktopAppActivationTarget { + readonly environmentId: EnvironmentId; + readonly platform: ExecutionEnvironmentPlatformOs; +} + +export interface DesktopAppActivationDependencies { + readonly getTarget: () => DesktopAppActivationTarget | null; + readonly findProject: ( + environmentId: EnvironmentId, + workspaceRoot: string, + ) => DesktopAppActivationProject | null; + readonly createProject: ( + environmentId: EnvironmentId, + workspaceRoot: string, + ) => Promise; + readonly waitForProject: (projectRef: ScopedProjectRef) => Promise; + readonly openThread: ( + projectRef: ScopedProjectRef, + ) => Promise<{ readonly threadId: ThreadId } | null>; +} + +function failure( + requestId: string, + code: DesktopAppActivationFailure["code"], + message: string, +): DesktopAppActivationFailure { + return { version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, requestId, ok: false, code, message }; +} + +export function desktopPlatformToEnvironmentOs( + platform: DesktopAppActivationRequest["platform"], +): ExecutionEnvironmentPlatformOs { + return platform === "win32" ? "windows" : platform; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback; +} + +export async function handleDesktopAppActivationRequest( + request: DesktopAppActivationRequest, + dependencies: DesktopAppActivationDependencies, +): Promise { + const target = dependencies.getTarget(); + if (target === null) { + return failure( + request.requestId, + "environment-unavailable", + "The desktop app's primary local environment is not connected.", + ); + } + + const requestPlatform = desktopPlatformToEnvironmentOs(request.platform); + if (requestPlatform !== target.platform) { + return failure( + request.requestId, + "platform-mismatch", + `The command path is for ${requestPlatform}, but the desktop app's primary environment uses ${target.platform}. Cross-platform path mapping is not supported.`, + ); + } + + let projectId = dependencies.findProject(target.environmentId, request.workspaceRoot)?.id ?? null; + if (projectId === null) { + try { + projectId = await dependencies.createProject(target.environmentId, request.workspaceRoot); + await dependencies.waitForProject({ environmentId: target.environmentId, projectId }); + } catch (error) { + return failure( + request.requestId, + "project-create-failed", + errorMessage(error, "Helm Code could not add the project."), + ); + } + } + + try { + const opened = await dependencies.openThread({ + environmentId: target.environmentId, + projectId, + }); + if (opened === null) { + return failure( + request.requestId, + "thread-open-failed", + "Helm Code could not open a new thread for the project.", + ); + } + return { + version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, + requestId: request.requestId, + ok: true, + projectId, + threadId: opened.threadId, + }; + } catch (error) { + return failure( + request.requestId, + "thread-open-failed", + errorMessage(error, "Helm Code could not open a new thread for the project."), + ); + } +} diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 28635f86..7e95297a 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -23,7 +23,7 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { resolveDefaultThreadEnvMode } from "@helmcode/shared/threadEnvMode"; -import { readThreadShell, useProjects, useThread } from "../state/entities"; +import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { readHelmCodeProjectFileDefaultThreadEnvMode } from "../lib/helmcodeProjectFileDefaults"; import { primaryServerSettingsAtom } from "../state/server"; @@ -51,7 +51,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef } export function useNewThreadHandler() { - const projects = useProjects(); // New-thread defaults are a user preference, and the settings UI only ever // edits the primary environment's settings.json. Reading the target // environment's own settings here would silently reset remote projects to @@ -87,6 +86,7 @@ export function useNewThreadHandler() { // prepared checkout, a task to write — addresses that one rather than looking the project // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { + const projects = readProjects(); const { getComposerDraft, getDraftSessionByLogicalProjectKey, @@ -427,7 +427,7 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router], + [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router], ); } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 3ee0eca3..1b6e9be4 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -12,7 +12,8 @@ import { type ChangeRequestStateLike, } from "@helmcode/client-runtime/state/thread-settled"; import type { ScopedThreadRef, ThreadId } from "@helmcode/contracts"; -import { useCallback } from "react"; +import { useRouter } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; import { @@ -28,8 +29,16 @@ import { readEnvironmentSupportsSnooze, readEnvironmentSupportsTitleRegeneration, readThreadShell, + useProjects, } from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { useUiStateStore } from "../uiStateStore"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; @@ -65,6 +74,19 @@ export function useThreadActionMenu(input: { readonly onStartRename: () => void; }) { const { threadRef, projectCwd, changeRequestState, onStartRename } = input; + const router = useRouter(); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const logicalProjectKeyByPhysicalKey = useMemo( + () => + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }), + [primaryEnvironmentId, projectGroupingSettings, projects], + ); const { settleThread, unsettleThread, @@ -182,6 +204,22 @@ export function useThreadActionMenu(input: { } }; switch (action) { + case "project-settings": { + const project = projects.find( + (candidate) => + candidate.environmentId === thread.environmentId && + candidate.id === thread.projectId, + ); + if (!project) return; + const projectKey = + logicalProjectKeyByPhysicalKey.get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey }, + }); + return; + } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -291,10 +329,14 @@ export function useThreadActionMenu(input: { copyThreadIdToClipboard, deleteThread, handleNewThread, + logicalProjectKeyByPhysicalKey, markThreadUnread, onStartRename, pinThread, projectCwd, + projectGroupingSettings, + projects, + router, settleThread, snoozeThread, threadRef, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c6a6f075..11968758 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; +import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -133,6 +134,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index f74ff4d6..78413d1b 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -218,6 +218,35 @@ export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { return appAtomRegistry.get(environmentProjects.projectAtom(ref)); } +export function readProjects(): ReadonlyArray { + return appAtomRegistry.get(environmentProjects.projectsAtom); +} + +/** Resolves when the project event reaches the live client store. */ +export function waitForProject( + ref: ScopedProjectRef, + timeoutMs = 10_000, +): Promise { + const current = readProject(ref); + if (current !== null) return Promise.resolve(current); + + return new Promise((resolve, reject) => { + let unsubscribe: (() => void) | null = null; + const timeout = setTimeout(() => { + unsubscribe?.(); + reject(new Error("The project did not appear in the desktop app.")); + }, timeoutMs); + const finish = (project: EnvironmentProject | null) => { + if (project === null) return; + clearTimeout(timeout); + unsubscribe?.(); + resolve(project); + }; + unsubscribe = appAtomRegistry.subscribe(environmentProjects.projectAtom(ref), finish); + finish(readProject(ref)); + }); +} + export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } diff --git a/docs/user/install.md b/docs/user/install.md index f29d8d65..13991753 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -8,6 +8,26 @@ Node.js `^22.16 || ^23.11 || >=24.10` on the machine that runs the Helm Code ser At least one provider CLI, installed and authenticated. See [Providers](#providers) below. +## Open a project in the desktop app + +When the Helm Code desktop app is running on the same machine, open the current directory with: + +```bash +npx helmcode@nightly app +``` + +Pass a path to open another directory: + +```bash +npx helmcode@nightly app ../my-project +``` + +The command adds the directory as a project when needed, focuses the desktop app, and opens a new +thread. It does not launch the desktop app, open a browser, or start a Helm Code server. A background +server does not count as the desktop app. The command also rejects SSH sessions because a remote +shell cannot focus a local desktop window. The CLI package and the running desktop app must both +include `helmcode app` support. + ## Desktop App Download the latest release from diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 20aab65f..b9d9b611 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -28,6 +28,24 @@ Log in with Codex normally: codex login ``` +## Send feedback to OpenAI + +In an existing Codex thread, send `/feedback` or `/feedback` followed by a description of the +issue. Helm Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy +and share with OpenAI employees. + +## Sub-agent models + +The web and desktop Agents panel shows each sub-agent's model and reasoning effort when Codex +reports them. If Codex does not report either value, Helm Code leaves it out instead of using the +parent agent's settings. + +## Approve access to other apps + +When a Codex tool needs access to an app such as Safari, Helm Code shows the app name and asks for +approval. You can approve, decline, or cancel the request from the desktop app, web app, or mobile +app. Some tools also offer approval for the current session or permanent approval. + ## I Want Work And Personal Codex Accounts Use one real Codex home and one shadow home. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index d4ea4755..00927db2 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -33,6 +33,8 @@ Helm Code works with the platforms your team already uses: - Push a branch and create a pull request from the Git actions controls in the toolbar - Helm Code can suggest titles and descriptions based on your commits +- With **Repository conventions** selected, generated source control text follows the project's + `AGENTS.md` along with recent commit subjects. Claude writers also follow `CLAUDE.md` - Supports GitHub Pull Requests, GitLab Merge Requests, Bitbucket Pull Requests, and Azure DevOps Pull Requests **Stay on top of open reviews** diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index e5ab2a38..40611788 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -567,6 +567,43 @@ describe("model and effort attribution", () => { expect(agents[0]!.effort).toBe("high"); }); + it("applies metadata-only updates without changing the current status", () => { + const waitingRows = [ + activity("task.updated", { + taskId: "task-metadata", + title: "Check metadata", + status: "waiting", + }), + activity("task.updated", { + taskId: "task-metadata", + model: "gpt-5.6-sol", + effort: "high", + }), + ]; + const waitingAgent = fold(waitingRows)[0]!; + expect(waitingAgent.status).toBe("waiting"); + expect(formatSubagentModelLabel(waitingAgent.model, waitingAgent.effort)).toBe( + "gpt-5.6-sol · high", + ); + + const idleRows = [ + ...waitingRows, + activity("task.updated", { taskId: "task-metadata", status: "idle" }), + activity("task.updated", { taskId: "task-metadata", model: "gpt-5.6-sol" }), + ]; + expect(fold(idleRows)[0]!.status).toBe("idle"); + + const completedAgent = fold([ + ...idleRows, + activity("task.progress", { taskId: "task-metadata", typedUsage: { totalTokens: 42 } }), + activity("task.completed", { taskId: "task-metadata", status: "completed" }), + activity("task.updated", { taskId: "task-metadata", effort: "high" }), + ])[0]!; + expect(completedAgent.status).toBe("completed"); + expect(completedAgent.model).toBe("gpt-5.6-sol"); + expect(completedAgent.effort).toBe("high"); + }); + it("formatSubagentModelLabel compacts ids and appends effort", () => { expect(formatSubagentModelLabel("claude-sonnet-5[1m]", "high")).toBe("sonnet-5[1m] · high"); expect(formatSubagentModelLabel("claude-opus-4-20250514", null)).toBe("opus-4"); diff --git a/packages/contracts/src/desktopAppActivation.ts b/packages/contracts/src/desktopAppActivation.ts new file mode 100644 index 00000000..020188cf --- /dev/null +++ b/packages/contracts/src/desktopAppActivation.ts @@ -0,0 +1,53 @@ +import * as Schema from "effect/Schema"; + +import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION = 1 as const; + +export const DesktopAppActivationPlatform = Schema.Literals(["darwin", "linux", "win32"]); +export type DesktopAppActivationPlatform = typeof DesktopAppActivationPlatform.Type; + +export const DesktopAppActivationRequest = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + type: Schema.Literal("open-workspace"), + workspaceRoot: TrimmedNonEmptyString, + platform: DesktopAppActivationPlatform, +}); +export type DesktopAppActivationRequest = typeof DesktopAppActivationRequest.Type; + +export const DesktopAppActivationErrorCode = Schema.Literals([ + "invalid-request", + "renderer-unavailable", + "environment-unavailable", + "platform-mismatch", + "project-create-failed", + "thread-open-failed", + "request-timeout", + "internal-error", +]); +export type DesktopAppActivationErrorCode = typeof DesktopAppActivationErrorCode.Type; + +export const DesktopAppActivationSuccess = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + ok: Schema.Literal(true), + projectId: ProjectId, + threadId: ThreadId, +}); +export type DesktopAppActivationSuccess = typeof DesktopAppActivationSuccess.Type; + +export const DesktopAppActivationFailure = Schema.Struct({ + version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION), + requestId: TrimmedNonEmptyString, + ok: Schema.Literal(false), + code: DesktopAppActivationErrorCode, + message: TrimmedNonEmptyString, +}); +export type DesktopAppActivationFailure = typeof DesktopAppActivationFailure.Type; + +export const DesktopAppActivationResponse = Schema.Union([ + DesktopAppActivationSuccess, + DesktopAppActivationFailure, +]); +export type DesktopAppActivationResponse = typeof DesktopAppActivationResponse.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 01ebd947..023ea6de 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,6 +5,7 @@ export * from "./environment.ts"; export * from "./environmentHttp.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; +export * from "./desktopAppActivation.ts"; export * from "./remoteAccess.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a56bd677..0f9d8db4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -100,6 +100,10 @@ import type { SourceControlRepositoryInfo, SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; +import type { + DesktopAppActivationRequest, + DesktopAppActivationResponse, +} from "./desktopAppActivation.ts"; export interface ContextMenuItem { id: T; @@ -1112,6 +1116,12 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** Present when the desktop shell accepts `helmcode app` activation requests. */ + appActivation?: { + setReady: (ready: boolean) => Promise; + complete: (response: DesktopAppActivationResponse) => Promise; + onRequest: (listener: (request: DesktopAppActivationRequest) => void) => () => void; + }; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`. diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index e876afff..d68eb3ec 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -163,7 +163,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const incomingRequests = yield* Queue.unbounded(); const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); - const remainder = yield* Ref.make(""); + const remainder: Array = []; const terminationHandled = yield* Ref.make(false); const terminationFailure = yield* Ref.make(Option.none()); const terminationSignal = yield* Deferred.make(); @@ -398,11 +398,24 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Stream.interruptWhen(Deferred.await(terminationSignal)), Stream.decodeText(), Stream.runForEach((chunk) => - Ref.modify(remainder, (current) => { - const combined = current + chunk; - const lines = combined.split("\n"); - const nextRemainder = lines.pop() ?? ""; - return [lines.map((line) => line.replace(/\r$/, "")), nextRemainder] as const; + Effect.sync(() => { + const lines: Array = []; + let start = 0; + for ( + let newline = chunk.indexOf("\n"); + newline !== -1; + newline = chunk.indexOf("\n", start) + ) { + remainder.push(chunk.slice(start, newline)); + lines.push(remainder.join("").replace(/\r$/, "")); + remainder.length = 0; + start = newline + 1; + } + // Keep unfinished lines in fragments so each chunk is scanned only once. + if (start < chunk.length) { + remainder.push(chunk.slice(start)); + } + return lines; }).pipe(Effect.flatMap((lines) => Effect.forEach(lines, handleLine, { discard: true }))), ), Effect.matchEffect({ @@ -411,8 +424,12 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.succeed(normalizeIncomingError(error, "read-input-stream")), ), onSuccess: () => - Ref.get(remainder).pipe( - Effect.flatMap((line) => (line.trim().length === 0 ? Effect.void : handleLine(line))), + Effect.sync(() => { + const line = remainder.join(""); + remainder.length = 0; + return line; + }).pipe( + Effect.flatMap(handleLine), Effect.matchEffect({ onFailure: (error) => handleTermination(() => Effect.succeed(error)), onSuccess: () => diff --git a/packages/shared/package.json b/packages/shared/package.json index 74709acf..7c4873ec 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -218,6 +218,10 @@ "./usageFormat": { "types": "./src/usageFormat.ts", "import": "./src/usageFormat.ts" + }, + "./desktopAppControl": { + "types": "./src/desktopAppControl.ts", + "import": "./src/desktopAppControl.ts" } }, "scripts": { diff --git a/packages/shared/src/desktopAppControl.test.ts b/packages/shared/src/desktopAppControl.test.ts new file mode 100644 index 00000000..34f2fe6f --- /dev/null +++ b/packages/shared/src/desktopAppControl.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveDesktopAppControlAddress } from "./desktopAppControl.ts"; + +describe("resolveDesktopAppControlAddress", () => { + it("keeps Unix socket paths short and separates desktop state directories", () => { + const first = resolveDesktopAppControlAddress({ + stateDir: `/home/user/${"long/".repeat(40)}userdata`, + platform: "linux", + tempDir: "/tmp", + userId: 1000, + joinPath: (...segments) => segments.join("/"), + }); + const second = resolveDesktopAppControlAddress({ + stateDir: "/home/user/.t3/other/userdata", + platform: "linux", + tempDir: "/tmp", + userId: 1000, + joinPath: (...segments) => segments.join("/"), + }); + + expect(first.directory).toBe("/tmp/helmcode-1000"); + expect(first.address.length).toBeLessThan(108); + expect(first.address).not.toBe(second.address); + }); + + it("uses a Windows named pipe", () => { + const result = resolveDesktopAppControlAddress({ + stateDir: "C:\\Users\\user\\.t3\\userdata", + platform: "win32", + tempDir: "C:\\Temp", + userId: undefined, + joinPath: (...segments) => segments.join("\\"), + }); + + expect(result.directory).toBeNull(); + expect(result.address).toMatch(/^\\\\\.\\pipe\\helmcode-app-[a-f0-9]{24}$/); + }); +}); diff --git a/packages/shared/src/desktopAppControl.ts b/packages/shared/src/desktopAppControl.ts new file mode 100644 index 00000000..18621ecf --- /dev/null +++ b/packages/shared/src/desktopAppControl.ts @@ -0,0 +1,43 @@ +import { sha256 } from "@noble/hashes/sha2"; + +export interface DesktopAppControlAddress { + readonly address: string; + readonly directory: string | null; +} + +function shortHash(value: string): string { + return Array.from(sha256(new TextEncoder().encode(value)).slice(0, 12), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +/** + * Returns the local-only socket address shared by the desktop shell and CLI. + * The state directory is hashed so custom Helm Code homes cannot exceed Unix socket + * path limits. + */ +export function resolveDesktopAppControlAddress(input: { + readonly stateDir: string; + readonly platform: NodeJS.Platform; + readonly tempDir: string; + readonly userId: number | undefined; + readonly joinPath: (...segments: readonly string[]) => string; +}): DesktopAppControlAddress { + const stateHash = shortHash(input.stateDir); + if (input.platform === "win32") { + return { + address: `\\\\.\\pipe\\helmcode-app-${stateHash}`, + directory: null, + }; + } + + // Falls back to a slice of the already-computed state hash, not a second + // hash of it — not user-specific, but still unique enough to segment the + // temp directory when no OS uid is available (e.g. Windows, sandboxed). + const identityKey = input.userId === undefined ? stateHash.slice(0, 12) : input.userId; + const directory = input.joinPath(input.tempDir, `helmcode-${identityKey}`); + return { + address: input.joinPath(directory, `${stateHash}.sock`), + directory, + }; +} diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index 9189ad1e..5dff48a3 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -51,4 +51,12 @@ export const HostProcessArguments = Context.Reference>( }, ); +/** Undefined on platforms without POSIX uids (Windows). */ +export const HostProcessUserId = Context.Reference( + "@helmcode/shared/hostProcess/HostProcessUserId", + { + defaultValue: () => process.getuid?.(), + }, +); + export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index fd3a3e4a..393dbd65 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -306,7 +306,7 @@ describe("readEnvironmentFromWindowsShell", () => { }); describe("mergePathValues", () => { - it("dedupes case-insensitively on Windows while preserving preferred order", () => { + it("sanitizes and dedupes Windows entries while preserving preferred order", () => { expect( mergePathValues( 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"', @@ -314,10 +314,20 @@ describe("mergePathValues", () => { "win32", ), ).toBe( - 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs";C:\\Windows\\System32', + "C:\\Users\\testuser\\AppData\\Roaming\\npm;C:\\Program Files\\nodejs;C:\\Windows\\System32", ); }); + it("removes stray quotes from Windows entries", () => { + expect( + mergePathValues( + 'C:\\Windows\\System32;C:\\cloudflared.exe;C:";C:\\Program Files\\nodejs', + undefined, + "win32", + ), + ).toBe("C:\\Windows\\System32;C:\\cloudflared.exe;C:;C:\\Program Files\\nodejs"); + }); + it("dedupes case-sensitively on POSIX", () => { expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe( "/usr/local/bin:/usr/bin:/USR/BIN", @@ -452,7 +462,7 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { }); effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { - it.effect("returns the baseline no-profile PATH patch when node is already available", () => + it.effect("uses known CLI directories as a fallback without changing shell PATH priority", () => Effect.gen(function* () { const readEnvironment = vi.fn( (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => @@ -475,6 +485,8 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { ), ).toEqual({ PATH: [ + "C:\\Shell\\Bin", + "C:\\Windows\\System32", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", @@ -482,8 +494,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Shell\\Bin", - "C:\\Windows\\System32", ].join(";"), }); expect(readEnvironment).toHaveBeenCalledTimes(1); @@ -524,6 +534,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { PATH: [ "C:\\Profile\\Node", "C:\\Windows\\System32", + "C:\\Shell\\Bin", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", @@ -531,7 +542,6 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Shell\\Bin", ].join(";"), FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", @@ -568,11 +578,11 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { ), ).toEqual({ PATH: [ + "C:\\Windows\\System32", "C:\\Users\\testuser\\AppData\\Roaming\\npm", "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", - "C:\\Windows\\System32", ].join(";"), FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 5fec5842..ae86a699 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -414,6 +414,10 @@ function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platfor return platform === "win32" ? normalized.toLowerCase() : normalized; } +function sanitizePathEntry(entry: string, platform: NodeJS.Platform): string { + return platform === "win32" ? entry.replaceAll('"', "") : entry; +} + export function mergePathValues( preferredPath: string | undefined, inheritedPath: string | undefined, @@ -427,14 +431,14 @@ export function mergePathValues( if (!rawValue) continue; for (const entry of rawValue.split(delimiter)) { - const trimmed = entry.trim(); - if (trimmed.length === 0) continue; + const sanitized = sanitizePathEntry(entry.trim(), platform); + if (sanitized.length === 0) continue; - const normalized = normalizePathEntryForComparison(trimmed, platform); + const normalized = normalizePathEntryForComparison(sanitized, platform); if (normalized.length === 0 || seen.has(normalized)) continue; seen.add(normalized); - merged.push(trimmed); + merged.push(sanitized); } } @@ -724,7 +728,9 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm }).PATH; const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); - const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); + // Preserve the order a user's shell uses. These directories fill gaps when + // desktop apps launch without the full interactive-shell PATH. + const baselinePath = mergePathValues(mergedPath, knownCliPath, "win32"); const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; const baselineEnv = mergeWindowsEnv(env, baselinePatch); diff --git a/vite.config.ts b/vite.config.ts index 6f60547e..80795110 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }, staged: { // Formatter only for now — no lint or typecheck on commit. - "*": "vp fmt", + "*": "vp fmt --no-error-on-unmatched-pattern", }, fmt: { ignorePatterns: [