Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.8.8",
"version": "0.8.9",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
225 changes: 165 additions & 60 deletions apps/desktop/src/server/operations/codex.ts

Large diffs are not rendered by default.

52 changes: 37 additions & 15 deletions apps/desktop/src/server/operations/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type RepoDeploymentConfig,
type ReposConfig
} from "./repos-config-utils.js";
import { expandHome } from "./symphony-utils.js";
import { checkAndMigrateLegacyWorkDir, expandHome, findFirstExisting } from "./symphony-utils.js";

type DeployStatus = "running" | "completed" | "failed" | "not-started";

Expand Down Expand Up @@ -79,7 +79,13 @@ export function registerDeployRoutes(
await saveReposConfig(reposConfig, configDir());
}

const claudeWorkDir = path.join(expandedWorktreePath, ".claude", "work");
const migrationResult = checkAndMigrateLegacyWorkDir(expandedWorktreePath);
if (migrationResult === "blocked") {
json(context, 409, { error: "A job started before the .closedloop-ai migration is still running. Stop it first, then retry." });
return;
}

const claudeWorkDir = path.join(expandedWorktreePath, ".closedloop-ai", "work");
await fs.mkdir(claudeWorkDir, { recursive: true });

const logFile = path.join(claudeWorkDir, "deploy.log");
Expand Down Expand Up @@ -124,6 +130,8 @@ export function registerDeployRoutes(
throw new Error("failed to start deploy process");
}

await fs.writeFile(path.join(claudeWorkDir, "process.pid"), String(child.pid));

child.on("exit", (code) => {
if (code === 0) {
return;
Expand Down Expand Up @@ -351,17 +359,31 @@ export function registerDeployRoutes(
throw error;
}

const claudeWorkDir = path.join(worktreeDir, ".claude", "work");
const logs = await readTextFile(path.join(claudeWorkDir, "deploy.log"));
const exitInfo = await readJsonFile<{ exitCode: number; failedCommand: string }>(
path.join(claudeWorkDir, "deploy-exit.json")
const newDeployWorkDir = path.join(worktreeDir, ".closedloop-ai", "work");
const oldDeployWorkDir = path.join(worktreeDir, ".claude", "work");
// Per-file resolution: each deploy artifact may be at either location
const logsPath = findFirstExisting(
path.join(newDeployWorkDir, "deploy.log"),
path.join(oldDeployWorkDir, "deploy.log")
);
const exitInfoPath = findFirstExisting(
path.join(newDeployWorkDir, "deploy-exit.json"),
path.join(oldDeployWorkDir, "deploy-exit.json")
);
const deployResult = await readJsonFile<{ url?: string; serviceId?: string }>(
path.join(claudeWorkDir, "deploy-result.json")
const deployResultPath = findFirstExisting(
path.join(newDeployWorkDir, "deploy-result.json"),
path.join(oldDeployWorkDir, "deploy-result.json")
);
const logs = logsPath ? await readTextFile(logsPath) : null;
const exitInfo = exitInfoPath
? await readJsonFile<{ exitCode: number; failedCommand: string }>(exitInfoPath)
: null;
const deployResult = deployResultPath
? await readJsonFile<{ url?: string; serviceId?: string }>(deployResultPath)
: null;

const processAlive = isProcessAlive(pidRaw);
const status = determineStatus(exitInfo, deployResult?.url, processAlive, logs, pidRaw);
const status = determineStatus(exitInfo, deployResult?.url, processAlive, logs ?? "", pidRaw);

json(context, 200, {
status,
Expand Down Expand Up @@ -606,7 +628,7 @@ function detectDeployment(repoPath: string): RepoDeploymentConfig | null {
};

const framework = detectFramework(deps);
const script = resolveStartCommand(packageJson.scripts ?? {});
const script = resolveStartCommand(packageJson.scripts ?? {}, repoPath);
if (!script) {
return null;
}
Expand Down Expand Up @@ -662,21 +684,21 @@ function detectFramework(dependencies: Record<string, string>): string | undefin
return undefined;
}

function resolveStartCommand(scripts: Record<string, string>): string | null {
function resolveStartCommand(scripts: Record<string, string>, repoPath: string): string | null {
if (scripts.dev) {
if (existsSync(path.join(process.cwd(), "pnpm-lock.yaml"))) {
if (existsSync(path.join(repoPath, "pnpm-lock.yaml"))) {
return "pnpm dev";
}
if (existsSync(path.join(process.cwd(), "yarn.lock"))) {
if (existsSync(path.join(repoPath, "yarn.lock"))) {
return "yarn dev";
}
return "npm run dev";
}
if (scripts.start) {
if (existsSync(path.join(process.cwd(), "pnpm-lock.yaml"))) {
if (existsSync(path.join(repoPath, "pnpm-lock.yaml"))) {
return "pnpm start";
}
if (existsSync(path.join(process.cwd(), "yarn.lock"))) {
if (existsSync(path.join(repoPath, "yarn.lock"))) {
return "yarn start";
}
return "npm run start";
Expand Down
56 changes: 36 additions & 20 deletions apps/desktop/src/server/operations/learnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import path from "node:path";
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
import { findPluginScript } from "./plugin-cache.js";
import { DirectoryNotAllowedError, assertPathAllowed } from "../security.js";
import { assertRepoAllowed, resolveWorktreeDir } from "./symphony-utils.js";
import { assertRepoAllowed, findFirstExisting, resolveWorktreeDir } from "./symphony-utils.js";

type ParsedLearningPattern = {
id: string;
Expand Down Expand Up @@ -74,8 +74,14 @@ export function registerLearningsRoutes(
return;
}

const claudeWorkDir = path.join(worktreeDir, ".claude", "work");
const chatHistoryPath = path.join(claudeWorkDir, chatFile);
const newLearningsWorkDir = path.join(worktreeDir, ".closedloop-ai", "work");
const oldLearningsWorkDir = path.join(worktreeDir, ".claude", "work");
// Per-file resolution: find chat history wherever it exists
const chatHistoryPath = findFirstExisting(
path.join(newLearningsWorkDir, chatFile),
path.join(oldLearningsWorkDir, chatFile)
) ?? path.join(newLearningsWorkDir, chatFile);
const claudeWorkDir = chatHistoryPath.startsWith(newLearningsWorkDir) ? newLearningsWorkDir : oldLearningsWorkDir;

try {
assertPathAllowed(claudeWorkDir, getAllowedDirectories());
Expand Down Expand Up @@ -147,15 +153,12 @@ export function registerLearningsRoutes(
}

const worktreeDir = resolveWorktreeDir(expandedRepoPath, ticketId);
const statusPath = path.join(
worktreeDir,
".claude",
"work",
".learnings",
"processing-status.json"
const statusPath = findFirstExisting(
path.join(worktreeDir, ".closedloop-ai", "work", ".learnings", "processing-status.json"),
path.join(worktreeDir, ".claude", "work", ".learnings", "processing-status.json")
);

if (!existsSync(statusPath)) {
if (!statusPath) {
json(context, 200, { status: "none" });
return;
}
Expand Down Expand Up @@ -201,7 +204,9 @@ export function registerLearningsRoutes(
return;
}

const claudeWorkDir = path.join(worktreeDir, ".claude", "work");
const newProcWorkDir = path.join(worktreeDir, ".closedloop-ai", "work");
// Always write to the new canonical path; reads may fall back to legacy.
const claudeWorkDir = newProcWorkDir;
const learningsDir = path.join(claudeWorkDir, ".learnings");
const pendingDir = path.join(learningsDir, "pending");
const processingStatusPath = path.join(learningsDir, "processing-status.json");
Expand All @@ -228,11 +233,23 @@ export function registerLearningsRoutes(
return;
}

if (!existsSync(pendingDir)) {
// Check both new and legacy locations for pending learnings
const legacyPendingDir = path.join(worktreeDir, ".claude", "work", ".learnings", "pending");
const effectivePendingDir = findFirstExisting(pendingDir, legacyPendingDir);
if (!effectivePendingDir) {
json(context, 200, { status: "skipped", reason: "No pending learnings directory" });
return;
}

// If pending learnings are at legacy location, copy them to new location
if (effectivePendingDir === legacyPendingDir && !existsSync(pendingDir)) {
await fs.mkdir(pendingDir, { recursive: true });
const legacyFiles = await fs.readdir(legacyPendingDir).catch(() => []);
for (const file of legacyFiles) {
await fs.copyFile(path.join(legacyPendingDir, file), path.join(pendingDir, file)).catch(() => {});
}
}

const pendingFiles = await fs
.readdir(pendingDir)
.then((entries) => entries.filter((entry) => entry.endsWith(".json")))
Expand Down Expand Up @@ -304,15 +321,12 @@ export function registerLearningsRoutes(
}

const worktreeDir = resolveWorktreeDir(expandedRepoPath, ticketId);
const statusPath = path.join(
worktreeDir,
".claude",
"work",
".learnings",
"chat-extraction-status.json"
const statusPath = findFirstExisting(
path.join(worktreeDir, ".closedloop-ai", "work", ".learnings", "chat-extraction-status.json"),
path.join(worktreeDir, ".claude", "work", ".learnings", "chat-extraction-status.json")
);

if (!existsSync(statusPath)) {
if (!statusPath) {
json(context, 200, { status: "none", count: 0 });
return;
}
Expand Down Expand Up @@ -372,7 +386,9 @@ export function registerLearningsRoutes(
return;
}

const claudeWorkDir = path.join(worktreeDir, ".claude", "work");
const newRecordWorkDir = path.join(worktreeDir, ".closedloop-ai", "work");
// Always write to the new canonical path; reads may fall back to legacy.
const claudeWorkDir = newRecordWorkDir;
const learningsDir = path.join(claudeWorkDir, ".learnings");

try {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/server/operations/metadata-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export function registerMetadataRoutes(
throw error;
}

const stateFile = path.join(expandedWorkDir, ".claude", "work", "state.json");
const newStateFile = path.join(expandedWorkDir, ".closedloop-ai", "work", "state.json");
const oldStateFile = path.join(expandedWorkDir, ".claude", "work", "state.json");
const stateFile = existsSync(newStateFile) ? newStateFile : oldStateFile;

if (!existsSync(stateFile)) {
json(context, 200, {
Expand Down
26 changes: 16 additions & 10 deletions apps/desktop/src/server/operations/symphony-attachments.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { OperationDispatcher, OperationRequestContext } from "../operation-dispatcher.js";
import { DirectoryNotAllowedError } from "../security.js";
import { assertRepoAllowed, resolveWorktreeDir } from "./symphony-utils.js";
import { assertRepoAllowed, findFirstExisting, resolveWorktreeDir } from "./symphony-utils.js";

const CONTENT_TYPES: Record<string, string> = {
".png": "image/png",
Expand Down Expand Up @@ -48,22 +47,29 @@ export function registerSymphonyAttachmentsRoutes(
}

const worktreeDir = resolveWorktreeDir(expandedRepoPath, ticketId);
const attachmentsDir = path.join(worktreeDir, ".claude", "work", "attachments");
const normalizedAttachmentPath = attachmentPath
.split("/")
.map((segment) => decodeURIComponent(segment))
.join(path.sep);
const filePath = path.resolve(attachmentsDir, normalizedAttachmentPath);
const resolvedAttachmentsDir = path.resolve(attachmentsDir);
const allowedPrefix = resolvedAttachmentsDir.endsWith(path.sep)
? resolvedAttachmentsDir
: `${resolvedAttachmentsDir}${path.sep}`;
if (!(filePath === resolvedAttachmentsDir || filePath.startsWith(allowedPrefix))) {

// Resolve both candidate absolute paths and verify neither escapes its attachments dir
const newAttachmentsDir = path.resolve(path.join(worktreeDir, ".closedloop-ai", "work", "attachments"));
const oldAttachmentsDir = path.resolve(path.join(worktreeDir, ".claude", "work", "attachments"));
const newFilePath = path.resolve(newAttachmentsDir, normalizedAttachmentPath);
const oldFilePath = path.resolve(oldAttachmentsDir, normalizedAttachmentPath);

const isUnderDir = (file: string, dir: string): boolean => {
const prefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
return file === dir || file.startsWith(prefix);
};

if (!isUnderDir(newFilePath, newAttachmentsDir) && !isUnderDir(oldFilePath, oldAttachmentsDir)) {
json(context, 403, { error: "Invalid path" });
return;
}

if (!existsSync(filePath)) {
const filePath = findFirstExisting(newFilePath, oldFilePath);
if (!filePath) {
json(context, 404, { error: "File not found" });
return;
}
Expand Down
Loading
Loading