From 4437d4246c12a01f996ed00a3dd30f7f679e7282 Mon Sep 17 00:00:00 2001 From: Moss Date: Wed, 24 Jun 2026 23:37:03 +0800 Subject: [PATCH] Integrate runtime support for Moss Desktop release workflow --- package.json | 7 +- scripts/audit-release-evidence.mjs | 257 ++ scripts/generate-update-manifest.mjs | 16 +- scripts/notarize-release-artifacts.mjs | 207 ++ scripts/smoke-packaged-app.mjs | 594 ++++ scripts/upload-release.mjs | 286 ++ scripts/verify-release-credentials.mjs | 270 ++ scripts/verify-release-packaging.mjs | 681 +++++ src/main/auth-manager.ts | 22 +- src/main/constants.ts | 10 + src/main/index.ts | 273 +- .../lib/agent-runtime/adapters/claude-code.ts | 122 + src/main/lib/agent-runtime/adapters/codex.ts | 73 + .../lib/agent-runtime/adapters/custom-acp.ts | 37 + src/main/lib/agent-runtime/adapters/hermes.ts | 67 + src/main/lib/agent-runtime/adapters/index.ts | 21 + .../codex-native-message-parts.ts | 302 ++ .../agent-runtime/codex-native-recovery.ts | 467 ++++ .../lib/agent-runtime/codex-native-resume.ts | 168 ++ .../lib/agent-runtime/codex-native-session.ts | 1593 +++++++++++ src/main/lib/agent-runtime/control-plane.ts | 394 +++ src/main/lib/agent-runtime/events.ts | 189 ++ .../agent-runtime/hermes-native-session.ts | 286 ++ src/main/lib/agent-runtime/index.ts | 10 + src/main/lib/agent-runtime/manifests.ts | 175 ++ src/main/lib/agent-runtime/session-actions.ts | 598 ++++ src/main/lib/agent-runtime/session-records.ts | 208 ++ src/main/lib/agent-runtime/session-store.ts | 48 + .../lib/agent-runtime/stale-stream-state.ts | 28 + src/main/lib/agent-runtime/types.ts | 301 ++ src/main/lib/auto-updater.ts | 27 +- src/main/lib/claude-plugin-settings.ts | 77 + src/main/lib/claude/env.ts | 123 + src/main/lib/claude/index.ts | 3 + src/main/lib/claude/transform.ts | 7 +- src/main/lib/claude/types.ts | 2 + src/main/lib/codex-automations.test.ts | 201 ++ src/main/lib/codex-automations.ts | 392 +++ src/main/lib/db/schema/index.ts | 5 + src/main/lib/git/watcher/git-watcher.ts | 6 +- src/main/lib/git/worktree.ts | 21 + src/main/lib/hermes/runtime.ts | 68 + src/main/lib/mcp-auth.ts | 112 +- src/main/lib/mcp-stdio-compat.test.ts | 276 ++ src/main/lib/mcp-stdio-compat.ts | 507 ++++ src/main/lib/moss-account/entitlement.test.ts | 224 ++ src/main/lib/moss-account/entitlement.ts | 513 ++++ src/main/lib/moss-account/index.ts | 1 + src/main/lib/moss-source/bootstrap.ts | 239 ++ src/main/lib/moss-source/frontmatter.ts | 43 + src/main/lib/moss-source/hooks.ts | 320 +++ src/main/lib/moss-source/index.ts | 10 + src/main/lib/moss-source/layout.ts | 47 + src/main/lib/moss-source/projection.ts | 908 ++++++ .../lib/moss-source/provider-config.test.ts | 413 +++ src/main/lib/moss-source/provider-config.ts | 585 ++++ src/main/lib/moss-source/provider-secrets.ts | 106 + src/main/lib/moss-source/registry.ts | 361 +++ src/main/lib/moss-source/runtime-context.ts | 406 +++ .../lib/moss-source/runtime-materializer.ts | 477 ++++ src/main/lib/moss-source/subagents.ts | 271 ++ src/main/lib/moss-source/types.ts | 39 + src/main/lib/plugins/index.ts | 28 +- .../codex-native-resources.test.ts | 494 ++++ .../codex-native-resources.ts | 885 ++++++ .../lib/shared-resources/governance.test.ts | 356 +++ src/main/lib/shared-resources/governance.ts | 579 ++++ src/main/lib/shared-resources/index.ts | 2 + .../lib/shared-resources/registry.test.ts | 112 + src/main/lib/shared-resources/registry.ts | 591 ++++ src/main/lib/shared-resources/types.ts | 96 + src/main/lib/trpc/routers/agent-runtime.ts | 828 ++++++ src/main/lib/trpc/routers/agent-utils.ts | 25 +- src/main/lib/trpc/routers/agents.ts | 49 + .../routers/chat-runtime-selection.test.ts | 52 + .../trpc/routers/chat-runtime-selection.ts | 55 + src/main/lib/trpc/routers/chats.ts | 536 +++- src/main/lib/trpc/routers/claude-settings.ts | 117 +- src/main/lib/trpc/routers/claude.ts | 140 +- .../trpc/routers/codex-mcp-session.test.ts | 62 + .../lib/trpc/routers/codex-mcp-session.ts | 25 + src/main/lib/trpc/routers/codex.ts | 1618 ++++++++++- src/main/lib/trpc/routers/files.ts | 20 +- src/main/lib/trpc/routers/hermes.ts | 811 ++++++ src/main/lib/trpc/routers/index.ts | 10 + src/main/lib/trpc/routers/moss-account.ts | 118 + src/main/lib/trpc/routers/plugins.ts | 4 +- .../lib/trpc/routers/release-readiness.ts | 435 +++ src/main/lib/trpc/routers/shared-resources.ts | 809 ++++++ src/main/lib/trpc/routers/skill-md.ts | 37 + src/main/lib/trpc/routers/skills.ts | 206 +- src/main/lib/vscode-theme-scanner.ts | 2 +- src/main/windows/main.ts | 137 +- src/renderer/components/ui/icons.tsx | 5 +- src/renderer/contexts/WindowContext.tsx | 17 + src/renderer/features/agents/atoms/index.ts | 125 +- .../features/agents/lib/agent-runtime.test.ts | 226 ++ .../features/agents/lib/agent-runtime.ts | 279 ++ .../features/agents/lib/models.test.ts | 50 + src/renderer/features/agents/lib/models.ts | 69 +- .../mentions/agents-mentions-editor.tsx | 6 +- .../plugins/plugin-entry-surfaces.test.ts | 72 + .../features/plugins/plugin-entry-surfaces.ts | 91 + .../features/plugins/plugin-route-state.ts | 389 +++ src/renderer/lib/atoms/index.ts | 148 + src/renderer/lib/mock-api.ts | 8 +- src/renderer/lib/trpc.ts | 44 +- src/shared/codex-runtime-notices.test.ts | 44 + src/shared/codex-runtime-notices.ts | 58 + src/shared/codex-tool-normalizer.ts | 2474 ++++++++++++++++- src/shared/plugin-deep-link.ts | 79 + 111 files changed, 27396 insertions(+), 527 deletions(-) create mode 100644 scripts/audit-release-evidence.mjs create mode 100644 scripts/notarize-release-artifacts.mjs create mode 100644 scripts/smoke-packaged-app.mjs create mode 100644 scripts/upload-release.mjs create mode 100644 scripts/verify-release-credentials.mjs create mode 100644 scripts/verify-release-packaging.mjs create mode 100644 src/main/lib/agent-runtime/adapters/claude-code.ts create mode 100644 src/main/lib/agent-runtime/adapters/codex.ts create mode 100644 src/main/lib/agent-runtime/adapters/custom-acp.ts create mode 100644 src/main/lib/agent-runtime/adapters/hermes.ts create mode 100644 src/main/lib/agent-runtime/adapters/index.ts create mode 100644 src/main/lib/agent-runtime/codex-native-message-parts.ts create mode 100644 src/main/lib/agent-runtime/codex-native-recovery.ts create mode 100644 src/main/lib/agent-runtime/codex-native-resume.ts create mode 100644 src/main/lib/agent-runtime/codex-native-session.ts create mode 100644 src/main/lib/agent-runtime/control-plane.ts create mode 100644 src/main/lib/agent-runtime/events.ts create mode 100644 src/main/lib/agent-runtime/hermes-native-session.ts create mode 100644 src/main/lib/agent-runtime/index.ts create mode 100644 src/main/lib/agent-runtime/manifests.ts create mode 100644 src/main/lib/agent-runtime/session-actions.ts create mode 100644 src/main/lib/agent-runtime/session-records.ts create mode 100644 src/main/lib/agent-runtime/session-store.ts create mode 100644 src/main/lib/agent-runtime/stale-stream-state.ts create mode 100644 src/main/lib/agent-runtime/types.ts create mode 100644 src/main/lib/claude-plugin-settings.ts create mode 100644 src/main/lib/codex-automations.test.ts create mode 100644 src/main/lib/codex-automations.ts create mode 100644 src/main/lib/hermes/runtime.ts create mode 100644 src/main/lib/mcp-stdio-compat.test.ts create mode 100644 src/main/lib/mcp-stdio-compat.ts create mode 100644 src/main/lib/moss-account/entitlement.test.ts create mode 100644 src/main/lib/moss-account/entitlement.ts create mode 100644 src/main/lib/moss-account/index.ts create mode 100644 src/main/lib/moss-source/bootstrap.ts create mode 100644 src/main/lib/moss-source/frontmatter.ts create mode 100644 src/main/lib/moss-source/hooks.ts create mode 100644 src/main/lib/moss-source/index.ts create mode 100644 src/main/lib/moss-source/layout.ts create mode 100644 src/main/lib/moss-source/projection.ts create mode 100644 src/main/lib/moss-source/provider-config.test.ts create mode 100644 src/main/lib/moss-source/provider-config.ts create mode 100644 src/main/lib/moss-source/provider-secrets.ts create mode 100644 src/main/lib/moss-source/registry.ts create mode 100644 src/main/lib/moss-source/runtime-context.ts create mode 100644 src/main/lib/moss-source/runtime-materializer.ts create mode 100644 src/main/lib/moss-source/subagents.ts create mode 100644 src/main/lib/moss-source/types.ts create mode 100644 src/main/lib/shared-resources/codex-native-resources.test.ts create mode 100644 src/main/lib/shared-resources/codex-native-resources.ts create mode 100644 src/main/lib/shared-resources/governance.test.ts create mode 100644 src/main/lib/shared-resources/governance.ts create mode 100644 src/main/lib/shared-resources/index.ts create mode 100644 src/main/lib/shared-resources/registry.test.ts create mode 100644 src/main/lib/shared-resources/registry.ts create mode 100644 src/main/lib/shared-resources/types.ts create mode 100644 src/main/lib/trpc/routers/agent-runtime.ts create mode 100644 src/main/lib/trpc/routers/chat-runtime-selection.test.ts create mode 100644 src/main/lib/trpc/routers/chat-runtime-selection.ts create mode 100644 src/main/lib/trpc/routers/codex-mcp-session.test.ts create mode 100644 src/main/lib/trpc/routers/codex-mcp-session.ts create mode 100644 src/main/lib/trpc/routers/hermes.ts create mode 100644 src/main/lib/trpc/routers/moss-account.ts create mode 100644 src/main/lib/trpc/routers/release-readiness.ts create mode 100644 src/main/lib/trpc/routers/shared-resources.ts create mode 100644 src/main/lib/trpc/routers/skill-md.ts create mode 100644 src/renderer/features/agents/lib/agent-runtime.test.ts create mode 100644 src/renderer/features/agents/lib/agent-runtime.ts create mode 100644 src/renderer/features/agents/lib/models.test.ts create mode 100644 src/renderer/features/plugins/plugin-entry-surfaces.test.ts create mode 100644 src/renderer/features/plugins/plugin-entry-surfaces.ts create mode 100644 src/renderer/features/plugins/plugin-route-state.ts create mode 100644 src/shared/codex-runtime-notices.test.ts create mode 100644 src/shared/codex-runtime-notices.ts create mode 100644 src/shared/plugin-deep-link.ts diff --git a/package.json b/package.json index da2a5e747..36d6a4ca9 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,12 @@ "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "ts:check": "tsgo --noEmit", - "postinstall": "node -e \"if(!process.env.VERCEL){require('child_process').execSync('electron-rebuild -f -w better-sqlite3,node-pty',{stdio:'inherit'})}\" && node scripts/patch-electron-dev.mjs" + "postinstall": "node -e \"if(!process.env.VERCEL){require('child_process').execSync('electron-rebuild -f -w better-sqlite3,node-pty',{stdio:'inherit'})}\" && node scripts/patch-electron-dev.mjs", + "test:runtime": "bun test src/main/lib/moss-account/entitlement.test.ts src/main/lib/moss-source/provider-config.test.ts src/main/lib/mcp-stdio-compat.test.ts src/main/lib/trpc/routers/chat-runtime-selection.test.ts src/main/lib/trpc/routers/codex-mcp-session.test.ts src/main/lib/codex-automations.test.ts src/main/lib/shared-resources/registry.test.ts src/main/lib/shared-resources/codex-native-resources.test.ts src/main/lib/shared-resources/governance.test.ts src/renderer/features/agents/lib/agent-runtime.test.ts src/renderer/features/agents/lib/models.test.ts src/renderer/features/plugins/plugin-entry-surfaces.test.ts src/shared/codex-runtime-notices.test.ts", + "release:credentials:strict": "node scripts/verify-release-credentials.mjs --require-credentials", + "test:packaged-app-smoke": "node scripts/smoke-packaged-app.mjs", + "release:notarize": "node scripts/notarize-release-artifacts.mjs", + "release:evidence:audit": "node scripts/audit-release-evidence.mjs" }, "dependencies": { "@ai-sdk/react": "^3.0.14", diff --git a/scripts/audit-release-evidence.mjs b/scripts/audit-release-evidence.mjs new file mode 100644 index 000000000..05a1e46d8 --- /dev/null +++ b/scripts/audit-release-evidence.mjs @@ -0,0 +1,257 @@ +#!/usr/bin/env node + +import fs from "node:fs" +import path from "node:path" + +const root = process.cwd() +const requireNotarization = process.argv.includes("--require-notarization") +const generatedAt = new Date().toISOString() +const stamp = generatedAt.replace(/[:.]/g, "-") +const reportDir = path.join(root, ".1code/program/release-evidence-audit", stamp) +const reportPath = path.join(reportDir, "report.json") +const latestPath = path.join(root, ".1code/program/release-evidence-audit/latest.json") +const failures = [] +const warnings = [] +const blockers = [] + +function projectPath(relativePath) { + return path.join(root, relativePath) +} + +function normalizeRelative(filePath) { + return path.relative(root, filePath).split(path.sep).join("/") +} + +function exists(relativePath) { + return fs.existsSync(projectPath(relativePath)) +} + +function listReleaseFiles() { + const releaseDir = projectPath("release") + if (!fs.existsSync(releaseDir)) return [] + return fs.readdirSync(releaseDir) + .map((name) => path.join(releaseDir, name)) + .filter((filePath) => fs.statSync(filePath).isFile()) + .sort() +} + +function releaseAppDirs() { + return [ + { + arch: "arm64", + path: "release/mac-arm64/1Code.app", + }, + { + arch: "x64", + path: "release/mac/1Code.app", + }, + ].map((app) => ({ + ...app, + present: exists(app.path), + })) +} + +function readJsonPath(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) + } catch (error) { + failures.push(`Could not parse ${normalizeRelative(filePath)}: ${error.message}`) + return undefined + } +} + +function readTextRelative(relativePath) { + const filePath = projectPath(relativePath) + if (!fs.existsSync(filePath)) return undefined + return fs.readFileSync(filePath, "utf8") +} + +function parseNotaryStatus(stdoutPath) { + const raw = readTextRelative(stdoutPath) + if (!raw) return { + path: stdoutPath, + parseable: false, + status: "missing", + } + + try { + const parsed = JSON.parse(raw) + return { + path: stdoutPath, + parseable: true, + id: parsed.id ?? null, + status: parsed.status ?? "unknown", + accepted: parsed.status === "Accepted", + } + } catch { + return { + path: stdoutPath, + parseable: false, + status: "unparseable", + } + } +} + +function commandOutputReferences(command) { + return [command.stdout, command.stderr] + .filter((value) => typeof value === "string" && value.length > 0) + .map((value) => ({ + path: value, + present: exists(value), + })) +} + +function inspectNotarizationReport(filePath) { + const report = readJsonPath(filePath) + const commands = Array.isArray(report?.commands) ? report.commands : [] + const commandFailures = commands.filter((command) => command.exitCode !== 0) + const references = commands.flatMap(commandOutputReferences) + const missingReferences = references.filter((reference) => !reference.present) + const notaryCommands = commands.filter((command) => String(command.label ?? "").startsWith("notarytool submit")) + const notaryStatuses = notaryCommands + .map((command) => command.stdout) + .filter((stdoutPath) => typeof stdoutPath === "string" && stdoutPath.length > 0) + .map(parseNotaryStatus) + const unacceptedStatuses = notaryStatuses.filter((status) => status.accepted !== true) + const summary = report?.summary ?? {} + const dryRun = report?.mode?.dryRun === true + const valid = Boolean(report) + && report.status === "passed" + && dryRun === false + && commands.length > 0 + && commandFailures.length === 0 + && missingReferences.length === 0 + && Number(summary.notarytoolSubmissions ?? 0) > 0 + && Number(summary.stapleCommands ?? 0) > 0 + && Number(summary.codesignVerifications ?? 0) > 0 + && Number(summary.spctlAssessments ?? 0) > 0 + && notaryStatuses.length > 0 + && unacceptedStatuses.length === 0 + + return { + path: normalizeRelative(filePath), + status: report?.status ?? "missing", + dryRun, + commandCount: commands.length, + commandFailures: commandFailures.map((command) => ({ + label: command.label ?? command.command ?? "unknown", + exitCode: command.exitCode ?? null, + })), + missingReferences, + summary: { + notarytoolSubmissions: Number(summary.notarytoolSubmissions ?? 0), + stapleCommands: Number(summary.stapleCommands ?? 0), + codesignVerifications: Number(summary.codesignVerifications ?? 0), + spctlAssessments: Number(summary.spctlAssessments ?? 0), + }, + notaryStatuses, + valid, + } +} + +const releaseFiles = listReleaseFiles() +const macArtifacts = releaseFiles + .filter((filePath) => /\.(dmg|zip)$/i.test(filePath)) + .map(normalizeRelative) +const updateManifests = releaseFiles + .filter((filePath) => /(?:latest|beta)-mac(?:-x64)?\.yml$/.test(path.basename(filePath))) + .map(normalizeRelative) +const notarizationReportFiles = releaseFiles + .filter((filePath) => /^notarization-.+\.json$/i.test(path.basename(filePath))) +const notarizationReports = notarizationReportFiles.map(inspectNotarizationReport) +const validNotarizationReports = notarizationReports.filter((report) => report.valid) +const apps = releaseAppDirs() +const presentApps = apps.filter((app) => app.present) +const notarizationEvidenceFiles = releaseFiles + .filter((filePath) => /notary|notar|codesign|staple|spctl/i.test(path.basename(filePath))) + .map(normalizeRelative) + +if (macArtifacts.length < 4) { + blockers.push(`Expected at least 4 macOS DMG/ZIP artifacts, found ${macArtifacts.length}.`) +} +if (updateManifests.length < 2) { + blockers.push(`Expected at least 2 macOS update manifests, found ${updateManifests.length}.`) +} +if (presentApps.length < 2) { + blockers.push(`Expected both packaged app directories, found ${presentApps.length}.`) +} +if (validNotarizationReports.length === 0) { + blockers.push("No valid signed/notarized release evidence report was found.") +} + +for (const report of notarizationReports) { + if (!report.valid) { + warnings.push(`Notarization report ${report.path} is not valid distributable evidence.`) + } +} + +if (requireNotarization && blockers.length > 0) { + failures.push(...blockers) +} + +const status = failures.length > 0 + ? "failed" + : blockers.length > 0 + ? "blocked" + : "passed" + +const report = { + status, + generatedAt, + mode: { + requireNotarization, + }, + releaseDir: "release", + artifacts: { + macArtifacts, + updateManifests, + notarizationEvidenceFiles, + apps, + }, + notarization: { + reports: notarizationReports, + validReports: validNotarizationReports.map((entry) => entry.path), + acceptedSubmissions: notarizationReports.reduce( + (count, entry) => count + entry.notaryStatuses.filter((status) => status.accepted === true).length, + 0, + ), + validReportCount: validNotarizationReports.length, + }, + distribution: { + distributable: status === "passed", + blockerCount: blockers.length, + blockers, + }, + warnings, + failures, +} + +fs.mkdirSync(reportDir, { recursive: true }) +fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) +fs.writeFileSync(latestPath, `${JSON.stringify({ + report: normalizeRelative(reportPath), + generatedAt, + status, +}, null, 2)}\n`) + +console.log("Moss release evidence audit") +console.log(`status: ${status}`) +console.log(`report: ${normalizeRelative(reportPath)}`) +console.log(`mac artifacts: ${macArtifacts.length}`) +console.log(`update manifests: ${updateManifests.length}`) +console.log(`notarization reports: ${notarizationReports.length}`) +console.log(`valid notarization reports: ${validNotarizationReports.length}`) + +for (const message of warnings) { + console.warn(`warning: ${message}`) +} +for (const message of blockers) { + console.warn(`blocker: ${message}`) +} +for (const message of failures) { + console.error(`error: ${message}`) +} + +if (failures.length > 0) { + process.exit(1) +} diff --git a/scripts/generate-update-manifest.mjs b/scripts/generate-update-manifest.mjs index d8f73ee6a..f7d09cfb0 100644 --- a/scripts/generate-update-manifest.mjs +++ b/scripts/generate-update-manifest.mjs @@ -10,8 +10,8 @@ * node scripts/generate-update-manifest.mjs * * The script expects ZIP files to exist in the release/ directory: - * - Agents-{version}-arm64-mac.zip - * - Agents-{version}-mac.zip + * - 1Code-{version}-arm64-mac.zip + * - 1Code-{version}-mac.zip * * Run this after `npm run dist` to generate the manifest files. */ @@ -77,8 +77,8 @@ function findReleaseFile(pattern, ext = ".zip") { */ function generateManifest(arch) { // electron-builder names files differently: - // arm64: Agents-{version}-arm64-mac.zip - // x64: Agents-{version}-mac.zip + // arm64: 1Code-{version}-arm64-mac.zip + // x64: 1Code-{version}-mac.zip const pattern = arch === "arm64" ? `${version}-arm64-mac` : `${version}-mac` const zipPath = findReleaseFile(pattern, ".zip") @@ -248,13 +248,13 @@ console.log("Next steps:") console.log("1. Upload the following files to cdn.21st.dev/releases/desktop/:") if (arm64Manifest) { console.log(` - ${prefix}-mac.yml`) - console.log(` - Agents-${version}-arm64-mac.zip`) - console.log(` - Agents-${version}-arm64.dmg (for manual download)`) + console.log(` - 1Code-${version}-arm64-mac.zip`) + console.log(` - 1Code-${version}-arm64.dmg (for manual download)`) } if (x64Manifest) { console.log(` - ${prefix}-mac-x64.yml`) - console.log(` - Agents-${version}-mac.zip`) - console.log(` - Agents-${version}.dmg (for manual download)`) + console.log(` - 1Code-${version}-mac.zip`) + console.log(` - 1Code-${version}.dmg (for manual download)`) } console.log("2. Create a release entry in the admin dashboard") console.log("=".repeat(50)) diff --git a/scripts/notarize-release-artifacts.mjs b/scripts/notarize-release-artifacts.mjs new file mode 100644 index 000000000..485b8837a --- /dev/null +++ b/scripts/notarize-release-artifacts.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" + +const root = process.cwd() +const dryRun = process.argv.includes("--dry-run") +const releaseDir = path.join(root, "release") +const generatedAt = new Date().toISOString() +const stamp = generatedAt.replace(/[:.]/g, "-") +const reportDir = dryRun + ? path.join(root, ".1code/program/release-packaging") + : releaseDir +const reportPath = path.join(reportDir, dryRun ? `notarization-dry-run-${stamp}.json` : `notarization-${stamp}.json`) +const failures = [] +const warnings = [] +const commands = [] + +function exists(filePath) { + return fs.existsSync(filePath) +} + +function relative(filePath) { + return path.relative(root, filePath).split(path.sep).join("/") +} + +function releaseFilesMatching(pattern) { + if (!exists(releaseDir)) return [] + return fs.readdirSync(releaseDir) + .map((name) => path.join(releaseDir, name)) + .filter((filePath) => fs.statSync(filePath).isFile()) + .filter((filePath) => pattern.test(path.basename(filePath))) + .sort() +} + +function releaseAppDirs() { + return [ + path.join(releaseDir, "mac-arm64/1Code.app"), + path.join(releaseDir, "mac/1Code.app"), + ].filter(exists) +} + +function redactArg(arg) { + if (arg === process.env.APPLE_ID) return "" + if (arg === process.env.APPLE_TEAM_ID) return "" + if (arg === process.env.APPLE_APP_SPECIFIC_PASSWORD) return "" + return arg +} + +function credentialState(name) { + return process.env[name] ? "set" : "missing" +} + +function runCommand(label, command, args, outputBaseName) { + const redactedArgs = args.map(redactArg) + const record = { + label, + command, + args: redactedArgs, + dryRun, + exitCode: dryRun ? 0 : undefined, + stdout: undefined, + stderr: undefined, + } + + if (dryRun) { + commands.push(record) + return + } + + const result = spawnSync(command, args, { + cwd: root, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }) + + const stdoutPath = path.join(releaseDir, `${outputBaseName}.stdout.txt`) + const stderrPath = path.join(releaseDir, `${outputBaseName}.stderr.txt`) + fs.writeFileSync(stdoutPath, result.stdout ?? "") + fs.writeFileSync(stderrPath, result.stderr ?? "") + + record.exitCode = result.status ?? 1 + record.stdout = relative(stdoutPath) + record.stderr = relative(stderrPath) + commands.push(record) + + if (record.exitCode !== 0) { + failures.push(`${label} failed with exit code ${record.exitCode}.`) + } +} + +const credentials = { + appleId: credentialState("APPLE_ID"), + appleTeamId: credentialState("APPLE_TEAM_ID"), + appSpecificPassword: credentialState("APPLE_APP_SPECIFIC_PASSWORD"), + appleIdentity: credentialState("APPLE_IDENTITY"), + cscLink: credentialState("CSC_LINK"), + cscKeyPassword: credentialState("CSC_KEY_PASSWORD"), +} + +for (const [name, state] of Object.entries(credentials)) { + if (state !== "set") failures.push(`Missing required signing/notarization credential: ${name}.`) +} + +const artifacts = releaseFilesMatching(/\.(dmg|zip)$/i) +const dmgArtifacts = artifacts.filter((filePath) => /\.dmg$/i.test(filePath)) +const apps = releaseAppDirs() + +if (artifacts.length === 0) { + failures.push("No DMG or ZIP release artifacts found in release/. Run bun run package:mac first.") +} +if (apps.length === 0) { + failures.push("No packaged .app directories found in release/mac*/. Run bun run package:mac first.") +} + +if (dryRun && failures.length > 0) { + warnings.push(...failures) + failures.length = 0 +} + +if (failures.length === 0) { + for (const artifact of artifacts) { + runCommand( + `notarytool submit ${relative(artifact)}`, + "xcrun", + [ + "notarytool", + "submit", + artifact, + "--apple-id", + process.env.APPLE_ID, + "--team-id", + process.env.APPLE_TEAM_ID, + "--password", + process.env.APPLE_APP_SPECIFIC_PASSWORD, + "--wait", + "--output-format", + "json", + ], + `notarytool-${stamp}-${path.basename(artifact).replace(/[^A-Za-z0-9_.-]/g, "_")}`, + ) + } + + for (const target of [...apps, ...dmgArtifacts]) { + runCommand( + `stapler staple ${relative(target)}`, + "xcrun", + ["stapler", "staple", target], + `staple-${stamp}-${path.basename(target).replace(/[^A-Za-z0-9_.-]/g, "_")}`, + ) + } + + for (const app of apps) { + runCommand( + `codesign verify ${relative(app)}`, + "codesign", + ["--verify", "--deep", "--strict", "--verbose=2", app], + `codesign-${stamp}-${path.basename(path.dirname(app)).replace(/[^A-Za-z0-9_.-]/g, "_")}`, + ) + runCommand( + `spctl assess ${relative(app)}`, + "spctl", + ["--assess", "--type", "execute", "--verbose=4", app], + `spctl-${stamp}-${path.basename(path.dirname(app)).replace(/[^A-Za-z0-9_.-]/g, "_")}`, + ) + } +} + +const report = { + status: failures.length === 0 ? "passed" : "failed", + generatedAt, + mode: { dryRun }, + credentials, + artifacts: artifacts.map(relative), + apps: apps.map(relative), + commands, + summary: { + notarytoolSubmissions: commands.filter((command) => command.label.startsWith("notarytool submit")).length, + stapleCommands: commands.filter((command) => command.label.startsWith("stapler staple")).length, + codesignVerifications: commands.filter((command) => command.label.startsWith("codesign verify")).length, + spctlAssessments: commands.filter((command) => command.label.startsWith("spctl assess")).length, + }, + warnings, + failures, +} + +fs.mkdirSync(reportDir, { recursive: true }) +fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) + +console.log("Moss notarization evidence") +console.log(`status: ${report.status}`) +console.log(`report: ${relative(reportPath)}`) +console.log(`artifacts: ${artifacts.length}`) +console.log(`apps: ${apps.length}`) +console.log(`commands: ${commands.length}`) + +for (const message of warnings) { + console.warn(`warning: ${message}`) +} +for (const message of failures) { + console.error(`error: ${message}`) +} + +if (failures.length > 0) { + process.exit(1) +} diff --git a/scripts/smoke-packaged-app.mjs b/scripts/smoke-packaged-app.mjs new file mode 100644 index 000000000..c3a90b605 --- /dev/null +++ b/scripts/smoke-packaged-app.mjs @@ -0,0 +1,594 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process" +import crypto from "node:crypto" +import fs from "node:fs" +import { createRequire } from "node:module" +import net from "node:net" +import os from "node:os" +import path from "node:path" + +const root = process.cwd() +const require = createRequire(import.meta.url) +const asar = require("@electron/asar") +const plist = require("plist") +const yaml = require("js-yaml") + +const failures = [] +const warnings = [] +const packageJson = readJson("package.json") +const version = packageJson?.version +const productName = packageJson?.build?.productName ?? "1Code" +const appId = packageJson?.build?.appId ?? "dev.21st.agents" +const publishUrl = packageJson?.build?.publish?.url ?? "https://cdn.21st.dev/releases/desktop" +const devUrlPatterns = [ + /https?:\/\/localhost:5173\b/i, + /https?:\/\/localhost:5174\b/i, + /https?:\/\/127\.0\.0\.1:5173\b/i, + /https?:\/\/127\.0\.0\.1:5174\b/i, +] + +function projectPath(relativePath) { + return path.join(root, relativePath) +} + +function normalizeRelative(filePath) { + return path.relative(root, filePath).split(path.sep).join("/") +} + +function exists(relativePath) { + return fs.existsSync(projectPath(relativePath)) +} + +function fail(message) { + failures.push(message) +} + +function warn(message) { + warnings.push(message) +} + +function read(relativePath) { + return fs.readFileSync(projectPath(relativePath), "utf8") +} + +function readJson(relativePath) { + try { + return JSON.parse(read(relativePath)) + } catch (error) { + fail(`Could not parse ${relativePath}: ${error.message}`) + return undefined + } +} + +function readYaml(relativePath) { + try { + return yaml.load(read(relativePath)) + } catch (error) { + fail(`Could not parse ${relativePath}: ${error.message}`) + return undefined + } +} + +function statFile(relativePath) { + const filePath = projectPath(relativePath) + if (!fs.existsSync(filePath)) return undefined + return fs.statSync(filePath) +} + +function sha256(relativePath) { + const filePath = projectPath(relativePath) + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex") +} + +function isExecutable(relativePath) { + const stat = statFile(relativePath) + return Boolean(stat && (stat.mode & 0o111)) +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: root, + encoding: "utf8", + }) + return { + command: [command, ...args].join(" "), + exitCode: result.status, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + error: result.error?.message, + } +} + +async function findFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, "127.0.0.1", () => { + const address = server.address() + const port = typeof address === "object" && address ? address.port : undefined + server.close(() => { + if (port) resolve(port) + else reject(new Error("Could not allocate a local smoke port.")) + }) + }) + server.on("error", reject) + }) +} + +function devUrlMatches(text) { + return devUrlPatterns + .filter((pattern) => pattern.test(text)) + .map((pattern) => pattern.source) +} + +function verifyNoDevUrls(label, text) { + const matches = devUrlMatches(text) + if (matches.length > 0) { + fail(`${label} contains explicit dev renderer URL pattern(s): ${matches.join(", ")}`) + } + return matches.length === 0 +} + +function readPlist(relativePath) { + try { + return plist.parse(read(relativePath)) + } catch (error) { + fail(`Could not parse ${relativePath}: ${error.message}`) + return undefined + } +} + +function summarizeCodesign(appDir) { + const verify = run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appDir]) + const details = run("codesign", ["-dv", "--verbose=4", appDir]) + const detailText = [details.stdout, details.stderr].filter(Boolean).join("\n") + return { + verify, + details: { + exitCode: details.exitCode, + signature: detailText.match(/^Signature=(.+)$/m)?.[1] ?? null, + identifier: detailText.match(/^Identifier=(.+)$/m)?.[1] ?? null, + teamIdentifier: detailText.match(/^TeamIdentifier=(.+)$/m)?.[1] ?? null, + rawStatus: detailText.includes("Signature=adhoc") ? "adhoc" : (details.exitCode === 0 ? "signed" : "unknown"), + }, + status: verify.exitCode === 0 && !detailText.includes("Signature=adhoc") + ? "signed" + : "unsigned-or-adhoc", + } +} + +function verifyInfoPlist(app) { + const infoPath = `${app.appDir}/Contents/Info.plist` + const info = readPlist(infoPath) + if (!info) return { path: infoPath, status: "failed" } + + if (info.CFBundleName !== productName) { + fail(`${infoPath} CFBundleName is ${info.CFBundleName ?? ""}, expected ${productName}.`) + } + if (info.CFBundleDisplayName !== productName) { + fail(`${infoPath} CFBundleDisplayName is ${info.CFBundleDisplayName ?? ""}, expected ${productName}.`) + } + if (info.CFBundleExecutable !== productName) { + fail(`${infoPath} CFBundleExecutable is ${info.CFBundleExecutable ?? ""}, expected ${productName}.`) + } + if (info.CFBundleIdentifier !== appId) { + fail(`${infoPath} CFBundleIdentifier is ${info.CFBundleIdentifier ?? ""}, expected ${appId}.`) + } + if (info.CFBundleShortVersionString !== version) { + fail(`${infoPath} CFBundleShortVersionString is ${info.CFBundleShortVersionString ?? ""}, expected ${version}.`) + } + if (info.CFBundleVersion !== version) { + fail(`${infoPath} CFBundleVersion is ${info.CFBundleVersion ?? ""}, expected ${version}.`) + } + + const schemes = (info.CFBundleURLTypes ?? []).flatMap((entry) => entry.CFBundleURLSchemes ?? []) + if (!schemes.includes("twentyfirst-agents")) { + fail(`${infoPath} is missing twentyfirst-agents URL scheme.`) + } + + const env = info.LSEnvironment ?? {} + const envText = JSON.stringify(env) + if (Object.hasOwn(env, "ELECTRON_RENDERER_URL")) { + fail(`${infoPath} LSEnvironment must not set ELECTRON_RENDERER_URL in packaged apps.`) + } + if (Object.hasOwn(env, "MAIN_VITE_API_URL")) { + fail(`${infoPath} LSEnvironment must not set MAIN_VITE_API_URL in packaged apps.`) + } + verifyNoDevUrls(`${infoPath} LSEnvironment`, envText) + + const atsDomains = Object.keys(info.NSAppTransportSecurity?.NSExceptionDomains ?? {}) + const localNetworkingException = atsDomains.includes("localhost") || atsDomains.includes("127.0.0.1") + if (localNetworkingException) { + warn(`${infoPath} keeps localhost ATS exceptions for local networking; this is recorded but not treated as a renderer URL leak.`) + } + + return { + path: infoPath, + status: "passed", + bundleName: info.CFBundleName, + bundleIdentifier: info.CFBundleIdentifier, + version: info.CFBundleShortVersionString, + schemes, + localNetworkingException, + electronAsarIntegrity: info.ElectronAsarIntegrity?.["Resources/app.asar"] ?? null, + environmentKeys: Object.keys(env), + } +} + +function verifyResources(app) { + const resourceDir = `${app.appDir}/Contents/Resources` + const executablePath = `${app.appDir}/Contents/MacOS/${productName}` + const asarPath = `${resourceDir}/app.asar` + const appUpdatePath = `${resourceDir}/app-update.yml` + const requiredFiles = [ + executablePath, + asarPath, + appUpdatePath, + `${resourceDir}/icon.icns`, + `${resourceDir}/migrations/meta/_journal.json`, + ] + const requiredBinaries = [ + `${resourceDir}/bin/claude`, + `${resourceDir}/bin/codex`, + `${resourceDir}/bin/VERSION`, + ] + + for (const file of requiredFiles) { + if (!exists(file)) fail(`${app.arch} packaged app is missing ${file}.`) + } + for (const file of requiredBinaries) { + if (!exists(file)) { + fail(`${app.arch} packaged app is missing bundled runtime ${file}.`) + } else if (path.basename(file) !== "VERSION" && !isExecutable(file)) { + fail(`${app.arch} bundled runtime ${file} is not executable.`) + } + } + + const appUpdate = exists(appUpdatePath) ? readYaml(appUpdatePath) : undefined + if (appUpdate) { + if (appUpdate.provider !== "generic") { + fail(`${appUpdatePath} provider is ${appUpdate.provider ?? ""}, expected generic.`) + } + if (appUpdate.url !== publishUrl) { + fail(`${appUpdatePath} url is ${appUpdate.url ?? ""}, expected ${publishUrl}.`) + } + verifyNoDevUrls(appUpdatePath, read(appUpdatePath)) + } + + const asarStat = statFile(asarPath) + if (asarStat && asarStat.size < 10_000_000) { + fail(`${asarPath} is unexpectedly small (${asarStat.size} bytes).`) + } + + return { + resourceDir, + executable: { + path: executablePath, + present: exists(executablePath), + executable: isExecutable(executablePath), + }, + asar: { + path: asarPath, + present: exists(asarPath), + size: asarStat?.size ?? 0, + sha256: exists(asarPath) ? sha256(asarPath) : null, + }, + appUpdate: appUpdate + ? { + path: appUpdatePath, + status: "passed", + provider: appUpdate.provider, + url: appUpdate.url, + noDevRendererUrl: verifyNoDevUrls(appUpdatePath, read(appUpdatePath)), + } + : null, + bundledBinaries: requiredBinaries.map((file) => ({ + path: file, + present: exists(file), + executable: path.basename(file) === "VERSION" ? null : isExecutable(file), + size: statFile(file)?.size ?? 0, + })), + } +} + +function verifyAsarRuntime(app) { + const asarPath = `${app.appDir}/Contents/Resources/app.asar` + if (!exists(asarPath)) return { status: "failed", path: asarPath } + + let files = [] + try { + files = asar.listPackage(projectPath(asarPath)) + } catch (error) { + fail(`Could not list ${asarPath}: ${error.message}`) + return { status: "failed", path: asarPath } + } + + for (const file of ["out/main/index.js", "out/preload/index.js"]) { + if (!files.includes(`/${file}`)) { + fail(`${asarPath} is missing ${file}.`) + } + } + + let mainEntry = "" + try { + mainEntry = asar.extractFile(projectPath(asarPath), "out/main/index.js").toString("utf8") + } catch (error) { + fail(`Could not extract out/main/index.js from ${asarPath}: ${error.message}`) + } + + const packagedApiGuard = /app\.isPackaged\)\s*{\s*return "https:\/\/21st\.dev";\s*}/.test(mainEntry) + if (!packagedApiGuard) { + fail(`${asarPath} out/main/index.js must keep the packaged app API URL guard returning https://21st.dev.`) + } + const noMainDevRendererUrl = verifyNoDevUrls(`${asarPath} out/main/index.js`, mainEntry) + if (mainEntry.includes("MAIN_VITE_API_URL")) { + fail(`${asarPath} out/main/index.js should not carry MAIN_VITE_API_URL into the production main bundle.`) + } + + return { + status: "passed", + path: asarPath, + fileCount: files.length, + containsMainEntry: files.includes("/out/main/index.js"), + containsPreloadEntry: files.includes("/out/preload/index.js"), + packagedApiGuard, + noMainDevRendererUrl, + electronRendererUrlGuardPresent: mainEntry.includes("ELECTRON_RENDERER_URL"), + } +} + +function verifyDistribution(app) { + const manifest = exists(app.updateManifest) ? readYaml(app.updateManifest) : undefined + if (!manifest) { + fail(`${app.updateManifest} is missing or invalid.`) + } else { + if (manifest.version !== version) { + fail(`${app.updateManifest} version is ${manifest.version ?? ""}, expected ${version}.`) + } + if (manifest.path !== path.basename(app.zipArtifact)) { + fail(`${app.updateManifest} path is ${manifest.path ?? ""}, expected ${path.basename(app.zipArtifact)}.`) + } + verifyNoDevUrls(app.updateManifest, read(app.updateManifest)) + } + + for (const artifact of [app.dmgArtifact, app.zipArtifact]) { + const stat = statFile(artifact) + if (!stat) { + fail(`${app.arch} release artifact is missing: ${artifact}`) + } else if (stat.size < 10_000_000) { + fail(`${app.arch} release artifact ${artifact} is unexpectedly small (${stat.size} bytes).`) + } + } + + return { + updateManifest: manifest + ? { + path: app.updateManifest, + status: "passed", + version: manifest.version, + artifactPath: manifest.path, + artifactSize: manifest.files?.[0]?.size ?? null, + noDevRendererUrl: verifyNoDevUrls(app.updateManifest, read(app.updateManifest)), + } + : null, + artifacts: [app.dmgArtifact, app.zipArtifact].map((artifact) => ({ + path: artifact, + present: exists(artifact), + size: statFile(artifact)?.size ?? 0, + })), + } +} + +function verifyApp(app) { + if (!exists(app.appDir)) { + fail(`${app.arch} packaged app is missing: ${app.appDir}`) + return { + arch: app.arch, + appDir: app.appDir, + status: "failed", + } + } + + const info = verifyInfoPlist(app) + const resources = verifyResources(app) + const runtime = verifyAsarRuntime(app) + const distribution = verifyDistribution(app) + const signing = summarizeCodesign(projectPath(app.appDir)) + if (signing.status === "unsigned-or-adhoc") { + warn(`${app.arch} packaged app is not developer-id signed yet; signing/notarization remains a separate release blocker.`) + } + + return { + arch: app.arch, + appDir: app.appDir, + status: "passed", + info, + resources, + runtime, + distribution, + signing, + } +} + +async function verifyLaunch(app) { + const executablePath = `${app.appDir}/Contents/MacOS/${productName}` + if (!exists(executablePath)) { + fail(`Cannot launch packaged app because executable is missing: ${executablePath}`) + return { + status: "failed", + arch: app.arch, + executablePath, + } + } + + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "moss-packaged-launch-")) + const port = await findFreePort() + const child = spawnSync(process.execPath, [ + "--input-type=module", + "-e", + ` + import { spawn } from "node:child_process"; + const child = spawn(${JSON.stringify(projectPath(executablePath))}, [ + ${JSON.stringify(`--remote-debugging-port=${port}`)}, + ${JSON.stringify(`--user-data-dir=${userDataDir}`)} + ], { + cwd: ${JSON.stringify(root)}, + env: { ...process.env, ELECTRON_ENABLE_LOGGING: "1" }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + let endpoint = null; + let earlyExit = null; + const started = Date.now(); + while (Date.now() - started < 15000) { + if (child.exitCode !== null) { + earlyExit = child.exitCode; + break; + } + try { + const response = await fetch(${JSON.stringify(`http://127.0.0.1:${port}/json/version`)}); + if (response.ok) { + endpoint = await response.json(); + break; + } + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)); + } + child.kill("SIGTERM"); + await new Promise((resolve) => setTimeout(resolve, 1500)); + if (child.exitCode === null) child.kill("SIGKILL"); + console.log(JSON.stringify({ + endpoint, + earlyExit, + stdout: stdout.slice(0, 2000), + stderr: stderr.slice(0, 2000) + })); + process.exit(endpoint ? 0 : 1); + `, + ], { + cwd: root, + encoding: "utf8", + }) + + fs.rmSync(userDataDir, { recursive: true, force: true }) + + let launchOutput = {} + try { + launchOutput = JSON.parse(child.stdout.trim() || "{}") + } catch { + launchOutput = { + parseError: true, + stdout: child.stdout.trim().slice(0, 2000), + stderr: child.stderr.trim().slice(0, 2000), + } + } + + const endpoint = launchOutput.endpoint + if (child.status !== 0 || !endpoint?.webSocketDebuggerUrl) { + fail(`${app.arch} packaged app launch smoke failed to reach Electron remote debugging endpoint.`) + } + + const combinedOutput = `${launchOutput.stdout ?? ""}\n${launchOutput.stderr ?? ""}` + verifyNoDevUrls(`${app.arch} packaged app launch output`, combinedOutput) + + return { + status: child.status === 0 && endpoint?.webSocketDebuggerUrl ? "passed" : "failed", + arch: app.arch, + executablePath, + remoteDebuggingEndpoint: `http://127.0.0.1:${port}/json/version`, + browser: endpoint?.Browser ?? null, + webSocketDebuggerUrlPresent: Boolean(endpoint?.webSocketDebuggerUrl), + earlyExit: launchOutput.earlyExit ?? null, + stdoutSample: launchOutput.stdout ?? "", + stderrSample: launchOutput.stderr ?? "", + exitCode: child.status, + } +} + +function writeReport(report) { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const reportDir = projectPath(path.join(".1code/program/packaged-app-smoke", timestamp)) + fs.mkdirSync(reportDir, { recursive: true }) + + const reportPath = path.join(reportDir, "report.json") + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) + + const latestPath = projectPath(".1code/program/packaged-app-smoke/latest.json") + fs.writeFileSync(latestPath, `${JSON.stringify({ + report: normalizeRelative(reportPath), + generatedAt: report.generatedAt, + status: report.status, + }, null, 2)}\n`) + + return reportPath +} + +if (!version) { + fail("package.json version is missing.") +} + +const apps = [ + { + arch: "arm64", + appDir: "release/mac-arm64/1Code.app", + updateManifest: "release/latest-mac.yml", + dmgArtifact: `release/1Code-${version}-arm64.dmg`, + zipArtifact: `release/1Code-${version}-arm64-mac.zip`, + }, + { + arch: "x64", + appDir: "release/mac/1Code.app", + updateManifest: "release/latest-mac-x64.yml", + dmgArtifact: `release/1Code-${version}.dmg`, + zipArtifact: `release/1Code-${version}-mac.zip`, + }, +] + +const appReports = apps.map(verifyApp) +const launchArch = process.arch === "arm64" ? "arm64" : "x64" +const launchApp = apps.find((app) => app.arch === launchArch) ?? apps[0] +const launch = await verifyLaunch(launchApp) +const report = { + status: failures.length === 0 ? "passed" : "failed", + generatedAt: new Date().toISOString(), + package: { + name: packageJson?.name, + version, + productName, + appId, + publishUrl, + }, + productionConfig: { + packagedApiUrl: "https://21st.dev", + devRendererUrlPolicy: "Info.plist, app-update.yml, update manifests, and app-owned main bundle must not point at localhost dev renderer URLs.", + allowedLocalNetworkingException: "localhost ATS exceptions are allowed for local MCP/runtime networking and are recorded as warnings.", + }, + apps: appReports, + launch, + warnings, + failures, +} + +const reportPath = writeReport(report) + +console.log("Moss packaged app smoke") +console.log(`status: ${report.status}`) +console.log(`report: ${normalizeRelative(reportPath)}`) +for (const app of appReports) { + console.log(`${app.arch}: app=${app.appDir} signing=${app.signing?.status ?? "missing"} asarFiles=${app.runtime?.fileCount ?? 0}`) +} +console.log(`launch: ${launch.arch} ${launch.status} ${launch.browser ?? "unknown-browser"}`) + +for (const message of warnings) { + console.warn(`warning: ${message}`) +} + +if (failures.length > 0) { + for (const message of failures) { + console.error(`error: ${message}`) + } + process.exit(1) +} diff --git a/scripts/upload-release.mjs b/scripts/upload-release.mjs new file mode 100644 index 000000000..e2e14b857 --- /dev/null +++ b/scripts/upload-release.mjs @@ -0,0 +1,286 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto" +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import process from "node:process" + +const root = process.cwd() +const args = process.argv.slice(2) +const dryRun = args.includes("--dry-run") +const includeEvidence = args.includes("--include-evidence") +const allowUnsigned = args.includes("--allow-unsigned") + +function argValue(name, fallback) { + const index = args.indexOf(name) + if (index >= 0 && args[index + 1]) return args[index + 1] + const inline = args.find((arg) => arg.startsWith(`${name}=`)) + if (inline) return inline.slice(name.length + 1) + return fallback +} + +const channel = argValue("--channel", process.env.RELEASE_CHANNEL ?? "latest") +if (!["latest", "beta"].includes(channel)) { + console.error(`Invalid release channel: ${channel}`) + process.exit(1) +} + +const provider = argValue( + "--provider", + process.env.RELEASE_UPLOAD_PROVIDER ?? "command", +) + +function projectPath(relativePath) { + return path.join(root, relativePath) +} + +function readJson(relativePath) { + return JSON.parse(fs.readFileSync(projectPath(relativePath), "utf8")) +} + +function normalizeRelative(filePath) { + return path.relative(root, filePath).split(path.sep).join("/") +} + +function sha512(filePath) { + return createHash("sha512").update(fs.readFileSync(filePath)).digest("base64") +} + +function contentTypeFor(filePath) { + const ext = path.extname(filePath).toLowerCase() + if (ext === ".yml" || ext === ".yaml") return "text/yaml" + if (ext === ".zip") return "application/zip" + if (ext === ".dmg") return "application/x-apple-diskimage" + if (ext === ".json") return "application/json" + if (ext === ".txt") return "text/plain" + return "application/octet-stream" +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'` +} + +function replaceTemplate(template, item) { + return template + .replaceAll("{file}", shellQuote(item.absolutePath)) + .replaceAll("{path}", shellQuote(item.absolutePath)) + .replaceAll("{key}", shellQuote(item.key)) + .replaceAll("{url}", shellQuote(item.url)) + .replaceAll("{contentType}", shellQuote(item.contentType)) +} + +function releaseBaseUrl(packageJson) { + const url = packageJson?.build?.publish?.url + if (typeof url !== "string" || !url.startsWith("https://")) { + throw new Error("package.json build.publish.url must be an HTTPS CDN URL.") + } + return url.replace(/\/+$/, "") +} + +function defaultUploadPrefix(baseUrl) { + const parsed = new URL(baseUrl) + return parsed.pathname.replace(/^\/+|\/+$/g, "") +} + +function requiredReleaseFiles(packageJson) { + const version = process.env.VERSION || packageJson.version + const prefix = channel === "beta" ? "beta" : "latest" + const names = [ + `${prefix}-mac.yml`, + `${prefix}-mac-x64.yml`, + `1Code-${version}-arm64-mac.zip`, + `1Code-${version}-mac.zip`, + `1Code-${version}-arm64.dmg`, + `1Code-${version}.dmg`, + ] + + if (includeEvidence) { + const releaseDir = projectPath("release") + if (fs.existsSync(releaseDir)) { + names.push( + ...fs.readdirSync(releaseDir) + .filter((name) => /notary|notar|codesign|staple|spctl/i.test(name)) + .sort(), + ) + } + } + + return [...new Set(names)].map((name) => path.join("release", name)) +} + +function validNotarizationReport() { + const releaseDir = projectPath("release") + if (!fs.existsSync(releaseDir)) return null + + for (const name of fs.readdirSync(releaseDir).sort()) { + if (!/^notarization-.+\.json$/i.test(name)) continue + const filePath = path.join(releaseDir, name) + try { + const report = JSON.parse(fs.readFileSync(filePath, "utf8")) + if ( + report.status === "passed" && + report.mode?.dryRun === false && + Number(report.summary?.notarytoolSubmissions ?? 0) > 0 && + Number(report.summary?.stapleCommands ?? 0) > 0 && + Number(report.summary?.codesignVerifications ?? 0) > 0 + ) { + return normalizeRelative(filePath) + } + } catch { + // Ignore malformed evidence; verify-release-packaging reports it separately. + } + } + + return null +} + +function buildUploadItems(packageJson) { + const baseUrl = releaseBaseUrl(packageJson) + const uploadPrefix = + process.env.RELEASE_UPLOAD_PREFIX ?? defaultUploadPrefix(baseUrl) + const missing = [] + const items = [] + + for (const relativePath of requiredReleaseFiles(packageJson)) { + const absolutePath = projectPath(relativePath) + if (!fs.existsSync(absolutePath)) { + missing.push(relativePath) + continue + } + + const name = path.basename(relativePath) + const key = [uploadPrefix, name].filter(Boolean).join("/") + items.push({ + name, + relativePath, + absolutePath, + key, + url: `${baseUrl}/${encodeURIComponent(name)}`, + size: fs.statSync(absolutePath).size, + sha512: sha512(absolutePath), + contentType: contentTypeFor(relativePath), + }) + } + + return { baseUrl, uploadPrefix, items, missing } +} + +function runUpload(item) { + if (provider === "command") { + const template = process.env.RELEASE_UPLOAD_COMMAND_TEMPLATE + if (!template) { + throw new Error( + "RELEASE_UPLOAD_COMMAND_TEMPLATE is required for command uploads. Use --dry-run to generate an upload plan only.", + ) + } + const command = replaceTemplate(template, item) + return spawnSync("sh", ["-c", command], { + cwd: root, + stdio: "inherit", + env: process.env, + }) + } + + if (provider === "wrangler") { + const bucket = process.env.CLOUDFLARE_R2_BUCKET ?? process.env.R2_BUCKET + if (!bucket) { + throw new Error("CLOUDFLARE_R2_BUCKET or R2_BUCKET is required for wrangler uploads.") + } + return spawnSync( + "npx", + ["wrangler", "r2", "object", "put", `${bucket}/${item.key}`, "--file", item.absolutePath], + { + cwd: root, + stdio: "inherit", + env: process.env, + }, + ) + } + + throw new Error(`Unsupported release upload provider: ${provider}`) +} + +function writePlan(plan) { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const dir = projectPath(path.join(".1code/program/release-upload", timestamp)) + fs.mkdirSync(dir, { recursive: true }) + const manifestPath = path.join(dir, "manifest.json") + fs.writeFileSync(manifestPath, `${JSON.stringify(plan, null, 2)}\n`) + fs.writeFileSync( + projectPath(".1code/program/release-upload/latest.json"), + `${JSON.stringify({ + manifest: normalizeRelative(manifestPath), + generatedAt: plan.generatedAt, + status: plan.status, + dryRun: plan.mode.dryRun, + }, null, 2)}\n`, + ) + return manifestPath +} + +const failures = [] +const uploaded = [] +const packageJson = readJson("package.json") +const { baseUrl, uploadPrefix, items, missing } = buildUploadItems(packageJson) +const notarizationReport = validNotarizationReport() + +if (missing.length > 0) { + failures.push(`Missing release upload artifact(s): ${missing.join(", ")}`) +} +if (!dryRun && !allowUnsigned && !notarizationReport) { + failures.push("No passing notarization report found; refusing real upload without --allow-unsigned.") +} + +if (failures.length === 0 && !dryRun) { + for (const item of items) { + try { + const result = runUpload(item) + if (result.status !== 0) { + failures.push(`Upload failed for ${item.relativePath} with exit code ${result.status ?? ""}.`) + } else { + uploaded.push(item.relativePath) + } + } catch (error) { + failures.push(error.message) + break + } + } +} + +const plan = { + status: failures.length === 0 ? "passed" : "failed", + generatedAt: new Date().toISOString(), + mode: { + dryRun, + provider, + channel, + includeEvidence, + allowUnsigned, + }, + target: { + baseUrl, + uploadPrefix, + notarizationReport, + }, + artifacts: items.map(({ absolutePath: _absolutePath, ...item }) => item), + uploaded, + missing, + failures, +} +const manifestPath = writePlan(plan) + +console.log("Moss release upload plan") +console.log(`status: ${plan.status}`) +console.log(`mode: ${dryRun ? "dry-run" : provider}`) +console.log(`manifest: ${normalizeRelative(manifestPath)}`) +console.log(`artifacts: ${items.length}`) +console.log(`target: ${baseUrl}`) + +for (const failure of failures) { + console.error(`error: ${failure}`) +} + +if (failures.length > 0) { + process.exit(1) +} diff --git a/scripts/verify-release-credentials.mjs b/scripts/verify-release-credentials.mjs new file mode 100644 index 000000000..753716654 --- /dev/null +++ b/scripts/verify-release-credentials.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" + +const root = process.cwd() +const requireCredentials = process.argv.includes("--require-credentials") +const generatedAt = new Date().toISOString() +const stamp = generatedAt.replace(/[:.]/g, "-") +const reportDir = path.join(root, ".1code/program/release-credentials", stamp) +const reportPath = path.join(reportDir, "report.json") +const latestPath = path.join(root, ".1code/program/release-credentials/latest.json") +const failures = [] +const warnings = [] +const blockers = [] + +const requiredCredentials = [ + { + env: "APPLE_IDENTITY", + role: "electron-builder Developer ID Application signing identity", + }, + { + env: "CSC_LINK", + role: "Developer ID certificate archive or encoded certificate", + }, + { + env: "CSC_KEY_PASSWORD", + role: "Developer ID certificate password", + }, + { + env: "APPLE_ID", + role: "Apple ID for notarytool", + }, + { + env: "APPLE_TEAM_ID", + role: "Apple Developer Team ID for notarytool", + }, + { + env: "APPLE_APP_SPECIFIC_PASSWORD", + role: "Apple app-specific password for notarytool", + }, +] + +const requiredWorkflowSecrets = requiredCredentials.map((credential) => `secrets.${credential.env}`) + +function projectPath(relativePath) { + return path.join(root, relativePath) +} + +function exists(relativePath) { + return fs.existsSync(projectPath(relativePath)) +} + +function read(relativePath) { + return fs.readFileSync(projectPath(relativePath), "utf8") +} + +function readJson(relativePath) { + try { + return JSON.parse(read(relativePath)) + } catch (error) { + failures.push(`Could not parse ${relativePath}: ${error.message}`) + return undefined + } +} + +function normalizeRelative(filePath) { + return path.relative(root, filePath).split(path.sep).join("/") +} + +function credentialValueKind(name) { + const value = process.env[name] + if (!value) return "missing" + if (name === "CSC_LINK") { + if (fs.existsSync(value)) return "file" + if (/^[A-Za-z0-9+/=\r\n]+$/.test(value) && value.length > 80) return "encoded" + } + return "env" +} + +function runTool(id, command, args) { + const result = spawnSync(command, args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + maxBuffer: 1024 * 1024, + }) + + return { + id, + command, + args, + status: result.status === 0 ? "passed" : "failed", + exitCode: result.status ?? 1, + stdoutPreview: (result.stdout ?? "").trim().slice(0, 500), + stderrPreview: (result.stderr ?? "").trim().slice(0, 500), + } +} + +function listReleaseFiles(pattern) { + const releaseDir = projectPath("release") + if (!fs.existsSync(releaseDir)) return [] + return fs.readdirSync(releaseDir) + .map((name) => path.join(releaseDir, name)) + .filter((filePath) => fs.statSync(filePath).isFile()) + .filter((filePath) => pattern.test(path.basename(filePath))) + .map(normalizeRelative) + .sort() +} + +const packageJson = readJson("package.json") +const releaseCredentialsScript = packageJson?.scripts?.["release:credentials"] +const releaseCredentialsStrictScript = packageJson?.scripts?.["release:credentials:strict"] +const releaseNotarizeScript = packageJson?.scripts?.["release:notarize"] + +if (releaseCredentialsScript !== "node scripts/verify-release-credentials.mjs") { + failures.push("package.json release:credentials must run scripts/verify-release-credentials.mjs.") +} +if (releaseCredentialsStrictScript !== "node scripts/verify-release-credentials.mjs --require-credentials") { + failures.push("package.json release:credentials:strict must run scripts/verify-release-credentials.mjs --require-credentials.") +} +if (releaseNotarizeScript !== "node scripts/notarize-release-artifacts.mjs") { + failures.push("package.json release:notarize must run scripts/notarize-release-artifacts.mjs.") +} +if (!exists("scripts/notarize-release-artifacts.mjs")) { + failures.push("Missing scripts/notarize-release-artifacts.mjs.") +} + +let workflowPresent = false +let workflowRequiredSecrets = [] +let workflowCredentialStep = false +if (exists(".github/workflows/moss-desktop-release.yml")) { + workflowPresent = true + const workflow = read(".github/workflows/moss-desktop-release.yml") + workflowRequiredSecrets = requiredWorkflowSecrets.map((secret) => ({ + secret: secret.replace("secrets.", ""), + present: workflow.includes(secret), + })) + workflowCredentialStep = workflow.includes("bun run release:credentials:strict") + if (!workflowCredentialStep) { + failures.push(".github/workflows/moss-desktop-release.yml must run bun run release:credentials:strict before packaging.") + } + for (const secret of workflowRequiredSecrets) { + if (!secret.present) { + failures.push(`.github/workflows/moss-desktop-release.yml is missing required secret contract: ${secret.secret}`) + } + } +} else { + failures.push("Missing .github/workflows/moss-desktop-release.yml.") +} + +if (exists("electron-builder.yml")) { + const builderOverride = read("electron-builder.yml") + if (!builderOverride.includes("identity: ${env.APPLE_IDENTITY}")) { + failures.push("electron-builder.yml must read signing identity from APPLE_IDENTITY.") + } + if (!builderOverride.includes("notarize: false")) { + failures.push("electron-builder.yml must keep built-in notarization disabled for explicit CI notarization.") + } +} else { + failures.push("Missing electron-builder.yml.") +} + +const credentials = requiredCredentials.map((credential) => ({ + ...credential, + state: process.env[credential.env] ? "set" : "missing", + valueKind: credentialValueKind(credential.env), +})) +const missingCredentials = credentials + .filter((credential) => credential.state !== "set") + .map((credential) => credential.env) + +if (missingCredentials.length > 0) { + blockers.push(`Missing required Apple signing/notarization credentials: ${missingCredentials.join(", ")}.`) +} + +const tools = [ + runTool("xcrun-notarytool", "xcrun", ["--find", "notarytool"]), + runTool("xcrun-stapler", "xcrun", ["--find", "stapler"]), + runTool("codesign", "xcrun", ["--find", "codesign"]), + runTool("spctl", "xcrun", ["--find", "spctl"]), +] +const failedTools = tools + .filter((tool) => tool.status !== "passed") + .map((tool) => tool.id) + +for (const tool of failedTools) { + failures.push(`Required macOS release tool is unavailable: ${tool}.`) +} + +if (requireCredentials && missingCredentials.length > 0) { + failures.push("Strict release credential preflight requires all Apple signing/notarization credentials.") +} + +const status = failures.length > 0 + ? "failed" + : missingCredentials.length > 0 + ? "blocked" + : "passed" + +if (status === "blocked") { + warnings.push(...blockers) +} + +const report = { + status, + generatedAt, + mode: { + requireCredentials, + }, + scripts: { + releaseCredentials: releaseCredentialsScript, + releaseCredentialsStrict: releaseCredentialsStrictScript, + releaseNotarize: releaseNotarizeScript, + }, + workflow: { + path: ".github/workflows/moss-desktop-release.yml", + present: workflowPresent, + credentialPreflightStep: workflowCredentialStep, + requiredSecrets: workflowRequiredSecrets, + }, + signing: { + electronBuilderIdentity: exists("electron-builder.yml") ? "env.APPLE_IDENTITY" : "missing", + notarizationMode: "external-ci", + electronBuilderNotarize: false, + }, + credentials: { + required: credentials, + complete: missingCredentials.length === 0, + missing: missingCredentials, + }, + tools: { + checks: tools, + complete: failedTools.length === 0, + missing: failedTools, + }, + artifacts: { + macArtifacts: listReleaseFiles(/\.(dmg|zip)$/i), + updateManifests: listReleaseFiles(/(?:latest|beta)-mac(?:-x64)?\.yml$/), + }, + warnings, + blockers, + failures, +} + +fs.mkdirSync(reportDir, { recursive: true }) +fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) +fs.writeFileSync(latestPath, `${JSON.stringify({ + report: normalizeRelative(reportPath), + generatedAt, + status, +}, null, 2)}\n`) + +console.log("Moss release credential preflight") +console.log(`status: ${status}`) +console.log(`report: ${normalizeRelative(reportPath)}`) +console.log(`credentials: ${credentials.length - missingCredentials.length}/${credentials.length}`) +console.log(`tools: ${tools.length - failedTools.length}/${tools.length}`) + +for (const message of warnings) { + console.warn(`warning: ${message}`) +} +for (const message of failures) { + console.error(`error: ${message}`) +} + +if (failures.length > 0) { + process.exit(1) +} diff --git a/scripts/verify-release-packaging.mjs b/scripts/verify-release-packaging.mjs new file mode 100644 index 000000000..8c3335346 --- /dev/null +++ b/scripts/verify-release-packaging.mjs @@ -0,0 +1,681 @@ +#!/usr/bin/env node + +import fs from "node:fs" +import path from "node:path" + +const root = process.cwd() +const requireArtifacts = process.argv.includes("--require-artifacts") +const requireBundledBinaries = process.argv.includes("--require-bundled-binaries") +const requireNotarization = process.argv.includes("--require-notarization") +const requireUploadPlan = process.argv.includes("--require-upload-plan") +const failures = [] +const warnings = [] + +function projectPath(relativePath) { + return path.join(root, relativePath) +} + +function exists(relativePath) { + return fs.existsSync(projectPath(relativePath)) +} + +function fail(message) { + failures.push(message) +} + +function warn(message) { + warnings.push(message) +} + +function read(relativePath) { + return fs.readFileSync(projectPath(relativePath), "utf8") +} + +function readJson(relativePath) { + try { + return JSON.parse(read(relativePath)) + } catch (error) { + fail(`Could not parse ${relativePath}: ${error.message}`) + return undefined + } +} + +function readJsonPath(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) + } catch (error) { + fail(`Could not parse ${normalizeRelative(filePath)}: ${error.message}`) + return undefined + } +} + +function normalizeRelative(filePath) { + return path.relative(root, filePath).split(path.sep).join("/") +} + +function assert(condition, message) { + if (!condition) fail(message) +} + +function hasScript(packageJson, scriptName) { + return typeof packageJson?.scripts?.[scriptName] === "string" && packageJson.scripts[scriptName].length > 0 +} + +function targetFor(macTargets, targetName) { + return macTargets.find((target) => target?.target === targetName) +} + +function includesAll(values, required) { + return required.every((value) => values.includes(value)) +} + +function listReleaseFiles() { + const releaseDir = projectPath("release") + if (!fs.existsSync(releaseDir)) return [] + return fs.readdirSync(releaseDir) + .map((name) => path.join(releaseDir, name)) + .filter((filePath) => fs.statSync(filePath).isFile()) + .sort() +} + +function workflowText() { + const workflowPath = ".github/workflows/moss-desktop-release.yml" + if (!exists(workflowPath)) return undefined + return read(workflowPath) +} + +function includesEvery(text, values) { + return values.every((value) => text.includes(value)) +} + +function verifyReleaseWorkflow(packageJson) { + const workflowPath = ".github/workflows/moss-desktop-release.yml" + const workflow = workflowText() + + assert(hasScript(packageJson, "release:notarize"), "Missing package script: release:notarize") + if (packageJson?.scripts?.["release:notarize"] !== "node scripts/notarize-release-artifacts.mjs") { + fail("package.json release:notarize must run scripts/notarize-release-artifacts.mjs.") + } + assert(hasScript(packageJson, "release:credentials"), "Missing package script: release:credentials") + if (packageJson?.scripts?.["release:credentials"] !== "node scripts/verify-release-credentials.mjs") { + fail("package.json release:credentials must run scripts/verify-release-credentials.mjs.") + } + assert(hasScript(packageJson, "release:credentials:strict"), "Missing package script: release:credentials:strict") + if (packageJson?.scripts?.["release:credentials:strict"] !== "node scripts/verify-release-credentials.mjs --require-credentials") { + fail("package.json release:credentials:strict must run scripts/verify-release-credentials.mjs --require-credentials.") + } + assert(exists("scripts/verify-release-credentials.mjs"), "Missing scripts/verify-release-credentials.mjs.") + assert(exists("scripts/notarize-release-artifacts.mjs"), "Missing scripts/notarize-release-artifacts.mjs.") + assert(hasScript(packageJson, "release:evidence:audit"), "Missing package script: release:evidence:audit") + if (packageJson?.scripts?.["release:evidence:audit"] !== "node scripts/audit-release-evidence.mjs") { + fail("package.json release:evidence:audit must run scripts/audit-release-evidence.mjs.") + } + assert(exists("scripts/audit-release-evidence.mjs"), "Missing scripts/audit-release-evidence.mjs.") + assert(hasScript(packageJson, "dist:upload"), "Missing package script: dist:upload") + if (packageJson?.scripts?.["dist:upload"] !== "node scripts/upload-release.mjs") { + fail("package.json dist:upload must run scripts/upload-release.mjs.") + } + assert(hasScript(packageJson, "dist:upload:dry-run"), "Missing package script: dist:upload:dry-run") + if (packageJson?.scripts?.["dist:upload:dry-run"] !== "node scripts/upload-release.mjs --dry-run") { + fail("package.json dist:upload:dry-run must run scripts/upload-release.mjs --dry-run.") + } + assert(exists("scripts/upload-release.mjs"), "Missing scripts/upload-release.mjs.") + assert(hasScript(packageJson, "test:packaged-app-smoke"), "Missing package script: test:packaged-app-smoke") + if (packageJson?.scripts?.["test:packaged-app-smoke"] !== "node scripts/smoke-packaged-app.mjs") { + fail("package.json test:packaged-app-smoke must run scripts/smoke-packaged-app.mjs.") + } + assert(exists("scripts/smoke-packaged-app.mjs"), "Missing scripts/smoke-packaged-app.mjs.") + + if (!workflow) { + fail(`Missing ${workflowPath}.`) + return { + workflowPath, + present: false, + requiredCommands: [], + requiredSecrets: [], + } + } + + const requiredCommands = [ + "bun install --frozen-lockfile", + "bun run claude:download:all", + "bun run codex:download:all", + "bun run release:credentials:strict", + "bun run test:runtime", + "bun run ts:check --pretty false", + "bun run build", + "bun run release:credentials:strict", + "bun run package:mac", + "bun run test:packaged-app-smoke", + "bun run release:notarize", + "node scripts/generate-update-manifest.mjs --channel", + "node scripts/upload-release.mjs --dry-run --channel", + "bun run release:evidence:audit --require-notarization", + "node scripts/verify-release-packaging.mjs --require-artifacts --require-bundled-binaries --require-notarization --require-upload-plan", + ] + const requiredSecrets = [ + "secrets.APPLE_IDENTITY", + "secrets.CSC_LINK", + "secrets.CSC_KEY_PASSWORD", + "secrets.APPLE_ID", + "secrets.APPLE_TEAM_ID", + "secrets.APPLE_APP_SPECIFIC_PASSWORD", + ] + + for (const command of requiredCommands) { + if (!workflow.includes(command)) { + fail(`${workflowPath} is missing required release command: ${command}`) + } + } + for (const secret of requiredSecrets) { + if (!workflow.includes(secret)) { + fail(`${workflowPath} is missing required secret contract: ${secret}`) + } + } + if (!workflow.includes("actions/upload-artifact@v4")) { + fail(`${workflowPath} must upload verified release artifacts.`) + } + if (!workflow.includes("release/notarization-*.json")) { + fail(`${workflowPath} must upload notarization evidence JSON files.`) + } + if (!workflow.includes(".1code/program/release-upload/**/manifest.json")) { + fail(`${workflowPath} must upload release upload plan evidence.`) + } + if (!workflow.includes(".1code/program/release-credentials/**/report.json")) { + fail(`${workflowPath} must upload release credential preflight evidence.`) + } + if (!workflow.includes(".1code/program/release-evidence-audit/**/report.json")) { + fail(`${workflowPath} must upload signed release evidence audit reports.`) + } + if (!workflow.includes(".1code/program/packaged-app-smoke/**/report.json")) { + fail(`${workflowPath} must upload packaged app smoke evidence.`) + } + + return { + workflowPath, + present: true, + requiredCommands: requiredCommands.map((command) => ({ + command, + present: workflow.includes(command), + })), + requiredSecrets: requiredSecrets.map((secret) => ({ + secret: secret.replace("secrets.", ""), + present: workflow.includes(secret), + })), + uploadsEvidence: includesEvery(workflow, [ + "actions/upload-artifact@v4", + "release/notarization-*.json", + "release/codesign-*.txt", + "release/staple-*.txt", + "release/spctl-*.txt", + ".1code/program/packaged-app-smoke/**/report.json", + ".1code/program/release-credentials/**/report.json", + ".1code/program/release-evidence-audit/**/report.json", + ".1code/program/release-upload/**/manifest.json", + ]), + } +} + +function verifyPackageReleaseScripts(packageJson) { + const releaseScript = packageJson?.scripts?.release ?? "" + const releaseLocalScript = packageJson?.scripts?.["release:local"] ?? "" + const requiredStrictReleaseCommands = [ + "bun install --frozen-lockfile", + "bun run claude:download:all", + "bun run codex:download:all", + "bun run release:credentials", + "bun run test:runtime", + "bun run ts:check --pretty false", + "bun run build", + "bun run package:mac", + "bun run test:packaged-app-smoke", + "bun run release:notarize", + "bun run dist:manifest", + "bun run dist:upload:dry-run", + "bun run release:evidence:audit --require-notarization", + "node scripts/verify-release-packaging.mjs --require-artifacts --require-bundled-binaries --require-notarization --require-upload-plan", + "bun run dist:upload", + ] + const requiredLocalReleaseCommands = [ + "bun run claude:download:all", + "bun run codex:download:all", + "bun run test:runtime", + "bun run ts:check --pretty false", + "bun run build", + "bun run package:mac", + "bun run test:packaged-app-smoke", + "bun run dist:manifest", + "bun run dist:upload:dry-run", + "bun run release:evidence:audit", + "node scripts/verify-release-packaging.mjs --require-artifacts --require-bundled-binaries --require-upload-plan", + ] + + assert(hasScript(packageJson, "release"), "Missing package script: release") + assert(hasScript(packageJson, "release:ci"), "Missing package script: release:ci") + assert(hasScript(packageJson, "release:local"), "Missing package script: release:local") + if (packageJson?.scripts?.["release:ci"] !== "bun run release") { + fail("package.json release:ci must delegate to the strict release script.") + } + if (packageJson?.scripts?.["release:dev"] !== "bun run release:local") { + fail("package.json release:dev must delegate to release:local.") + } + + for (const command of requiredStrictReleaseCommands) { + if (!releaseScript.includes(command)) { + fail(`package.json release script is missing required command: ${command}`) + } + } + for (const command of requiredLocalReleaseCommands) { + if (!releaseLocalScript.includes(command)) { + fail(`package.json release:local script is missing required command: ${command}`) + } + } + if (releaseScript.includes("upload-release-wrangler.sh") || releaseScript.includes("bun i ")) { + fail("package.json release script must not use the old upload-release-wrangler.sh or non-frozen bun install path.") + } + + return { + release: releaseScript, + releaseCi: packageJson?.scripts?.["release:ci"], + releaseLocal: releaseLocalScript, + releaseDev: packageJson?.scripts?.["release:dev"], + requiredStrictReleaseCommands: requiredStrictReleaseCommands.map((command) => ({ + command, + present: releaseScript.includes(command), + })), + requiredLocalReleaseCommands: requiredLocalReleaseCommands.map((command) => ({ + command, + present: releaseLocalScript.includes(command), + })), + } +} + +function verifiedNotarizationReports(releaseFiles) { + return releaseFiles + .filter((filePath) => /^notarization-.+\.json$/i.test(path.basename(filePath))) + .map((filePath) => ({ + filePath, + report: readJsonPath(filePath), + })) + .filter(({ report }) => { + if (!report) return false + return report.status === "passed" + && report.mode?.dryRun === false + && Number(report.summary?.notarytoolSubmissions ?? 0) > 0 + && Number(report.summary?.stapleCommands ?? 0) > 0 + && Number(report.summary?.codesignVerifications ?? 0) > 0 + }) +} + +function latestUploadPlan() { + const latestPath = projectPath(".1code/program/release-upload/latest.json") + if (!fs.existsSync(latestPath)) return undefined + const latest = readJson(".1code/program/release-upload/latest.json") + if (!latest?.manifest) return undefined + const manifestPath = projectPath(latest.manifest) + if (!fs.existsSync(manifestPath)) return { + latest, + manifestPath, + manifest: undefined, + } + return { + latest, + manifestPath, + manifest: readJsonPath(manifestPath), + } +} + +function latestCredentialPreflight() { + const latestPath = projectPath(".1code/program/release-credentials/latest.json") + if (!fs.existsSync(latestPath)) return undefined + const latest = readJson(".1code/program/release-credentials/latest.json") + if (!latest?.report) return undefined + const reportPath = projectPath(latest.report) + if (!fs.existsSync(reportPath)) return { + latest, + reportPath, + report: undefined, + } + return { + latest, + reportPath, + report: readJsonPath(reportPath), + } +} + +function latestReleaseEvidenceAudit() { + const latestPath = projectPath(".1code/program/release-evidence-audit/latest.json") + if (!fs.existsSync(latestPath)) return undefined + const latest = readJson(".1code/program/release-evidence-audit/latest.json") + if (!latest?.report) return undefined + const reportPath = projectPath(latest.report) + if (!fs.existsSync(reportPath)) return { + latest, + reportPath, + report: undefined, + } + return { + latest, + reportPath, + report: readJsonPath(reportPath), + } +} + +function packagedAppBinaryState() { + const apps = [ + { + arch: "arm64", + appDir: "release/mac-arm64/1Code.app", + }, + { + arch: "x64", + appDir: "release/mac/1Code.app", + }, + ] + const requiredBinaries = ["bin/claude", "bin/codex", "bin/VERSION"] + + return apps.map((app) => { + const resourceDir = path.join(app.appDir, "Contents", "Resources") + const binaries = requiredBinaries.map((binary) => { + const relativePath = path.join(resourceDir, binary).split(path.sep).join("/") + return { + name: binary, + path: relativePath, + present: exists(relativePath), + } + }) + + return { + ...app, + present: exists(app.appDir), + binaries, + complete: binaries.every((binary) => binary.present), + } + }) +} + +function writeReport(report) { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const reportDir = projectPath(path.join(".1code/program/release-packaging", timestamp)) + fs.mkdirSync(reportDir, { recursive: true }) + + const reportPath = path.join(reportDir, "report.json") + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) + + const latestPath = projectPath(".1code/program/release-packaging/latest.json") + fs.writeFileSync(latestPath, `${JSON.stringify({ + report: normalizeRelative(reportPath), + generatedAt: report.generatedAt, + status: report.status, + }, null, 2)}\n`) + + return reportPath +} + +const packageJson = readJson("package.json") +const build = packageJson?.build ?? {} +const mac = build.mac ?? {} +const macTargets = Array.isArray(mac.target) ? mac.target : [] +const dmgTarget = targetFor(macTargets, "dmg") +const zipTarget = targetFor(macTargets, "zip") +const asarUnpack = Array.isArray(build.asarUnpack) ? build.asarUnpack : [] + +assert(hasScript(packageJson, "build"), "Missing package script: build") +assert(hasScript(packageJson, "package:mac"), "Missing package script: package:mac") +assert(hasScript(packageJson, "dist:manifest"), "Missing package script: dist:manifest") +assert(hasScript(packageJson, "verify:program"), "Missing package script: verify:program") +const releaseWorkflow = verifyReleaseWorkflow(packageJson) +const releaseScripts = verifyPackageReleaseScripts(packageJson) + +assert(build.asar === true, "Electron build must enable asar packaging.") +assert(asarUnpack.some((entry) => entry.includes("better-sqlite3")), "asarUnpack must include better-sqlite3 native files.") +assert(asarUnpack.some((entry) => entry.includes("node-pty")), "asarUnpack must include node-pty native files.") +assert(asarUnpack.some((entry) => entry.includes("@anthropic-ai/claude-agent-sdk")), "asarUnpack must include Claude Code SDK files.") +assert(asarUnpack.some((entry) => entry.includes("@zed-industries/codex-acp")), "asarUnpack must include Codex ACP files.") + +assert(dmgTarget, "mac target must include dmg.") +assert(zipTarget, "mac target must include zip for auto-update.") +assert(dmgTarget && includesAll(dmgTarget.arch ?? [], ["arm64", "x64"]), "mac dmg target must include arm64 and x64.") +assert(zipTarget && includesAll(zipTarget.arch ?? [], ["arm64", "x64"]), "mac zip target must include arm64 and x64.") +assert(mac.hardenedRuntime === true, "mac.hardenedRuntime must be true.") +assert(mac.entitlements === "build/entitlements.mac.plist", "mac.entitlements must point to build/entitlements.mac.plist.") +assert(mac.entitlementsInherit === "build/entitlements.mac.plist", "mac.entitlementsInherit must point to build/entitlements.mac.plist.") +assert(mac.icon === "build/icon.icns", "mac.icon must point to build/icon.icns.") +assert(build.dmg?.contents?.some((entry) => entry?.type === "link" && entry?.path === "/Applications"), "DMG layout must include an Applications link.") +assert(build.publish?.provider === "generic", "publish.provider must be generic for electron-updater CDN manifests.") +assert(typeof build.publish?.url === "string" && build.publish.url.startsWith("https://"), "publish.url must be an HTTPS URL.") + +if (!exists("electron-builder.yml")) { + fail("Missing electron-builder.yml override.") +} else { + const builderOverride = read("electron-builder.yml") + assert(builderOverride.includes("identity: ${env.APPLE_IDENTITY}"), "electron-builder.yml must read signing identity from APPLE_IDENTITY.") + assert(builderOverride.includes("notarize: false"), "electron-builder.yml must keep built-in notarization disabled for explicit CI notarization.") +} + +if (!exists("build/entitlements.mac.plist")) { + fail("Missing build/entitlements.mac.plist.") +} else { + const entitlements = read("build/entitlements.mac.plist") + for (const key of [ + "com.apple.security.cs.allow-jit", + "com.apple.security.cs.allow-unsigned-executable-memory", + "com.apple.security.cs.disable-library-validation", + "com.apple.security.network.client", + "com.apple.security.network.server", + "com.apple.security.device.audio-input", + ]) { + assert(entitlements.includes(key), `Entitlements file is missing ${key}.`) + } +} + +if (!exists("scripts/generate-update-manifest.mjs")) { + fail("Missing scripts/generate-update-manifest.mjs.") +} + +if (!process.env.APPLE_IDENTITY) { + warn("APPLE_IDENTITY is not set; local package builds will be unsigned or ad-hoc unless electron-builder finds another identity.") +} + +const releaseFiles = listReleaseFiles() +const releaseArtifacts = releaseFiles.filter((filePath) => /\.(dmg|zip)$/i.test(filePath)) +const updateManifests = releaseFiles.filter((filePath) => /(?:latest|beta)-mac(?:-x64)?\.yml$/.test(path.basename(filePath))) +const notarizationEvidence = releaseFiles.filter((filePath) => /notary|notar|codesign|staple|spctl/i.test(path.basename(filePath))) +const validNotarizationReports = verifiedNotarizationReports(releaseFiles) +const packagedAppBinaries = packagedAppBinaryState() +const uploadPlan = latestUploadPlan() +const credentialPreflight = latestCredentialPreflight() +const releaseEvidenceAudit = latestReleaseEvidenceAudit() + +if (releaseArtifacts.length === 0) { + const message = "No macOS release artifacts were found in release/." + if (requireArtifacts) fail(message) + else warn(`${message} Run bun run package:mac to produce DMG/ZIP evidence.`) +} + +if (requireArtifacts && updateManifests.length === 0) { + fail("No macOS update manifests were found in release/. Run bun run dist:manifest after packaging.") +} + +const missingBundledBinaries = packagedAppBinaries.flatMap((app) => + app.binaries + .filter((binary) => !binary.present) + .map((binary) => `${app.arch}:${binary.name}`), +) +if (missingBundledBinaries.length > 0) { + const message = `Packaged app is missing bundled runtime binaries: ${missingBundledBinaries.join(", ")}.` + if (requireBundledBinaries) fail(message) + else warn(message) +} + +if (requireNotarization && validNotarizationReports.length === 0) { + fail("No passing notarization report was found in release/. Run bun run release:notarize in CI with Apple signing credentials.") +} + +if (uploadPlan?.manifest?.status && uploadPlan.manifest.status !== "passed") { + fail(`Release upload plan ${normalizeRelative(uploadPlan.manifestPath)} has status ${uploadPlan.manifest.status}, expected passed.`) +} +if (requireUploadPlan) { + if (!uploadPlan?.manifest) { + fail("No release upload plan was found. Run bun run dist:upload:dry-run after generating update manifests.") + } else if (uploadPlan.manifest.mode?.dryRun !== true) { + fail(`Release upload plan ${normalizeRelative(uploadPlan.manifestPath)} must be a dry-run pre-upload plan.`) + } else if (!Array.isArray(uploadPlan.manifest.artifacts) || uploadPlan.manifest.artifacts.length < 6) { + fail(`Release upload plan ${normalizeRelative(uploadPlan.manifestPath)} does not include all required macOS artifacts and manifests.`) + } +} + +if (credentialPreflight?.report?.status === "failed") { + fail(`Release credential preflight ${normalizeRelative(credentialPreflight.reportPath)} failed.`) +} +if (requireNotarization) { + if (!credentialPreflight?.report) { + fail("No release credential preflight report was found. Run bun run release:credentials:strict before packaging.") + } else if (credentialPreflight.report.status !== "passed") { + fail(`Release credential preflight ${normalizeRelative(credentialPreflight.reportPath)} status is ${credentialPreflight.report.status}, expected passed for notarized release verification.`) + } +} + +if (releaseEvidenceAudit?.report?.status === "failed") { + fail(`Release evidence audit ${normalizeRelative(releaseEvidenceAudit.reportPath)} failed.`) +} +if (requireNotarization) { + if (!releaseEvidenceAudit?.report) { + fail("No release evidence audit report was found. Run bun run release:evidence:audit --require-notarization after notarization and upload-plan generation.") + } else if (releaseEvidenceAudit.report.status !== "passed") { + fail(`Release evidence audit ${normalizeRelative(releaseEvidenceAudit.reportPath)} status is ${releaseEvidenceAudit.report.status}, expected passed for notarized release verification.`) + } else if (releaseEvidenceAudit.report.distribution?.distributable !== true) { + fail(`Release evidence audit ${normalizeRelative(releaseEvidenceAudit.reportPath)} did not mark the artifacts distributable.`) + } +} else if (!releaseEvidenceAudit?.report) { + warn("No release evidence audit report was found. Run bun run release:evidence:audit to record the current signed/notarized distribution state.") +} + +const report = { + status: failures.length === 0 ? "passed" : "failed", + generatedAt: new Date().toISOString(), + mode: { + requireArtifacts, + requireBundledBinaries, + requireNotarization, + requireUploadPlan, + }, + scripts: { + build: packageJson?.scripts?.build, + packageMac: packageJson?.scripts?.["package:mac"], + distManifest: packageJson?.scripts?.["dist:manifest"], + distUpload: packageJson?.scripts?.["dist:upload"], + distUploadDryRun: packageJson?.scripts?.["dist:upload:dry-run"], + packagedAppSmoke: packageJson?.scripts?.["test:packaged-app-smoke"], + releaseCredentials: packageJson?.scripts?.["release:credentials"], + releaseCredentialsStrict: packageJson?.scripts?.["release:credentials:strict"], + releaseNotarize: packageJson?.scripts?.["release:notarize"], + releaseEvidenceAudit: packageJson?.scripts?.["release:evidence:audit"], + release: releaseScripts.release, + releaseCi: releaseScripts.releaseCi, + releaseLocal: releaseScripts.releaseLocal, + releaseDev: releaseScripts.releaseDev, + releaseScriptChecks: { + requiredStrictReleaseCommands: releaseScripts.requiredStrictReleaseCommands, + requiredLocalReleaseCommands: releaseScripts.requiredLocalReleaseCommands, + }, + }, + mac: { + targets: macTargets, + hardenedRuntime: mac.hardenedRuntime === true, + entitlements: mac.entitlements, + entitlementsInherit: mac.entitlementsInherit, + icon: mac.icon, + publish: build.publish, + }, + signing: { + appleIdentityEnv: process.env.APPLE_IDENTITY ? "set" : "missing", + electronBuilderIdentity: exists("electron-builder.yml") ? "env.APPLE_IDENTITY" : "missing", + notarizationMode: "external-ci", + electronBuilderNotarize: false, + releaseWorkflow, + credentialPreflight: credentialPreflight?.report + ? { + report: normalizeRelative(credentialPreflight.reportPath), + status: credentialPreflight.report.status, + requireCredentials: credentialPreflight.report.mode?.requireCredentials === true, + credentialsComplete: credentialPreflight.report.credentials?.complete === true, + missingCredentials: Array.isArray(credentialPreflight.report.credentials?.missing) + ? credentialPreflight.report.credentials.missing + : [], + toolsComplete: credentialPreflight.report.tools?.complete === true, + missingTools: Array.isArray(credentialPreflight.report.tools?.missing) + ? credentialPreflight.report.tools.missing + : [], + workflowCredentialPreflightStep: credentialPreflight.report.workflow?.credentialPreflightStep === true, + blockers: Array.isArray(credentialPreflight.report.blockers) + ? credentialPreflight.report.blockers + : [], + } + : null, + evidenceAudit: releaseEvidenceAudit?.report + ? { + report: normalizeRelative(releaseEvidenceAudit.reportPath), + status: releaseEvidenceAudit.report.status, + requireNotarization: releaseEvidenceAudit.report.mode?.requireNotarization === true, + distributable: releaseEvidenceAudit.report.distribution?.distributable === true, + blockerCount: Number(releaseEvidenceAudit.report.distribution?.blockerCount ?? 0), + validNotarizationReports: Array.isArray(releaseEvidenceAudit.report.notarization?.validReports) + ? releaseEvidenceAudit.report.notarization.validReports + : [], + acceptedSubmissions: Number(releaseEvidenceAudit.report.notarization?.acceptedSubmissions ?? 0), + } + : null, + validNotarizationReports: validNotarizationReports.map(({ filePath }) => normalizeRelative(filePath)), + }, + artifacts: { + releaseDir: "release", + files: releaseFiles.map(normalizeRelative), + macArtifacts: releaseArtifacts.map(normalizeRelative), + updateManifests: updateManifests.map(normalizeRelative), + notarizationEvidence: notarizationEvidence.map(normalizeRelative), + packagedAppBinaries, + }, + distribution: { + uploadScript: exists("scripts/upload-release.mjs") ? "scripts/upload-release.mjs" : "missing", + uploadPlan: uploadPlan?.manifest + ? { + manifest: normalizeRelative(uploadPlan.manifestPath), + status: uploadPlan.manifest.status, + dryRun: uploadPlan.manifest.mode?.dryRun === true, + provider: uploadPlan.manifest.mode?.provider, + channel: uploadPlan.manifest.mode?.channel, + target: uploadPlan.manifest.target, + artifactCount: Array.isArray(uploadPlan.manifest.artifacts) + ? uploadPlan.manifest.artifacts.length + : 0, + } + : null, + }, + warnings, + failures, +} + +const reportPath = writeReport(report) + +console.log("Moss release packaging verification") +console.log(`status: ${report.status}`) +console.log(`report: ${normalizeRelative(reportPath)}`) +console.log(`mac artifacts: ${releaseArtifacts.length}`) +console.log(`update manifests: ${updateManifests.length}`) +console.log(`notarization evidence: ${notarizationEvidence.length}`) +console.log(`bundled binaries: ${packagedAppBinaries.map((app) => `${app.arch}=${app.complete ? "complete" : "incomplete"}`).join(", ")}`) +console.log(`upload plan: ${uploadPlan?.manifest ? normalizeRelative(uploadPlan.manifestPath) : "missing"}`) +console.log(`credential preflight: ${credentialPreflight?.report ? `${credentialPreflight.report.status} (${normalizeRelative(credentialPreflight.reportPath)})` : "missing"}`) +console.log(`release evidence audit: ${releaseEvidenceAudit?.report ? `${releaseEvidenceAudit.report.status} (${normalizeRelative(releaseEvidenceAudit.reportPath)})` : "missing"}`) + +for (const message of warnings) { + console.warn(`warning: ${message}`) +} + +if (failures.length > 0) { + for (const message of failures) { + console.error(`error: ${message}`) + } + process.exit(1) +} diff --git a/src/main/auth-manager.ts b/src/main/auth-manager.ts index e31b7bc1b..d139f233c 100644 --- a/src/main/auth-manager.ts +++ b/src/main/auth-manager.ts @@ -1,6 +1,6 @@ import { AuthStore, AuthData, AuthUser } from "./auth-store" import { app, BrowserWindow } from "electron" -import { AUTH_SERVER_PORT } from "./constants" +import { getAuthServerPort } from "./constants" // Get API URL - in packaged app always use production, in dev allow override function getApiBaseUrl(): string { @@ -214,7 +214,7 @@ export class AuthManager { // In dev mode, use localhost callback (we run HTTP server on AUTH_SERVER_PORT) // Also pass the protocol so web knows which deep link to use as fallback if (this.isDev) { - authUrl += `&callback=${encodeURIComponent(`http://localhost:${AUTH_SERVER_PORT}/auth/callback`)}` + authUrl += `&callback=${encodeURIComponent(`http://localhost:${getAuthServerPort()}/auth/callback`)}` // Pass dev protocol so production web can use correct deep link if callback fails authUrl += `&protocol=twentyfirst-agents-dev` } @@ -256,7 +256,23 @@ export class AuthManager { * Fetch user's subscription plan from web backend * Used for PostHog analytics enrichment */ - async fetchUserPlan(): Promise<{ email: string; plan: string; status: string | null } | null> { + async fetchUserPlan(): Promise<{ + email: string + plan: string + status: string | null + quota?: { + includedCredits?: number | null + usedCredits?: number | null + remainingCredits?: number | null + resetAt?: string | null + unit?: string | null + } | null + includedCredits?: number | null + usedCredits?: number | null + remainingCredits?: number | null + resetAt?: string | null + quotaUnit?: string | null + } | null> { const token = await this.getValidToken() if (!token) return null diff --git a/src/main/constants.ts b/src/main/constants.ts index 088f24356..954c04039 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -3,3 +3,13 @@ export const IS_DEV = !!process.env.ELECTRON_RENDERER_URL // Auth server port - use different port in dev to allow running alongside production export const AUTH_SERVER_PORT = IS_DEV ? 21322 : 21321 + +let runtimeAuthServerPort = AUTH_SERVER_PORT + +export function getAuthServerPort(): number { + return runtimeAuthServerPort +} + +export function setAuthServerPort(port: number): void { + runtimeAuthServerPort = port +} diff --git a/src/main/index.ts b/src/main/index.ts index 57af873f0..711f7923c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,20 @@ import * as Sentry from "@sentry/electron/main" -import { app, BrowserWindow, dialog, Menu, nativeImage, session } from "electron" +import { + app, + BrowserWindow, + dialog, + Menu, + nativeImage, + session, +} from "electron" import { existsSync, readFileSync, readlinkSync, unlinkSync } from "fs" import { createServer } from "http" import { join } from "path" -import { AuthManager, initAuthManager, getAuthManager as getAuthManagerFromModule } from "./auth-manager" +import { + AuthManager, + initAuthManager, + getAuthManager as getAuthManagerFromModule, +} from "./auth-manager" import { identify, initAnalytics, @@ -16,6 +27,7 @@ import { checkForUpdates, downloadUpdate, initAutoUpdater, + registerAutoUpdaterIpcHandlers, setupFocusUpdateCheck, } from "./lib/auto-updater" import { closeDatabase, initDatabase } from "./lib/db" @@ -28,8 +40,20 @@ import { } from "./lib/cli" import { cleanupGitWatchers } from "./lib/git/watcher" import { cancelAllPendingOAuth, handleMcpOAuthCallback } from "./lib/mcp-auth" -import { getAllMcpConfigHandler, hasActiveClaudeSessions, abortAllClaudeSessions } from "./lib/trpc/routers/claude" -import { getAllCodexMcpConfigHandler, hasActiveCodexStreams, abortAllCodexStreams } from "./lib/trpc/routers/codex" +import { + getAllMcpConfigHandler, + hasActiveClaudeSessions, + abortAllClaudeSessions, +} from "./lib/trpc/routers/claude" +import { + getAllCodexMcpConfigHandler, + hasActiveCodexStreams, + abortAllCodexStreams, +} from "./lib/trpc/routers/codex" +import { + hasActiveHermesStreams, + abortAllHermesStreams, +} from "./lib/trpc/routers/hermes" import { createMainWindow, createWindow, @@ -39,7 +63,16 @@ import { } from "./windows/main" import { windowManager } from "./windows/window-manager" -import { IS_DEV, AUTH_SERVER_PORT } from "./constants" +import { + IS_DEV, + AUTH_SERVER_PORT, + getAuthServerPort, + setAuthServerPort, +} from "./constants" +import { + parsePluginDeepLink, + type PluginDeepLinkTarget, +} from "../shared/plugin-deep-link" // Deep link protocol (must match package.json build.protocols.schemes) // Use different protocol in dev to avoid conflicts with production app @@ -185,6 +218,41 @@ export async function handleAuthCode(code: string): Promise { } } +function sendPluginDeepLink( + win: BrowserWindow, + target: PluginDeepLinkTarget, +): void { + if (win.isDestroyed()) return + + const send = () => { + if (!win.isDestroyed()) { + win.webContents.send("plugin:open-detail", target) + } + } + + if (win.webContents.isLoading()) { + win.webContents.once("did-finish-load", send) + } else { + send() + } + + if (win.isMinimized()) win.restore() + win.focus() +} + +function dispatchPluginDeepLink(target: PluginDeepLinkTarget): void { + const windows = getAllWindows() + + if (windows.length === 0) { + sendPluginDeepLink(createMainWindow(), target) + return + } + + for (const win of windows) { + sendPluginDeepLink(win, target) + } +} + // Handle deep link function handleDeepLink(url: string): void { console.log("[DeepLink] Received:", url) @@ -210,6 +278,16 @@ function handleDeepLink(url: string): void { return } } + + // Handle plugin catalog links: + // twentyfirst-agents://plugins/github + // twentyfirst-agents:///plugins/github + // twentyfirst-agents://plugins/github/try-in-chat + const pluginDeepLink = parsePluginDeepLink(url) + if (pluginDeepLink) { + dispatchPluginDeepLink(pluginDeepLink) + return + } } catch (e) { console.error("[DeepLink] Failed to parse:", e) } @@ -290,29 +368,30 @@ const FAVICON_DATA_URI = `data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}` // Start local HTTP server for auth callbacks // This catches http://localhost:{AUTH_SERVER_PORT}/auth/callback?code=xxx and /callback (for MCP OAuth) const server = createServer((req, res) => { - const url = new URL(req.url || "", `http://localhost:${AUTH_SERVER_PORT}`) - - // Serve favicon - if (url.pathname === "/favicon.ico" || url.pathname === "/favicon.svg") { - res.writeHead(200, { "Content-Type": "image/svg+xml" }) - res.end(FAVICON_SVG) - return - } + const requestHost = req.headers.host || `localhost:${getAuthServerPort()}` + const url = new URL(req.url || "", `http://${requestHost}`) + + // Serve favicon + if (url.pathname === "/favicon.ico" || url.pathname === "/favicon.svg") { + res.writeHead(200, { "Content-Type": "image/svg+xml" }) + res.end(FAVICON_SVG) + return + } - if (url.pathname === "/auth/callback") { - const code = url.searchParams.get("code") - console.log( - "[Auth Server] Received callback with code:", - code?.slice(0, 8) + "...", - ) + if (url.pathname === "/auth/callback") { + const code = url.searchParams.get("code") + console.log( + "[Auth Server] Received callback with code:", + code?.slice(0, 8) + "...", + ) - if (code) { - // Handle the auth code - handleAuthCode(code) + if (code) { + // Handle the auth code + handleAuthCode(code) - // Send success response and close the browser tab - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(` + // Send success response and close the browser tab + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(` @@ -375,28 +454,28 @@ const server = createServer((req, res) => { `) - } else { - res.writeHead(400, { "Content-Type": "text/plain" }) - res.end("Missing code parameter") - } - } else if (url.pathname === "/callback") { - // Handle MCP OAuth callback - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - console.log( - "[Auth Server] Received MCP OAuth callback with code:", - code?.slice(0, 8) + "...", - "state:", - state?.slice(0, 8) + "...", - ) + } else { + res.writeHead(400, { "Content-Type": "text/plain" }) + res.end("Missing code parameter") + } + } else if (url.pathname === "/callback") { + // Handle MCP OAuth callback + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + console.log( + "[Auth Server] Received MCP OAuth callback with code:", + code?.slice(0, 8) + "...", + "state:", + state?.slice(0, 8) + "...", + ) - if (code && state) { - // Handle the MCP OAuth callback - handleMcpOAuthCallback(code, state) + if (code && state) { + // Handle the MCP OAuth callback + handleMcpOAuthCallback(code, state) - // Send success response and close the browser tab - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(` + // Send success response and close the browser tab + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(` @@ -459,19 +538,45 @@ const server = createServer((req, res) => { `) - } else { - res.writeHead(400, { "Content-Type": "text/plain" }) - res.end("Missing code or state parameter") - } } else { - res.writeHead(404, { "Content-Type": "text/plain" }) - res.end("Not found") + res.writeHead(400, { "Content-Type": "text/plain" }) + res.end("Missing code or state parameter") + } + } else { + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("Not found") + } +}) + +function listenForAuthCallbacks(port: number, attempt = 0): void { + setAuthServerPort(port) + + server.once("error", (error: NodeJS.ErrnoException) => { + if (IS_DEV && error.code === "EADDRINUSE" && attempt < 20) { + const nextPort = port + 1 + console.warn( + `[Auth Server] Port ${port} is already in use; retrying on ${nextPort}`, + ) + listenForAuthCallbacks(nextPort, attempt + 1) + return + } + + console.error("[Auth Server] Failed to listen:", error) + if (!IS_DEV) { + throw error } }) -server.listen(AUTH_SERVER_PORT, () => { - console.log(`[Auth Server] Listening on http://localhost:${AUTH_SERVER_PORT}`) -}) + server.listen(port, () => { + const address = server.address() + const actualPort = + address && typeof address === "object" ? address.port : port + setAuthServerPort(actualPort) + console.log(`[Auth Server] Listening on http://localhost:${actualPort}`) + }) +} + +listenForAuthCallbacks(AUTH_SERVER_PORT) // Clean up stale lock files from crashed instances // Returns true if locks were cleaned, false otherwise @@ -496,7 +601,12 @@ function cleanupStaleLocks(): boolean { } catch { // Process doesn't exist, clean up stale locks console.log("[App] Cleaning stale locks (pid", pid, "not running)") - const filesToRemove = ["SingletonLock", "SingletonSocket", "SingletonCookie"] + const filesToRemove = [ + "SingletonLock", + "SingletonSocket", + "SingletonCookie", + "DevToolsActivePort", + ] for (const file of filesToRemove) { const filePath = join(userDataPath, file) if (existsSync(filePath)) { @@ -516,6 +626,10 @@ function cleanupStaleLocks(): boolean { return false } +// Clean crashed dev instances before Electron evaluates the single-instance lock. +// If the lock belongs to a live process, cleanupStaleLocks() leaves it intact. +cleanupStaleLocks() + // Prevent multiple instances let gotTheLock = app.requestSingleInstanceLock() @@ -558,7 +672,6 @@ if (gotTheLock) { // app.name = "Agents Dev" // } - // Register protocol handler (must be after app is ready) initialRegistration = registerProtocol() @@ -613,11 +726,14 @@ if (gotTheLock) { // Menu icons: PNG template for settings (auto light/dark via "Template" suffix), // macOS native SF Symbol for terminal const settingsMenuIcon = nativeImage.createFromPath( - join(__dirname, "../../build/settingsTemplate.png") + join(__dirname, "../../build/settingsTemplate.png"), ) - const terminalMenuIcon = process.platform === "darwin" - ? nativeImage.createFromNamedImage("terminal")?.resize({ width: 12, height: 12 }) - : null + const terminalMenuIcon = + process.platform === "darwin" + ? nativeImage + .createFromNamedImage("terminal") + ?.resize({ width: 12, height: 12 }) + : null // Function to build and set application menu const buildMenu = () => { @@ -675,11 +791,15 @@ if (gotTheLock) { dialog.showMessageBox({ type: "info", message: "CLI command uninstalled", - detail: "The '1code' command has been removed from your PATH.", + detail: + "The '1code' command has been removed from your PATH.", }) buildMenu() } else { - dialog.showErrorBox("Uninstallation Failed", result.error || "Unknown error") + dialog.showErrorBox( + "Uninstallation Failed", + result.error || "Unknown error", + ) } } else { const result = await installCli() @@ -692,7 +812,10 @@ if (gotTheLock) { }) buildMenu() } else { - dialog.showErrorBox("Installation Failed", result.error || "Unknown error") + dialog.showErrorBox( + "Installation Failed", + result.error || "Unknown error", + ) } } }, @@ -708,7 +831,11 @@ if (gotTheLock) { label: "Quit", accelerator: "CmdOrCtrl+Q", click: async () => { - if (hasActiveClaudeSessions() || hasActiveCodexStreams()) { + if ( + hasActiveClaudeSessions() || + hasActiveCodexStreams() || + hasActiveHermesStreams() + ) { const { dialog } = await import("electron") const { response } = await dialog.showMessageBox({ type: "warning", @@ -717,11 +844,13 @@ if (gotTheLock) { cancelId: 0, title: "Active Sessions", message: "There are active agent sessions running.", - detail: "Quitting now will interrupt them. Are you sure you want to quit?", + detail: + "Quitting now will interrupt them. Are you sure you want to quit?", }) if (response === 1) { abortAllClaudeSessions() abortAllCodexStreams() + abortAllHermesStreams() setIsQuitting(true) app.quit() } @@ -793,7 +922,11 @@ if (gotTheLock) { click: () => { const win = BrowserWindow.getFocusedWindow() if (!win) return - if (hasActiveClaudeSessions() || hasActiveCodexStreams()) { + if ( + hasActiveClaudeSessions() || + hasActiveCodexStreams() || + hasActiveHermesStreams() + ) { dialog .showMessageBox(win, { type: "warning", @@ -809,6 +942,7 @@ if (gotTheLock) { if (response === 1) { abortAllClaudeSessions() abortAllCodexStreams() + abortAllHermesStreams() win.webContents.reloadIgnoringCache() } }) @@ -853,7 +987,7 @@ if (gotTheLock) { } // macOS: Set dock menu (right-click on dock icon) - if (process.platform === "darwin") { + if (process.platform === "darwin" && app.dock) { const dockMenu = Menu.buildFromTemplate([ { label: "New Window", @@ -939,6 +1073,11 @@ if (gotTheLock) { console.error("[App] Failed to initialize database:", error) } + // Dev builds still render update preferences, but should not initialize updater. + if (!app.isPackaged) { + registerAutoUpdaterIpcHandlers() + } + // Create main window createMainWindow() diff --git a/src/main/lib/agent-runtime/adapters/claude-code.ts b/src/main/lib/agent-runtime/adapters/claude-code.ts new file mode 100644 index 000000000..7e8a2294f --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/claude-code.ts @@ -0,0 +1,122 @@ +import { eq } from "drizzle-orm" +import { getClaudeShellEnvironment, resolveClaudeCodeExecutable } from "../../claude/env" +import { getExistingClaudeToken } from "../../claude-token" +import { + anthropicAccounts, + anthropicSettings, + claudeCodeCredentials, + getDatabase, +} from "../../db" +import { getAgentRuntimeManifest } from "../manifests" +import type { + AgentRuntimeAdapter, + AgentRuntimeAvailability, + AgentRuntimeHealth, + AgentRuntimeSessionRef, +} from "../types" + +function getClaudeStoredAuthMethod(): "oauth" | null { + try { + const db = getDatabase() + const settings = db + .select() + .from(anthropicSettings) + .where(eq(anthropicSettings.id, "singleton")) + .get() + + if (settings?.activeAccountId) { + const account = db + .select() + .from(anthropicAccounts) + .where(eq(anthropicAccounts.id, settings.activeAccountId)) + .get() + if (account?.oauthToken) return "oauth" + } + + const credential = db + .select() + .from(claudeCodeCredentials) + .where(eq(claudeCodeCredentials.id, "default")) + .get() + + return credential?.oauthToken ? "oauth" : null + } catch { + return null + } +} + +function getClaudeShellAuthMethod(): "api-key" | "shell-config" | null { + try { + const shellEnv = getClaudeShellEnvironment() + if (shellEnv.ANTHROPIC_API_KEY || shellEnv.ANTHROPIC_AUTH_TOKEN) { + return "api-key" + } + if (shellEnv.ANTHROPIC_BASE_URL) { + return "shell-config" + } + } catch { + return null + } + + return null +} + +async function inspectClaudeRuntime(): Promise { + const manifest = getAgentRuntimeManifest("claude-code") + const executable = resolveClaudeCodeExecutable() + + if (executable.reason && executable.source === "bundled") { + return { + availability: "not-installed", + statusReason: executable.reason, + authMethod: "unknown", + models: manifest.models?.map((model) => ({ + ...model, + availability: "not-installed", + reason: executable.reason, + })), + } + } + + const authMethod = + getClaudeStoredAuthMethod() ?? + getClaudeShellAuthMethod() ?? + (getExistingClaudeToken() ? "oauth" : null) + + if (!authMethod) { + return { + availability: "needs-auth", + statusReason: "Claude Code is installed but no OAuth token, API key, or proxy config was found.", + authMethod: "not-authenticated", + models: manifest.models?.map((model) => ({ + ...model, + availability: "needs-auth", + reason: "Claude Code authentication is required.", + })), + } + } + + return { + availability: "available", + statusReason: `Claude Code auth detected via ${authMethod}; executable: ${executable.source}.`, + authMethod, + models: manifest.models?.map((model) => ({ + ...model, + availability: "available", + })), + } +} + +export const claudeCodeAdapter: AgentRuntimeAdapter = { + manifest: getAgentRuntimeManifest("claude-code"), + async inspect( + _session: AgentRuntimeSessionRef, + ): Promise { + return inspectClaudeRuntime() + }, + async canStart( + _session: AgentRuntimeSessionRef, + ): Promise { + return (await inspectClaudeRuntime()).availability + }, +} diff --git a/src/main/lib/agent-runtime/adapters/codex.ts b/src/main/lib/agent-runtime/adapters/codex.ts new file mode 100644 index 000000000..8eebcb2c8 --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/codex.ts @@ -0,0 +1,73 @@ +import { getCodexIntegrationStatus } from "../../trpc/routers/codex" +import { getAgentRuntimeManifest } from "../manifests" +import type { + AgentRuntimeAdapter, + AgentRuntimeAvailability, + AgentRuntimeHealth, + AgentRuntimeSessionRef, +} from "../types" + +async function inspectCodexRuntime(): Promise { + const manifest = getAgentRuntimeManifest("codex") + + try { + const integration = await getCodexIntegrationStatus() + const authMethod = integration.state === "connected_api_key" + ? "api-key" + : integration.state === "connected_chatgpt" + ? "oauth" + : "not-authenticated" + + if (integration.isConnected) { + return { + availability: "available", + statusReason: `Codex auth detected via ${integration.state}.`, + authMethod, + models: manifest.models?.map((model) => ({ + ...model, + availability: "available", + })), + } + } + + return { + availability: "needs-auth", + statusReason: + integration.rawOutput || + "Codex CLI is installed but no login was found.", + authMethod, + models: manifest.models?.map((model) => ({ + ...model, + availability: "needs-auth", + reason: "Codex authentication is required.", + })), + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const missingBinary = message.includes("Bundled Codex CLI not found") + return { + availability: missingBinary ? "not-installed" : "error", + statusReason: message, + authMethod: "unknown", + models: manifest.models?.map((model) => ({ + ...model, + availability: missingBinary ? "not-installed" : "error", + reason: message, + })), + } + } +} + +export const codexAdapter: AgentRuntimeAdapter = { + manifest: getAgentRuntimeManifest("codex"), + async inspect( + _session: AgentRuntimeSessionRef, + ): Promise { + return inspectCodexRuntime() + }, + async canStart( + _session: AgentRuntimeSessionRef, + ): Promise { + return (await inspectCodexRuntime()).availability + }, +} diff --git a/src/main/lib/agent-runtime/adapters/custom-acp.ts b/src/main/lib/agent-runtime/adapters/custom-acp.ts new file mode 100644 index 000000000..0192a1df8 --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/custom-acp.ts @@ -0,0 +1,37 @@ +import { getAgentRuntimeManifest } from "../manifests" +import type { + AgentRuntimeAdapter, + AgentRuntimeAvailability, + AgentRuntimeHealth, + AgentRuntimeSessionRef, +} from "../types" + +async function inspectCustomAcpRuntime(): Promise { + const manifest = getAgentRuntimeManifest("custom-acp") + + return { + availability: "unsupported", + statusReason: + "Configure a Moss Custom ACP endpoint or command adapter before starting sessions.", + authMethod: "unsupported", + models: manifest.models?.map((model) => ({ + ...model, + availability: "unsupported", + reason: "Custom ACP does not have a configured adapter yet.", + })), + } +} + +export const customAcpAdapter: AgentRuntimeAdapter = { + manifest: getAgentRuntimeManifest("custom-acp"), + async inspect( + _session: AgentRuntimeSessionRef, + ): Promise { + return inspectCustomAcpRuntime() + }, + async canStart( + _session: AgentRuntimeSessionRef, + ): Promise { + return (await inspectCustomAcpRuntime()).availability + }, +} diff --git a/src/main/lib/agent-runtime/adapters/hermes.ts b/src/main/lib/agent-runtime/adapters/hermes.ts new file mode 100644 index 000000000..956c4b792 --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/hermes.ts @@ -0,0 +1,67 @@ +import { getAgentRuntimeManifest } from "../manifests" +import type { + AgentRuntimeAdapter, + AgentRuntimeAvailability, + AgentRuntimeHealth, + AgentRuntimeSessionRef, +} from "../types" +import { resolveHermesRuntime } from "../../hermes/runtime" + +async function inspectHermesRuntime(): Promise { + const manifest = getAgentRuntimeManifest("hermes") + const runtime = resolveHermesRuntime() + + if (!runtime.executable && !runtime.sourceRoot) { + return { + availability: "not-installed", + statusReason: "Hermes CLI and source root were not found.", + authMethod: "unknown", + models: manifest.models?.map((model) => ({ + ...model, + availability: "not-installed", + reason: "Hermes is not installed.", + })), + } + } + + if ((runtime.acpExecutable || runtime.executable) && runtime.acpAdapterPath) { + const launchPath = runtime.acpExecutable || `${runtime.executable} acp` + return { + availability: "available", + statusReason: + `Hermes ACP transport is available via ${launchPath}.`, + authMethod: "shell-config", + models: manifest.models?.map((model) => ({ + ...model, + availability: "available", + reason: "Hermes uses the current ACP runtime model unless a concrete ACP model is selected.", + })), + } + } + + return { + availability: "unsupported", + statusReason: + `Hermes source detected at ${runtime.sourceRoot || "unknown"}, but executable or ACP adapter is missing.`, + authMethod: "unknown", + models: manifest.models?.map((model) => ({ + ...model, + availability: "unsupported", + reason: "Hermes executable or ACP adapter is missing.", + })), + } +} + +export const hermesAdapter: AgentRuntimeAdapter = { + manifest: getAgentRuntimeManifest("hermes"), + async inspect( + _session: AgentRuntimeSessionRef, + ): Promise { + return inspectHermesRuntime() + }, + async canStart( + _session: AgentRuntimeSessionRef, + ): Promise { + return (await inspectHermesRuntime()).availability + }, +} diff --git a/src/main/lib/agent-runtime/adapters/index.ts b/src/main/lib/agent-runtime/adapters/index.ts new file mode 100644 index 000000000..8300e96d6 --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/index.ts @@ -0,0 +1,21 @@ +import { claudeCodeAdapter } from "./claude-code" +import { codexAdapter } from "./codex" +import { customAcpAdapter } from "./custom-acp" +import { hermesAdapter } from "./hermes" +import type { AgentEngineId, AgentRuntimeAdapter } from "../types" + +export const agentRuntimeAdapters: Record< + AgentEngineId, + AgentRuntimeAdapter +> = { + "claude-code": claudeCodeAdapter, + codex: codexAdapter, + hermes: hermesAdapter, + "custom-acp": customAcpAdapter, +} + +export function getAgentRuntimeAdapter( + engineId: AgentEngineId, +): AgentRuntimeAdapter { + return agentRuntimeAdapters[engineId] +} diff --git a/src/main/lib/agent-runtime/codex-native-message-parts.ts b/src/main/lib/agent-runtime/codex-native-message-parts.ts new file mode 100644 index 000000000..7b35720ff --- /dev/null +++ b/src/main/lib/agent-runtime/codex-native-message-parts.ts @@ -0,0 +1,302 @@ +import { isCodexRuntimeNoticeText } from "../../../shared/codex-runtime-notices" + +export type CodexNativeMessagePart = { + type: string + text?: string + state?: string + toolCallId?: string + toolName?: string + input?: unknown + result?: unknown + output?: unknown + startedAt?: number + title?: string + [key: string]: unknown +} + +export type CodexNativeToolResultUpdate = { + output: unknown + input?: unknown + isError?: boolean +} + +export type CodexNativeMessagePartChange = { + part: CodexNativeMessagePart + didStart: boolean +} + +function mergeToolInput( + existingInput: unknown, + nextInput: unknown, +): unknown { + const canMergeInput = + existingInput && + typeof existingInput === "object" && + !Array.isArray(existingInput) && + nextInput && + typeof nextInput === "object" && + !Array.isArray(nextInput) + + if (!canMergeInput) return nextInput + + return { + ...(existingInput as Record), + ...(nextInput as Record), + } +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +function normalizeToolCommand(value: string): string { + return value.replace(/\s+/g, " ").trim() +} + +function normalizeComparableText(value: unknown): string { + return String(value ?? "") + .replace(/\s+/g, " ") + .trim() +} + +export const isCodexNativeRuntimeNoticeText = isCodexRuntimeNoticeText + +function getLastTextPart(parts: CodexNativeMessagePart[]) { + const lastPart = parts.at(-1) + return lastPart?.type === "text" ? lastPart : null +} + +function isDuplicateAdjacentText( + parts: CodexNativeMessagePart[], + nextText: string, +): boolean { + const lastTextPart = getLastTextPart(parts) + if (!lastTextPart) return false + const normalizedNextText = normalizeComparableText(nextText) + return ( + Boolean(normalizedNextText) && + normalizeComparableText(lastTextPart.text) === normalizedNextText + ) +} + +function getToolInputCommand(input: unknown): string | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined + + const record = input as Record + return ( + stringValue(record.cmd) ?? + stringValue(record.command) ?? + stringValue(record.rawCommand) + ) +} + +function getToolSignature(toolName: string | undefined, input: unknown): string | null { + if (!toolName) return null + const command = getToolInputCommand(input) + if (!command) return null + return `${toolName}:${normalizeToolCommand(command)}` +} + +function areNativeMirrorCallIds(first: string, second: string): boolean { + return ( + (first.startsWith("call_") && second.startsWith("item_")) || + (first.startsWith("item_") && second.startsWith("call_")) + ) +} + +export function createCodexNativeMessagePartsAccumulator() { + const messageParts: CodexNativeMessagePart[] = [] + const toolParts: CodexNativeMessagePart[] = [] + const toolPartIndexByCallId = new Map() + const recentToolPartIndexBySignature = new Map() + let activeTextPart: CodexNativeMessagePart | null = null + let finalTextPart: CodexNativeMessagePart | null = null + let lastStandaloneCommentaryText: string | null = null + + const resetRecentToolSignatures = () => { + recentToolPartIndexBySignature.clear() + } + + const closeActiveTextPart = () => { + activeTextPart = null + } + + const closeFinalTextPart = () => { + finalTextPart = null + } + + return { + get parts() { + return messageParts + }, + get toolParts() { + return toolParts + }, + replaceWith(snapshot: { + parts: CodexNativeMessagePart[] + toolParts: CodexNativeMessagePart[] + }) { + messageParts.splice(0, messageParts.length, ...snapshot.parts) + toolParts.splice(0, toolParts.length, ...snapshot.toolParts) + toolPartIndexByCallId.clear() + for (const [index, part] of toolParts.entries()) { + if (typeof part.toolCallId === "string") { + toolPartIndexByCallId.set(part.toolCallId, index) + } + } + resetRecentToolSignatures() + activeTextPart = null + finalTextPart = null + lastStandaloneCommentaryText = null + }, + closeActiveTextPart, + closeFinalTextPart, + closeTextParts() { + closeActiveTextPart() + closeFinalTextPart() + }, + appendTextDelta(delta: string): CodexNativeMessagePartChange | null { + if (!delta) return null + if (isCodexNativeRuntimeNoticeText(delta)) return null + lastStandaloneCommentaryText = null + resetRecentToolSignatures() + if (!activeTextPart) { + if (isDuplicateAdjacentText(messageParts, delta)) return null + activeTextPart = { + type: "text", + text: "", + state: "done", + } + messageParts.push(activeTextPart) + activeTextPart.text = delta + return { part: activeTextPart, didStart: true } + } + + activeTextPart.text = `${activeTextPart.text ?? ""}${delta}` + return { part: activeTextPart, didStart: false } + }, + appendFinalTextDelta(delta: string): CodexNativeMessagePartChange | null { + if (!delta) return null + if (isCodexNativeRuntimeNoticeText(delta)) return null + lastStandaloneCommentaryText = null + resetRecentToolSignatures() + closeActiveTextPart() + if (!finalTextPart) { + if (isDuplicateAdjacentText(messageParts, delta)) return null + finalTextPart = { + type: "text", + text: "", + state: "done", + } + messageParts.push(finalTextPart) + finalTextPart.text = delta + return { part: finalTextPart, didStart: true } + } + + finalTextPart.text = `${finalTextPart.text ?? ""}${delta}` + return { part: finalTextPart, didStart: false } + }, + appendCommentaryText(text: string): CodexNativeMessagePart | null { + const commentaryText = text.trim() + if (!commentaryText) return null + if (isCodexNativeRuntimeNoticeText(commentaryText)) return null + if (commentaryText === lastStandaloneCommentaryText) return null + if (isDuplicateAdjacentText(messageParts, commentaryText)) return null + + resetRecentToolSignatures() + closeActiveTextPart() + const commentaryPart: CodexNativeMessagePart = { + type: "text", + text: commentaryText, + state: "done", + } + messageParts.push(commentaryPart) + lastStandaloneCommentaryText = commentaryText + return commentaryPart + }, + startTool(params: { + callId: string + toolName: string + input: unknown + title?: string + startedAt?: number + }): CodexNativeMessagePartChange { + lastStandaloneCommentaryText = null + closeActiveTextPart() + closeFinalTextPart() + + const existingIndex = toolPartIndexByCallId.get(params.callId) + if (typeof existingIndex === "number") { + return { + part: toolParts[existingIndex], + didStart: false, + } + } + + const signature = getToolSignature(params.toolName, params.input) + const duplicateIndex = signature + ? recentToolPartIndexBySignature.get(signature) + : undefined + const duplicatePart = + typeof duplicateIndex === "number" ? toolParts[duplicateIndex] : null + if ( + typeof duplicateIndex === "number" && + duplicatePart && + typeof duplicatePart.toolCallId === "string" && + areNativeMirrorCallIds(duplicatePart.toolCallId, params.callId) + ) { + duplicatePart.input = mergeToolInput(duplicatePart.input, params.input) + if (params.title && !duplicatePart.title) duplicatePart.title = params.title + toolPartIndexByCallId.set(params.callId, duplicateIndex) + return { + part: duplicatePart, + didStart: false, + } + } + + toolPartIndexByCallId.set(params.callId, toolParts.length) + const toolPart: CodexNativeMessagePart = { + type: `tool-${params.toolName}`, + toolCallId: params.callId, + toolName: params.toolName, + input: params.input, + state: "call", + startedAt: params.startedAt ?? Date.now(), + ...(params.title ? { title: params.title } : {}), + } + toolParts.push(toolPart) + messageParts.push(toolPart) + if (signature) { + recentToolPartIndexBySignature.set(signature, toolParts.length - 1) + } + + return { + part: toolPart, + didStart: true, + } + }, + updateToolResult( + callId: string, + update: CodexNativeToolResultUpdate, + ): CodexNativeMessagePart | null { + const partIndex = toolPartIndexByCallId.get(callId) + if (typeof partIndex !== "number") return null + + const toolPart = toolParts[partIndex] + toolPart.result = update.output + toolPart.output = update.output + toolPart.state = update.isError ? "output-error" : "result" + if (update.input !== undefined) { + toolPart.input = mergeToolInput(toolPart.input, update.input) + } + + return toolPart + }, + snapshot() { + return { + parts: messageParts, + toolParts, + } + }, + } +} diff --git a/src/main/lib/agent-runtime/codex-native-recovery.ts b/src/main/lib/agent-runtime/codex-native-recovery.ts new file mode 100644 index 000000000..9d11ad588 --- /dev/null +++ b/src/main/lib/agent-runtime/codex-native-recovery.ts @@ -0,0 +1,467 @@ +import { createHash } from "node:crypto" +import { readdir, readFile } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" +import { + codexJsonlEventToNativeToolEvent, + extractCodexJsonlEventText, + isCodexJsonlCommentaryTextEvent, + isCodexJsonlDeltaTextEvent, + isCodexJsonlFinalTextEvent, + parseCodexJsonlEventLine, + type CodexJsonlEvent, +} from "./codex-native-session" +import { + createCodexNativeMessagePartsAccumulator, + type CodexNativeMessagePart, +} from "./codex-native-message-parts" + +export type CodexNativeRecoveredMessage = { + role?: string + parts?: CodexNativeMessagePart[] + content?: unknown + [key: string]: unknown +} + +export type CodexNativeSessionTurn = { + userText: string + events: CodexJsonlEvent[] +} + +export type CodexNativeMessageRecoveryResult< + TMessage extends CodexNativeRecoveredMessage, +> = { + messages: TMessage[] + changed: boolean + recoveredAssistantCount: number +} + +function stableEventHash(event: CodexJsonlEvent): string { + try { + return createHash("sha1").update(JSON.stringify(event)).digest("hex") + } catch { + return createHash("sha1").update(String(event)).digest("hex") + } +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function normalizeText(value: unknown): string { + return String(value ?? "") + .replace(/\s+/g, " ") + .trim() +} + +function getToolInputCommand(input: unknown): string | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return undefined + + const record = input as Record + return ( + stringValue(record.cmd) ?? + stringValue(record.command) ?? + stringValue(record.rawCommand) + ) +} + +function getPartToolSignature( + part: Pick, +): string | null { + if (!part.type?.startsWith("tool-")) return null + const toolName = stringValue(part.toolName) ?? part.type.slice("tool-".length) + const command = getToolInputCommand(part.input) + if (!toolName || !command) return null + return `${toolName}:${normalizeText(command)}` +} + +function areNativeMirrorCallIds(first: unknown, second: unknown): boolean { + if (typeof first !== "string" || typeof second !== "string") return false + return ( + (first.startsWith("call_") && second.startsWith("item_")) || + (first.startsWith("item_") && second.startsWith("call_")) + ) +} + +function extractTextFromContent(content: unknown): string | undefined { + if (typeof content === "string") return content + if (!Array.isArray(content)) return undefined + + const text = content + .map((item) => { + if (!item || typeof item !== "object") return "" + const record = item as Record + return stringValue(record.text) ?? stringValue(record.content) ?? "" + }) + .join("") + .trim() + + return text || undefined +} + +function extractCodexUserEventText(event: CodexJsonlEvent): string { + const payload = (event as any)?.payload + const extractedText = extractCodexJsonlEventText(event) + const text = + extractedText ?? + stringValue(payload?.message) ?? + stringValue(payload?.text) ?? + extractTextFromContent(payload?.content) ?? + extractTextFromContent((event as any)?.content) + + return normalizeText(text) +} + +export function isCodexJsonlUserEvent(event: CodexJsonlEvent): boolean { + const payload = (event as any)?.payload + if ((event as any)?.type === "event_msg" && payload?.type === "user_message") { + return true + } + if ((event as any)?.type === "response_item" && payload?.role === "user") { + return true + } + if (payload?.role === "user" || (event as any)?.role === "user") { + return true + } + return false +} + +export function buildNativePartsFromCodexEvents(events: CodexJsonlEvent[]) { + const parts = createCodexNativeMessagePartsAccumulator() + const handledEventHashes = new Set() + const seenFinalTexts = new Set() + + for (const event of events) { + const eventHash = stableEventHash(event) + if (handledEventHashes.has(eventHash)) continue + handledEventHashes.add(eventHash) + + if (isCodexJsonlUserEvent(event)) continue + + const text = extractCodexJsonlEventText(event) + if (text && isCodexJsonlCommentaryTextEvent(event)) { + parts.appendCommentaryText(text) + continue + } + + const toolEvent = codexJsonlEventToNativeToolEvent(event) + if (toolEvent?.kind === "tool-input") { + parts.startTool({ + callId: toolEvent.callId, + toolName: toolEvent.toolName, + input: toolEvent.input, + ...(toolEvent.title ? { title: toolEvent.title } : {}), + }) + } else if (toolEvent?.kind === "tool-output") { + parts.updateToolResult(toolEvent.callId, { + output: toolEvent.output, + ...(toolEvent.input !== undefined ? { input: toolEvent.input } : {}), + ...(toolEvent.isError ? { isError: true } : {}), + }) + } + + if (!text) continue + + if (isCodexJsonlFinalTextEvent(event)) { + const finalText = text.trim() + if (finalText && !seenFinalTexts.has(finalText)) { + seenFinalTexts.add(finalText) + parts.appendFinalTextDelta(text) + } + continue + } + + if (isCodexJsonlDeltaTextEvent(event)) { + parts.appendTextDelta(text) + } + } + + return parts.snapshot() +} + +export function getCodexNativePartsRichness(snapshot: { + parts: Array<{ + type: string + text?: string + result?: unknown + output?: unknown + }> + toolParts: Array<{ result?: unknown; output?: unknown }> +}): number { + const textParts = snapshot.parts.filter( + (part) => + part.type === "text" && + typeof part.text === "string" && + part.text.trim(), + ).length + const toolParts = snapshot.toolParts.length + const toolOutputs = snapshot.toolParts.filter( + (part) => part.result !== undefined || part.output !== undefined, + ).length + return textParts * 10 + toolParts * 3 + toolOutputs +} + +function isCodexNativeSetupLeakText(text: string): boolean { + if (!text) return false + return ( + text.startsWith("") || + text.startsWith("") || + text.startsWith("# AGENTS.md instructions") || + text.includes("Filesystem sandboxing defines which files can be read or written") || + text.includes("A skill is a set of local instructions to follow") || + text.includes("Approval policy is currently") + ) +} + +function getCodexNativePartsDuplicatePenalty(snapshot: { + parts: CodexNativeMessagePart[] +}): number { + let penalty = 0 + let previousText = "" + let toolCallIdBySignature = new Map() + + for (const part of snapshot.parts) { + if (part.type === "text") { + const text = normalizeText(part.text) + if (isCodexNativeSetupLeakText(text)) penalty += 100 + if (text && text === previousText) penalty += 1 + previousText = text + toolCallIdBySignature = new Map() + continue + } + + previousText = "" + if (!part.type?.startsWith("tool-")) continue + + const signature = getPartToolSignature(part) + if (!signature) continue + + const previousCallId = toolCallIdBySignature.get(signature) + if (areNativeMirrorCallIds(previousCallId, part.toolCallId)) { + penalty += 1 + continue + } + + toolCallIdBySignature.set(signature, part.toolCallId) + } + + return penalty +} + +function getCodexNativePartsReplayScore(snapshot: { + parts: CodexNativeMessagePart[] + toolParts: Array<{ result?: unknown; output?: unknown }> +}): number { + return ( + getCodexNativePartsRichness(snapshot) - + getCodexNativePartsDuplicatePenalty(snapshot) * 20 + ) +} + +export function splitCodexSessionEventsIntoTurns( + events: CodexJsonlEvent[], +): CodexNativeSessionTurn[] { + const turns: CodexNativeSessionTurn[] = [] + let currentTurn: CodexNativeSessionTurn | null = null + + for (const event of events) { + if (isCodexJsonlUserEvent(event)) { + const userText = extractCodexUserEventText(event) + if ( + currentTurn && + currentTurn.events.length === 0 && + normalizeText(currentTurn.userText) === userText + ) { + continue + } + + currentTurn = { userText, events: [] } + turns.push(currentTurn) + continue + } + + currentTurn?.events.push(event) + } + + return turns.filter((turn) => turn.events.length > 0) +} + +function getMessageText(message: CodexNativeRecoveredMessage): string { + if (Array.isArray(message.parts)) { + const text = message.parts + .map((part) => { + if (!part || typeof part !== "object") return "" + if (part.type === "text" && typeof part.text === "string") { + return part.text + } + return "" + }) + .join("") + .trim() + if (text) return normalizeText(text) + } + + if (typeof message.content === "string") { + return normalizeText(message.content) + } + + return "" +} + +function getMessageSnapshot(message: CodexNativeRecoveredMessage) { + const parts = Array.isArray(message.parts) ? message.parts : [] + const toolParts = parts.filter((part) => part.type?.startsWith("tool-")) + return { parts, toolParts } +} + +function userTextsMatch(storedText: string, turnText: string): boolean { + if (!storedText || !turnText) return false + if (storedText === turnText) return true + return storedText.includes(turnText) || turnText.includes(storedText) +} + +function findMatchingTurnIndex( + turns: CodexNativeSessionTurn[], + turnCursor: number, + userText: string, +): number { + const normalizedUserText = normalizeText(userText) + if (!normalizedUserText) return turnCursor + + for (let index = turnCursor; index < turns.length; index += 1) { + if (userTextsMatch(normalizedUserText, normalizeText(turns[index].userText))) { + return index + } + } + + return turnCursor +} + +export function recoverCodexNativeMessagesFromSessionEvents< + TMessage extends CodexNativeRecoveredMessage, +>( + messages: TMessage[], + events: CodexJsonlEvent[], +): CodexNativeMessageRecoveryResult { + const turns = splitCodexSessionEventsIntoTurns(events) + if (turns.length === 0 || messages.length === 0) { + return { messages, changed: false, recoveredAssistantCount: 0 } + } + + let changed = false + let recoveredAssistantCount = 0 + let turnCursor = 0 + const nextMessages = [...messages] + + for (let index = 0; index < nextMessages.length; index += 1) { + const userMessage = nextMessages[index] + if (userMessage.role !== "user") continue + + const assistantIndex = nextMessages.findIndex( + (message, candidateIndex) => + candidateIndex > index && message.role === "assistant", + ) + if (assistantIndex === -1 || turnCursor >= turns.length) continue + + const turnIndex = findMatchingTurnIndex( + turns, + turnCursor, + getMessageText(userMessage), + ) + const turn = turns[turnIndex] + if (!turn) continue + + const replaySnapshot = buildNativePartsFromCodexEvents(turn.events) + const existingSnapshot = getMessageSnapshot(nextMessages[assistantIndex]) + if ( + getCodexNativePartsReplayScore(replaySnapshot) > + getCodexNativePartsReplayScore(existingSnapshot) + ) { + nextMessages[assistantIndex] = { + ...nextMessages[assistantIndex], + parts: replaySnapshot.parts, + } + changed = true + recoveredAssistantCount += 1 + } + + turnCursor = turnIndex + 1 + index = assistantIndex + } + + return { + messages: changed ? nextMessages : messages, + changed, + recoveredAssistantCount, + } +} + +export async function findCodexNativeSessionFileById( + sessionId: string, +): Promise { + const cleanedSessionId = sessionId.trim() + if (!cleanedSessionId) return null + + const sessionsRoot = join( + process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), + "sessions", + ) + const fileSuffix = `-${cleanedSessionId}.jsonl` + const sortDesc = (values: string[]) => + values.sort((left, right) => + right.localeCompare(left, undefined, { numeric: true }), + ) + const listNames = async (dirPath: string): Promise => { + try { + return await readdir(dirPath, { encoding: "utf8" }) + } catch { + return [] + } + } + + const years = sortDesc( + (await listNames(sessionsRoot)).filter((name) => /^\d{4}$/.test(name)), + ) + for (const year of years) { + const yearPath = join(sessionsRoot, year) + const months = sortDesc( + (await listNames(yearPath)).filter((name) => /^\d{2}$/.test(name)), + ) + for (const month of months) { + const monthPath = join(yearPath, month) + const days = sortDesc( + (await listNames(monthPath)).filter((name) => /^\d{2}$/.test(name)), + ) + for (const day of days) { + const dayPath = join(monthPath, day) + const fileName = (await listNames(dayPath)).find((name) => + name.endsWith(fileSuffix), + ) + if (fileName) return join(dayPath, fileName) + } + } + } + + return null +} + +export async function readCodexNativeSessionEventsById( + sessionId: string, +): Promise { + const sessionFile = await findCodexNativeSessionFileById(sessionId) + if (!sessionFile) return [] + + let rawContent = "" + try { + rawContent = await readFile(sessionFile, "utf8") + } catch { + return [] + } + + const events: CodexJsonlEvent[] = [] + for (const line of rawContent.split(/\r?\n/)) { + const event = parseCodexJsonlEventLine(line) + if (event) events.push(event) + } + return events +} diff --git a/src/main/lib/agent-runtime/codex-native-resume.ts b/src/main/lib/agent-runtime/codex-native-resume.ts new file mode 100644 index 000000000..6ab3fa525 --- /dev/null +++ b/src/main/lib/agent-runtime/codex-native-resume.ts @@ -0,0 +1,168 @@ +import { stripCodexRuntimeNoticeText } from "../../../shared/codex-runtime-notices" + +export const CODEX_NATIVE_RESUME_MAX_STORED_MESSAGES_BYTES = 8 * 1024 * 1024 + +export type CodexNativeResumeSkipReason = + | "force-new-session" + | "oversized-transcript" + | "runtime-notice-only-terminal" + +export type CodexStoredMessage = { + role?: unknown + parts?: unknown + metadata?: unknown + [key: string]: unknown +} + +type CodexStoredPart = { + type?: unknown + text?: unknown + [key: string]: unknown +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null +} + +function parseRuntimeMetadata( + runtimeMetadata: string | Record | null | undefined, +): Record { + if (!runtimeMetadata) return {} + if (typeof runtimeMetadata === "object") return runtimeMetadata + try { + const parsed = JSON.parse(runtimeMetadata) + return asRecord(parsed) ?? {} + } catch { + return {} + } +} + +function isRuntimeNoticeTextPart(part: unknown): boolean { + const record = asRecord(part) as CodexStoredPart | null + if (!record) return false + if (record.type !== "text") return false + const stripped = stripCodexRuntimeNoticeText(record.text) + return stripped.changed && stripped.text.trim().length === 0 +} + +export function isCodexNativeRuntimeNoticeOnlyAssistantMessage( + message: CodexStoredMessage, +): boolean { + if (message.role !== "assistant") return false + if (!Array.isArray(message.parts) || message.parts.length === 0) return false + return message.parts.every(isRuntimeNoticeTextPart) +} + +export function stripCodexNativeRuntimeNoticeMessages( + messages: CodexStoredMessage[], +): { + messages: CodexStoredMessage[] + removedCount: number + removedPartCount: number +} { + let removedCount = 0 + let removedPartCount = 0 + const cleanedMessages: CodexStoredMessage[] = [] + + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.parts)) { + cleanedMessages.push(message) + continue + } + + const cleanedParts: unknown[] = [] + let messageChanged = false + + for (const part of message.parts) { + const record = asRecord(part) as CodexStoredPart | null + if (!record || record.type !== "text") { + cleanedParts.push(part) + continue + } + + const stripped = stripCodexRuntimeNoticeText(record.text) + if (!stripped.changed) { + cleanedParts.push(part) + continue + } + + removedPartCount += 1 + messageChanged = true + if (stripped.text.trim().length === 0) continue + + cleanedParts.push({ + ...record, + text: stripped.text, + }) + } + + if (cleanedParts.length === 0) { + removedCount += 1 + continue + } + + cleanedMessages.push( + !messageChanged && cleanedParts.length === message.parts.length + ? message + : { + ...message, + parts: cleanedParts, + }, + ) + } + + return { + messages: cleanedMessages, + removedCount, + removedPartCount, + } +} + +export function shouldStartFreshCodexNativeSession(params: { + storedMessagesByteLength: number + runtimeMetadata: string | Record | null | undefined + candidateSessionId?: string | null + forceNewSession?: boolean + messages: CodexStoredMessage[] +}): { + startFresh: boolean + reason?: CodexNativeResumeSkipReason +} { + if (params.forceNewSession) { + return { startFresh: true, reason: "force-new-session" } + } + + if (!params.candidateSessionId) { + return { startFresh: false } + } + + if ( + params.storedMessagesByteLength > + CODEX_NATIVE_RESUME_MAX_STORED_MESSAGES_BYTES + ) { + return { startFresh: true, reason: "oversized-transcript" } + } + + const lastAssistantMessage = [...params.messages] + .reverse() + .find((message) => message.role === "assistant") + + if ( + lastAssistantMessage && + isCodexNativeRuntimeNoticeOnlyAssistantMessage(lastAssistantMessage) + ) { + return { startFresh: true, reason: "runtime-notice-only-terminal" } + } + + const runtimeMetadata = parseRuntimeMetadata(params.runtimeMetadata) + if ( + runtimeMetadata.resultSubtype === "running" && + !params.messages.some((message) => message.role === "assistant") + ) { + return { startFresh: true, reason: "runtime-notice-only-terminal" } + } + + return { startFresh: false } +} diff --git a/src/main/lib/agent-runtime/codex-native-session.ts b/src/main/lib/agent-runtime/codex-native-session.ts new file mode 100644 index 000000000..7df058473 --- /dev/null +++ b/src/main/lib/agent-runtime/codex-native-session.ts @@ -0,0 +1,1593 @@ +import { spawn } from "node:child_process" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { StringDecoder } from "node:string_decoder" +import type { AgentPermissionMode } from "./types" + +export type CodexNativeBridgeAction = "start" | "resume" | "fork" + +export type CodexNativeBridgeKind = + | "codex-exec-start" + | "codex-exec-resume" + | "codex-tui-fork" + +export type CodexNativeBridgeMode = "headless-exec" | "native-tui" +export type CodexNativePromptSource = "stdin" | "argument" | "none" + +export interface CodexNativeSessionBridgePlan { + engine: "codex" + action: CodexNativeBridgeAction + bridge: CodexNativeBridgeKind + mode: CodexNativeBridgeMode + command: string + args: string[] + cwd: string + sessionId?: string + modelId?: string + modelReasoningEffort?: string + permissionMode: AgentPermissionMode + promptSource: CodexNativePromptSource + imagePaths: string[] + imageCount: number + canRunHeadless: boolean + notes: string[] +} + +export interface CodexNativeImageAttachment { + base64Data: string + mediaType: string + filename?: string | null +} + +export interface BuildCodexNativeSessionBridgePlanInput { + action: CodexNativeBridgeAction + sessionId?: string | null + cwd: string + modelId?: string | null + permissionMode?: AgentPermissionMode | null + prompt?: string | null + promptSource?: CodexNativePromptSource + command?: string | null + includeJson?: boolean + skipGitRepoCheck?: boolean + imagePaths?: string[] | null +} + +export interface CodexNativeCommandRunnerInput { + command: string + args: string[] + cwd: string + stdin?: string + env?: NodeJS.ProcessEnv + abortSignal?: AbortSignal + onStdoutJsonEvent?: (event: CodexJsonlEvent) => void +} + +export interface CodexNativeCommandRunnerResult { + stdout: string + stderr: string + exitCode: number | null +} + +export type CodexNativeCommandRunner = ( + input: CodexNativeCommandRunnerInput, +) => Promise + +export type CodexJsonlEvent = Record + +export type CodexNativeToolEvent = + | { + kind: "tool-input" + callId: string + toolName: string + input: unknown + title?: string + } + | { + kind: "tool-output" + callId: string + output: unknown + toolName?: string + input?: unknown + title?: string + isError?: boolean + } + +export type CodexNativeTextAppendKind = + | "fresh" + | "suffix" + | "overlap" + | "duplicate" + | "separate" + +export interface CodexNativeTextAppendResult { + appendText: string + kind: CodexNativeTextAppendKind +} + +export interface CodexExecResumeEventSummary { + nativeSessionId?: string + lastText?: string + usage?: Record + error?: string +} + +export interface RunCodexExecResumeBridgeInput { + sessionId: string + cwd: string + prompt: string + modelId?: string | null + permissionMode?: AgentPermissionMode | null + command?: string | null + includeJson?: boolean + skipGitRepoCheck?: boolean + runner?: CodexNativeCommandRunner + env?: NodeJS.ProcessEnv + abortSignal?: AbortSignal + onEvent?: (event: CodexJsonlEvent) => void + images?: CodexNativeImageAttachment[] | null +} + +export interface RunCodexExecStartBridgeInput { + cwd: string + prompt: string + modelId?: string | null + permissionMode?: AgentPermissionMode | null + command?: string | null + includeJson?: boolean + skipGitRepoCheck?: boolean + runner?: CodexNativeCommandRunner + env?: NodeJS.ProcessEnv + abortSignal?: AbortSignal + onEvent?: (event: CodexJsonlEvent) => void + images?: CodexNativeImageAttachment[] | null +} + +export interface RunCodexExecBridgeInput + extends Omit { + action: "start" | "resume" + sessionId?: string | null + runner?: CodexNativeCommandRunner +} + +export interface CodexExecResumeBridgeResult + extends CodexNativeCommandRunnerResult, + CodexExecResumeEventSummary { + success: boolean + plan: CodexNativeSessionBridgePlan + events: CodexJsonlEvent[] +} + +const MIN_CODEX_NATIVE_TEXT_OVERLAP = 8 +const MIN_CODEX_NATIVE_REPEATED_FINAL_TEXT_LENGTH = 80 + +function normalizeCodexNativeComparableText(value: string): string { + return value.replace(/\r\n/g, "\n") +} + +function codexNativeTextOverlapLength( + existingText: string, + nextText: string, +): number { + const maxLength = Math.min(existingText.length, nextText.length) + + for (let length = maxLength; length >= MIN_CODEX_NATIVE_TEXT_OVERLAP; length--) { + if (existingText.endsWith(nextText.slice(0, length))) { + return length + } + } + + return 0 +} + +export function reconcileCodexNativeTextAppend( + existingText: string, + nextText: string, +): CodexNativeTextAppendResult { + if (!nextText) return { appendText: "", kind: "duplicate" } + if (!existingText) return { appendText: nextText, kind: "fresh" } + if (nextText.startsWith(existingText)) { + const appendText = nextText.slice(existingText.length) + return { + appendText, + kind: appendText ? "suffix" : "duplicate", + } + } + if (existingText.includes(nextText)) { + return { appendText: "", kind: "duplicate" } + } + + const overlapLength = codexNativeTextOverlapLength(existingText, nextText) + if (overlapLength > 0) { + const appendText = nextText.slice(overlapLength) + return { + appendText, + kind: appendText ? "overlap" : "duplicate", + } + } + + return { appendText: nextText, kind: "separate" } +} + +export function isCodexNativeRepeatedFinalText( + existingText: string, + nextText: string, +): boolean { + const existing = normalizeCodexNativeComparableText(existingText).trim() + const next = normalizeCodexNativeComparableText(nextText).trim() + if ( + existing.length < MIN_CODEX_NATIVE_REPEATED_FINAL_TEXT_LENGTH || + next.length <= existing.length || + !next.startsWith(existing) + ) { + return false + } + + const repeatedSuffix = next.slice(existing.length) + if (repeatedSuffix.length % existing.length !== 0) return false + + return repeatedSuffix === existing.repeat(repeatedSuffix.length / existing.length) +} + +function cleanString(value: string | null | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +export function splitCodexTextForStreamingDeltas( + text: string, + maxChunkLength = 36, +): string[] { + if (!text) return [] + if (maxChunkLength <= 0 || text.length <= maxChunkLength) return [text] + + const chunks: string[] = [] + let remaining = text + const minSoftBreakIndex = Math.max(1, Math.floor(maxChunkLength * 0.45)) + + while (remaining.length > maxChunkLength) { + const candidate = remaining.slice(0, maxChunkLength + 1) + let breakIndex = -1 + + for (let index = candidate.length - 1; index >= minSoftBreakIndex; index -= 1) { + const char = candidate[index] + if (char && /[\s\n.,!?;:,。!?;:、]/.test(char)) { + breakIndex = index + 1 + break + } + } + + if (breakIndex <= 0) { + breakIndex = maxChunkLength + } + + chunks.push(remaining.slice(0, breakIndex)) + remaining = remaining.slice(breakIndex) + } + + if (remaining) chunks.push(remaining) + return chunks +} + +function requireCleanString( + value: string | null | undefined, + label: string, +): string { + const cleaned = cleanString(value) + if (!cleaned) { + throw new Error(`Codex native ${label} is required.`) + } + return cleaned +} + +function tomlString(value: string): string { + return JSON.stringify(value) +} + +function splitModelAndReasoning(modelId: string | undefined): { + modelId?: string + reasoningEffort?: string +} { + const cleaned = cleanString(modelId) + if (!cleaned) return {} + + const separatorIndex = cleaned.indexOf("/") + if (separatorIndex === -1) return { modelId: cleaned } + + const baseModel = cleanString(cleaned.slice(0, separatorIndex)) + const reasoningEffort = cleanString(cleaned.slice(separatorIndex + 1)) + return { + ...(baseModel ? { modelId: baseModel } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + } +} + +function appendCodexModelArgs( + args: string[], + modelId: string | undefined, +): { + modelId?: string + modelReasoningEffort?: string +} { + const parsed = splitModelAndReasoning(modelId) + if (parsed.modelId) args.push("-m", parsed.modelId) + if (parsed.reasoningEffort) { + args.push("-c", `model_reasoning_effort=${tomlString(parsed.reasoningEffort)}`) + } + + return { + ...(parsed.modelId ? { modelId: parsed.modelId } : {}), + ...(parsed.reasoningEffort + ? { modelReasoningEffort: parsed.reasoningEffort } + : {}), + } +} + +function appendCodexExecPermissionArgs( + args: string[], + permissionMode: AgentPermissionMode, +): string[] { + if (permissionMode === "bypass" || permissionMode === "full-access") { + args.push("--dangerously-bypass-approvals-and-sandbox") + return [ + permissionMode === "full-access" + ? "Moss full-access maps to Codex dangerous approval and sandbox bypass." + : "Moss bypass maps to Codex dangerous approval and sandbox bypass.", + ] + } + + if (permissionMode === "custom") { + return [ + "Moss custom permissions defer sandbox and approval policy to Codex config.toml.", + ] + } + + const sandboxMode = + permissionMode === "plan" || permissionMode === "read-only" + ? "read-only" + : "workspace-write" + const approvalPolicy = + permissionMode === "ask-approval" || permissionMode === "read-only" + ? "on-request" + : "never" + args.push("-c", `sandbox_mode=${tomlString(sandboxMode)}`) + args.push("-c", `approval_policy=${tomlString(approvalPolicy)}`) + + return [ + permissionMode === "plan" || permissionMode === "read-only" + ? "Moss read-only permissions map to Codex read-only sandbox." + : "Moss workspace permissions map to Codex workspace-write sandbox.", + approvalPolicy === "on-request" + ? "Moss ask-approval permissions map to Codex on-request approvals." + : "Legacy Moss agent permissions keep Codex exec approvals disabled for compatibility.", + ] +} + +function cleanImagePaths(imagePaths: string[] | null | undefined): string[] { + return Array.from( + new Set( + (imagePaths ?? []) + .map((imagePath) => cleanString(imagePath)) + .filter((imagePath): imagePath is string => Boolean(imagePath)), + ), + ) +} + +function appendCodexImageArgs( + args: string[], + imagePaths: string[] | null | undefined, +): string[] { + const cleanedPaths = cleanImagePaths(imagePaths) + for (const imagePath of cleanedPaths) { + args.push("-i", imagePath) + } + return cleanedPaths +} + +function codexImageNotes(imagePaths: string[]): string[] { + if (imagePaths.length === 0) return [] + return [ + `Codex exec attaches ${imagePaths.length} image file(s) through native --image arguments.`, + "Moss materializes uploaded images as transient files and removes them after the native command exits.", + ] +} + +function appendCodexTuiPermissionArgs( + args: string[], + permissionMode: AgentPermissionMode, +): string[] { + if (permissionMode === "bypass" || permissionMode === "full-access") { + args.push("--dangerously-bypass-approvals-and-sandbox") + return [ + permissionMode === "full-access" + ? "Moss full-access maps to Codex dangerous approval and sandbox bypass." + : "Moss bypass maps to Codex dangerous approval and sandbox bypass.", + ] + } + + if (permissionMode === "custom") { + return [ + "Moss custom permissions defer sandbox and approval policy to Codex config.toml.", + ] + } + + args.push( + "-s", + permissionMode === "plan" || permissionMode === "read-only" + ? "read-only" + : "workspace-write", + ) + args.push("-a", "on-request") + return [ + permissionMode === "plan" || permissionMode === "read-only" + ? "Moss read-only permissions map to Codex read-only sandbox." + : "Moss workspace permissions map to Codex workspace-write sandbox.", + "Codex native fork is TUI-backed, so interactive approvals remain available.", + ] +} + +function appendPromptArg( + args: string[], + prompt: string | null | undefined, + defaultSource: "stdin" | "none", + requestedSource?: CodexNativePromptSource, +): CodexNativePromptSource { + const cleanedPrompt = cleanString(prompt) + + if (requestedSource === "none") { + return "none" + } + + if (requestedSource === "stdin") { + args.push("-") + return "stdin" + } + + if (cleanedPrompt) { + args.push(cleanedPrompt) + return "argument" + } + + if (defaultSource === "stdin") { + args.push("-") + return "stdin" + } + + return "none" +} + +export function buildCodexNativeSessionBridgePlan( + input: BuildCodexNativeSessionBridgePlanInput, +): CodexNativeSessionBridgePlan { + const command = cleanString(input.command) ?? "codex" + const cwd = requireCleanString(input.cwd, "working directory") + const modelId = cleanString(input.modelId) + const permissionMode = input.permissionMode ?? "agent" + + if (input.action === "start") { + const args = ["exec"] + if (input.includeJson ?? true) args.push("--json") + if (input.skipGitRepoCheck ?? true) args.push("--skip-git-repo-check") + args.push("-C", cwd) + const modelArgs = appendCodexModelArgs(args, modelId) + const imagePaths = appendCodexImageArgs(args, input.imagePaths) + const notes = [ + ...appendCodexExecPermissionArgs(args, permissionMode), + ...codexImageNotes(imagePaths), + ] + const promptSource = appendPromptArg( + args, + input.prompt, + "stdin", + input.promptSource, + ) + + return { + engine: "codex", + action: "start", + bridge: "codex-exec-start", + mode: "headless-exec", + command, + args, + cwd, + ...modelArgs, + permissionMode, + promptSource, + imagePaths, + imageCount: imagePaths.length, + canRunHeadless: true, + notes, + } + } + + if (input.action === "resume") { + const sessionId = requireCleanString(input.sessionId, "session id") + const args = ["exec", "resume"] + if (input.includeJson ?? true) args.push("--json") + if (input.skipGitRepoCheck ?? true) args.push("--skip-git-repo-check") + const modelArgs = appendCodexModelArgs(args, modelId) + const imagePaths = appendCodexImageArgs(args, input.imagePaths) + const notes = [ + ...appendCodexExecPermissionArgs(args, permissionMode), + ...codexImageNotes(imagePaths), + ] + args.push(sessionId) + const promptSource = appendPromptArg( + args, + input.prompt, + "stdin", + input.promptSource, + ) + + return { + engine: "codex", + action: "resume", + bridge: "codex-exec-resume", + mode: "headless-exec", + command, + args, + cwd, + sessionId, + ...modelArgs, + permissionMode, + promptSource, + imagePaths, + imageCount: imagePaths.length, + canRunHeadless: true, + notes, + } + } + + const sessionId = requireCleanString(input.sessionId, "session id") + const args = ["fork", "--no-alt-screen", "-C", cwd] + const modelArgs = appendCodexModelArgs(args, modelId) + const requestedImagePaths = cleanImagePaths(input.imagePaths) + const notes = appendCodexTuiPermissionArgs(args, permissionMode) + args.push(sessionId) + const promptSource = appendPromptArg( + args, + input.prompt, + "none", + input.promptSource, + ) + + return { + engine: "codex", + action: "fork", + bridge: "codex-tui-fork", + mode: "native-tui", + command, + args, + cwd, + sessionId, + ...modelArgs, + permissionMode, + promptSource, + imagePaths: [], + imageCount: 0, + canRunHeadless: false, + notes: [ + ...notes, + "Codex fork is exposed by the native TUI command; no headless exec fork exists yet.", + ...(requestedImagePaths.length > 0 + ? ["Codex TUI fork image paths are not mapped because --image is only used on exec bridges."] + : []), + ], + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function eventPayload(event: CodexJsonlEvent): Record { + return isRecord(event.payload) ? event.payload : {} +} + +function eventItem(event: CodexJsonlEvent): Record { + const payload = eventPayload(event) + if (isRecord(payload.item)) return payload.item + if (isRecord(event.item)) return event.item + return {} +} + +function eventMessage(event: CodexJsonlEvent): Record { + const payload = eventPayload(event) + const item = eventItem(event) + if (isRecord(payload.message)) return payload.message + if (isRecord(item.message)) return item.message + if (isRecord(event.message)) return event.message + return {} +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? cleanString(value) : undefined +} + +function parseJsonLikeValue(value: unknown): unknown { + if (typeof value !== "string") return value + const trimmed = value.trim() + if (!trimmed) return value + + try { + return JSON.parse(trimmed) + } catch { + return value + } +} + +function recordValue(value: unknown): Record | undefined { + const parsed = parseJsonLikeValue(value) + return isRecord(parsed) ? parsed : undefined +} + +function contentStringValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + return value.trim() ? value : undefined +} + +function extractContentText(value: unknown): string | undefined { + if (typeof value === "string") return contentStringValue(value) + + if (Array.isArray(value)) { + const parts = value + .map((part) => extractContentText(part)) + .filter((part): part is string => Boolean(part)) + return cleanString(parts.join("")) + } + + if (!isRecord(value)) return undefined + + for (const key of ["text", "output_text", "delta"]) { + const text = contentStringValue(value[key]) + if (text) return text + } + + for (const key of ["content", "message"]) { + const text = extractContentText(value[key]) + if (text) return text + } + + return undefined +} + +function extractEventRole(event: CodexJsonlEvent): string | undefined { + const payload = eventPayload(event) + const item = eventItem(event) + const message = eventMessage(event) + return ( + stringValue(message.role) ?? + stringValue(item.role) ?? + stringValue(payload.role) ?? + stringValue(event.role) + ) +} + +function canUseEventAsAssistantTextSource(event: CodexJsonlEvent): boolean { + const payload = eventPayload(event) + const payloadType = stringValue(payload.type)?.toLowerCase() + if ((event as any)?.type === "event_msg" && payloadType === "user_message") { + return false + } + + const role = extractEventRole(event)?.toLowerCase() + return !role || role === "assistant" +} + +function extractEventPhase(event: CodexJsonlEvent): string | undefined { + const payload = eventPayload(event) + const item = eventItem(event) + const message = eventMessage(event) + return ( + stringValue(message.phase) ?? + stringValue(item.phase) ?? + stringValue(payload.phase) ?? + stringValue(event.phase) + ) +} + +function extractEventText(event: CodexJsonlEvent): string | undefined { + if (!canUseEventAsAssistantTextSource(event)) return undefined + + const payload = eventPayload(event) + const item = eventItem(event) + const message = eventMessage(event) + const candidates = [ + event.output_text, + payload.output_text, + item.output_text, + message.output_text, + event.text, + payload.text, + item.text, + message.text, + event.message, + payload.message, + item.message, + event.last_agent_message, + payload.last_agent_message, + item.last_agent_message, + event.delta, + payload.delta, + item.delta, + message.delta, + message.content, + item.content, + payload.content, + ] + + for (const candidate of candidates) { + const text = extractContentText(candidate) + if (text) return text + } + + return undefined +} + +function extractSessionId(event: CodexJsonlEvent): string | undefined { + const payload = eventPayload(event) + const item = eventItem(event) + const eventType = stringValue(event.type) + const payloadType = stringValue(payload.type) + const candidates = [ + event.nativeSessionId, + payload.nativeSessionId, + event.sessionId, + payload.sessionId, + event.session_id, + payload.session_id, + event.conversation_id, + payload.conversation_id, + event.threadId, + payload.threadId, + item.threadId, + event.thread_id, + payload.thread_id, + item.thread_id, + ] + + if (eventType === "session_meta" || payloadType === "session_meta") { + candidates.push(payload.id, event.id) + } + + for (const candidate of candidates) { + const sessionId = stringValue(candidate) + if (sessionId) return sessionId + } + + return undefined +} + +function extractUsage(event: CodexJsonlEvent): Record | undefined { + const payload = eventPayload(event) + const item = eventItem(event) + for (const candidate of [ + event.usage, + payload.usage, + item.usage, + event.token_usage, + payload.token_usage, + ]) { + if (isRecord(candidate)) return candidate + } + return undefined +} + +function extractError(event: CodexJsonlEvent): string | undefined { + const payload = eventPayload(event) + const item = eventItem(event) + const errorCandidates = [event.error, payload.error, item.error] + + for (const candidate of errorCandidates) { + const direct = stringValue(candidate) + if (direct) return direct + if (isRecord(candidate)) { + const message = stringValue(candidate.message) + if (message) return message + } + } + + return undefined +} + +function isDeltaTextEvent(event: CodexJsonlEvent): boolean { + const payload = eventPayload(event) + const item = eventItem(event) + const eventType = stringValue(event.type) + const payloadType = stringValue(payload.type) + const itemType = stringValue(item.type) + return [eventType, payloadType, itemType].some((type) => + type?.toLowerCase().includes("delta"), + ) +} + +function outputObject(params: { + output?: unknown + stdout?: unknown + stderr?: unknown + exitCode?: unknown + success?: unknown + status?: unknown + result?: unknown +}): Record { + const output: Record = {} + if (params.output !== undefined) output.output = params.output + if (typeof params.stdout === "string") output.stdout = params.stdout + if (typeof params.stderr === "string") output.stderr = params.stderr + if (typeof params.exitCode === "number" || params.exitCode === null) { + output.exitCode = params.exitCode + } + if (typeof params.success === "boolean") output.success = params.success + if (typeof params.status === "string") output.status = params.status + if (params.result !== undefined) output.result = params.result + return output +} + +function unwrapShellCommand(command: string): string { + const trimmed = command.trim() + const shellMatch = trimmed.match( + /^(?:\/(?:usr\/)?bin\/)?(?:zsh|bash|sh)\s+-lc\s+([\s\S]+)$/i, + ) + const wrappedCommand = shellMatch?.[1]?.trim() + if (!wrappedCommand) return trimmed + + const singleQuoted = wrappedCommand.match(/^'([\s\S]*)'$/) + if (singleQuoted) return singleQuoted[1].replace(/'\\''/g, "'") + + const doubleQuoted = wrappedCommand.match(/^"([\s\S]*)"$/) + if (doubleQuoted) return doubleQuoted[1].replace(/\\"/g, '"') + + return wrappedCommand +} + +function getFunctionCallInput( + payload: Record, +): Record { + const parsed = + recordValue(payload.arguments) ?? + recordValue(payload.input) ?? + recordValue(payload.args) ?? + {} + return { ...parsed } +} + +function getCommandInput( + input: Record, +): Record { + const command = + stringValue(input.cmd) ?? + stringValue(input.command) ?? + (Array.isArray(input.command) + ? [...input.command] + .reverse() + .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : undefined) + + return { + ...input, + ...(command ? { command } : {}), + ...(stringValue(input.workdir) && !stringValue(input.cwd) + ? { cwd: stringValue(input.workdir) } + : {}), + } +} + +function firstChangedPath(changes: unknown): string | undefined { + if (!isRecord(changes)) return undefined + return Object.keys(changes).find((filePath) => filePath.trim().length > 0) +} + +function getPatchInput(rawInput: unknown): Record { + const patchText = typeof rawInput === "string" ? rawInput : undefined + const parsedInput = recordValue(rawInput) + return { + ...(parsedInput ?? {}), + ...(patchText ? { patch: patchText } : {}), + } +} + +function mapNativeFunctionNameToTool( + name: string, + input: Record, +): { toolName: string; input: unknown; title?: string } { + const normalizedName = name.replace(/^functions\./, "") + + if ( + normalizedName === "exec_command" || + normalizedName === "shell" || + normalizedName === "bash" || + normalizedName === "run_command" + ) { + const commandInput = getCommandInput(input) + const command = stringValue(commandInput.command) + return { + toolName: "Bash", + input: commandInput, + ...(command ? { title: `Run ${command}` } : {}), + } + } + + if ( + normalizedName === "apply_patch" || + normalizedName === "edit" || + normalizedName === "write_file" + ) { + return { + toolName: normalizedName === "write_file" ? "Write" : "Edit", + input, + } + } + + if (normalizedName === "read_file" || normalizedName === "read") { + return { toolName: "Read", input } + } + + if ( + normalizedName === "grep" || + normalizedName === "rg" || + normalizedName === "search" || + normalizedName === "search_code" + ) { + return { toolName: "Grep", input } + } + + if (normalizedName === "glob" || normalizedName === "list_files") { + return { toolName: "Glob", input } + } + + if (normalizedName.startsWith("mcp__")) { + return { toolName: normalizedName, input } + } + + return { + toolName: normalizedName, + input: { + ...input, + toolName: normalizedName, + }, + } +} + +function toolEventFromCommandExecutionItem( + event: CodexJsonlEvent, + item: Record, +): CodexNativeToolEvent | null { + const callId = + stringValue(item.id) ?? + stringValue(item.call_id) ?? + stringValue(item.callId) + const rawCommand = stringValue(item.command) + if (!callId || !rawCommand) return null + + const command = unwrapShellCommand(rawCommand) + const status = stringValue(item.status) + const eventType = stringValue(event.type) + const input: Record = { + command, + cmd: command, + ...(rawCommand !== command ? { rawCommand } : {}), + ...(status ? { executionStatus: status } : {}), + } + + if (eventType === "item.started" || status === "in_progress") { + return { + kind: "tool-input", + callId, + toolName: "Bash", + input, + title: `Run ${command}`, + } + } + + const exitCode = item.exit_code ?? item.exitCode + const output = outputObject({ + stdout: item.aggregated_output, + output: item.output, + stderr: item.stderr, + exitCode, + success: typeof exitCode === "number" ? exitCode === 0 : undefined, + status, + }) + + return { + kind: "tool-output", + callId, + toolName: "Bash", + input, + output, + isError: + status === "failed" || + status === "error" || + (typeof exitCode === "number" && exitCode !== 0), + title: `Run ${command}`, + } +} + +function toolEventFromResponseItem( + payload: Record, +): CodexNativeToolEvent | null { + const payloadType = stringValue(payload.type) + const callId = stringValue(payload.call_id) ?? stringValue(payload.callId) + + if (!callId) return null + + if (payloadType === "function_call") { + const name = stringValue(payload.name) ?? "unknown" + const mapped = mapNativeFunctionNameToTool(name, getFunctionCallInput(payload)) + return { + kind: "tool-input", + callId, + toolName: mapped.toolName, + input: mapped.input, + ...(mapped.title ? { title: mapped.title } : {}), + } + } + + if (payloadType === "function_call_output") { + const output = outputObject({ + output: payload.output, + stdout: payload.stdout, + stderr: payload.stderr, + exitCode: payload.exit_code ?? payload.exitCode, + success: payload.success, + status: payload.status, + }) + return { + kind: "tool-output", + callId, + output: Object.keys(output).length > 0 ? output : payload.output, + isError: payload.success === false, + } + } + + if (payloadType === "custom_tool_call") { + const name = stringValue(payload.name) ?? "unknown" + const rawInput = payload.input ?? payload.arguments + const input = + name === "apply_patch" + ? getPatchInput(rawInput) + : getFunctionCallInput({ ...payload, arguments: rawInput }) + const mapped = mapNativeFunctionNameToTool(name, input) + return { + kind: "tool-input", + callId, + toolName: mapped.toolName, + input: mapped.input, + ...(mapped.title ? { title: mapped.title } : {}), + } + } + + if (payloadType === "custom_tool_call_output") { + const output = outputObject({ + output: payload.output, + stdout: payload.stdout, + stderr: payload.stderr, + exitCode: payload.exit_code ?? payload.exitCode, + success: payload.success, + status: payload.status, + }) + return { + kind: "tool-output", + callId, + output: Object.keys(output).length > 0 ? output : payload.output, + isError: payload.success === false, + } + } + + if (payloadType === "tool_search_call") { + const args = recordValue(payload.arguments) ?? {} + const query = stringValue(args.query) + return { + kind: "tool-input", + callId, + toolName: "Grep", + input: { + ...args, + ...(query ? { pattern: query, query } : {}), + toolName: "Search tools", + }, + ...(query ? { title: `Search ${query}` } : {}), + } + } + + if (payloadType === "tool_search_output") { + return { + kind: "tool-output", + callId, + output: outputObject({ + output: payload.output ?? payload.tools, + success: payload.status === "completed" ? true : undefined, + status: payload.status, + }), + isError: payload.status === "failed", + } + } + + return null +} + +function toolEventFromEventMessage( + payload: Record, +): CodexNativeToolEvent | null { + const payloadType = stringValue(payload.type) + const callId = stringValue(payload.call_id) ?? stringValue(payload.callId) + + if (!callId) return null + + if (payloadType === "patch_apply_end") { + const filePath = firstChangedPath(payload.changes) + return { + kind: "tool-output", + callId, + toolName: "Edit", + input: { + ...(filePath ? { file_path: filePath } : {}), + changes: payload.changes, + }, + output: outputObject({ + stdout: payload.stdout, + stderr: payload.stderr, + success: payload.success, + status: payload.success === false ? "failed" : "completed", + result: payload.changes, + }), + isError: payload.success === false, + } + } + + if (payloadType === "mcp_tool_call_end") { + const invocation = isRecord(payload.invocation) ? payload.invocation : {} + const server = stringValue(invocation.server) ?? "mcp" + const tool = stringValue(invocation.tool) ?? "tool" + const result = isRecord(payload.result) ? payload.result : payload.result + const isError = + isRecord(payload.result) && payload.result.isError === true + ? true + : payload.status === "failed" + return { + kind: "tool-output", + callId, + toolName: `mcp__${server}__${tool}`, + input: isRecord(invocation.arguments) ? invocation.arguments : {}, + output: result, + isError, + } + } + + return null +} + +const nativeResponseItemToolTypes = new Set([ + "function_call", + "function_call_output", + "custom_tool_call", + "custom_tool_call_output", + "tool_search_call", + "tool_search_output", +]) + +const nativeEventMessageToolTypes = new Set([ + "patch_apply_end", + "mcp_tool_call_end", +]) + +export function codexJsonlEventToNativeToolEvent( + event: CodexJsonlEvent, +): CodexNativeToolEvent | null { + const payload = eventPayload(event) + const item = eventItem(event) + const candidates = [payload, item, event] + + if (stringValue(item.type) === "command_execution") { + const commandEvent = toolEventFromCommandExecutionItem(event, item) + if (commandEvent) return commandEvent + } + + for (const candidate of candidates) { + const candidateType = stringValue(candidate.type) + if (candidateType && nativeResponseItemToolTypes.has(candidateType)) { + return toolEventFromResponseItem(candidate) + } + } + + for (const candidate of candidates) { + const candidateType = stringValue(candidate.type) + if (candidateType && nativeEventMessageToolTypes.has(candidateType)) { + return toolEventFromEventMessage(candidate) + } + } + + return null +} + +export function parseCodexJsonlEventLine(line: string): CodexJsonlEvent | null { + const trimmed = line.trim() + if (!trimmed) return null + + try { + const parsed: unknown = JSON.parse(trimmed) + return isRecord(parsed) ? parsed : null + } catch { + // Keep the bridge tolerant of CLI warnings or partial output. + return null + } +} + +export function extractCodexJsonlEventText( + event: CodexJsonlEvent, +): string | undefined { + return extractEventText(event) +} + +export function isCodexJsonlCommentaryTextEvent( + event: CodexJsonlEvent, +): boolean { + if (!canUseEventAsAssistantTextSource(event)) return false + if (extractEventPhase(event)?.toLowerCase() !== "commentary") return false + return Boolean(extractEventText(event)) +} + +export function isCodexJsonlFinalTextEvent(event: CodexJsonlEvent): boolean { + if (!canUseEventAsAssistantTextSource(event)) return false + if (!extractEventText(event)) return false + + const payload = eventPayload(event) + const item = eventItem(event) + const phase = extractEventPhase(event)?.toLowerCase() + const payloadType = stringValue(payload.type)?.toLowerCase() + const itemType = stringValue(item.type)?.toLowerCase() + + return ( + phase === "final_answer" || + payloadType === "task_complete" || + itemType === "task_complete" + ) +} + +export function extractCodexJsonlEventSessionId( + event: CodexJsonlEvent, +): string | undefined { + return extractSessionId(event) +} + +export function isCodexJsonlDeltaTextEvent(event: CodexJsonlEvent): boolean { + return isDeltaTextEvent(event) +} + +export function parseCodexJsonlEvents(stdout: string): CodexJsonlEvent[] { + const events: CodexJsonlEvent[] = [] + + for (const line of stdout.split(/\r?\n/)) { + const event = parseCodexJsonlEventLine(line) + if (event) events.push(event) + } + + return events +} + +export function summarizeCodexExecResumeEvents( + events: CodexJsonlEvent[], +): CodexExecResumeEventSummary { + const summary: CodexExecResumeEventSummary = {} + let accumulatedDeltaText = "" + + for (const event of events) { + const sessionId = extractSessionId(event) + if (sessionId) summary.nativeSessionId = sessionId + const text = extractEventText(event) + if (text) { + if (isDeltaTextEvent(event)) { + accumulatedDeltaText += text + summary.lastText = accumulatedDeltaText + } else { + const existingText = accumulatedDeltaText || summary.lastText || "" + if (isCodexNativeRepeatedFinalText(existingText, text)) { + summary.lastText = existingText + } else if (accumulatedDeltaText) { + const textAppend = reconcileCodexNativeTextAppend( + accumulatedDeltaText, + text, + ) + if (textAppend.kind !== "separate") { + accumulatedDeltaText += textAppend.appendText + summary.lastText = accumulatedDeltaText + } else { + accumulatedDeltaText = "" + summary.lastText = text + } + } else { + summary.lastText = text + } + } + } + const usage = extractUsage(event) + if (usage) summary.usage = usage + const error = extractError(event) + if (error) summary.error = error + } + + return summary +} + +function stripDataUrlPrefix(value: string): string { + const trimmed = value.trim() + const dataUrlMatch = trimmed.match(/^data:[^,]+;base64,(.*)$/is) + return (dataUrlMatch ? dataUrlMatch[1] : trimmed).replace(/\s/g, "") +} + +function codexImageExtension(input: CodexNativeImageAttachment): string { + const filenameExtension = input.filename + ? path.extname(path.basename(input.filename)).toLowerCase() + : "" + if ( + [ + ".png", + ".jpg", + ".jpeg", + ".webp", + ".gif", + ".bmp", + ".tif", + ".tiff", + ].includes(filenameExtension) + ) { + return filenameExtension + } + + const mediaType = cleanString(input.mediaType)?.toLowerCase() + switch (mediaType) { + case "image/png": + return ".png" + case "image/jpeg": + case "image/jpg": + return ".jpg" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + case "image/bmp": + return ".bmp" + case "image/tiff": + return ".tiff" + default: + return ".img" + } +} + +async function materializeCodexImages( + images: CodexNativeImageAttachment[] | null | undefined, +): Promise<{ directory?: string; imagePaths: string[] }> { + const usableImages = (images ?? []).filter( + (image) => cleanString(image.base64Data) && cleanString(image.mediaType), + ) + if (usableImages.length === 0) return { imagePaths: [] } + + const directory = await mkdtemp(path.join(tmpdir(), "moss-codex-images-")) + const imagePaths: string[] = [] + + try { + for (const [index, image] of usableImages.entries()) { + const base64Payload = stripDataUrlPrefix(image.base64Data) + if (!base64Payload) continue + const imagePath = path.join( + directory, + `image-${String(index + 1).padStart(2, "0")}${codexImageExtension(image)}`, + ) + await writeFile(imagePath, Buffer.from(base64Payload, "base64")) + imagePaths.push(imagePath) + } + } catch (error) { + await rm(directory, { recursive: true, force: true }) + throw error + } + + if (imagePaths.length === 0) { + await rm(directory, { recursive: true, force: true }) + return { imagePaths: [] } + } + + return { directory, imagePaths } +} + +export function spawnCodexNativeCommand( + input: CodexNativeCommandRunnerInput, +): Promise { + return new Promise((resolve, reject) => { + if (input.abortSignal?.aborted) { + reject(new Error("Codex native command aborted.")) + return + } + + const child = spawn(input.command, input.args, { + cwd: input.cwd, + env: input.env ? { ...process.env, ...input.env } : process.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }) + + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + const stdoutDecoder = new StringDecoder("utf8") + let stdoutLineBuffer = "" + let forceKillTimer: ReturnType | null = null + let didClose = false + + const emitStdoutJsonLine = (line: string) => { + const event = parseCodexJsonlEventLine(line) + if (!event) return + + try { + input.onStdoutJsonEvent?.(event) + } catch (error) { + console.warn("[codex] Ignoring native JSONL stream callback error:", error) + } + } + + const processStdoutJsonText = (text: string) => { + if (!text) return + stdoutLineBuffer += text + + while (true) { + const lineEndIndex = stdoutLineBuffer.search(/\r?\n/) + if (lineEndIndex === -1) return + + const line = stdoutLineBuffer.slice(0, lineEndIndex) + const newlineLength = + stdoutLineBuffer[lineEndIndex] === "\r" && + stdoutLineBuffer[lineEndIndex + 1] === "\n" + ? 2 + : 1 + stdoutLineBuffer = stdoutLineBuffer.slice(lineEndIndex + newlineLength) + emitStdoutJsonLine(line) + } + } + + const flushStdoutJsonText = () => { + processStdoutJsonText(stdoutDecoder.end()) + if (stdoutLineBuffer.trim()) { + emitStdoutJsonLine(stdoutLineBuffer) + } + stdoutLineBuffer = "" + } + + const abortChild = () => { + if (didClose) return + child.kill("SIGTERM") + forceKillTimer = + forceKillTimer ?? + setTimeout(() => { + if (!didClose) child.kill("SIGKILL") + }, 2000) + } + + input.abortSignal?.addEventListener("abort", abortChild, { once: true }) + + child.stdout?.on("data", (chunk) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)) + stdoutChunks.push(buffer) + processStdoutJsonText(stdoutDecoder.write(buffer)) + }) + child.stderr?.on("data", (chunk) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + }) + child.stdin?.on("error", () => { + // The CLI may exit before stdin is fully written on fast failures. + }) + child.on("error", reject) + child.on("close", (exitCode) => { + didClose = true + flushStdoutJsonText() + if (forceKillTimer) { + clearTimeout(forceKillTimer) + } + input.abortSignal?.removeEventListener("abort", abortChild) + resolve({ + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + exitCode, + }) + }) + + if (typeof input.stdin === "string") { + child.stdin?.write(input.stdin) + } + child.stdin?.end() + }) +} + +export async function runCodexExecBridge( + input: RunCodexExecBridgeInput, +): Promise { + const prompt = requireCleanString(input.prompt, `${input.action} prompt`) + if (input.action === "resume") { + requireCleanString(input.sessionId, "session id") + } + const materializedImages = await materializeCodexImages(input.images) + const plan = buildCodexNativeSessionBridgePlan({ + action: input.action, + sessionId: input.sessionId, + cwd: input.cwd, + modelId: input.modelId, + permissionMode: input.permissionMode, + command: input.command, + includeJson: input.includeJson, + skipGitRepoCheck: input.skipGitRepoCheck, + prompt, + promptSource: "stdin", + imagePaths: materializedImages.imagePaths, + }) + const runner = input.runner ?? spawnCodexNativeCommand + const forwardedEventKeys = new Set() + const getForwardedEventKey = (event: CodexJsonlEvent): string => { + try { + return JSON.stringify(event) + } catch { + return String(event) + } + } + const forwardEvent = (event: CodexJsonlEvent) => { + if (!input.onEvent) return + const eventKey = getForwardedEventKey(event) + if (forwardedEventKeys.has(eventKey)) return + forwardedEventKeys.add(eventKey) + try { + input.onEvent(event) + } catch (error) { + console.warn("[codex] Ignoring native JSONL event callback error:", error) + } + } + let result: CodexNativeCommandRunnerResult + try { + result = await runner({ + command: plan.command, + args: plan.args, + cwd: plan.cwd, + stdin: prompt, + env: input.env, + abortSignal: input.abortSignal, + onStdoutJsonEvent: forwardEvent, + }) + } finally { + if (materializedImages.directory) { + await rm(materializedImages.directory, { recursive: true, force: true }) + } + } + const events = parseCodexJsonlEvents(result.stdout) + for (const event of events) { + forwardEvent(event) + } + const summary = summarizeCodexExecResumeEvents(events) + const exitError = + result.exitCode === 0 + ? undefined + : summary.error ?? cleanString(result.stderr) ?? `Codex exited with ${result.exitCode}.` + const error = exitError ?? summary.error + + return { + ...result, + plan, + events, + ...summary, + ...(error ? { error } : {}), + success: result.exitCode === 0 && !error, + } +} + +export async function runCodexExecStartBridge( + input: RunCodexExecStartBridgeInput, +): Promise { + return runCodexExecBridge({ + ...input, + action: "start", + }) +} + +export async function runCodexExecResumeBridge( + input: RunCodexExecResumeBridgeInput, +): Promise { + return runCodexExecBridge({ + ...input, + action: "resume", + }) +} diff --git a/src/main/lib/agent-runtime/control-plane.ts b/src/main/lib/agent-runtime/control-plane.ts new file mode 100644 index 000000000..883a83285 --- /dev/null +++ b/src/main/lib/agent-runtime/control-plane.ts @@ -0,0 +1,394 @@ +import { eq } from "drizzle-orm" +import { + chats, + getDatabase, + projects, + subChats, + type Chat, + type Project, + type SubChat, +} from "../db" +import { + readMossProjectionManifestSummary, + readMossProviderConfig, + resolveMossProviderForEngine, + summarizeMossProviderReadResult, + type MossProjectionManifestSummary, + type MossProviderSecretResolver, + type MossProviderSummary, +} from "../moss-source" +import { getAgentRuntimeManifest } from "./manifests" +import { + buildMossSessionActionPlan, + type MossSessionActionPlan, +} from "./session-actions" +import { AGENT_ENGINE_IDS, DEFAULT_AGENT_ENGINE_ID, type AgentEngineId } from "./types" + +export type MossSessionControlStatus = + | "ready" + | "missing-project" + | "missing-project-path" + +export type MossNativeSessionState = + | "linked" + | "pending-native-session" + +export type MossProjectionSessionState = + | "materialized" + | "native" + | "not-materialized" + | "unknown" + +export interface MossProviderRouteSummary { + status: "resolved" | "missing" | "unconfigured" | "parse-error" + providerId?: string + label?: string + mode?: string + model?: string + authMethod?: string + apiKeySource?: "inline" | "env" | "stored" + hasBaseUrl: boolean + baseUrlSource?: "inline" | "env" | "stored" + baseUrlEnv?: string + warnings: string[] + reason?: string + error?: string +} + +export interface MossSessionControlEntry { + chatId: string + chatName: string | null + workspacePath: string + branch: string | null + subChatId: string + subChatName: string | null + engine: AgentEngineId + modelId: string | null + nativeSessionId: string | null + configDir: string | null + permissionMode: string + mossManaged: boolean + nativeSessionLinked: boolean + nativeSessionState: MossNativeSessionState + projectionState: MossProjectionSessionState + messageCount: number + actions: MossSessionActionPlan["actions"] + providerId?: string + providerModel?: string + updatedAt: string | null + runtimeUpdatedAt: string | null +} + +export interface MossSessionControlPlane { + status: MossSessionControlStatus + projectPath: string | null + scopePath: string | null + projectId: string | null + projectName: string | null + summary: { + workspaces: number + sessions: number + mossManagedSessions: number + nativeSessionLinked: number + pendingNativeSessions: number + engines: Record + } + provider: MossProviderSummary + providerRoutes: Record + projectionManifest: MossProjectionManifestSummary + sessions: MossSessionControlEntry[] +} + +function emptyProviderSummary(projectPath: string | null): MossProviderSummary { + return { + status: "missing", + sourcePath: projectPath ? `${projectPath}/.moss/providers.yaml` : "", + providers: [], + } +} + +function emptyProjectionSummary( + projectPath: string | null, +): MossProjectionManifestSummary { + return { + status: "missing", + sourcePath: projectPath ? `${projectPath}/.moss/projections/manifest.json` : "", + totalEntries: 0, + engines: [], + } +} + +function emptyProviderRoutes(): Record { + const routes = {} as Record + for (const engineId of AGENT_ENGINE_IDS) { + routes[engineId] = { + status: "missing", + hasBaseUrl: false, + warnings: [], + reason: "No project path is selected.", + } + } + return routes +} + +function parseRuntimeMetadata(value: string | null): Record { + if (!value) return {} + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === "object" ? parsed as Record : {} + } catch { + return {} + } +} + +function toIsoString( + value: Date | string | number | null | undefined, +): string | null { + if (!value) return null + if (value instanceof Date) return value.toISOString() + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +function normalizeEngine(value: string | null | undefined): AgentEngineId { + return AGENT_ENGINE_IDS.includes(value as AgentEngineId) + ? value as AgentEngineId + : DEFAULT_AGENT_ENGINE_ID +} + +function getProjectionState( + engine: AgentEngineId, + manifest: MossProjectionManifestSummary, +): MossProjectionSessionState { + if (engine === "hermes") return "native" + if (manifest.status !== "found") return "unknown" + const engineEntries = manifest.engines.find((entry) => entry.engineId === engine) + return engineEntries && engineEntries.entries > 0 + ? "materialized" + : "not-materialized" +} + +async function buildProviderRoutes( + projectPath: string, + secretResolver?: MossProviderSecretResolver, +): Promise> { + const entries = await Promise.all( + AGENT_ENGINE_IDS.map(async (engineId) => { + const resolved = await resolveMossProviderForEngine({ + projectPath, + engineId, + createIfMissing: true, + secretResolver, + }) + return [ + engineId, + { + status: resolved.status, + providerId: resolved.providerId, + label: resolved.label, + mode: resolved.mode, + model: resolved.model, + authMethod: resolved.authMethod, + apiKeySource: resolved.apiKeySource, + hasBaseUrl: Boolean(resolved.baseUrl), + baseUrlSource: resolved.baseUrlSource, + baseUrlEnv: resolved.baseUrlEnv, + warnings: resolved.warnings, + reason: resolved.reason, + error: resolved.error, + } satisfies MossProviderRouteSummary, + ] as const + }), + ) + return Object.fromEntries(entries) as Record< + AgentEngineId, + MossProviderRouteSummary + > +} + +function findProjectScope(projectPath: string): { + project: Project | null + scopePath: string + chats: Chat[] +} { + const db = getDatabase() + const project = db + .select() + .from(projects) + .where(eq(projects.path, projectPath)) + .get() + + if (project) { + return { + project, + scopePath: project.path, + chats: db + .select() + .from(chats) + .where(eq(chats.projectId, project.id)) + .all(), + } + } + + const worktreeChat = db + .select() + .from(chats) + .where(eq(chats.worktreePath, projectPath)) + .get() + if (!worktreeChat) { + return { + project: null, + scopePath: projectPath, + chats: [], + } + } + + const sourceProject = db + .select() + .from(projects) + .where(eq(projects.id, worktreeChat.projectId)) + .get() ?? null + + return { + project: sourceProject, + scopePath: projectPath, + chats: [worktreeChat], + } +} + +function listSubChatsForChat(chatId: string): SubChat[] { + return getDatabase() + .select() + .from(subChats) + .where(eq(subChats.chatId, chatId)) + .all() +} + +function buildEntries(params: { + projectPath: string + chats: Chat[] + projectionManifest: MossProjectionManifestSummary + providerRoutes: Record +}): MossSessionControlEntry[] { + const entries = params.chats.flatMap((chat) => + listSubChatsForChat(chat.id).map((subChat) => { + const engine = normalizeEngine(subChat.engine) + const metadata = parseRuntimeMetadata(subChat.runtimeMetadata) + const nativeSessionId = + subChat.engineSessionId ?? + (engine === "claude-code" ? subChat.sessionId : null) + const nativeSessionLinked = Boolean(nativeSessionId) + const manifest = getAgentRuntimeManifest(engine) + const actionPlan = buildMossSessionActionPlan({ + subChatId: subChat.id, + engine, + nativeSessionId, + messages: subChat.messages, + features: manifest.features, + }) + + return { + chatId: chat.id, + chatName: chat.name, + workspacePath: chat.worktreePath || params.projectPath, + branch: chat.branch, + subChatId: subChat.id, + subChatName: subChat.name, + engine, + modelId: subChat.modelId, + nativeSessionId, + configDir: subChat.engineConfigDir, + permissionMode: subChat.mode, + mossManaged: true, + nativeSessionLinked, + nativeSessionState: nativeSessionLinked + ? "linked" + : "pending-native-session", + projectionState: getProjectionState(engine, params.projectionManifest), + messageCount: actionPlan.messageCount, + actions: actionPlan.actions, + providerId: params.providerRoutes[engine]?.providerId, + providerModel: params.providerRoutes[engine]?.model, + updatedAt: toIsoString(subChat.updatedAt), + runtimeUpdatedAt: + typeof metadata.updatedAt === "string" ? metadata.updatedAt : null, + } satisfies MossSessionControlEntry + }), + ) + + return entries.sort((a, b) => + (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""), + ) +} + +function buildSummary( + chats: Chat[], + sessions: MossSessionControlEntry[], +): MossSessionControlPlane["summary"] { + const engines = Object.fromEntries( + AGENT_ENGINE_IDS.map((engineId) => [engineId, 0]), + ) as Record + for (const session of sessions) { + engines[session.engine] += 1 + } + + return { + workspaces: chats.length, + sessions: sessions.length, + mossManagedSessions: sessions.filter((session) => session.mossManaged).length, + nativeSessionLinked: sessions.filter((session) => session.nativeSessionLinked) + .length, + pendingNativeSessions: sessions.filter( + (session) => !session.nativeSessionLinked, + ).length, + engines, + } +} + +export async function getMossSessionControlPlane(params: { + projectPath?: string | null + secretResolver?: MossProviderSecretResolver +}): Promise { + const projectPath = params.projectPath?.trim() + if (!projectPath) { + return { + status: "missing-project-path", + projectPath: null, + scopePath: null, + projectId: null, + projectName: null, + summary: buildSummary([], []), + provider: emptyProviderSummary(null), + providerRoutes: emptyProviderRoutes(), + projectionManifest: emptyProjectionSummary(null), + sessions: [], + } + } + + const [providerRead, providerRoutes, projectionManifest] = await Promise.all([ + readMossProviderConfig(projectPath, { createIfMissing: true }), + buildProviderRoutes(projectPath, params.secretResolver), + readMossProjectionManifestSummary(projectPath), + ]) + const provider = summarizeMossProviderReadResult(providerRead) + const scope = findProjectScope(projectPath) + const sessions = buildEntries({ + projectPath, + chats: scope.chats, + projectionManifest, + providerRoutes, + }) + + return { + status: scope.project ? "ready" : "missing-project", + projectPath: scope.project?.path ?? projectPath, + scopePath: scope.scopePath, + projectId: scope.project?.id ?? null, + projectName: scope.project?.name ?? null, + summary: buildSummary(scope.chats, sessions), + provider, + providerRoutes, + projectionManifest, + sessions, + } +} diff --git a/src/main/lib/agent-runtime/events.ts b/src/main/lib/agent-runtime/events.ts new file mode 100644 index 000000000..77d3e520e --- /dev/null +++ b/src/main/lib/agent-runtime/events.ts @@ -0,0 +1,189 @@ +import type { + AgentRuntimeBlockStatus, + AgentRuntimeConversationBlock, + AgentRuntimeStreamEvent, +} from "./types" + +const CONVERSATION_BLOCK_TYPES = new Set([ + "exec", + "mcp-tool-call", + "patch", + "generated-image", + "text-output", + "todo-list", + "proposed-plan", + "active-goal", + "permission-request", + "user-input", + "status", + "dynamic-tool-call", + "automation-update", + "multi-agent-action", + "context-compaction", + "model-change", + "model-reroute", + "goal-status", + "realtime-state", + "dictation-state", + "queued-follow-up", + "rate-limit-status", + "usage-status", + "project-event", + "library-artifact", + "pull-request-status", + "diagnostic-snapshot", +]) + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function cleanString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function normalizeBlockStatus(value: unknown): AgentRuntimeBlockStatus | undefined { + if ( + value === "queued" || + value === "running" || + value === "completed" || + value === "failed" || + value === "interrupted" || + value === "blocked" + ) { + return value + } + + return undefined +} + +function normalizeRuntimeConversationBlock( + value: unknown, +): AgentRuntimeConversationBlock | null { + if (!isRecord(value)) return null + + const type = cleanString(value.type) + const id = cleanString(value.id) + if (!type || !id || !CONVERSATION_BLOCK_TYPES.has(type)) return null + + const { id: _id, type: _type, status: _status, ...rest } = value + const status = normalizeBlockStatus(_status) + + return { + ...rest, + id, + type, + ...(status ? { status } : {}), + } as AgentRuntimeConversationBlock +} + +function normalizeConversationBlockUpdate( + value: Record, +): AgentRuntimeStreamEvent | null { + const id = cleanString(value.id) + if (!id || !isRecord(value.patch)) return null + + const { status: _status, ...patch } = value.patch + const status = normalizeBlockStatus(_status) + + return { + type: "conversation-block-update", + id, + patch: { + ...patch, + ...(status ? { status } : {}), + } as Partial, + } +} + +export function normalizeRuntimeStreamEvent( + chunk: Record, +): AgentRuntimeStreamEvent | null { + if (chunk.type === "text" && typeof chunk.text === "string") { + return { type: "text", text: chunk.text } + } + + if (chunk.type === "tool-call") { + const name = cleanString(chunk.name) + if (!name) return null + return { + type: "tool-call", + id: cleanString(chunk.id), + name, + input: chunk.input, + } + } + + if (chunk.type === "tool-result") { + return { + type: "tool-result", + id: cleanString(chunk.id), + name: cleanString(chunk.name), + result: chunk.result, + } + } + + if (chunk.type === "conversation-block") { + const block = normalizeRuntimeConversationBlock(chunk.block) + return block ? { type: "conversation-block", block } : null + } + + if (chunk.type === "conversation-block-update") { + return normalizeConversationBlockUpdate(chunk) + } + + if (typeof chunk.type === "string" && CONVERSATION_BLOCK_TYPES.has(chunk.type)) { + const block = normalizeRuntimeConversationBlock(chunk) + return block ? { type: "conversation-block", block } : null + } + + if (chunk.type === "finish") { + return { + type: "finish", + nativeSessionId: + typeof chunk.sessionId === "string" ? chunk.sessionId : null, + resultSubtype: + chunk.resultSubtype === "error" || + chunk.resultSubtype === "cancelled" || + chunk.resultSubtype === "success" + ? chunk.resultSubtype + : undefined, + } + } + + if ( + chunk.type === "auth-error" || + (chunk.type === "error" && typeof chunk.errorText === "string") + ) { + return { + type: chunk.type === "auth-error" ? "auth-error" : "error", + message: String(chunk.errorText), + } + } + + if (chunk.type === "message-metadata") { + const metadata = chunk.messageMetadata + if (metadata && typeof metadata === "object") { + const value = metadata as Record + return { + type: "usage", + inputTokens: + typeof value.inputTokens === "number" ? value.inputTokens : undefined, + outputTokens: + typeof value.outputTokens === "number" + ? value.outputTokens + : undefined, + totalTokens: + typeof value.totalTokens === "number" ? value.totalTokens : undefined, + modelContextWindow: + typeof value.modelContextWindow === "number" + ? value.modelContextWindow + : undefined, + } + } + } + + return null +} diff --git a/src/main/lib/agent-runtime/hermes-native-session.ts b/src/main/lib/agent-runtime/hermes-native-session.ts new file mode 100644 index 000000000..742f4608f --- /dev/null +++ b/src/main/lib/agent-runtime/hermes-native-session.ts @@ -0,0 +1,286 @@ +import { spawn } from "node:child_process" +import type { AgentPermissionMode } from "./types" + +export type HermesNativeBridgeAction = "resume" | "fork" | "rollback" + +export type HermesNativeBridgeKind = + | "hermes-cli-resume" + | "hermes-acp-session-control" + +export type HermesNativeBridgeMode = + | "headless-cli" + | "moss-owned-session-control" + +export type HermesNativePromptSource = "argument" | "none" + +export type HermesNativeSessionStrategy = + | "resume-cli-session" + | "reuse-session-with-moss-fork-boundary" + | "reuse-session-with-moss-rollback-boundary" + +export interface HermesNativeSessionBridgePlan { + engine: "hermes" + action: HermesNativeBridgeAction + bridge: HermesNativeBridgeKind + mode: HermesNativeBridgeMode + nativeSessionStrategy: HermesNativeSessionStrategy + command: string + args: string[] + cwd: string + sessionId: string + modelId?: string + permissionMode: AgentPermissionMode + promptSource: HermesNativePromptSource + canRunHeadless: boolean + mossOwnedControl: true + targetMessageId?: string + targetSdkMessageUuid?: string + notes: string[] +} + +export interface BuildHermesNativeSessionBridgePlanInput { + action: HermesNativeBridgeAction + sessionId?: string | null + cwd: string + modelId?: string | null + permissionMode?: AgentPermissionMode | null + prompt?: string | null + promptSource?: HermesNativePromptSource + command?: string | null + targetMessageId?: string | null + targetSdkMessageUuid?: string | null +} + +export interface HermesNativeCommandRunnerInput { + command: string + args: string[] + cwd: string + stdin?: string + env?: NodeJS.ProcessEnv + abortSignal?: AbortSignal +} + +export interface HermesNativeCommandRunnerResult { + stdout: string + stderr: string + exitCode: number | null +} + +export type HermesNativeCommandRunner = ( + input: HermesNativeCommandRunnerInput, +) => Promise + +export interface HermesCliResumeBridgeSummary { + nativeSessionId?: string + lastText?: string + error?: string +} + +export interface RunHermesCliResumeBridgeInput { + sessionId: string + cwd: string + prompt: string + modelId?: string | null + permissionMode?: AgentPermissionMode | null + command?: string | null + runner?: HermesNativeCommandRunner + env?: NodeJS.ProcessEnv + abortSignal?: AbortSignal +} + +export interface HermesCliResumeBridgeResult + extends HermesNativeCommandRunnerResult, + HermesCliResumeBridgeSummary { + success: boolean + plan: HermesNativeSessionBridgePlan +} + +function cleanString(value: string | null | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +function requireCleanString( + value: string | null | undefined, + label: string, +): string { + const cleaned = cleanString(value) + if (!cleaned) { + throw new Error(`Hermes native ${label} is required.`) + } + return cleaned +} + +export function buildHermesNativeSessionBridgePlan( + input: BuildHermesNativeSessionBridgePlanInput, +): HermesNativeSessionBridgePlan { + const command = cleanString(input.command) ?? "hermes" + const cwd = requireCleanString(input.cwd, "working directory") + const sessionId = requireCleanString(input.sessionId, "session id") + const modelId = cleanString(input.modelId) + const permissionMode = input.permissionMode ?? "agent" + + if (input.action === "resume") { + const args = ["--resume", sessionId] + const prompt = cleanString(input.prompt) + const promptSource = + input.promptSource === "none" || !prompt ? "none" : "argument" + if (promptSource === "argument" && prompt) { + args.push("-z", prompt) + } + + return { + engine: "hermes", + action: "resume", + bridge: "hermes-cli-resume", + mode: "headless-cli", + nativeSessionStrategy: "resume-cli-session", + command, + args, + cwd, + sessionId, + ...(modelId ? { modelId } : {}), + permissionMode, + promptSource, + canRunHeadless: true, + mossOwnedControl: true, + notes: [ + "Hermes exposes native resume through hermes --resume .", + "When a prompt is supplied, Moss can run a one-shot resume with -z; otherwise the plan records a resume-ready native session.", + ], + } + } + + const isFork = input.action === "fork" + const targetMessageId = cleanString(input.targetMessageId) + const targetSdkMessageUuid = cleanString(input.targetSdkMessageUuid) + + return { + engine: "hermes", + action: input.action, + bridge: "hermes-acp-session-control", + mode: "moss-owned-session-control", + nativeSessionStrategy: isFork + ? "reuse-session-with-moss-fork-boundary" + : "reuse-session-with-moss-rollback-boundary", + command, + args: [], + cwd, + sessionId, + ...(modelId ? { modelId } : {}), + permissionMode, + promptSource: "none", + canRunHeadless: true, + mossOwnedControl: true, + ...(targetMessageId ? { targetMessageId } : {}), + ...(targetSdkMessageUuid ? { targetSdkMessageUuid } : {}), + notes: [ + "The live Hermes CLI does not expose separate fork or rollback commands.", + "Moss keeps the Hermes native session linked and records the fork/rollback boundary in the Moss-owned session-control layer instead of creating a second real config.", + ], + } +} + +export function spawnHermesNativeCommand( + input: HermesNativeCommandRunnerInput, +): Promise { + return new Promise((resolve, reject) => { + if (input.abortSignal?.aborted) { + reject(new Error("Hermes native command aborted.")) + return + } + + const child = spawn(input.command, input.args, { + cwd: input.cwd, + env: input.env ? { ...process.env, ...input.env } : process.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }) + + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + let forceKillTimer: ReturnType | null = null + let didClose = false + + const abortChild = () => { + if (didClose) return + child.kill("SIGTERM") + forceKillTimer = + forceKillTimer ?? + setTimeout(() => { + if (!didClose) child.kill("SIGKILL") + }, 2000) + } + + input.abortSignal?.addEventListener("abort", abortChild, { once: true }) + + child.stdout?.on("data", (chunk) => { + stdoutChunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)), + ) + }) + child.stderr?.on("data", (chunk) => { + stderrChunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)), + ) + }) + child.stdin?.on("error", () => { + // Hermes may exit before stdin is fully written on fast failures. + }) + child.on("error", reject) + child.on("close", (exitCode) => { + didClose = true + if (forceKillTimer) clearTimeout(forceKillTimer) + input.abortSignal?.removeEventListener("abort", abortChild) + resolve({ + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + exitCode, + }) + }) + + if (typeof input.stdin === "string") { + child.stdin?.write(input.stdin) + } + child.stdin?.end() + }) +} + +export async function runHermesCliResumeBridge( + input: RunHermesCliResumeBridgeInput, +): Promise { + const prompt = requireCleanString(input.prompt, "resume prompt") + const plan = buildHermesNativeSessionBridgePlan({ + action: "resume", + sessionId: input.sessionId, + cwd: input.cwd, + modelId: input.modelId, + permissionMode: input.permissionMode, + command: input.command, + prompt, + promptSource: "argument", + }) + const runner = input.runner ?? spawnHermesNativeCommand + const result = await runner({ + command: plan.command, + args: plan.args, + cwd: plan.cwd, + env: input.env, + abortSignal: input.abortSignal, + }) + const stdout = cleanString(result.stdout) + const stderr = cleanString(result.stderr) + const error = + result.exitCode === 0 + ? undefined + : stderr ?? stdout ?? `Hermes exited with ${result.exitCode}.` + + return { + ...result, + plan, + nativeSessionId: plan.sessionId, + ...(stdout ? { lastText: stdout } : {}), + ...(error ? { error } : {}), + success: result.exitCode === 0 && !error, + } +} diff --git a/src/main/lib/agent-runtime/index.ts b/src/main/lib/agent-runtime/index.ts new file mode 100644 index 000000000..0dd6de18c --- /dev/null +++ b/src/main/lib/agent-runtime/index.ts @@ -0,0 +1,10 @@ +export * from "./types" +export * from "./manifests" +export * from "./session-store" +export * from "./session-actions" +export * from "./session-records" +export * from "./codex-native-session" +export * from "./hermes-native-session" +export * from "./control-plane" +export * from "./adapters" +export * from "./events" diff --git a/src/main/lib/agent-runtime/manifests.ts b/src/main/lib/agent-runtime/manifests.ts new file mode 100644 index 000000000..8dfa312b1 --- /dev/null +++ b/src/main/lib/agent-runtime/manifests.ts @@ -0,0 +1,175 @@ +import * as os from "os" +import * as path from "path" +import type { + AgentEngineId, + AgentRuntimeManifest, +} from "./types" + +const home = os.homedir() + +export const AGENT_RUNTIME_MANIFESTS: Record = { + "claude-code": { + id: "claude-code", + label: "Claude Code", + vendor: "Anthropic", + availability: "available", + defaultModelId: "opus", + features: [ + "chat", + "resume", + "fork", + "rollback", + "mcp", + "agents", + "skills", + "commands", + "plugins", + "memory", + "images", + "usage", + "permissions", + "projects", + "library", + "pull-requests", + "follow-ups", + "rate-limits", + ], + configRoots: { + user: path.join(home, ".claude"), + project: ".claude", + sessions: "claude-sessions/", + }, + models: [ + { id: "opus", label: "Opus 4.6" }, + { id: "sonnet", label: "Sonnet 4.6" }, + { id: "haiku", label: "Haiku 4.5" }, + ], + }, + codex: { + id: "codex", + label: "OpenAI Codex", + vendor: "OpenAI", + availability: "available", + defaultModelId: "gpt-5.5/medium", + features: [ + "chat", + "resume", + "mcp", + "plugins", + "memory", + "images", + "usage", + "permissions", + "projects", + "library", + "pull-requests", + "follow-ups", + "rate-limits", + "realtime-voice", + "dictation", + "diagnostics", + ], + configRoots: { + user: path.join(home, ".codex"), + project: ".codex", + sessions: path.join(home, ".codex", "sessions"), + }, + models: [ + { id: "gpt-5.5", label: "GPT 5.5" }, + { id: "gpt-5.4", label: "GPT 5.4" }, + { id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }, + { id: "gpt-5.2", label: "GPT 5.2" }, + ], + notes: [ + "Codex currently uses ACP session resources plus ~/.codex/config.toml MCP configuration.", + "Native resume is prepared through codex exec resume; native fork is available as a TUI command plan until Codex exposes a headless fork bridge.", + "Agent, skill, and command projection must be rendered into prompt/context until native support is implemented.", + ], + }, + hermes: { + id: "hermes", + label: "Hermes", + vendor: "Moss / Hermes", + availability: "available", + defaultModelId: "moss-default", + features: [ + "chat", + "resume", + "fork", + "rollback", + "mcp", + "agents", + "skills", + "commands", + "plugins", + "memory", + "images", + "usage", + "permissions", + "projects", + "library", + "pull-requests", + "follow-ups", + "rate-limits", + "realtime-voice", + "dictation", + "diagnostics", + ], + configRoots: { + user: path.join(home, ".hermes"), + project: ".moss", + }, + models: [ + { id: "moss-default", label: "Moss Default" }, + ], + notes: [ + "Hermes is the native Moss core target and consumes .moss as its canonical project source.", + "The local Hermes CLI exposes an ACP server surface and uses the current Hermes runtime model by default.", + "Hermes native resume is planned through hermes --resume; fork and rollback stay Moss-owned because the live Hermes CLI does not expose separate fork or rollback commands.", + ], + }, + "custom-acp": { + id: "custom-acp", + label: "Custom ACP", + vendor: "User / ACP", + availability: "unsupported", + defaultModelId: "custom-acp", + features: [ + "chat", + "mcp", + "agents", + "skills", + "commands", + "plugins", + "memory", + "usage", + "permissions", + "projects", + "library", + "pull-requests", + "follow-ups", + "rate-limits", + ], + configRoots: { + user: path.join(home, ".moss", "custom-acp"), + project: ".moss/custom-acp", + sessions: ".moss/custom-acp/sessions/", + }, + models: [ + { id: "custom-acp", label: "Custom ACP Default" }, + ], + notes: [ + "Custom ACP is a governed external engine slot under Moss Unified Source.", + "Moss provider, resource, and projection settings can be prepared now; session start remains disabled until a custom ACP endpoint or command adapter is configured.", + "Shared skills, MCP, plugins, hooks, memory, and subagents are projected from .moss instead of maintained as a second real copy.", + ], + }, +} + +export function getAgentRuntimeManifest(engineId: AgentEngineId): AgentRuntimeManifest { + return AGENT_RUNTIME_MANIFESTS[engineId] +} + +export function listAgentRuntimeManifests(): AgentRuntimeManifest[] { + return Object.values(AGENT_RUNTIME_MANIFESTS) +} diff --git a/src/main/lib/agent-runtime/session-actions.ts b/src/main/lib/agent-runtime/session-actions.ts new file mode 100644 index 000000000..19c804098 --- /dev/null +++ b/src/main/lib/agent-runtime/session-actions.ts @@ -0,0 +1,598 @@ +import type { AgentEngineId, AgentRuntimeFeature } from "./types" + +export const MOSS_SESSION_ACTION_IDS = [ + "resume", + "fork", + "rollback", +] as const + +export type MossSessionActionId = (typeof MOSS_SESSION_ACTION_IDS)[number] + +export type MossSessionActionStatus = + | "ready" + | "unavailable" + | "unsupported" + | "needs-native-session" + | "needs-target" + +export type MossSessionActionMode = + | "native" + | "moss-transcript" + | "message-history" + +export type MossSessionNativeBridge = + | "claude-code-session" + | "codex-exec-resume" + | "hermes-cli-resume" + | "hermes-acp-session-control" + +export interface MossSessionMessage { + id?: string + role?: string + content?: unknown + parts?: unknown + metadata?: Record + [key: string]: unknown +} + +export interface MossSessionActionState { + status: MossSessionActionStatus + mode?: MossSessionActionMode + nativeBridge?: MossSessionNativeBridge + canRunHeadless?: boolean + reason?: string + targetMessageId?: string + targetSdkMessageUuid?: string + targetLabel?: string +} + +export interface MossSessionActionPlan { + subChatId: string + engine: AgentEngineId + messageCount: number + latestMessageId?: string + latestAssistantMessageId?: string + latestAssistantSdkMessageUuid?: string + rollbackTargetMessageId?: string + rollbackTargetSdkMessageUuid?: string + actions: Record +} + +export interface BuildMossSessionActionPlanInput { + subChatId: string + engine: AgentEngineId + nativeSessionId?: string | null + messages: string | MossSessionMessage[] | null | undefined + features?: AgentRuntimeFeature[] +} + +export interface MossForkSnapshot { + messages: MossSessionMessage[] + messageCount: number + forkAtSdkUuid: string | null + mode: MossSessionActionMode + nativeSessionLinked: boolean +} + +export interface BuildMossForkSnapshotInput { + engine: AgentEngineId + nativeSessionId?: string | null + messages: string | MossSessionMessage[] | null | undefined + features?: AgentRuntimeFeature[] + targetMessageId?: string + targetMessageIndex?: number +} + +export interface MossRollbackSnapshot { + messages: MossSessionMessage[] + messageCount: number + targetMessageId: string | null + targetSdkMessageUuid: string | null + mode: MossSessionActionMode + nativeSessionLinked: boolean +} + +export interface BuildMossRollbackSnapshotInput { + engine: AgentEngineId + nativeSessionId?: string | null + messages: string | MossSessionMessage[] | null | undefined + features?: AgentRuntimeFeature[] + targetMessageId?: string + targetSdkMessageUuid?: string +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function cleanMetadata( + value: unknown, + extra?: Record, + options?: { clearNativeSession?: boolean }, +): Record { + const metadata = isRecord(value) ? { ...value } : {} + delete metadata.shouldResume + delete metadata.shouldForkResume + if (options?.clearNativeSession) { + delete metadata.sessionId + delete metadata.sdkMessageUuid + } + return { + ...metadata, + ...(extra ?? {}), + } +} + +function messageId(message: MossSessionMessage): string | undefined { + return typeof message.id === "string" ? message.id : undefined +} + +function messageRole(message: MossSessionMessage): string | undefined { + return typeof message.role === "string" ? message.role : undefined +} + +function messageSdkUuid(message: MossSessionMessage): string | undefined { + const metadata = isRecord(message.metadata) ? message.metadata : {} + const value = metadata.sdkMessageUuid + return typeof value === "string" && value ? value : undefined +} + +function messageLabel(message: MossSessionMessage, fallback: string): string { + const parts = Array.isArray(message.parts) ? message.parts : [] + for (const part of parts) { + if (!isRecord(part)) continue + const text = part.text + if (typeof text === "string" && text.trim()) { + return text.trim().replace(/\s+/g, " ").slice(0, 80) + } + } + + if (typeof message.content === "string" && message.content.trim()) { + return message.content.trim().replace(/\s+/g, " ").slice(0, 80) + } + + return fallback +} + +function hasFeature( + features: AgentRuntimeFeature[] | undefined, + feature: AgentRuntimeFeature, +): boolean { + return !features || features.includes(feature) +} + +function findLastIndex( + values: T[], + predicate: (value: T, index: number) => boolean, +): number { + for (let index = values.length - 1; index >= 0; index -= 1) { + if (predicate(values[index], index)) return index + } + return -1 +} + +function supportsNativeForkBridge( + engine: AgentEngineId, + nativeSessionId: string | null | undefined, + features: AgentRuntimeFeature[] | undefined, + forkAtSdkUuid: string | null, +): boolean { + if (engine === "hermes") { + return Boolean(nativeSessionId && hasFeature(features, "fork")) + } + + return Boolean( + engine === "claude-code" && + nativeSessionId && + forkAtSdkUuid && + hasFeature(features, "fork"), + ) +} + +function supportsNativeRollbackBridge( + engine: AgentEngineId, + nativeSessionId: string | null | undefined, + features: AgentRuntimeFeature[] | undefined, + targetSdkUuid: string | null, + hasTarget: boolean, +): boolean { + if (engine === "hermes") { + return Boolean(nativeSessionId && hasTarget && hasFeature(features, "rollback")) + } + + return Boolean( + engine === "claude-code" && + nativeSessionId && + targetSdkUuid && + hasFeature(features, "rollback"), + ) +} + +export function parseMossSessionMessages( + value: string | MossSessionMessage[] | null | undefined, +): MossSessionMessage[] { + if (!value) return [] + let parsed: unknown + try { + parsed = typeof value === "string" ? JSON.parse(value || "[]") : value + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + return parsed.filter(isRecord) as MossSessionMessage[] +} + +export function buildMossSessionActionPlan( + input: BuildMossSessionActionPlanInput, +): MossSessionActionPlan { + const messages = parseMossSessionMessages(input.messages) + const latestMessage = messages[messages.length - 1] + const latestAssistantIndex = findLastIndex( + messages, + (message) => messageRole(message) === "assistant", + ) + const latestAssistant = + latestAssistantIndex >= 0 ? messages[latestAssistantIndex] : undefined + const latestAssistantWithSdkIndex = findLastIndex( + messages, + (message) => + messageRole(message) === "assistant" && Boolean(messageSdkUuid(message)), + ) + const latestAssistantWithSdk = + latestAssistantWithSdkIndex >= 0 + ? messages[latestAssistantWithSdkIndex] + : undefined + const rollbackTarget = latestAssistantWithSdk ?? latestAssistant ?? latestMessage + const rollbackTargetSdkUuid = rollbackTarget + ? messageSdkUuid(rollbackTarget) ?? null + : null + const rollbackTargetMessageId = rollbackTarget + ? messageId(rollbackTarget) ?? null + : null + const nativeSessionLinked = Boolean(input.nativeSessionId) + const latestAssistantSdkMessageUuid = latestAssistant + ? messageSdkUuid(latestAssistant) + : undefined + const forkAtSdkUuid = latestAssistant ? messageSdkUuid(latestAssistant) ?? null : null + const nativeFork = supportsNativeForkBridge( + input.engine, + input.nativeSessionId, + input.features, + forkAtSdkUuid, + ) + const nativeRollback = supportsNativeRollbackBridge( + input.engine, + input.nativeSessionId, + input.features, + rollbackTargetSdkUuid, + Boolean(rollbackTarget), + ) + + const resume: MossSessionActionState = !hasFeature(input.features, "resume") + ? { + status: "unsupported", + reason: `${input.engine} does not advertise native resume support.`, + } + : nativeSessionLinked + ? { + status: "ready", + mode: "native", + ...(input.engine === "codex" + ? { nativeBridge: "codex-exec-resume" as const } + : input.engine === "claude-code" + ? { nativeBridge: "claude-code-session" as const } + : input.engine === "hermes" + ? { nativeBridge: "hermes-cli-resume" as const } + : {}), + canRunHeadless: input.engine === "codex" || input.engine === "hermes", + reason: + input.engine === "codex" + ? "Codex native session id is linked through codex exec resume." + : input.engine === "hermes" + ? "Hermes native session id is linked through hermes --resume." + : "Native session id is linked.", + } + : messages.length > 0 + ? { + status: "needs-native-session", + mode: "message-history", + reason: "Transcript exists, but no native engine session has been linked yet.", + } + : { + status: "unavailable", + reason: "No transcript is available to resume.", + } + + const fork: MossSessionActionState = messages.length === 0 + ? { + status: "unavailable", + reason: "No transcript is available to fork.", + } + : nativeFork + ? { + status: "ready", + mode: "native", + nativeBridge: + input.engine === "hermes" + ? "hermes-acp-session-control" + : "claude-code-session", + targetMessageId: latestAssistant ? messageId(latestAssistant) : undefined, + targetSdkMessageUuid: forkAtSdkUuid ?? undefined, + targetLabel: latestAssistant + ? messageLabel(latestAssistant, "Latest assistant message") + : undefined, + reason: + input.engine === "hermes" + ? "Hermes fork stays linked to the Moss-owned Hermes session and starts from the selected Moss transcript boundary." + : "Claude Code native fork bridge can resume at the selected assistant turn.", + } + : { + status: "ready", + mode: "moss-transcript", + targetMessageId: latestMessage ? messageId(latestMessage) : undefined, + targetLabel: latestMessage + ? messageLabel(latestMessage, "Latest message") + : undefined, + reason: "Moss will clone the transcript and start a fresh native engine session.", + } + + const rollback: MossSessionActionState = messages.length === 0 + ? { + status: "unavailable", + reason: "No transcript is available to roll back.", + } + : !rollbackTarget + ? { + status: "needs-target", + reason: "No rollback target message was found.", + } + : nativeRollback + ? { + status: "ready", + mode: "native", + nativeBridge: + input.engine === "hermes" + ? "hermes-acp-session-control" + : "claude-code-session", + targetMessageId: rollbackTargetMessageId ?? undefined, + targetSdkMessageUuid: rollbackTargetSdkUuid ?? undefined, + targetLabel: messageLabel(rollbackTarget, "Rollback target"), + reason: + input.engine === "hermes" + ? "Hermes rollback stays linked to the Moss-owned Hermes session and truncates to the selected Moss transcript boundary." + : "Claude Code rollback can resume at the target assistant turn.", + } + : messages.length > 1 + ? { + status: "ready", + mode: "message-history", + targetMessageId: rollbackTargetMessageId ?? undefined, + targetSdkMessageUuid: rollbackTargetSdkUuid ?? undefined, + targetLabel: messageLabel(rollbackTarget, "Rollback target"), + reason: "Moss will truncate the transcript and clear stale native session ids.", + } + : { + status: "needs-target", + mode: "message-history", + reason: "At least two messages are needed for message-history rollback.", + } + + return { + subChatId: input.subChatId, + engine: input.engine, + messageCount: messages.length, + latestMessageId: latestMessage ? messageId(latestMessage) : undefined, + latestAssistantMessageId: latestAssistant + ? messageId(latestAssistant) + : undefined, + latestAssistantSdkMessageUuid, + rollbackTargetMessageId: rollbackTargetMessageId ?? undefined, + rollbackTargetSdkMessageUuid: rollbackTargetSdkUuid ?? undefined, + actions: { + resume, + fork, + rollback, + }, + } +} + +function resolveForkCutoffIndex( + messages: MossSessionMessage[], + targetMessageId: string | undefined, + targetMessageIndex: number | undefined, +): number { + if (messages.length === 0) return -1 + if (targetMessageId) { + const byId = messages.findIndex((message) => messageId(message) === targetMessageId) + if (byId >= 0) return byId + } + if ( + typeof targetMessageIndex === "number" && + targetMessageIndex >= 0 && + targetMessageIndex < messages.length + ) { + return targetMessageIndex + } + return messages.length - 1 +} + +function createForkMessageId(index: number): string { + return `fork-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}` +} + +export function buildMossForkSnapshot( + input: BuildMossForkSnapshotInput, +): MossForkSnapshot { + const messages = parseMossSessionMessages(input.messages) + const cutoffIndex = resolveForkCutoffIndex( + messages, + input.targetMessageId, + input.targetMessageIndex, + ) + if (cutoffIndex < 0) { + throw new Error("No transcript is available to fork.") + } + + const messagesToFork = messages.slice(0, cutoffIndex + 1) + const lastAssistantIndex = findLastIndex( + messagesToFork, + (message) => messageRole(message) === "assistant", + ) + const lastAssistant = + lastAssistantIndex >= 0 ? messagesToFork[lastAssistantIndex] : undefined + const forkAtSdkUuid = lastAssistant ? messageSdkUuid(lastAssistant) ?? null : null + const nativeFork = supportsNativeForkBridge( + input.engine, + input.nativeSessionId, + input.features, + forkAtSdkUuid, + ) + + return { + messages: messagesToFork.map((message, index) => ({ + ...message, + id: createForkMessageId(index), + metadata: cleanMetadata( + message.metadata, + input.engine === "claude-code" && + nativeFork && + index === lastAssistantIndex && + forkAtSdkUuid + ? { shouldForkResume: true } + : undefined, + { clearNativeSession: !nativeFork }, + ), + })), + messageCount: messagesToFork.length, + forkAtSdkUuid, + mode: nativeFork ? "native" : "moss-transcript", + nativeSessionLinked: nativeFork, + } +} + +function resolveRollbackTargetIndex( + messages: MossSessionMessage[], + targetMessageId: string | undefined, + targetSdkMessageUuid: string | undefined, +): number { + if (messages.length === 0) return -1 + if (targetMessageId) { + const byId = messages.findIndex((message) => messageId(message) === targetMessageId) + if (byId >= 0) return byId + } + if (targetSdkMessageUuid) { + const bySdkUuid = messages.findIndex( + (message) => messageSdkUuid(message) === targetSdkMessageUuid, + ) + if (bySdkUuid >= 0) return bySdkUuid + } + const lastAssistantWithSdk = findLastIndex( + messages, + (message) => + messageRole(message) === "assistant" && Boolean(messageSdkUuid(message)), + ) + if (lastAssistantWithSdk >= 0) return lastAssistantWithSdk + const lastAssistant = findLastIndex( + messages, + (message) => messageRole(message) === "assistant", + ) + if (lastAssistant >= 0) return lastAssistant + return messages.length - 1 +} + +export function buildMossRollbackSnapshot( + input: BuildMossRollbackSnapshotInput, +): MossRollbackSnapshot { + const messages = parseMossSessionMessages(input.messages) + const targetIndex = resolveRollbackTargetIndex( + messages, + input.targetMessageId, + input.targetSdkMessageUuid, + ) + if (targetIndex < 0) { + throw new Error("No transcript is available to roll back.") + } + + const target = messages[targetIndex] + const targetSdkUuid = messageSdkUuid(target) ?? null + const nativeRollback = supportsNativeRollbackBridge( + input.engine, + input.nativeSessionId, + input.features, + targetSdkUuid, + true, + ) + const truncated = messages.slice(0, targetIndex + 1) + + return { + messages: truncated.map((message, index) => ({ + ...message, + metadata: cleanMetadata( + message.metadata, + input.engine === "claude-code" && + nativeRollback && + index === truncated.length - 1 + ? { shouldResume: true } + : undefined, + { clearNativeSession: !nativeRollback }, + ), + })), + messageCount: truncated.length, + targetMessageId: messageId(target) ?? null, + targetSdkMessageUuid: targetSdkUuid, + mode: nativeRollback ? "native" : "message-history", + nativeSessionLinked: nativeRollback, + } +} + +export function shouldIgnoreMossStoredMessageSessionIds( + runtimeMetadata: string | null | undefined, +): boolean { + if (!runtimeMetadata) return false + + let parsed: unknown + try { + parsed = JSON.parse(runtimeMetadata) + } catch { + return false + } + if (!isRecord(parsed) || !isRecord(parsed.mossSessionControl)) { + return false + } + + const control = parsed.mossSessionControl + const action = control.action + const mode = control.mode + if (mode === "native") return false + + return ( + (action === "fork" && mode === "moss-transcript") || + (action === "rollback" && mode === "message-history") + ) +} + +export function mergeMossSessionControlMetadata( + runtimeMetadata: string | null | undefined, + controlMetadata: Record, +): string { + let parsed: Record = {} + if (runtimeMetadata) { + try { + const value = JSON.parse(runtimeMetadata) + if (isRecord(value)) parsed = value + } catch { + parsed = {} + } + } + + return JSON.stringify({ + ...parsed, + mossSessionControl: { + ...(isRecord(parsed.mossSessionControl) + ? parsed.mossSessionControl + : {}), + ...controlMetadata, + updatedAt: new Date().toISOString(), + }, + }) +} diff --git a/src/main/lib/agent-runtime/session-records.ts b/src/main/lib/agent-runtime/session-records.ts new file mode 100644 index 000000000..f4b249002 --- /dev/null +++ b/src/main/lib/agent-runtime/session-records.ts @@ -0,0 +1,208 @@ +import { getAgentRuntimeManifest } from "./manifests" +import { + buildMossForkSnapshot, + buildMossRollbackSnapshot, + mergeMossSessionControlMetadata, + type MossForkSnapshot, + type MossRollbackSnapshot, +} from "./session-actions" +import { AGENT_ENGINE_IDS, DEFAULT_AGENT_ENGINE_ID, type AgentEngineId } from "./types" + +export interface MossSessionSubChatRecord { + id: string + chatId: string + name: string | null + mode: string + messages: string + sessionId: string | null + engine: string | null + engineSessionId: string | null + engineConfigDir: string | null + modelId: string | null + runtimeMetadata: string | null +} + +export interface MossForkSubChatInsertValues { + id: string + chatId: string + name: string + mode: string + messages: string + sessionId: string | null + engine: AgentEngineId + engineSessionId: string | null + engineConfigDir: string | null + modelId: string | null + runtimeMetadata: string +} + +export interface MossRollbackSubChatUpdateValues { + messages: string + sessionId: string | null + engineSessionId: string | null + runtimeMetadata: string + updatedAt: Date +} + +export interface BuildMossForkSubChatRecordInput { + sourceSubChat: MossSessionSubChatRecord + targetSubChatId: string + targetName: string + targetMessageId?: string + targetMessageIndex?: number + nativeBridgePlan?: unknown + forceTranscript?: boolean + fallbackReason?: string + metadata?: Record +} + +export interface BuildMossForkSubChatRecordResult { + engine: AgentEngineId + sourceNativeSessionId: string | null + snapshot: MossForkSnapshot + nativeSessionLinked: boolean + insertValues: MossForkSubChatInsertValues +} + +export interface BuildMossRollbackSubChatUpdateInput { + subChat: MossSessionSubChatRecord + targetMessageId?: string + targetSdkMessageUuid?: string + appliedGitCheckpoint?: boolean + nativeBridgePlan?: unknown + metadata?: Record +} + +export interface BuildMossRollbackSubChatUpdateResult { + engine: AgentEngineId + sourceNativeSessionId: string | null + snapshot: MossRollbackSnapshot + nativeSessionLinked: boolean + updateValues: MossRollbackSubChatUpdateValues +} + +export function normalizeMossSessionRecordEngine( + value: string | null | undefined, +): AgentEngineId { + return AGENT_ENGINE_IDS.includes(value as AgentEngineId) + ? value as AgentEngineId + : DEFAULT_AGENT_ENGINE_ID +} + +export function nativeSessionIdForMossSessionRecord( + subChat: Pick, + engine: AgentEngineId, +): string | null { + return subChat.engineSessionId ?? (engine === "claude-code" ? subChat.sessionId : null) +} + +export function buildMossForkSubChatRecord( + input: BuildMossForkSubChatRecordInput, +): BuildMossForkSubChatRecordResult { + const engine = normalizeMossSessionRecordEngine(input.sourceSubChat.engine) + const manifest = getAgentRuntimeManifest(engine) + const originalNativeSessionId = nativeSessionIdForMossSessionRecord( + input.sourceSubChat, + engine, + ) + const sourceNativeSessionId = input.forceTranscript + ? null + : originalNativeSessionId + const snapshot = buildMossForkSnapshot({ + engine, + nativeSessionId: sourceNativeSessionId, + messages: input.sourceSubChat.messages, + features: manifest.features, + targetMessageId: input.targetMessageId, + targetMessageIndex: input.targetMessageIndex, + }) + const nativeSessionLinked = snapshot.nativeSessionLinked + const runtimeMetadata = mergeMossSessionControlMetadata( + input.sourceSubChat.runtimeMetadata, + { + action: "fork", + mode: snapshot.mode, + sourceSubChatId: input.sourceSubChat.id, + sourceEngineSessionId: originalNativeSessionId, + nativeSessionLinked, + targetMessageId: input.targetMessageId ?? null, + targetMessageIndex: input.targetMessageIndex ?? null, + forkAtSdkUuid: snapshot.forkAtSdkUuid, + nativeBridgePlan: input.nativeBridgePlan, + ...(input.fallbackReason ? { fallbackReason: input.fallbackReason } : {}), + ...(input.metadata ?? {}), + }, + ) + + return { + engine, + sourceNativeSessionId, + snapshot, + nativeSessionLinked, + insertValues: { + id: input.targetSubChatId, + chatId: input.sourceSubChat.chatId, + name: input.targetName, + mode: input.sourceSubChat.mode, + messages: JSON.stringify(snapshot.messages), + sessionId: + nativeSessionLinked && engine === "claude-code" + ? input.sourceSubChat.sessionId + : null, + engine, + engineSessionId: nativeSessionLinked ? originalNativeSessionId : null, + engineConfigDir: input.sourceSubChat.engineConfigDir, + modelId: input.sourceSubChat.modelId, + runtimeMetadata, + }, + } +} + +export function buildMossRollbackSubChatUpdate( + input: BuildMossRollbackSubChatUpdateInput, +): BuildMossRollbackSubChatUpdateResult { + const engine = normalizeMossSessionRecordEngine(input.subChat.engine) + const manifest = getAgentRuntimeManifest(engine) + const sourceNativeSessionId = nativeSessionIdForMossSessionRecord( + input.subChat, + engine, + ) + const snapshot = buildMossRollbackSnapshot({ + engine, + nativeSessionId: sourceNativeSessionId, + messages: input.subChat.messages, + features: manifest.features, + targetMessageId: input.targetMessageId, + targetSdkMessageUuid: input.targetSdkMessageUuid, + }) + const nativeSessionLinked = snapshot.nativeSessionLinked + + return { + engine, + sourceNativeSessionId, + snapshot, + nativeSessionLinked, + updateValues: { + messages: JSON.stringify(snapshot.messages), + sessionId: + nativeSessionLinked && engine === "claude-code" + ? input.subChat.sessionId + : null, + engineSessionId: nativeSessionLinked ? sourceNativeSessionId : null, + runtimeMetadata: mergeMossSessionControlMetadata( + input.subChat.runtimeMetadata, + { + action: "rollback", + mode: snapshot.mode, + nativeSessionLinked, + targetMessageId: snapshot.targetMessageId, + targetSdkMessageUuid: snapshot.targetSdkMessageUuid, + appliedGitCheckpoint: Boolean(input.appliedGitCheckpoint), + nativeBridgePlan: input.nativeBridgePlan, + ...(input.metadata ?? {}), + }, + ), + updatedAt: new Date(), + }, + } +} diff --git a/src/main/lib/agent-runtime/session-store.ts b/src/main/lib/agent-runtime/session-store.ts new file mode 100644 index 000000000..1675fa703 --- /dev/null +++ b/src/main/lib/agent-runtime/session-store.ts @@ -0,0 +1,48 @@ +import { eq } from "drizzle-orm" +import { getDatabase, subChats } from "../db" +import type { AgentEngineId, AgentPermissionMode } from "./types" + +type RuntimeMetadata = Record + +export type PersistAgentRuntimeSessionInput = { + subChatId: string + engine: AgentEngineId + nativeSessionId?: string | null + configDir?: string | null + modelId?: string | null + permissionMode?: AgentPermissionMode + metadata?: RuntimeMetadata + updateLegacySessionId?: boolean +} + +function serializeMetadata( + input: PersistAgentRuntimeSessionInput, +): string { + return JSON.stringify({ + ...(input.metadata ?? {}), + ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), + updatedAt: new Date().toISOString(), + }) +} + +export function persistAgentRuntimeSession( + input: PersistAgentRuntimeSessionInput, +): void { + const db = getDatabase() + const values = { + engine: input.engine, + engineSessionId: input.nativeSessionId ?? null, + engineConfigDir: input.configDir ?? null, + modelId: input.modelId ?? null, + runtimeMetadata: serializeMetadata(input), + updatedAt: new Date(), + ...(input.updateLegacySessionId + ? { sessionId: input.nativeSessionId ?? null } + : {}), + } + + db.update(subChats) + .set(values) + .where(eq(subChats.id, input.subChatId)) + .run() +} diff --git a/src/main/lib/agent-runtime/stale-stream-state.ts b/src/main/lib/agent-runtime/stale-stream-state.ts new file mode 100644 index 000000000..ca3436c86 --- /dev/null +++ b/src/main/lib/agent-runtime/stale-stream-state.ts @@ -0,0 +1,28 @@ +export type AgentStreamSubChatState = { + id: string + engine: string + streamId?: string | null +} + +export type AgentStreamActivityLookup = { + isActiveCodexStream: (subChatId: string, streamId: string) => boolean + isActiveHermesStream: (subChatId: string, streamId: string) => boolean +} + +export function shouldClearStaleAgentStreamId( + subChat: AgentStreamSubChatState, + activity: AgentStreamActivityLookup, +): boolean { + const streamId = subChat.streamId + if (!streamId) return false + + if (subChat.engine === "codex") { + return !activity.isActiveCodexStream(subChat.id, streamId) + } + + if (subChat.engine === "hermes") { + return !activity.isActiveHermesStream(subChat.id, streamId) + } + + return false +} diff --git a/src/main/lib/agent-runtime/types.ts b/src/main/lib/agent-runtime/types.ts new file mode 100644 index 000000000..8ca2143da --- /dev/null +++ b/src/main/lib/agent-runtime/types.ts @@ -0,0 +1,301 @@ +import type { + CodexBlockStatus, + CodexConversationBlock, +} from "../../../shared/codex-tool-normalizer" + +export const AGENT_ENGINE_IDS = [ + "claude-code", + "codex", + "hermes", + "custom-acp", +] as const + +export type AgentEngineId = (typeof AGENT_ENGINE_IDS)[number] +export const DEFAULT_AGENT_ENGINE_ID: AgentEngineId = "hermes" + +export type AgentRuntimeAvailability = + | "available" + | "needs-auth" + | "not-installed" + | "unsupported" + | "error" + +export type AgentRuntimeFeature = + | "chat" + | "resume" + | "fork" + | "rollback" + | "mcp" + | "agents" + | "skills" + | "commands" + | "plugins" + | "memory" + | "images" + | "usage" + | "permissions" + | "projects" + | "library" + | "pull-requests" + | "follow-ups" + | "rate-limits" + | "realtime-voice" + | "dictation" + | "diagnostics" + +export type AgentPermissionMode = + | "plan" + | "agent" + | "bypass" + | "read-only" + | "ask-approval" + | "full-access" + | "custom" + +export type AgentRuntimeAuthMethod = + | "oauth" + | "api-key" + | "shell-config" + | "not-authenticated" + | "unsupported" + | "unknown" + +export interface AgentRuntimeModel { + id: string + label: string +} + +export interface AgentRuntimeModelHealth extends AgentRuntimeModel { + availability: AgentRuntimeAvailability + reason?: string +} + +export interface AgentRuntimeHealth { + availability: AgentRuntimeAvailability + statusReason?: string + authMethod?: AgentRuntimeAuthMethod + models?: AgentRuntimeModelHealth[] +} + +export interface AgentRuntimeManifest { + id: AgentEngineId + label: string + vendor: string + availability: AgentRuntimeAvailability + features: AgentRuntimeFeature[] + defaultModelId?: string + models?: AgentRuntimeModel[] + configRoots: { + user?: string + project?: string + sessions?: string + } + notes?: string[] +} + +export interface AgentRuntimeSessionRef { + subChatId: string + chatId: string + engineId: AgentEngineId + nativeSessionId?: string | null + modelId?: string | null + permissionMode: AgentPermissionMode + cwd: string + projectPath?: string | null + runtimeConfigDir?: string | null + metadata?: Record +} + +export interface AgentRuntimeStartRequest { + session: AgentRuntimeSessionRef + prompt: string + images?: Array<{ + base64Data: string + mediaType: string + filename?: string + }> + forceNewSession?: boolean +} + +export type AgentRuntimeBlockStatus = CodexBlockStatus | "blocked" + +export type AgentRuntimeAutomationAction = + | "created" + | "updated" + | "deleted" + | "enabled" + | "disabled" + | "started" + | "completed" + | "failed" + | "paused" + | "resumed" + +export interface AgentRuntimeBaseConversationBlock { + id: string + type: string + turnId?: string + status?: AgentRuntimeBlockStatus + title?: string + summary?: string + input?: unknown + output?: unknown + metadata?: Record +} + +export interface AgentRuntimeAutomationUpdateBlock + extends AgentRuntimeBaseConversationBlock { + type: "automation-update" + automationId?: string + action?: AgentRuntimeAutomationAction | (string & {}) +} + +export interface AgentRuntimeMultiAgentActionBlock + extends AgentRuntimeBaseConversationBlock { + type: "multi-agent-action" + agentId?: string + agentLabel?: string + action?: "spawn" | "message" | "handoff" | "complete" | "failed" | (string & {}) +} + +export interface AgentRuntimeContextCompactionBlock + extends AgentRuntimeBaseConversationBlock { + type: "context-compaction" + previousInputTokens?: number + nextInputTokens?: number + droppedMessages?: number +} + +export interface AgentRuntimeModelChangeBlock + extends AgentRuntimeBaseConversationBlock { + type: "model-change" | "model-reroute" + fromModelId?: string + toModelId?: string + reason?: string +} + +export interface AgentRuntimeGoalStatusBlock + extends AgentRuntimeBaseConversationBlock { + type: "goal-status" + goalId?: string +} + +export interface AgentRuntimeRealtimeStateBlock + extends AgentRuntimeBaseConversationBlock { + type: "realtime-state" | "dictation-state" + mode?: "dictation" | "voice-only" | "voice_and_screen" | (string & {}) + microphoneDeviceId?: string +} + +export interface AgentRuntimeQueuedFollowUpBlock + extends AgentRuntimeBaseConversationBlock { + type: "queued-follow-up" + followUpId?: string + queueState?: "queued" | "sending" | "sent" | "failed" | "cancelled" | (string & {}) +} + +export interface AgentRuntimeUsageStatusBlock + extends AgentRuntimeBaseConversationBlock { + type: "rate-limit-status" | "usage-status" + window?: "hourly" | "daily" | "weekly" | "monthly" | "annual" | (string & {}) + remaining?: number + limit?: number + resetAt?: string +} + +export interface AgentRuntimeProjectEventBlock + extends AgentRuntimeBaseConversationBlock { + type: "project-event" + projectId?: string + projectName?: string + action?: "created" | "updated" | "pinned" | "unpinned" | "selected" | (string & {}) +} + +export interface AgentRuntimeLibraryArtifactBlock + extends AgentRuntimeBaseConversationBlock { + type: "library-artifact" + artifactId?: string + artifactKind?: "file" | "image" | "site" | "document" | "spreadsheet" | (string & {}) + path?: string + url?: string +} + +export interface AgentRuntimePullRequestStatusBlock + extends AgentRuntimeBaseConversationBlock { + type: "pull-request-status" + pullRequestId?: string + url?: string + reviewState?: "queued" | "running" | "changes-requested" | "approved" | "failed" | (string & {}) + checksState?: "pending" | "running" | "passing" | "failing" | "unknown" | (string & {}) +} + +export interface AgentRuntimeDiagnosticSnapshotBlock + extends AgentRuntimeBaseConversationBlock { + type: "diagnostic-snapshot" + snapshotKind?: "child-processes" | "renderer-memory" | "trace-recording" | (string & {}) + path?: string +} + +export type AgentRuntimeConversationBlock = + | CodexConversationBlock + | AgentRuntimeAutomationUpdateBlock + | AgentRuntimeMultiAgentActionBlock + | AgentRuntimeContextCompactionBlock + | AgentRuntimeModelChangeBlock + | AgentRuntimeGoalStatusBlock + | AgentRuntimeRealtimeStateBlock + | AgentRuntimeQueuedFollowUpBlock + | AgentRuntimeUsageStatusBlock + | AgentRuntimeProjectEventBlock + | AgentRuntimeLibraryArtifactBlock + | AgentRuntimePullRequestStatusBlock + | AgentRuntimeDiagnosticSnapshotBlock + +export type AgentRuntimeStreamEvent = + | { + type: "text" + text: string + } + | { + type: "tool-call" + id?: string + name: string + input?: unknown + } + | { + type: "tool-result" + id?: string + name?: string + result?: unknown + } + | { + type: "usage" + inputTokens?: number + outputTokens?: number + totalTokens?: number + modelContextWindow?: number + } + | { + type: "conversation-block" + block: AgentRuntimeConversationBlock + } + | { + type: "conversation-block-update" + id: string + patch: Partial + } + | { + type: "auth-error" | "error" + message: string + } + | { + type: "finish" + nativeSessionId?: string | null + resultSubtype?: "success" | "error" | "cancelled" + } + +export interface AgentRuntimeAdapter { + manifest: AgentRuntimeManifest + inspect?(session: AgentRuntimeSessionRef): Promise + canStart(session: AgentRuntimeSessionRef): Promise +} diff --git a/src/main/lib/auto-updater.ts b/src/main/lib/auto-updater.ts index 59c2c0cdf..190f42235 100644 --- a/src/main/lib/auto-updater.ts +++ b/src/main/lib/auto-updater.ts @@ -1,6 +1,10 @@ import { BrowserWindow, ipcMain, app } from "electron" import log from "electron-log" -import { autoUpdater, type UpdateInfo, type ProgressInfo } from "electron-updater" +import { + autoUpdater, + type UpdateInfo, + type ProgressInfo, +} from "electron-updater" import { readFileSync, writeFileSync, existsSync } from "fs" import { join } from "path" @@ -65,6 +69,7 @@ function saveChannel(channel: UpdateChannel): void { } let getAllWindows: (() => BrowserWindow[]) | null = null +let updateIpcHandlersRegistered = false /** * Send update event to all renderer windows @@ -110,7 +115,7 @@ export async function initAutoUpdater(getWindows: () => BrowserWindow[]) { // Add cache-busting to update requests autoUpdater.requestHeaders = { "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", + Pragma: "no-cache", } // Event: Checking for updates @@ -178,15 +183,22 @@ export async function initAutoUpdater(getWindows: () => BrowserWindow[]) { }) // Register IPC handlers - registerIpcHandlers() + registerAutoUpdaterIpcHandlers() log.info("[AutoUpdater] Initialized with feed URL:", CDN_BASE) } /** - * Register IPC handlers for update operations + * Register IPC handlers for update operations. + * + * The renderer asks for update state/channel in both packaged and dev builds. + * Dev still needs these handlers registered, but should not initialize + * electron-updater or perform network update checks. */ -function registerIpcHandlers() { +export function registerAutoUpdaterIpcHandlers() { + if (updateIpcHandlersRegistered) return + updateIpcHandlersRegistered = true + // Check for updates ipcMain.handle("update:check", async (_event, force?: boolean) => { if (!app.isPackaged) { @@ -201,7 +213,10 @@ function registerIpcHandlers() { provider: "generic", url: `${CDN_BASE}${cacheBuster}`, }) - log.info("[AutoUpdater] Force check with cache-busting:", `${CDN_BASE}${cacheBuster}`) + log.info( + "[AutoUpdater] Force check with cache-busting:", + `${CDN_BASE}${cacheBuster}`, + ) } const result = await autoUpdater.checkForUpdates() // Reset feed URL back to normal after force check diff --git a/src/main/lib/claude-plugin-settings.ts b/src/main/lib/claude-plugin-settings.ts new file mode 100644 index 000000000..8453dab23 --- /dev/null +++ b/src/main/lib/claude-plugin-settings.ts @@ -0,0 +1,77 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json") + +let enabledPluginsCache: { plugins: string[]; timestamp: number } | null = null +const ENABLED_PLUGINS_CACHE_TTL_MS = 5000 + +let approvedMcpCache: { servers: string[]; timestamp: number } | null = null +const APPROVED_MCP_CACHE_TTL_MS = 5000 + +export function invalidateEnabledPluginsCache(): void { + enabledPluginsCache = null +} + +export function invalidateApprovedMcpCache(): void { + approvedMcpCache = null +} + +export async function readClaudeSettings(): Promise> { + try { + const content = await fs.readFile(CLAUDE_SETTINGS_PATH, "utf-8") + return JSON.parse(content) + } catch { + return {} + } +} + +export async function writeClaudeSettings(settings: Record): Promise { + const dir = path.dirname(CLAUDE_SETTINGS_PATH) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8") +} + +export async function getEnabledPlugins(): Promise { + if ( + enabledPluginsCache && + Date.now() - enabledPluginsCache.timestamp < ENABLED_PLUGINS_CACHE_TTL_MS + ) { + return enabledPluginsCache.plugins + } + + const settings = await readClaudeSettings() + const plugins = Array.isArray(settings.enabledPlugins) + ? settings.enabledPlugins as string[] + : [] + + enabledPluginsCache = { plugins, timestamp: Date.now() } + return plugins +} + +export async function getApprovedPluginMcpServers(): Promise { + if ( + approvedMcpCache && + Date.now() - approvedMcpCache.timestamp < APPROVED_MCP_CACHE_TTL_MS + ) { + return approvedMcpCache.servers + } + + const settings = await readClaudeSettings() + const servers = Array.isArray(settings.approvedPluginMcpServers) + ? settings.approvedPluginMcpServers as string[] + : [] + + approvedMcpCache = { servers, timestamp: Date.now() } + return servers +} + +export async function isPluginMcpApproved( + pluginSource: string, + serverName: string, +): Promise { + const approved = await getApprovedPluginMcpServers() + const identifier = `${pluginSource}:${serverName}` + return approved.includes(identifier) +} diff --git a/src/main/lib/claude/env.ts b/src/main/lib/claude/env.ts index 0ea2ab0cf..e95a8c81c 100644 --- a/src/main/lib/claude/env.ts +++ b/src/main/lib/claude/env.ts @@ -36,6 +36,65 @@ const STRIPPED_ENV_KEYS = !app.isPackaged // Cache the bundled binary path (only compute once) let cachedBinaryPath: string | null = null let binaryPathComputed = false +let cachedExecutableResolution: ClaudeExecutableResolution | null = null + +export type ClaudeExecutableSource = "bundled" | "system" + +export type ClaudeExecutableResolution = { + path: string + source: ClaudeExecutableSource + bundledPath: string + reason?: string +} + +function isExecutableFile(filePath: string): boolean { + try { + const stats = fs.statSync(filePath) + if (!stats.isFile()) return false + if (process.platform === "win32") return true + return (stats.mode & fs.constants.X_OK) !== 0 + } catch { + return false + } +} + +function findExecutableInPath( + binaryName: string, + pathValue: string | undefined, +): string | null { + if (!pathValue) return null + + for (const rawDir of pathValue.split(path.delimiter)) { + const dir = rawDir.trim() + if (!dir) continue + + const candidate = path.join(dir, binaryName) + if (isExecutableFile(candidate)) { + return candidate + } + } + + return null +} + +function getCommonUserBinPaths(): string[] { + const home = os.homedir() + const paths = [ + path.join(home, ".local/bin"), + path.join(home, "bin"), + ] + + if (process.platform === "darwin") { + paths.push("/opt/homebrew/bin", "/usr/local/bin") + } + + if (process.platform === "win32") { + const appData = process.env.APPDATA + if (appData) paths.push(path.join(appData, "npm")) + } + + return paths +} /** * Get path to the bundled Claude binary. @@ -104,6 +163,70 @@ export function getBundledClaudeBinaryPath(): string { return binaryPath } +/** + * Resolve the Claude Code executable used by the SDK. + * Prefer the app-bundled binary for packaged builds, but allow a system + * `claude` fallback in development so runtime switching can be tested without + * downloading the bundle artifact first. + */ +export function resolveClaudeCodeExecutable(): ClaudeExecutableResolution { + if (cachedExecutableResolution) { + return cachedExecutableResolution + } + + const bundledPath = getBundledClaudeBinaryPath() + if (isExecutableFile(bundledPath)) { + cachedExecutableResolution = { + path: bundledPath, + source: "bundled", + bundledPath, + } + return cachedExecutableResolution + } + + const binaryName = process.platform === "win32" ? "claude.exe" : "claude" + const pathValues = [process.env.PATH] + + try { + const shellEnv = getClaudeShellEnvironment() + pathValues.unshift(shellEnv.PATH) + } catch (error) { + console.warn("[claude-binary] Failed to inspect shell PATH:", error) + } + + pathValues.push(getCommonUserBinPaths().join(path.delimiter)) + const systemPath = findExecutableInPath( + binaryName, + pathValues.filter(Boolean).join(path.delimiter), + ) + if (systemPath) { + cachedExecutableResolution = { + path: systemPath, + source: "system", + bundledPath, + reason: "Bundled Claude Code binary is missing; using system claude.", + } + console.warn( + "[claude-binary] Bundled binary missing; using system Claude Code:", + systemPath, + ) + return cachedExecutableResolution + } + + cachedExecutableResolution = { + path: bundledPath, + source: "bundled", + bundledPath, + reason: + "Bundled Claude Code binary is missing and no system claude executable was found on PATH.", + } + return cachedExecutableResolution +} + +export function getClaudeCodeExecutablePath(): string { + return resolveClaudeCodeExecutable().path +} + /** * Parse environment variables from shell output */ diff --git a/src/main/lib/claude/index.ts b/src/main/lib/claude/index.ts index 9d42c7ead..e38a48bdc 100644 --- a/src/main/lib/claude/index.ts +++ b/src/main/lib/claude/index.ts @@ -11,6 +11,9 @@ export { clearClaudeEnvCache, logClaudeEnv, getBundledClaudeBinaryPath, + getClaudeCodeExecutablePath, + resolveClaudeCodeExecutable, } from "./env" +export type { ClaudeExecutableResolution } from "./env" export { checkOfflineFallback } from "./offline-handler" export type { OfflineCheckResult, CustomClaudeConfig } from "./offline-handler" diff --git a/src/main/lib/claude/transform.ts b/src/main/lib/claude/transform.ts index 0d1a1cec4..11af6ef57 100644 --- a/src/main/lib/claude/transform.ts +++ b/src/main/lib/claude/transform.ts @@ -1,6 +1,9 @@ import type { MCPServer, MCPServerStatus, MessageMetadata, UIMessageChunk } from "./types"; -export function createTransformer(options?: { isUsingOllama?: boolean }) { +export function createTransformer(options?: { + emitSdkMessageUuid?: boolean + isUsingOllama?: boolean +}) { const isUsingOllama = options?.isUsingOllama === true let textId: string | null = null let textStarted = false @@ -171,7 +174,7 @@ export function createTransformer(options?: { isUsingOllama?: boolean }) { yield { type: "tool-input-start", toolCallId: currentToolCallId, - toolName: currentToolName, + toolName: currentToolName ?? "unknown", } } diff --git a/src/main/lib/claude/types.ts b/src/main/lib/claude/types.ts index defd75512..ef16eaee6 100644 --- a/src/main/lib/claude/types.ts +++ b/src/main/lib/claude/types.ts @@ -20,6 +20,7 @@ export type UIMessageChunk = toolCallId: string toolName: string input: unknown + providerMetadata?: Record } | { type: "tool-output-available"; toolCallId: string; output: unknown } | { type: "tool-output-error"; toolCallId: string; errorText: string } @@ -38,6 +39,7 @@ export type UIMessageChunk = }> } | { type: "ask-user-question-timeout"; toolUseId: string } + | { type: "ask-user-question-result"; toolUseId: string; result: unknown } | { type: "message-metadata"; messageMetadata: MessageMetadata } // Session initialization (MCP servers, plugins, tools) | { diff --git a/src/main/lib/codex-automations.test.ts b/src/main/lib/codex-automations.test.ts new file mode 100644 index 000000000..541e39ab8 --- /dev/null +++ b/src/main/lib/codex-automations.test.ts @@ -0,0 +1,201 @@ +import { mkdtemp, readFile, rm } from "fs/promises" +import { tmpdir } from "os" +import { join } from "path" +import { describe, expect, test } from "bun:test" + +import { + createLocalCodexAutomation, + deleteLocalCodexAutomation, + listLocalCodexAutomations, + parseCodexAutomationToml, + runLocalCodexAutomationNow, + updateLocalCodexAutomation, +} from "./codex-automations" + +async function useTempCodexHome() { + const tempCodexHome = await mkdtemp(join(tmpdir(), "1code-codex-automations-")) + process.env.CODEX_HOME = tempCodexHome + return tempCodexHome +} + +describe("local Codex automations", () => { + test("parses real Codex automation.toml shape", () => { + expect( + parseCodexAutomationToml(` +version = 1 +id = "1code" +kind = "cron" +name = "1Code 自动化对齐临时验收" +prompt = "临时验收任务" +status = "ACTIVE" +rrule = "FREQ=HOURLY;INTERVAL=1" +model = "gpt-5.5" +reasoning_effort = "low" +execution_environment = "local" +cwds = ["/Users/moss/Projects/1code"] +created_at = 1781687761313 +updated_at = 1781687761313 +`), + ).toMatchObject({ + id: "1code", + status: "ACTIVE", + reasoning_effort: "low", + execution_environment: "local", + cwds: ["/Users/moss/Projects/1code"], + }) + }) + + test("creates, lists, updates, runs, and deletes official-shaped TOML files", async () => { + const originalCodexHome = process.env.CODEX_HOME + const codexHome = await useTempCodexHome() + try { + const created = await createLocalCodexAutomation({ + name: "1Code 自动化对齐临时验收", + prompt: "检查当前工作区。", + status: "ACTIVE", + rrule: "FREQ=HOURLY;INTERVAL=1", + model: "gpt-5.5", + reasoningEffort: "low", + executionEnvironment: "local", + cwds: ["/Users/moss/Projects/1code"], + }) + + expect(created).toMatchObject({ + source: "codex-local", + name: "1Code 自动化对齐临时验收", + status: "ACTIVE", + engine: "codex", + reasoning_effort: "low", + execution_environment: "local", + }) + + const list = await listLocalCodexAutomations() + expect(list.map((automation) => automation.id)).toEqual([created.id]) + + await updateLocalCodexAutomation({ id: created.id, status: "PAUSED" }) + expect((await listLocalCodexAutomations())[0]).toMatchObject({ + id: created.id, + status: "PAUSED", + }) + + await runLocalCodexAutomationNow({ automationId: created.id }) + expect(typeof (await listLocalCodexAutomations())[0]?.last_run_at).toBe( + "number", + ) + + const toml = await readFile( + join(codexHome, "automations", created.id, "automation.toml"), + "utf8", + ) + expect(toml).toContain('execution_environment = "local"') + expect(toml).toContain('engine = "codex"') + expect(toml).toContain('reasoning_effort = "low"') + + await deleteLocalCodexAutomation({ id: created.id }) + expect(await listLocalCodexAutomations()).toEqual([]) + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + await rm(codexHome, { force: true, recursive: true }) + } + }) + + test("defaults new local automations to the Hermes model", async () => { + const originalCodexHome = process.env.CODEX_HOME + const codexHome = await useTempCodexHome() + try { + const created = await createLocalCodexAutomation({ + name: "Hermes 默认自动化", + prompt: "确认自动化默认模型。", + }) + + expect(created).toMatchObject({ + model: "moss-default", + engine: "hermes", + source: "codex-local", + }) + + const toml = await readFile( + join(codexHome, "automations", created.id, "automation.toml"), + "utf8", + ) + expect(toml).toContain('model = "moss-default"') + expect(toml).toContain('engine = "hermes"') + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + await rm(codexHome, { force: true, recursive: true }) + } + }) + + test("persists the selected Claude Code engine for local automations", async () => { + const originalCodexHome = process.env.CODEX_HOME + const codexHome = await useTempCodexHome() + try { + const created = await createLocalCodexAutomation({ + name: "Claude 自动化", + prompt: "用 Claude Code 检查当前工作区。", + model: "claude-sonnet", + }) + + expect(created).toMatchObject({ + model: "claude-sonnet", + engine: "claude-code", + source: "codex-local", + }) + + const toml = await readFile( + join(codexHome, "automations", created.id, "automation.toml"), + "utf8", + ) + expect(toml).toContain('model = "claude-sonnet"') + expect(toml).toContain('engine = "claude-code"') + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + await rm(codexHome, { force: true, recursive: true }) + } + }) + + test("persists the selected Custom ACP engine for local automations", async () => { + const originalCodexHome = process.env.CODEX_HOME + const codexHome = await useTempCodexHome() + try { + const created = await createLocalCodexAutomation({ + name: "Custom ACP 自动化", + prompt: "用自定义 ACP 检查当前工作区。", + model: "custom-acp", + engine: "custom-acp", + }) + + expect(created).toMatchObject({ + model: "custom-acp", + engine: "custom-acp", + source: "codex-local", + }) + + const toml = await readFile( + join(codexHome, "automations", created.id, "automation.toml"), + "utf8", + ) + expect(toml).toContain('model = "custom-acp"') + expect(toml).toContain('engine = "custom-acp"') + } finally { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + await rm(codexHome, { force: true, recursive: true }) + } + }) +}) diff --git a/src/main/lib/codex-automations.ts b/src/main/lib/codex-automations.ts new file mode 100644 index 000000000..cd0687487 --- /dev/null +++ b/src/main/lib/codex-automations.ts @@ -0,0 +1,392 @@ +import { mkdir, readFile, readdir, rm, writeFile } from "fs/promises" +import { homedir } from "os" +import { join, resolve, sep } from "path" +import { parseTOML, stringifyTOML } from "confbox/toml" + +export type LocalCodexAutomationRecord = Record & { + id: string + source: "codex-local" + sourcePath: string +} + +type AutomationInput = Record + +const AUTOMATION_FILE_NAME = "automation.toml" +const AUTOMATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ +const HERMES_AUTOMATION_MODEL_ID = "moss-default" +const AUTOMATION_ENGINE_IDS = [ + "hermes", + "codex", + "claude-code", + "custom-acp", +] as const +type LocalAutomationEngineId = (typeof AUTOMATION_ENGINE_IDS)[number] + +export function getCodexAutomationsRoot(): string { + const codexHome = expandHome(process.env.CODEX_HOME?.trim() || "~/.codex") + return join(codexHome, "automations") +} + +export async function listLocalCodexAutomations(): Promise< + LocalCodexAutomationRecord[] +> { + const root = getCodexAutomationsRoot() + let entries + try { + entries = await readdir(root, { withFileTypes: true }) + } catch (error) { + if (isMissingPathError(error)) return [] + throw error + } + + const records = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => readLocalCodexAutomation(entry.name)), + ) + + return records + .filter((record): record is LocalCodexAutomationRecord => Boolean(record)) + .sort((a, b) => getAutomationTime(b) - getAutomationTime(a)) +} + +export async function createLocalCodexAutomation( + input: AutomationInput, +): Promise { + const now = Date.now() + const id = await reserveAutomationId(asString(input.id) || asString(input.name)) + const kind = asString(input.kind) || inferAutomationKind(input) + const prompt = asString(input.prompt) || asString(input.agentPrompt) + const executionEnvironment = + asString(input.executionEnvironment) || + asString(input.execution_environment) || + (kind === "heartbeat" ? null : "worktree") + const model = asString(input.model) || HERMES_AUTOMATION_MODEL_ID + const engine = normalizeAutomationEngine(input, model) + + const stored: Record = { + version: 1, + id, + kind, + name: asString(input.name) || "未命名自动化", + prompt, + status: asString(input.status) || "ACTIVE", + rrule: asString(input.rrule) || "FREQ=HOURLY;INTERVAL=1", + model, + engine, + reasoning_effort: + asString(input.reasoning_effort) || + asString(input.reasoningEffort) || + "high", + created_at: now, + updated_at: now, + } + + const cwds = asStringArray(input.cwds) + if (cwds.length > 0) stored.cwds = cwds + if (executionEnvironment) stored.execution_environment = executionEnvironment + + const targetThreadId = + asString(input.targetThreadId) || asString(input.target_thread_id) + if (targetThreadId) stored.target_thread_id = targetThreadId + + await writeAutomationById(id, stored) + const created = await readLocalCodexAutomation(id) + if (!created) throw new Error(`Failed to create automation ${id}`) + return created +} + +export async function updateLocalCodexAutomation( + input: AutomationInput, +): Promise { + const id = requireAutomationId(input) + const current = await readStoredAutomation(id) + if (!current) throw new Error(`Automation not found: ${id}`) + + const next: Record = { ...current } + if (typeof input.status === "string") next.status = input.status + if (typeof input.isEnabled === "boolean") { + next.status = input.isEnabled ? "ACTIVE" : "PAUSED" + } + copyStringField(input, next, "name") + copyStringField(input, next, "prompt") + copyStringField(input, next, "rrule") + copyStringField(input, next, "model") + copyStringField(input, next, "kind") + + const model = asString(next.model) || HERMES_AUTOMATION_MODEL_ID + const nextEngine = normalizeAutomationEngine({ ...next, ...input }, model) + if (nextEngine) next.engine = nextEngine + + const reasoning = asString(input.reasoning_effort) || asString(input.reasoningEffort) + if (reasoning) next.reasoning_effort = reasoning + const executionEnvironment = + asString(input.execution_environment) || asString(input.executionEnvironment) + if (executionEnvironment) next.execution_environment = executionEnvironment + const cwds = asStringArray(input.cwds) + if (cwds.length > 0) next.cwds = cwds + + next.updated_at = Date.now() + await writeAutomationById(id, next) + const updated = await readLocalCodexAutomation(id) + if (!updated) throw new Error(`Failed to update automation ${id}`) + return updated +} + +export async function deleteLocalCodexAutomation( + input: AutomationInput, +): Promise<{ ok: true; id: string }> { + const id = requireAutomationId(input) + const dir = resolveAutomationDir(id) + if (!dir) throw new Error(`Invalid automation id: ${id}`) + await rm(dir, { force: true, recursive: true }) + return { ok: true, id } +} + +export async function runLocalCodexAutomationNow( + input: AutomationInput, +): Promise { + const id = requireAutomationId(input) + const current = await readStoredAutomation(id) + if (!current) throw new Error(`Automation not found: ${id}`) + + await writeAutomationById(id, { + ...current, + last_run_at: Date.now(), + updated_at: Date.now(), + }) + + const updated = await readLocalCodexAutomation(id) + if (!updated) throw new Error(`Failed to run automation ${id}`) + return updated +} + +export function parseCodexAutomationToml(text: string): Record { + return parseTOML>(text) +} + +async function readLocalCodexAutomation( + id: string, +): Promise { + const sourcePath = resolveAutomationFile(id) + if (!sourcePath) return null + + try { + const text = await readFile(sourcePath, "utf8") + const parsed = parseCodexAutomationToml(text) + const parsedId = asString(parsed.id) || id + if (!AUTOMATION_ID_PATTERN.test(parsedId)) return null + return normalizeLocalRecord(parsed, parsedId, sourcePath) + } catch (error) { + if (isMissingPathError(error)) return null + console.warn("[codex-automations] Failed to read automation:", id, error) + return null + } +} + +async function readStoredAutomation( + id: string, +): Promise | null> { + const sourcePath = resolveAutomationFile(id) + if (!sourcePath) return null + try { + return parseCodexAutomationToml(await readFile(sourcePath, "utf8")) + } catch (error) { + if (isMissingPathError(error)) return null + throw error + } +} + +async function writeAutomationById( + id: string, + automation: Record, +): Promise { + const dir = resolveAutomationDir(id) + if (!dir) throw new Error(`Invalid automation id: ${id}`) + await mkdir(dir, { recursive: true }) + const filePath = join(dir, AUTOMATION_FILE_NAME) + await writeFile(filePath, stringifyTOML(orderAutomationFields(automation)), "utf8") +} + +async function reserveAutomationId(seed: string | null): Promise { + const base = slugAutomationId(seed || "automation") + let candidate = base + let suffix = 2 + while (await readLocalCodexAutomation(candidate)) { + candidate = `${base}-${suffix}` + suffix += 1 + } + return candidate +} + +function slugAutomationId(value: string): string { + const ascii = value + .normalize("NFKD") + .replace(/[^\w\s-]/g, "") + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + return AUTOMATION_ID_PATTERN.test(ascii) ? ascii : `automation-${Date.now()}` +} + +function resolveAutomationFile(id: string): string | null { + const dir = resolveAutomationDir(id) + return dir ? join(dir, AUTOMATION_FILE_NAME) : null +} + +function resolveAutomationDir(id: string): string | null { + if (!AUTOMATION_ID_PATTERN.test(id)) return null + const root = resolve(getCodexAutomationsRoot()) + const dir = resolve(root, id) + return dir === root || !dir.startsWith(`${root}${sep}`) ? null : dir +} + +function normalizeLocalRecord( + raw: Record, + id: string, + sourcePath: string, +): LocalCodexAutomationRecord { + return { + ...raw, + id, + source: "codex-local", + sourcePath, + engine: normalizeAutomationEngine(raw, asString(raw.model) || HERMES_AUTOMATION_MODEL_ID), + hostName: raw.hostName ?? raw.host_name ?? "本机", + } +} + +function orderAutomationFields( + automation: Record, +): Record { + const ordered: Record = {} + const fieldOrder = [ + "version", + "id", + "kind", + "name", + "prompt", + "status", + "rrule", + "model", + "engine", + "reasoning_effort", + "execution_environment", + "target_thread_id", + "cwds", + "last_run_at", + "created_at", + "updated_at", + ] + + for (const key of fieldOrder) { + const value = automation[key] + if (value !== undefined && value !== null) ordered[key] = value + } + for (const [key, value] of Object.entries(automation)) { + if (!(key in ordered) && value !== undefined && value !== null) { + ordered[key] = value + } + } + return ordered +} + +function inferAutomationKind(input: AutomationInput): string { + return asString(input.targetThreadId) || asString(input.target_thread_id) + ? "heartbeat" + : "cron" +} + +function requireAutomationId(input: AutomationInput): string { + const id = asString(input.id) || asString(input.automationId) + if (!id || !AUTOMATION_ID_PATTERN.test(id)) { + throw new Error(`Invalid automation id: ${id ?? ""}`) + } + return id +} + +function getAutomationTime(automation: Record): number { + const value = + automation.updated_at ?? + automation.updatedAt ?? + automation.created_at ?? + automation.createdAt + if (typeof value === "number") return value + if (typeof value === "string") { + const parsed = new Date(value).getTime() + return Number.isNaN(parsed) ? 0 : parsed + } + return 0 +} + +function normalizeAutomationEngine( + input: AutomationInput, + model: string, +): LocalAutomationEngineId { + const rawEngine = + asString(input.engine) || + asString(input.agentEngineId) || + asString(input.agent_engine_id) || + asString(input.runtimeEngine) || + asString(input.runtime_engine) + if (isLocalAutomationEngineId(rawEngine)) return rawEngine + + const normalizedModel = model.trim().toLowerCase() + if ( + normalizedModel.includes("claude") || + normalizedModel.includes("sonnet") || + normalizedModel.includes("opus") + ) { + return "claude-code" + } + if (normalizedModel.startsWith("gpt-") || normalizedModel.includes("codex")) { + return "codex" + } + if (normalizedModel.includes("custom-acp")) { + return "custom-acp" + } + return "hermes" +} + +function isLocalAutomationEngineId( + value: string | null, +): value is LocalAutomationEngineId { + return AUTOMATION_ENGINE_IDS.includes(value as LocalAutomationEngineId) +} + +function copyStringField( + input: AutomationInput, + output: Record, + field: string, +): void { + const value = asString(input[field]) + if (value) output[field] = value +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean) +} + +function expandHome(input: string): string { + if (input === "~") return homedir() + if (input.startsWith("~/")) return join(homedir(), input.slice(2)) + return input +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) +} diff --git a/src/main/lib/db/schema/index.ts b/src/main/lib/db/schema/index.ts index fe6aa3490..9b03969a1 100644 --- a/src/main/lib/db/schema/index.ts +++ b/src/main/lib/db/schema/index.ts @@ -73,6 +73,11 @@ export const subChats = sqliteTable("sub_chats", { .notNull() .references(() => chats.id, { onDelete: "cascade" }), sessionId: text("session_id"), // Claude SDK session ID for resume + engine: text("engine").notNull().default("claude-code"), // "claude-code" | "codex" | "hermes" | "custom-acp" + engineSessionId: text("engine_session_id"), // Native engine session ID for resume + engineConfigDir: text("engine_config_dir"), // Per-engine config/session projection dir + modelId: text("model_id"), // Last selected runtime model for this sub-chat + runtimeMetadata: text("runtime_metadata"), // JSON object for engine-specific metadata streamId: text("stream_id"), // Track in-progress streams mode: text("mode").notNull().default("agent"), // "plan" | "agent" messages: text("messages").notNull().default("[]"), // JSON array diff --git a/src/main/lib/git/watcher/git-watcher.ts b/src/main/lib/git/watcher/git-watcher.ts index 141868db8..aa8da85b2 100644 --- a/src/main/lib/git/watcher/git-watcher.ts +++ b/src/main/lib/git/watcher/git-watcher.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "events"; +import type { FSWatcher } from "chokidar"; -// Chokidar is ESM-only, so we need to dynamically import it -type FSWatcher = Awaited>["FSWatcher"] extends new () => infer T ? T : never; +// Chokidar is ESM-only at runtime, so loading still happens through dynamic import. // Simple debounce implementation to avoid lodash-es dependency in main process function debounce unknown>( @@ -160,7 +160,7 @@ export class GitWatcher extends EventEmitter { this.pendingChanges.set(path, "unlink"); flushChanges(); }) - .on("error", (error: Error) => { + .on("error", (error: unknown) => { console.error("[GitWatcher] Error:", error); this.emit("error", error); }); diff --git a/src/main/lib/git/worktree.ts b/src/main/lib/git/worktree.ts index 298c3de28..bc3f5908c 100644 --- a/src/main/lib/git/worktree.ts +++ b/src/main/lib/git/worktree.ts @@ -896,10 +896,29 @@ export interface WorktreeResult { error?: string; } +export interface WorktreeCreatedContext { + worktreePath: string; + branch?: string; + baseBranch?: string; +} + export interface CreateWorktreeForChatOptions { + onCreated?: (context: WorktreeCreatedContext) => Promise | void; onSetupComplete?: (result: WorktreeSetupResult) => void; } +async function notifyWorktreeCreated( + options: CreateWorktreeForChatOptions | undefined, + context: WorktreeCreatedContext, +): Promise { + try { + await options?.onCreated?.(context); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.warn(`[worktree] onCreated hook failed: ${errorMsg}`); + } +} + /** * Create a git worktree for a chat (wrapper for chats.ts) * @param projectPath - Path to the main repository @@ -920,6 +939,7 @@ export async function createWorktreeForChat( const isRepo = await git.checkIsRepo(); if (!isRepo) { + await notifyWorktreeCreated(options, { worktreePath: projectPath }); return { success: true, worktreePath: projectPath }; } @@ -938,6 +958,7 @@ export async function createWorktreeForChat( const startPoint = branchType === "local" ? baseBranch : `origin/${baseBranch}`; await createWorktree(projectPath, branch, worktreePath, startPoint); + await notifyWorktreeCreated(options, { worktreePath, branch, baseBranch }); // Run worktree setup commands in BACKGROUND (don't block chat creation) // This allows the user to start chatting immediately while deps install diff --git a/src/main/lib/hermes/runtime.ts b/src/main/lib/hermes/runtime.ts new file mode 100644 index 000000000..15c9aad25 --- /dev/null +++ b/src/main/lib/hermes/runtime.ts @@ -0,0 +1,68 @@ +import * as fs from "fs" +import * as os from "os" +import * as path from "path" + +export type HermesRuntimeResolution = { + executable?: string + sourceRoot?: string + acpAdapterPath?: string + acpExecutable?: string +} + +export type HermesAcpLaunch = { + command: string + args: string[] +} + +function pathExists(filePath: string): boolean { + try { + fs.accessSync(filePath) + return true + } catch { + return false + } +} + +function compactCandidates(candidates: Array): string[] { + return candidates.filter((candidate): candidate is string => + Boolean(candidate?.trim()), + ) +} + +export function resolveHermesRuntime(): HermesRuntimeResolution { + const home = os.homedir() + const sourceRoot = path.join(home, ".hermes", "hermes-agent") + const acpAdapterPath = path.join(sourceRoot, "acp_adapter", "server.py") + + const executableCandidates = compactCandidates([ + process.env.HERMES_BIN, + path.join(home, ".local", "bin", "hermes"), + path.join(sourceRoot, "venv", "bin", "hermes"), + ]) + const acpExecutableCandidates = compactCandidates([ + process.env.HERMES_ACP_BIN, + path.join(home, ".local", "bin", "hermes-acp"), + path.join(sourceRoot, "venv", "bin", "hermes-acp"), + ]) + + return { + executable: executableCandidates.find(pathExists), + sourceRoot: pathExists(sourceRoot) ? sourceRoot : undefined, + acpAdapterPath: pathExists(acpAdapterPath) ? acpAdapterPath : undefined, + acpExecutable: acpExecutableCandidates.find(pathExists), + } +} + +export function resolveHermesAcpLaunch(): HermesAcpLaunch { + const runtime = resolveHermesRuntime() + + if (runtime.acpExecutable) { + return { command: runtime.acpExecutable, args: [] } + } + + if (runtime.executable) { + return { command: runtime.executable, args: ["acp"] } + } + + throw new Error("Hermes ACP executable was not found.") +} diff --git a/src/main/lib/mcp-auth.ts b/src/main/lib/mcp-auth.ts index f24646865..94b77e143 100644 --- a/src/main/lib/mcp-auth.ts +++ b/src/main/lib/mcp-auth.ts @@ -2,6 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { BrowserWindow, shell } from 'electron'; +import net from 'node:net'; import { getMcpServerConfig, GLOBAL_MCP_PATH, @@ -13,6 +14,10 @@ import { getClaudeShellEnvironment } from './claude/env'; import { CraftOAuth, fetchOAuthMetadata, getMcpBaseUrl, type OAuthMetadata, type OAuthTokens } from './oauth'; import { discoverPluginMcpServers } from './plugins'; import { bringToFront } from './window'; +import { + extractLoopbackMcpBridgeEndpoint, + resolveHostCompatibleMcpStdioConfig, +} from './mcp-stdio-compat'; /** @@ -83,6 +88,83 @@ const BLOCKED_ENV_VARS = [ 'OPENAI_API_KEY', ]; +function isTcpPortListening(host: string, port: number, timeoutMs = 300): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + let settled = false; + + const finish = (value: boolean) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + resolve(value); + }; + + socket.setTimeout(timeoutMs); + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.once('timeout', () => finish(false)); + }); +} + +function reserveFreeLoopbackPort(host: string): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + + server.once('error', reject); + server.listen(0, host, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : null; + server.close(() => { + if (port) { + resolve(port); + } else { + reject(new Error(`Unable to reserve a free port on ${host}`)); + } + }); + }); + }); +} + +async function avoidLoopbackBridgePortCollision(config: { + command: string; + args?: string[]; + env?: Record; + cwd?: string; +}): Promise<{ + command: string; + args?: string[]; + env?: Record; + cwd?: string; +}> { + const endpoint = extractLoopbackMcpBridgeEndpoint(config.env); + if (!endpoint) return config; + + const isListening = await isTcpPortListening(endpoint.host, endpoint.port); + if (!isListening) return config; + + try { + const fallbackPort = await reserveFreeLoopbackPort(endpoint.host); + console.warn( + `[MCP] ${endpoint.host}:${endpoint.port} is already in use; probing stdio server with temporary ${endpoint.portEnvKey}=${fallbackPort}`, + ); + return { + ...config, + env: { + ...config.env, + [endpoint.portEnvKey]: String(fallbackPort), + }, + }; + } catch (error) { + console.warn( + `[MCP] ${endpoint.host}:${endpoint.port} is already in use and no temporary probe port could be reserved:`, + error, + ); + return config; + } +} + /** * Fetch tools from a stdio-based MCP server * Uses shell environment to ensure proper PATH (homebrew, nvm, etc.) in production @@ -91,6 +173,8 @@ export async function fetchMcpToolsStdio(config: { command: string; args?: string[]; env?: Record; + cwd?: string; + sourcePath?: string | null; }): Promise { let transport: StdioClientTransport | null = null; @@ -113,10 +197,26 @@ export async function fetchMcpToolsStdio(config: { } } + const launchConfig = resolveHostCompatibleMcpStdioConfig(config); + if (!launchConfig.ok) { + console.warn(`[MCP] Skipping stdio server probe: ${launchConfig.reason}`); + return []; + } + + if (launchConfig.rewrites.length > 0) { + const rewritten = launchConfig.rewrites + .map((rewrite) => `${rewrite.from} -> ${rewrite.to}`) + .join(', '); + console.log(`[MCP] Applied stdio path mapping: ${rewritten}`); + } + + const stdioConfig = await avoidLoopbackBridgePortCollision(launchConfig.config); + transport = new StdioClientTransport({ - command: config.command, - args: config.args, - env: { ...safeEnv, ...config.env }, + command: stdioConfig.command, + args: stdioConfig.args, + env: { ...safeEnv, ...stdioConfig.env }, + cwd: stdioConfig.cwd, }); await client.connect(transport); @@ -139,14 +239,14 @@ export async function fetchMcpToolsStdio(config: { } } -import { AUTH_SERVER_PORT, IS_DEV } from '../constants'; +import { getAuthServerPort, IS_DEV } from '../constants'; const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; function getMcpOAuthRedirectUri(): string { return IS_DEV - ? `http://localhost:${AUTH_SERVER_PORT}/callback` - : `http://127.0.0.1:${AUTH_SERVER_PORT}/callback`; + ? `http://localhost:${getAuthServerPort()}/callback` + : `http://127.0.0.1:${getAuthServerPort()}/callback`; } interface PendingOAuth { diff --git a/src/main/lib/mcp-stdio-compat.test.ts b/src/main/lib/mcp-stdio-compat.test.ts new file mode 100644 index 000000000..ccc1e4377 --- /dev/null +++ b/src/main/lib/mcp-stdio-compat.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, test } from "bun:test" +import { + extractLoopbackMcpBridgeEndpoint, + resolveHostCompatibleMcpStdioConfig, +} from "./mcp-stdio-compat" + +describe("extractLoopbackMcpBridgeEndpoint", () => { + test("detects a loopback MCP bridge endpoint from env", () => { + expect( + extractLoopbackMcpBridgeEndpoint({ + HOOLA_CANVAS_MCP_BRIDGE_HOST: "127.0.0.1", + HOOLA_CANVAS_MCP_BRIDGE_PORT: "47841", + }), + ).toEqual({ + host: "127.0.0.1", + port: 47841, + hostEnvKey: "HOOLA_CANVAS_MCP_BRIDGE_HOST", + portEnvKey: "HOOLA_CANVAS_MCP_BRIDGE_PORT", + }) + }) + + test("ignores non-loopback bridge endpoints", () => { + expect( + extractLoopbackMcpBridgeEndpoint({ + HOOLA_CANVAS_MCP_BRIDGE_HOST: "0.0.0.0", + HOOLA_CANVAS_MCP_BRIDGE_PORT: "47841", + }), + ).toBeNull() + }) + + test("ignores invalid bridge ports", () => { + expect( + extractLoopbackMcpBridgeEndpoint({ + HOOLA_CANVAS_MCP_BRIDGE_HOST: "127.0.0.1", + HOOLA_CANVAS_MCP_BRIDGE_PORT: "70000", + }), + ).toBeNull() + }) +}) + +describe("resolveHostCompatibleMcpStdioConfig", () => { + test("maps a Windows project root path through the source project path", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "node", + args: ["D:\\Moss\\services\\mcp-apps-ui\\servers\\server.mjs", "--stdio"], + sourcePath: "/Users/moss/Codex/Moss", + }, + { + platform: "darwin", + exists: (targetPath) => + targetPath === "/Users/moss/Codex/Moss" || + targetPath === "/Users/moss/Codex/Moss/services/mcp-apps-ui/servers/server.mjs", + }, + ) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.config.cwd).toBe("/Users/moss/Codex/Moss") + expect(result.config.args?.[0]).toBe( + "/Users/moss/Codex/Moss/services/mcp-apps-ui/servers/server.mjs", + ) + } + }) + + test("blocks unmapped Windows absolute paths on macOS", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "node", + args: ["D:\\External\\server.mjs", "--stdio"], + sourcePath: "/Users/moss/Codex/Moss", + }, + { + platform: "darwin", + exists: (targetPath) => targetPath === "/Users/moss/Codex/Moss", + }, + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("Windows path is not mapped") + } + }) + + test("skips a rewritten local Node script that is missing", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "node", + args: ["D:\\Moss\\missing\\server.mjs", "--stdio"], + sourcePath: "/Users/moss/Codex/Moss", + }, + { + platform: "darwin", + exists: (targetPath) => targetPath === "/Users/moss/Codex/Moss", + }, + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("Local stdio script does not exist") + } + }) + + test("skips a local Node script with unresolved bare dependencies", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "node", + args: ["D:\\Moss\\servers\\premium\\index.mjs", "--stdio"], + sourcePath: "/Users/moss/Codex/Moss", + }, + { + platform: "darwin", + exists: (targetPath) => + targetPath === "/Users/moss/Codex/Moss" || + targetPath === "/Users/moss/Codex/Moss/servers/premium/index.mjs", + readFile: () => + 'import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";', + canResolve: () => false, + }, + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("Local stdio script dependency is not installed") + expect(result.reason).toContain("@modelcontextprotocol/sdk") + } + }) + + test("skips node commands that do not name a stdio server script", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "node", + args: ["--version"], + sourcePath: "/Users/moss/Movies/Videos", + }, + { + platform: "darwin", + exists: (targetPath) => targetPath === "/Users/moss/Movies/Videos", + }, + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("no entry script") + } + }) + + test("skips a missing relative Node script resolved against cwd", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "node", + args: ["./mcp/server.mjs"], + cwd: "/Users/moss/Projects/1code", + }, + { + platform: "darwin", + exists: (targetPath) => targetPath === "/Users/moss/Projects/1code", + }, + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("Local stdio script does not exist") + expect(result.reason).toContain("/Users/moss/Projects/1code/mcp/server.mjs") + } + }) + + test("skips a relative Node script when no cwd or source path is available", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "node", + args: ["./mcp/server.mjs"], + }, + { + platform: "darwin", + exists: () => true, + }, + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("Relative stdio script requires cwd") + expect(result.reason).toContain("./mcp/server.mjs") + } + }) + + test("uses cwd for dependency checks on an existing relative Node script", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "node", + args: ["./mcp/server.mjs"], + cwd: "/Users/moss/Projects/1code", + }, + { + platform: "darwin", + exists: (targetPath) => + targetPath === "/Users/moss/Projects/1code" || + targetPath === "/Users/moss/Projects/1code/mcp/server.mjs", + readFile: () => 'import { Server } from "@modelcontextprotocol/sdk/server/index.js";', + canResolve: () => false, + }, + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("Local stdio script dependency is not installed") + expect(result.reason).toContain("@modelcontextprotocol/sdk") + } + }) + + test("uses source path to resolve relative cwd before launching a local command", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "./mcp/server", + args: ["--stdio"], + cwd: ".", + sourcePath: "/Users/moss/Projects/1code", + }, + { + platform: "darwin", + exists: (targetPath) => + targetPath === "/Users/moss/Projects/1code" || + targetPath === "/Users/moss/Projects/1code/mcp/server", + }, + ) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.config.cwd).toBe("/Users/moss/Projects/1code") + } + }) + + test("skips a missing relative app command before the stdio transport spawns it", () => { + const result = resolveHostCompatibleMcpStdioConfig( + { + command: "./Codex Computer Use.app/Contents/SharedSupport/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient", + args: ["mcp"], + cwd: "/Users/moss/.codex/plugins/cache/openai-bundled/computer-use/1.0.809", + }, + { + platform: "darwin", + exists: (targetPath) => + targetPath === "/Users/moss/.codex/plugins/cache/openai-bundled/computer-use/1.0.809", + }, + ) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toContain("Local stdio command does not exist") + expect(result.reason).toContain("SkyComputerUseClient") + } + }) + + test("allows an existing relative app command to run from an absolute plugin cwd", () => { + const command = + "./Codex Computer Use.app/Contents/SharedSupport/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient" + const cwd = "/Users/moss/.codex/plugins/cache/openai-bundled/computer-use/1.0.809" + const result = resolveHostCompatibleMcpStdioConfig( + { + command, + args: ["mcp"], + cwd, + }, + { + platform: "darwin", + exists: (targetPath) => targetPath === cwd || targetPath === `${cwd}/${command.slice(2)}`, + }, + ) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.config.command).toBe(command) + expect(result.config.cwd).toBe(cwd) + } + }) +}) diff --git a/src/main/lib/mcp-stdio-compat.ts b/src/main/lib/mcp-stdio-compat.ts new file mode 100644 index 000000000..46f0fffbd --- /dev/null +++ b/src/main/lib/mcp-stdio-compat.ts @@ -0,0 +1,507 @@ +import { existsSync, readFileSync } from "fs" +import { createRequire } from "module" +import * as path from "path" + +export interface McpStdioLaunchConfig { + command: string + args?: string[] + env?: Record + cwd?: string + sourcePath?: string | null +} + +export interface McpPathMapping { + from: string + to: string +} + +export interface McpLoopbackBridgeEndpoint { + host: string + port: number + hostEnvKey: string + portEnvKey: string +} + +export type McpStdioCompatResult = + | { + ok: true + config: { + command: string + args?: string[] + env?: Record + cwd?: string + } + rewrites: McpPathMapping[] + } + | { + ok: false + reason: string + rewrites: McpPathMapping[] + } + +interface ResolveOptions { + platform?: NodeJS.Platform + pathMappings?: McpPathMapping[] + exists?: (targetPath: string) => boolean + readFile?: (targetPath: string) => string + canResolve?: (specifier: string, fromPath: string) => boolean +} + +const WINDOWS_ABSOLUTE_PATH = /^([A-Za-z]):[\\/]+(.+)$/ +const WINDOWS_WILDCARD_ROOT = /^\*:[\\/]+(.+)$/ +const NODE_OPTIONS_WITH_VALUE = new Set([ + "-e", + "--eval", + "-p", + "--print", + "-r", + "--require", + "--loader", + "--import", +]) +const LOCAL_SCRIPT_EXTENSIONS = /\.(?:[cm]?js|[cm]?ts|tsx)$/i +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]) + +function isLoopbackHost(value: string): boolean { + return LOOPBACK_HOSTS.has(value.trim().toLowerCase()) +} + +function parseTcpPort(value: string): number | null { + const port = Number(value) + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null + return port +} + +export function extractLoopbackMcpBridgeEndpoint( + env?: Record, +): McpLoopbackBridgeEndpoint | null { + if (!env) return null + + for (const [portEnvKey, portValue] of Object.entries(env)) { + if (!portEnvKey.endsWith("_MCP_BRIDGE_PORT")) continue + + const hostEnvKey = `${portEnvKey.slice(0, -"_PORT".length)}_HOST` + const host = env[hostEnvKey] + if (!host || !isLoopbackHost(host)) continue + + const port = parseTcpPort(portValue) + if (!port) continue + + return { + host, + port, + hostEnvKey, + portEnvKey, + } + } + + return null +} + +function parseWindowsPath(value: string): { root: string; rest: string } | null { + const match = value.match(WINDOWS_ABSOLUTE_PATH) + if (!match) return null + + const restSegments = match[2] + .split(/[\\/]+/) + .map((segment) => segment.trim()) + .filter(Boolean) + + if (restSegments.length === 0) return null + + return { + root: restSegments[0], + rest: restSegments.slice(1).join("/"), + } +} + +function isWindowsAbsolutePath(value: string): boolean { + return WINDOWS_ABSOLUTE_PATH.test(value) +} + +function normalizeWindowsRoot(value: string): string | null { + const wildcard = value.match(WINDOWS_WILDCARD_ROOT) + if (wildcard) { + const root = wildcard[1].split(/[\\/]+/).find(Boolean) + return root ? root.toLowerCase() : null + } + + const parsed = parseWindowsPath(value) + if (!parsed) return null + return parsed.root.toLowerCase() +} + +function sourceRootMapping(sourcePath?: string | null): McpPathMapping | null { + if (!sourcePath) return null + const sourceBase = path.basename(sourcePath) + if (!sourceBase) return null + return { + from: `*:\\${sourceBase}`, + to: sourcePath, + } +} + +function rewriteWindowsPath( + value: string, + mappings: McpPathMapping[], +): { value: string; rewrite?: McpPathMapping } { + const parsed = parseWindowsPath(value) + if (!parsed) return { value } + + for (const mapping of mappings) { + const fromRoot = normalizeWindowsRoot(mapping.from) + if (!fromRoot || fromRoot !== parsed.root.toLowerCase()) continue + + const mapped = parsed.rest ? path.join(mapping.to, parsed.rest) : mapping.to + return { + value: mapped, + rewrite: { + from: value, + to: mapped, + }, + } + } + + return { value } +} + +function resolveString( + value: string, + mappings: McpPathMapping[], +): { value: string; rewrite?: McpPathMapping } { + return rewriteWindowsPath(value, mappings) +} + +function isNodeLikeCommand(command: string): boolean { + const commandName = path.basename(command).toLowerCase() + return ["node", "node.exe", "bun", "bun.exe"].includes(commandName) +} + +function looksLikeLocalScriptArg(value: string): boolean { + return path.isAbsolute(value) || + value.startsWith(".") || + value.includes("/") || + value.includes("\\") || + LOCAL_SCRIPT_EXTENSIONS.test(value) +} + +function findNodeScriptArg(args: string[] | undefined): string | null { + if (!args?.length) return null + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (!arg) continue + if (arg === "--") return args[index + 1] ?? null + if (NODE_OPTIONS_WITH_VALUE.has(arg)) { + index += 1 + continue + } + if ([...NODE_OPTIONS_WITH_VALUE].some((option) => arg.startsWith(`${option}=`))) { + continue + } + if (arg.startsWith("-")) continue + return arg + } + + return null +} + +function findRelativeNodeScriptWithoutCwd( + command: string, + args: string[] | undefined, + cwd: string | undefined, +): string | null { + if (!isNodeLikeCommand(command) || cwd) return null + + const scriptArg = findNodeScriptArg(args) + if (!scriptArg || !looksLikeLocalScriptArg(scriptArg)) return null + if (path.isAbsolute(scriptArg) || isWindowsAbsolutePath(scriptArg)) return null + + return scriptArg +} + +function resolveNodeScriptPath(scriptArg: string | null, cwd: string | undefined): string | null { + if (!scriptArg || !looksLikeLocalScriptArg(scriptArg)) return null + if (path.isAbsolute(scriptArg)) return scriptArg + if (!cwd) return null + return path.resolve(cwd, scriptArg) +} + +function looksLikeLocalCommand(value: string): boolean { + return path.isAbsolute(value) || + isWindowsAbsolutePath(value) || + value.startsWith(".") || + value.includes("/") || + value.includes("\\") +} + +function resolveLocalCommandPath(command: string, cwd: string | undefined): string | null { + if (!looksLikeLocalCommand(command)) return null + if (path.isAbsolute(command)) return command + if (isWindowsAbsolutePath(command)) return command + if (!cwd) return null + return path.resolve(cwd, command) +} + +function findRelativeLocalCommandWithoutCwd(command: string, cwd: string | undefined): string | null { + if (cwd || !looksLikeLocalCommand(command)) return null + if (path.isAbsolute(command) || isWindowsAbsolutePath(command)) return null + return command +} + +function findMissingLocalCommand( + command: string, + cwd: string | undefined, + exists: (targetPath: string) => boolean, +): string | null { + const commandPath = resolveLocalCommandPath(command, cwd) + if (!commandPath) return null + return exists(commandPath) ? null : commandPath +} + +function findMissingNodeScript( + command: string, + args: string[] | undefined, + cwd: string | undefined, + exists: (targetPath: string) => boolean, +): string | null { + if (!isNodeLikeCommand(command)) return null + + const scriptArg = findNodeScriptArg(args) + const scriptPath = resolveNodeScriptPath(scriptArg, cwd) + if (!scriptPath) return null + + return exists(scriptPath) ? null : scriptPath +} + +function findNodeLaunchScript( + command: string, + args: string[] | undefined, + cwd: string | undefined, + exists: (targetPath: string) => boolean, +): string | null { + if (!isNodeLikeCommand(command)) return null + + const scriptArg = findNodeScriptArg(args) + const scriptPath = resolveNodeScriptPath(scriptArg, cwd) + if (!scriptPath || !exists(scriptPath)) return null + + return scriptPath +} + +function isBareModuleSpecifier(specifier: string): boolean { + return !specifier.startsWith(".") && + !specifier.startsWith("/") && + !specifier.startsWith("node:") && + !isWindowsAbsolutePath(specifier) +} + +function resolveLocalModulePath( + fromPath: string, + specifier: string, + exists: (targetPath: string) => boolean, +): string | null { + const base = path.resolve(path.dirname(fromPath), specifier) + const candidates = [ + base, + `${base}.js`, + `${base}.mjs`, + `${base}.cjs`, + path.join(base, "index.js"), + path.join(base, "index.mjs"), + path.join(base, "index.cjs"), + ] + + return candidates.find((candidate) => exists(candidate)) ?? null +} + +function extractModuleSpecifiers(source: string): string[] { + const specifiers = new Set() + const patterns = [ + /\bimport\s+(?:[^'"]+\s+from\s*)?["']([^"']+)["']/g, + /\bexport\s+[^'"]+\s+from\s*["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, + ] + + for (const pattern of patterns) { + let match: RegExpExecArray | null + while ((match = pattern.exec(source))) { + specifiers.add(match[1]) + } + } + + return [...specifiers] +} + +function canResolveBareSpecifier(specifier: string, fromPath: string): boolean { + try { + createRequire(fromPath).resolve(specifier) + return true + } catch { + return false + } +} + +function findMissingNodeDependency( + entryPath: string, + options: Required>, + visited = new Set(), +): string | null { + if (visited.has(entryPath)) return null + if (visited.size >= 16) return null + visited.add(entryPath) + + let source: string + try { + source = options.readFile(entryPath) + } catch { + return null + } + + for (const specifier of extractModuleSpecifiers(source)) { + if (isBareModuleSpecifier(specifier)) { + if (!options.canResolve(specifier, entryPath)) { + return `${specifier} imported by ${entryPath}` + } + continue + } + + if (specifier.startsWith(".")) { + const localPath = resolveLocalModulePath(entryPath, specifier, options.exists) + if (!localPath) continue + const missing = findMissingNodeDependency(localPath, options, visited) + if (missing) return missing + } + } + + return null +} + +export function resolveHostCompatibleMcpStdioConfig( + config: McpStdioLaunchConfig, + options: ResolveOptions = {}, +): McpStdioCompatResult { + const platform = options.platform ?? process.platform + const exists = options.exists ?? existsSync + const readFile = options.readFile ?? ((targetPath: string) => readFileSync(targetPath, "utf-8")) + const canResolve = options.canResolve ?? canResolveBareSpecifier + const mappings = [ + ...(options.pathMappings ?? []), + ...(sourceRootMapping(config.sourcePath) ? [sourceRootMapping(config.sourcePath)!] : []), + ] + const rewrites: McpPathMapping[] = [] + + const command = resolveString(config.command, mappings) + if (command.rewrite) rewrites.push(command.rewrite) + + const args = config.args?.map((arg) => { + const resolved = resolveString(arg, mappings) + if (resolved.rewrite) rewrites.push(resolved.rewrite) + return resolved.value + }) + + const env = config.env + ? Object.fromEntries( + Object.entries(config.env).map(([key, value]) => { + if (typeof value !== "string") return [key, value] + const resolved = resolveString(value, mappings) + if (resolved.rewrite) rewrites.push(resolved.rewrite) + return [key, resolved.value] + }), + ) + : undefined + + const cwdInput = + config.cwd ?? + (config.sourcePath && exists(config.sourcePath) ? config.sourcePath : undefined) + const cwdResult = cwdInput ? resolveString(cwdInput, mappings) : undefined + if (cwdResult?.rewrite) rewrites.push(cwdResult.rewrite) + const cwd = + cwdResult?.value && + !path.isAbsolute(cwdResult.value) && + config.sourcePath && + exists(config.sourcePath) + ? path.resolve(config.sourcePath, cwdResult.value) + : cwdResult?.value + + if (platform !== "win32") { + const unresolved = [command.value, ...(args ?? []), ...(cwd ? [cwd] : []), ...Object.values(env ?? {})].find( + (value) => typeof value === "string" && isWindowsAbsolutePath(value), + ) + if (unresolved) { + return { + ok: false, + reason: `Windows path is not mapped on ${platform}: ${unresolved}`, + rewrites, + } + } + } + + const relativeLocalCommandWithoutCwd = findRelativeLocalCommandWithoutCwd(command.value, cwd) + if (relativeLocalCommandWithoutCwd) { + return { + ok: false, + reason: `Relative stdio command requires cwd before launch: ${relativeLocalCommandWithoutCwd}`, + rewrites, + } + } + + const missingLocalCommand = findMissingLocalCommand(command.value, cwd, exists) + if (missingLocalCommand) { + return { + ok: false, + reason: `Local stdio command does not exist: ${missingLocalCommand}`, + rewrites, + } + } + + const relativeScriptWithoutCwd = findRelativeNodeScriptWithoutCwd(command.value, args, cwd) + if (relativeScriptWithoutCwd) { + return { + ok: false, + reason: `Relative stdio script requires cwd before launch: ${relativeScriptWithoutCwd}`, + rewrites, + } + } + + const missingScript = findMissingNodeScript(command.value, args, cwd, exists) + if (missingScript) { + return { + ok: false, + reason: `Local stdio script does not exist: ${missingScript}`, + rewrites, + } + } + + if (isNodeLikeCommand(command.value) && !findNodeScriptArg(args)) { + return { + ok: false, + reason: "Node stdio command has no entry script", + rewrites, + } + } + + const scriptPath = findNodeLaunchScript(command.value, args, cwd, exists) + const missingDependency = scriptPath + ? findMissingNodeDependency(scriptPath, { exists, readFile, canResolve }) + : null + if (missingDependency) { + return { + ok: false, + reason: `Local stdio script dependency is not installed: ${missingDependency}`, + rewrites, + } + } + + return { + ok: true, + config: { + command: command.value, + args, + env, + cwd, + }, + rewrites, + } +} diff --git a/src/main/lib/moss-account/entitlement.test.ts b/src/main/lib/moss-account/entitlement.test.ts new file mode 100644 index 000000000..53b402e51 --- /dev/null +++ b/src/main/lib/moss-account/entitlement.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "bun:test" +import type { AuthUser } from "../../auth-store" +import { buildMossAccountEntitlement } from "./entitlement" + +const user: AuthUser = { + id: "user_1", + email: "moss@example.com", + name: "Moss User", + imageUrl: null, + username: "moss", +} + +const providerReadResult = { + status: "found" as const, + sourcePath: "/repo/.moss/providers.yaml", + config: { + version: 1, + defaultProvider: "moss", + credentialPolicy: { + singleUserConfiguration: true, + allowCustomBaseUrl: true, + allowCustomApiKey: true, + shareAcrossEngines: true, + }, + providers: { + moss: { + id: "moss", + label: "Moss Managed", + mode: "bundled-quota", + runtime: "any", + engines: { + hermes: { model: "moss-default" }, + "claude-code": { model: "opus" }, + codex: { model: "gpt-5.5/high" }, + "custom-acp": { model: "custom-acp" }, + }, + }, + custom: { + id: "custom", + label: "Custom", + mode: "custom-url-key", + runtime: "any", + apiKeyEnv: "MOSS_CUSTOM_API_KEY", + baseUrl: "https://custom.test/v1", + baseUrlEnv: "MOSS_CUSTOM_BASE_URL", + engines: { + hermes: { model: "moss-custom" }, + "claude-code": { model: "opus-custom" }, + codex: { model: "gpt-custom/high" }, + "custom-acp": { model: "custom-acp-custom" }, + }, + }, + }, + }, +} + +describe("Moss account entitlement", () => { + test("requires sign-in for Moss managed quota when no user is present", () => { + const entitlement = buildMossAccountEntitlement({ + user: null, + providerReadResult, + }) + + expect(entitlement.status).toBe("needs-sign-in") + expect(entitlement.account.signedIn).toBe(false) + expect(entitlement.quota.status).toBe("needs-sign-in") + expect(entitlement.provider.credentialStatus).toBe("moss-managed") + expect(entitlement.engines.every((engine) => engine.status === "needs-sign-in")).toBe(true) + }) + + test("marks Moss managed quota ready for an active paid account", () => { + const entitlement = buildMossAccountEntitlement({ + user, + plan: { + plan: "moss_pro", + status: "active", + source: "backend", + }, + providerReadResult, + }) + + expect(entitlement.status).toBe("ready") + expect(entitlement.account.email).toBe("moss@example.com") + expect(entitlement.plan.isPaid).toBe(true) + expect(entitlement.quota.status).toBe("available") + expect(entitlement.quota.includedCredits).toBe(10000) + expect(entitlement.quota.remainingCredits).toBe(null) + expect(entitlement.quota.source).toBe("plan-default") + expect(entitlement.engines.map((engine) => engine.providerId)).toEqual([ + "moss", + "moss", + "moss", + "moss", + ]) + expect(entitlement.engines.every((engine) => engine.status === "ready")).toBe(true) + }) + + test("makes active free Moss quota available without marking the plan paid", () => { + const entitlement = buildMossAccountEntitlement({ + user, + plan: { + plan: "moss_free", + status: "active", + source: "backend", + }, + providerReadResult, + }) + + expect(entitlement.status).toBe("ready") + expect(entitlement.plan.isPaid).toBe(false) + expect(entitlement.quota.status).toBe("available") + expect(entitlement.quota.includedCredits).toBe(250) + expect(entitlement.quota.unit).toBe("credits") + expect(entitlement.engines.every((engine) => engine.status === "ready")).toBe(true) + }) + + test("uses backend quota usage when the Moss account service returns it", () => { + const entitlement = buildMossAccountEntitlement({ + user, + plan: { + plan: "moss_pro", + status: "active", + source: "backend", + quota: { + includedCredits: 10000, + usedCredits: 1234, + resetAt: "2026-07-01T00:00:00.000Z", + unit: "credits", + source: "backend", + }, + }, + providerReadResult, + }) + + expect(entitlement.status).toBe("ready") + expect(entitlement.quota.source).toBe("backend") + expect(entitlement.quota.usedCredits).toBe(1234) + expect(entitlement.quota.remainingCredits).toBe(8766) + expect(entitlement.quota.resetAt).toBe("2026-07-01T00:00:00.000Z") + }) + + test("allows custom shared key route without a Moss paid plan", () => { + const customProviderReadResult = { + ...providerReadResult, + config: { + ...providerReadResult.config, + defaultProvider: "custom", + }, + } + + const entitlement = buildMossAccountEntitlement({ + user: null, + providerReadResult: customProviderReadResult, + storedSecrets: { + custom: { hasApiKey: true }, + }, + }) + + expect(entitlement.status).toBe("custom-ready") + expect(entitlement.provider.useCustomProvider).toBe(true) + expect(entitlement.provider.credentialStatus).toBe("stored-key") + expect(entitlement.provider.baseUrlStatus).toBe("configured-url") + expect(entitlement.engines.map((engine) => engine.model)).toEqual([ + "opus-custom", + "gpt-custom/high", + "moss-custom", + "custom-acp-custom", + ]) + expect(entitlement.engines.every((engine) => engine.status === "ready")).toBe(true) + expect(JSON.stringify(entitlement)).not.toContain("sk-") + }) + + test("surfaces custom provider env-key state across every engine", () => { + const customProviderReadResult = { + ...providerReadResult, + config: { + ...providerReadResult.config, + defaultProvider: "custom", + }, + } + + const entitlement = buildMossAccountEntitlement({ + user, + providerReadResult: customProviderReadResult, + }) + + expect(entitlement.status).toBe("custom-ready") + expect(entitlement.provider.credentialStatus).toBe("env-key") + expect(entitlement.provider.baseUrlStatus).toBe("configured-url") + expect(entitlement.engines.every((engine) => engine.status === "ready")).toBe(true) + }) + + test("requires a custom provider base URL before the shared route is ready", () => { + const customProviderReadResult = { + ...providerReadResult, + config: { + ...providerReadResult.config, + defaultProvider: "custom", + providers: { + ...providerReadResult.config.providers, + custom: { + ...providerReadResult.config.providers.custom, + baseUrl: undefined, + baseUrlEnv: undefined, + }, + }, + }, + } + + const entitlement = buildMossAccountEntitlement({ + user: null, + providerReadResult: customProviderReadResult, + storedSecrets: { + custom: { hasApiKey: true }, + }, + }) + + expect(entitlement.status).toBe("custom-needs-url") + expect(entitlement.provider.credentialStatus).toBe("stored-key") + expect(entitlement.provider.baseUrlStatus).toBe("missing-url") + expect(entitlement.provider.reason).toContain("requires one base URL") + expect(entitlement.engines.every((engine) => engine.status === "needs-base-url")).toBe(true) + }) +}) diff --git a/src/main/lib/moss-account/entitlement.ts b/src/main/lib/moss-account/entitlement.ts new file mode 100644 index 000000000..1a2c6bcb6 --- /dev/null +++ b/src/main/lib/moss-account/entitlement.ts @@ -0,0 +1,513 @@ +import type { AuthUser } from "../../auth-store" +import { AGENT_ENGINE_IDS, type AgentEngineId } from "../agent-runtime/types" +import type { + MossProviderConfig, + MossProviderDefinition, + MossProviderReadResult, +} from "../moss-source" + +export type MossAccountEntitlementStatus = + | "ready" + | "needs-sign-in" + | "custom-ready" + | "custom-needs-key" + | "custom-needs-url" + | "missing-project" + | "provider-error" + +export type MossProviderCredentialStatus = + | "moss-managed" + | "stored-key" + | "inline-key" + | "env-key" + | "missing-key" + | "missing-project" + | "provider-error" + +export type MossProviderBaseUrlStatus = + | "configured-url" + | "env-url" + | "missing-url" + | "not-required" + | "missing-project" + | "provider-error" + +export interface MossAccountPlan { + plan: string + status: string | null + source?: "backend" | "local" + quota?: MossAccountPlanQuota | null +} + +export interface MossAccountPlanQuota { + includedCredits?: number | null + usedCredits?: number | null + remainingCredits?: number | null + resetAt?: string | null + unit?: string | null + source?: "backend" | "plan-default" | "none" +} + +export interface MossStoredProviderSecretSummary { + hasApiKey?: boolean +} + +export interface MossAccountEntitlement { + status: MossAccountEntitlementStatus + account: { + signedIn: boolean + userId: string | null + email: string | null + name: string | null + } + plan: { + id: string + status: string + isPaid: boolean + source: "backend" | "local" | "none" + } + quota: { + providerId: "moss" + label: string + mode: "bundled-quota" + status: "available" | "needs-sign-in" | "checking" | "inactive" + sharedAcrossEngines: boolean + includedCredits: number | null + usedCredits: number | null + remainingCredits: number | null + resetAt: string | null + unit: string + source: "backend" | "plan-default" | "none" + reason: string + } + provider: { + sourcePath: string + defaultProvider: string + activeProviderId: string | null + useCustomProvider: boolean + sharedAcrossEngines: boolean + credentialStatus: MossProviderCredentialStatus + baseUrlStatus: MossProviderBaseUrlStatus + reason: string + } + engines: Array<{ + engineId: AgentEngineId + providerId: string | null + model: string | null + credentialMode: MossProviderCredentialStatus + status: "ready" | "needs-sign-in" | "needs-api-key" | "needs-base-url" | "unconfigured" + }> +} + +const ENGINE_IDS: readonly AgentEngineId[] = AGENT_ENGINE_IDS +const PAID_PLAN_IDS = new Set([ + "moss_pro", + "moss_team", + "moss_enterprise", + "onecode_pro", + "onecode_max_100", + "onecode_max", +]) + +const PLAN_QUOTA_DEFAULTS: Record> & Pick> = { + moss_free: { + includedCredits: 250, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "credits", + source: "plan-default", + }, + onecode_free: { + includedCredits: 250, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "credits", + source: "plan-default", + }, + moss_pro: { + includedCredits: 10000, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "credits", + source: "plan-default", + }, + onecode_pro: { + includedCredits: 10000, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "credits", + source: "plan-default", + }, + onecode_max_100: { + includedCredits: 100000, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "credits", + source: "plan-default", + }, + onecode_max: { + includedCredits: 250000, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "credits", + source: "plan-default", + }, + moss_team: { + includedCredits: 50000, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "credits", + source: "plan-default", + }, + moss_enterprise: { + includedCredits: null, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "contract credits", + source: "plan-default", + }, +} + +function isActivePaidPlan(plan: MossAccountPlan | null | undefined): boolean { + if (!plan) return false + return PAID_PLAN_IDS.has(plan.plan) && plan.status === "active" +} + +function normalizeQuotaNumber(value: number | null | undefined): number | null { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return null + } + return Math.floor(value) +} + +function buildPlanQuotaAllowance( + plan: MossAccountPlan | null | undefined, +): Pick< + MossAccountEntitlement["quota"], + | "includedCredits" + | "usedCredits" + | "remainingCredits" + | "resetAt" + | "unit" + | "source" +> { + if (!plan) { + return { + includedCredits: null, + usedCredits: null, + remainingCredits: null, + resetAt: null, + unit: "credits", + source: "none", + } + } + + const fallback = PLAN_QUOTA_DEFAULTS[plan.plan] + const quota = plan.quota ?? null + const source = quota ? "backend" : fallback?.source ?? "none" + const includedCredits = + normalizeQuotaNumber(quota?.includedCredits) ?? + fallback?.includedCredits ?? + null + const usedCredits = normalizeQuotaNumber(quota?.usedCredits) + const remainingCredits = + normalizeQuotaNumber(quota?.remainingCredits) ?? + (includedCredits !== null && usedCredits !== null + ? Math.max(includedCredits - usedCredits, 0) + : null) + + return { + includedCredits, + usedCredits, + remainingCredits, + resetAt: quota?.resetAt ?? fallback?.resetAt ?? null, + unit: quota?.unit ?? fallback?.unit ?? "credits", + source, + } +} + +function hasManagedQuotaAccess(plan: MossAccountPlan | null | undefined): boolean { + if (!plan || plan.status !== "active") return false + const allowance = buildPlanQuotaAllowance(plan) + return allowance.source !== "none" +} + +function getProvider( + config: MossProviderConfig | undefined, + providerId: string | undefined, +): MossProviderDefinition | undefined { + if (!config || !providerId) return undefined + return config.providers[providerId] +} + +function getProviderCredentialStatus(params: { + provider?: MossProviderDefinition + storedSecret?: MossStoredProviderSecretSummary + missingProject?: boolean + providerError?: boolean +}): MossProviderCredentialStatus { + if (params.missingProject) return "missing-project" + if (params.providerError) return "provider-error" + const provider = params.provider + if (!provider) return "missing-key" + if (provider.mode === "bundled-quota") return "moss-managed" + if (params.storedSecret?.hasApiKey) return "stored-key" + if (provider.apiKey) return "inline-key" + if (provider.apiKeyEnv) return "env-key" + return "missing-key" +} + +function hasEnvValue(envName: string | undefined): boolean { + if (!envName) return false + const value = process.env[envName] + return typeof value === "string" && value.trim().length > 0 +} + +function getProviderBaseUrlStatus(params: { + provider?: MossProviderDefinition + missingProject?: boolean + providerError?: boolean +}): MossProviderBaseUrlStatus { + if (params.missingProject) return "missing-project" + if (params.providerError) return "provider-error" + + const provider = params.provider + if (!provider) return "missing-url" + if (provider.mode !== "custom-url-key") return "not-required" + if (provider.baseUrl) return "configured-url" + if (hasEnvValue(provider.baseUrlEnv)) return "env-url" + + const supportedEngineIds = ENGINE_IDS.filter((engineId) => + providerSupportsEngine(provider, engineId), + ) + if (supportedEngineIds.length === 0) return "missing-url" + + const engineUrlStates = supportedEngineIds.map((engineId) => { + const engineConfig = provider.engines?.[engineId] + if (engineConfig?.baseUrl) return "configured-url" + if (hasEnvValue(engineConfig?.baseUrlEnv)) return "env-url" + return "missing-url" + }) + + if (engineUrlStates.every((status) => status !== "missing-url")) { + return engineUrlStates.includes("configured-url") + ? "configured-url" + : "env-url" + } + + return "missing-url" +} + +function providerSupportsEngine( + provider: MossProviderDefinition | undefined, + engineId: AgentEngineId, +): boolean { + if (!provider) return false + if (provider.engines?.[engineId]) return true + if (provider.runtime === "any") return true + if (provider.runtime === engineId) return true + if (provider.runtime === "claude" && engineId === "claude-code") return true + if (provider.runtime === "openai" && engineId === "codex") return true + return provider.mode === "bundled-quota" +} + +function getEngineModel( + provider: MossProviderDefinition | undefined, + engineId: AgentEngineId, +): string | null { + if (!provider) return null + return ( + provider.engines?.[engineId]?.model ?? + provider.models?.[engineId] ?? + provider.model ?? + null + ) +} + +function buildQuota(params: { + user: AuthUser | null + plan: MossAccountPlan | null + sharedAcrossEngines: boolean +}): MossAccountEntitlement["quota"] { + const allowance = buildPlanQuotaAllowance(params.plan) + + if (!params.user) { + return { + providerId: "moss", + label: "Moss Managed", + mode: "bundled-quota", + status: "needs-sign-in", + sharedAcrossEngines: params.sharedAcrossEngines, + ...allowance, + reason: + "Sign in to use Moss managed quota across Hermes, Claude Code, Codex, and Custom ACP.", + } + } + + if (!params.plan) { + return { + providerId: "moss", + label: "Moss Managed", + mode: "bundled-quota", + status: "checking", + sharedAcrossEngines: params.sharedAcrossEngines, + ...allowance, + reason: "Moss account is signed in; quota status needs a backend entitlement refresh.", + } + } + + if (hasManagedQuotaAccess(params.plan)) { + return { + providerId: "moss", + label: "Moss Managed", + mode: "bundled-quota", + status: "available", + sharedAcrossEngines: params.sharedAcrossEngines, + ...allowance, + reason: "Moss managed quota is available for the shared provider route.", + } + } + + return { + providerId: "moss", + label: "Moss Managed", + mode: "bundled-quota", + status: "inactive", + sharedAcrossEngines: params.sharedAcrossEngines, + ...allowance, + reason: "Moss account is signed in, but the current plan does not include managed quota.", + } +} + +export function buildMossAccountEntitlement(params: { + user?: AuthUser | null + plan?: MossAccountPlan | null + providerReadResult?: MossProviderReadResult | null + storedSecrets?: Record +}): MossAccountEntitlement { + const user = params.user ?? null + const plan = params.plan ?? null + const providerReadResult = params.providerReadResult ?? null + const config = providerReadResult?.config + const providerStatus = providerReadResult?.status + const missingProject = !providerReadResult || providerStatus === "missing" + const providerError = providerStatus === "parse-error" + const defaultProvider = config?.defaultProvider ?? "moss" + const sharedAcrossEngines = + config?.credentialPolicy?.shareAcrossEngines ?? true + const activeProvider = providerError + ? undefined + : getProvider(config, defaultProvider) ?? getProvider(config, "moss") + const activeProviderId = activeProvider?.id ?? null + const useCustomProvider = activeProviderId === "custom" + const credentialStatus = getProviderCredentialStatus({ + provider: activeProvider, + storedSecret: activeProviderId + ? params.storedSecrets?.[activeProviderId] + : undefined, + missingProject, + providerError, + }) + const baseUrlStatus = getProviderBaseUrlStatus({ + provider: activeProvider, + missingProject, + providerError, + }) + const quota = buildQuota({ + user, + plan, + sharedAcrossEngines, + }) + + let status: MossAccountEntitlementStatus + if (missingProject) { + status = "missing-project" + } else if (providerError) { + status = "provider-error" + } else if (useCustomProvider) { + if ( + credentialStatus !== "stored-key" && + credentialStatus !== "inline-key" && + credentialStatus !== "env-key" + ) { + status = "custom-needs-key" + } else if (baseUrlStatus === "missing-url") { + status = "custom-needs-url" + } else { + status = "custom-ready" + } + } else { + status = quota.status === "available" ? "ready" : "needs-sign-in" + } + + return { + status, + account: { + signedIn: Boolean(user), + userId: user?.id ?? null, + email: user?.email ?? null, + name: user?.name ?? null, + }, + plan: { + id: plan?.plan ?? (user ? "unknown" : "signed-out"), + status: plan?.status ?? (user ? "unknown" : "signed-out"), + isPaid: isActivePaidPlan(plan), + source: plan?.source ?? (plan ? "backend" : "none"), + }, + quota, + provider: { + sourcePath: providerReadResult?.sourcePath ?? "", + defaultProvider, + activeProviderId, + useCustomProvider, + sharedAcrossEngines, + credentialStatus, + baseUrlStatus, + reason: + providerReadResult?.error ?? + (useCustomProvider && baseUrlStatus === "missing-url" + ? "Custom URL/key requires one base URL before Hermes, Claude Code, Codex, and Custom ACP can share it." + : useCustomProvider + ? "Custom URL/key is shared across all engines." + : "Moss Managed quota is the default shared provider route."), + }, + engines: ENGINE_IDS.map((engineId) => { + const supportsEngine = providerSupportsEngine(activeProvider, engineId) + let engineStatus: MossAccountEntitlement["engines"][number]["status"] + if (!supportsEngine) { + engineStatus = "unconfigured" + } else if (useCustomProvider) { + if ( + credentialStatus !== "stored-key" && + credentialStatus !== "inline-key" && + credentialStatus !== "env-key" + ) { + engineStatus = "needs-api-key" + } else if (baseUrlStatus === "missing-url") { + engineStatus = "needs-base-url" + } else { + engineStatus = "ready" + } + } else { + engineStatus = + quota.status === "available" ? "ready" : "needs-sign-in" + } + + return { + engineId, + providerId: activeProviderId, + model: getEngineModel(activeProvider, engineId), + credentialMode: credentialStatus, + status: engineStatus, + } + }), + } +} diff --git a/src/main/lib/moss-account/index.ts b/src/main/lib/moss-account/index.ts new file mode 100644 index 000000000..836c20d41 --- /dev/null +++ b/src/main/lib/moss-account/index.ts @@ -0,0 +1 @@ +export * from "./entitlement" diff --git a/src/main/lib/moss-source/bootstrap.ts b/src/main/lib/moss-source/bootstrap.ts new file mode 100644 index 000000000..edc70ab50 --- /dev/null +++ b/src/main/lib/moss-source/bootstrap.ts @@ -0,0 +1,239 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { getMossBootstrapDirectories, getMossSourceLayout } from "./layout" + +export interface EnsureMossSourceOptions { + projectPath: string +} + +export interface EnsureMossSourceResult { + projectPath: string + root: string + status: "created" | "updated" | "skipped" | "conflict" + created: string[] + skipped: string[] + conflicts: Array<{ path: string; reason: string }> + reason?: string +} + +const DEFAULT_MOSS_INSTRUCTIONS = `# Moss + +This workspace uses Moss Unified Source as the single source of truth for rules, memory, skills, MCP, plugins, hooks, subagents, and providers. + +- Keep canonical configuration under .moss. +- Project Claude Code, Codex, Hermes, and custom ACP agent files from .moss instead of maintaining duplicate real data. +- Sessions may remain engine-native, but shared resources and provider routing belong to Moss. +` + +const DEFAULT_WORKSPACE_CONFIG = `version: 1 +name: Moss Workspace +source: moss-unified-source +` + +const DEFAULT_PROVIDER_CONFIG = `version: 1 +defaultProvider: moss +credentialPolicy: + singleUserConfiguration: true + allowCustomBaseUrl: true + allowCustomApiKey: true + shareAcrossEngines: true +providers: + moss: + label: Moss Managed + mode: bundled-quota + runtime: any + engines: + hermes: + model: moss-default + claude-code: + model: opus + codex: + model: gpt-5.5/medium + custom-acp: + model: custom-acp + custom: + label: Custom OpenAI-Compatible + mode: custom-url-key + runtime: any + apiKeyEnv: MOSS_CUSTOM_API_KEY + baseUrlEnv: MOSS_CUSTOM_BASE_URL + engines: + hermes: + model: moss-custom + claude-code: + model: opus + codex: + model: gpt-5.5/medium + authMethod: openai-api-key + custom-acp: + model: custom-acp +` + +const DEFAULT_MCP_CONFIG = `${JSON.stringify({ mcpServers: {} }, null, 2)}\n` + +const DEFAULT_STARTER_MEMORY = `--- +name: moss-workspace +description: Canonical Moss workspace operating memory. +--- + +Moss owns one shared source for this workspace. Rules, memory, skills, MCP servers, plugins, hooks, subagents, and provider routing live under .moss and are projected into Claude Code, Codex, Hermes, and custom ACP agents as needed. + +Do not maintain a second real copy in engine-native folders. If an engine needs a native path, use the Moss projection or adapter output. +` + +const DEFAULT_STARTER_SKILL = `--- +name: moss-workspace +description: Use the Moss unified workspace source and shared resource map. +--- + +# Moss Workspace + +Use this skill when a task depends on workspace rules, shared memory, installed tools, hooks, or provider routing. + +## Protocol + +1. Treat .moss/source/moss.md as the canonical rules file. +2. Read .moss/source/workspace.yaml for workspace defaults. +3. Use .moss/memory, .moss/skills, .moss/mcp, .moss/plugins, .moss/hooks, .moss/subagents, and .moss/providers.yaml as the only real resource source. +4. If an engine needs a native path, use the Moss projection or adapter output instead of editing the native copy. +` + +const DEFAULT_STARTER_HOOK = `--- +name: session-start +description: Starter hook template projected from Moss Unified Source. +event: MossSessionStart +enabled: true +--- + +This hook is intentionally commandless. It proves that hooks are installed and projected from .moss without executing user code until the user adds an explicit command. +` + +const DEFAULT_STARTER_PLUGIN = `--- +name: moss-starter +description: Starter plugin manifest projected from Moss Unified Source. +enabled: true +--- + +Moss Starter is the built-in plugin entry that proves installed plugin metadata is owned once under .moss/plugins and exposed to each engine through projection or adapter manifests. +` + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +async function ensureDefaultFile(params: { + filePath: string + content: string + created: string[] + skipped: string[] +}) { + if (await pathExists(params.filePath)) { + params.skipped.push(params.filePath) + return + } + + await fs.mkdir(path.dirname(params.filePath), { recursive: true }) + await fs.writeFile(params.filePath, params.content, "utf-8") + params.created.push(params.filePath) +} + +export async function ensureMossSource( + options: EnsureMossSourceOptions, +): Promise { + const projectPath = path.resolve(options.projectPath) + const layout = getMossSourceLayout(projectPath) + const result: EnsureMossSourceResult = { + projectPath, + root: layout.root, + status: "skipped", + created: [], + skipped: [], + conflicts: [], + } + + try { + const stat = await fs.lstat(layout.root) + if (!stat.isDirectory()) { + result.status = "conflict" + result.conflicts.push({ + path: layout.root, + reason: ".moss exists and is not a directory.", + }) + return result + } + } catch { + await fs.mkdir(layout.root, { recursive: true }) + result.created.push(layout.root) + } + + for (const dirPath of getMossBootstrapDirectories(layout)) { + if (await pathExists(dirPath)) { + result.skipped.push(dirPath) + continue + } + await fs.mkdir(dirPath, { recursive: true }) + result.created.push(dirPath) + } + + await ensureDefaultFile({ + filePath: layout.sourceInstruction, + content: DEFAULT_MOSS_INSTRUCTIONS, + created: result.created, + skipped: result.skipped, + }) + await ensureDefaultFile({ + filePath: layout.workspaceConfig, + content: DEFAULT_WORKSPACE_CONFIG, + created: result.created, + skipped: result.skipped, + }) + await ensureDefaultFile({ + filePath: layout.providersConfig, + content: DEFAULT_PROVIDER_CONFIG, + created: result.created, + skipped: result.skipped, + }) + await ensureDefaultFile({ + filePath: layout.mcpConfig, + content: DEFAULT_MCP_CONFIG, + created: result.created, + skipped: result.skipped, + }) + await ensureDefaultFile({ + filePath: path.join(layout.memoryRoot, "moss-workspace.md"), + content: DEFAULT_STARTER_MEMORY, + created: result.created, + skipped: result.skipped, + }) + await ensureDefaultFile({ + filePath: path.join(layout.skillsRoot, "moss-workspace", "SKILL.md"), + content: DEFAULT_STARTER_SKILL, + created: result.created, + skipped: result.skipped, + }) + await ensureDefaultFile({ + filePath: path.join(layout.hooksRoot, "session-start.md"), + content: DEFAULT_STARTER_HOOK, + created: result.created, + skipped: result.skipped, + }) + await ensureDefaultFile({ + filePath: path.join(layout.pluginsRoot, "moss-starter.md"), + content: DEFAULT_STARTER_PLUGIN, + created: result.created, + skipped: result.skipped, + }) + + if (result.created.length > 0) { + result.status = result.skipped.length > 0 ? "updated" : "created" + return result + } + + result.reason = "Moss Unified Source already exists." + return result +} diff --git a/src/main/lib/moss-source/frontmatter.ts b/src/main/lib/moss-source/frontmatter.ts new file mode 100644 index 000000000..da93b66b0 --- /dev/null +++ b/src/main/lib/moss-source/frontmatter.ts @@ -0,0 +1,43 @@ +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) +const yaml = require("js-yaml") as { + load(source: string): unknown + dump(value: unknown, options?: Record): string +} + +export function stringifyMossFrontmatter( + content: string, + data: Record, +): string { + const frontmatter = yaml + .dump(data, { + lineWidth: -1, + noRefs: true, + sortKeys: false, + }) + .trimEnd() + return `---\n${frontmatter}\n---\n${content}` +} + +export function parseMossFrontmatter(raw: string): { + data: Record + content: string +} { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/) + if (!match) { + return { + data: {}, + content: raw, + } + } + + const loaded = yaml.load(match[1]) + return { + data: + loaded && typeof loaded === "object" && !Array.isArray(loaded) + ? (loaded as Record) + : {}, + content: match[2] ?? "", + } +} diff --git a/src/main/lib/moss-source/hooks.ts b/src/main/lib/moss-source/hooks.ts new file mode 100644 index 000000000..b207129fa --- /dev/null +++ b/src/main/lib/moss-source/hooks.ts @@ -0,0 +1,320 @@ +import { spawn } from "node:child_process" +import { createHash } from "node:crypto" +import path from "node:path" +import type { AgentEngineId } from "../agent-runtime/types" +import type { SharedResource } from "../shared-resources/types" +import { discoverMossSourceResources } from "./registry" + +export type MossHookRunStatus = "passed" | "failed" | "skipped" | "timed-out" + +export interface MossHookRunResult { + resourceId: string + name: string + event: string + status: MossHookRunStatus + command?: string + commandHash?: string + exitCode?: number | null + elapsedMs: number + stdout?: string + stderr?: string + error?: string + timedOut?: boolean +} + +export interface MossHookRunSummary { + status: MossHookRunStatus + event: string + engineId: AgentEngineId + projectPath: string + matchedCount: number + executedCount: number + skippedCount: number + failedCount: number + timedOutCount: number + payloadHash: string + results: MossHookRunResult[] + warnings: string[] +} + +export interface RunMossHooksOptions { + projectPath: string + event: string + engineId: AgentEngineId + cwd?: string + payload?: Record + env?: Record + timeoutMs?: number + maxHooks?: number +} + +const DEFAULT_HOOK_TIMEOUT_MS = 10_000 +const DEFAULT_MAX_HOOKS = 20 + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex") +} + +function normalizeEvent(value: string | undefined): string { + return value?.trim().toLowerCase() ?? "" +} + +function redactHookOutput(value: string): string { + return value + .replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-REDACTED") + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer REDACTED") + .replace( + /(["']?(?:api[_-]?key|token|secret|password|authorization)["']?\s*[:=]\s*["']?)([^"',\n}]+)/gi, + "$1REDACTED", + ) + .slice(0, 4000) +} + +function hookEvent(resource: SharedResource): string { + const event = resource.metadata?.event + return typeof event === "string" && event.trim() ? event.trim() : "Stop" +} + +function hookCommand(resource: SharedResource): string | undefined { + const command = resource.metadata?.command + return typeof command === "string" && command.trim() + ? command.trim() + : undefined +} + +function isHookEnabled(resource: SharedResource): boolean { + if (resource.enabled === false) return false + if (resource.metadata?.hookEnabled === false) return false + return true +} + +function visibleResult(result: MossHookRunResult): MossHookRunResult { + return { + ...result, + command: result.command ? redactHookOutput(result.command) : undefined, + stdout: result.stdout ? redactHookOutput(result.stdout) : undefined, + stderr: result.stderr ? redactHookOutput(result.stderr) : undefined, + error: result.error ? redactHookOutput(result.error) : undefined, + } +} + +function runHookCommand(params: { + resource: SharedResource + command: string + event: string + engineId: AgentEngineId + projectPath: string + cwd: string + payloadJson: string + payloadHash: string + env?: Record + timeoutMs: number +}): Promise { + const startedAt = Date.now() + + return new Promise((resolve) => { + const child = spawn(params.command, { + cwd: params.cwd, + env: { + ...process.env, + ...params.env, + MOSS_HOOK_EVENT: params.event, + MOSS_HOOK_ENGINE: params.engineId, + MOSS_HOOK_RESOURCE_ID: params.resource.id, + MOSS_HOOK_NAME: params.resource.name, + MOSS_HOOK_PROJECT_PATH: params.projectPath, + MOSS_HOOK_CWD: params.cwd, + MOSS_HOOK_PAYLOAD_JSON: params.payloadJson, + MOSS_HOOK_PAYLOAD_SHA256: params.payloadHash, + }, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + let timedOut = false + let forceKillTimer: ReturnType | null = null + + const timeout = setTimeout(() => { + timedOut = true + child.kill("SIGTERM") + forceKillTimer = setTimeout(() => child.kill("SIGKILL"), 2000) + }, params.timeoutMs) + + child.stdout.on("data", (chunk) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + }) + child.stderr.on("data", (chunk) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + }) + child.once("error", (error) => { + clearTimeout(timeout) + if (forceKillTimer) clearTimeout(forceKillTimer) + resolve( + visibleResult({ + resourceId: params.resource.id, + name: params.resource.name, + event: params.event, + status: "failed", + command: params.command, + commandHash: sha256(params.command), + elapsedMs: Date.now() - startedAt, + error: error.message, + }), + ) + }) + child.once("close", (exitCode) => { + clearTimeout(timeout) + if (forceKillTimer) clearTimeout(forceKillTimer) + const status: MossHookRunStatus = timedOut + ? "timed-out" + : exitCode === 0 + ? "passed" + : "failed" + + resolve( + visibleResult({ + resourceId: params.resource.id, + name: params.resource.name, + event: params.event, + status, + command: params.command, + commandHash: sha256(params.command), + exitCode, + elapsedMs: Date.now() - startedAt, + stdout: Buffer.concat(stdoutChunks).toString("utf-8").trim(), + stderr: Buffer.concat(stderrChunks).toString("utf-8").trim(), + timedOut, + }), + ) + }) + }) +} + +export async function runMossHooks( + options: RunMossHooksOptions, +): Promise { + const payloadJson = JSON.stringify(options.payload ?? {}) + const payloadHash = sha256(payloadJson) + const warnings: string[] = [] + const event = options.event.trim() + const normalizedEvent = normalizeEvent(event) + const timeoutMs = options.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS + const maxHooks = options.maxHooks ?? DEFAULT_MAX_HOOKS + + let resources: SharedResource[] = [] + try { + resources = await discoverMossSourceResources(options.projectPath) + } catch (error) { + return { + status: "failed", + event, + engineId: options.engineId, + projectPath: options.projectPath, + matchedCount: 0, + executedCount: 0, + skippedCount: 0, + failedCount: 1, + timedOutCount: 0, + payloadHash, + results: [ + { + resourceId: "moss:hooks", + name: "Moss hook discovery", + event, + status: "failed", + elapsedMs: 0, + error: error instanceof Error ? error.message : String(error), + }, + ], + warnings, + } + } + + const matchedHooks = resources + .filter((resource) => resource.kind === "hook" && resource.scope === "moss") + .filter((resource) => normalizeEvent(hookEvent(resource)) === normalizedEvent) + .slice(0, maxHooks) + + const allMatches = resources + .filter((resource) => resource.kind === "hook" && resource.scope === "moss") + .filter((resource) => normalizeEvent(hookEvent(resource)) === normalizedEvent) + + if (allMatches.length > matchedHooks.length) { + warnings.push( + `Moss hook run limited to ${matchedHooks.length} of ${allMatches.length} matching hooks.`, + ) + } + + const results: MossHookRunResult[] = [] + for (const resource of matchedHooks) { + if (!isHookEnabled(resource)) { + results.push({ + resourceId: resource.id, + name: resource.name, + event, + status: "skipped", + elapsedMs: 0, + error: "Hook is disabled.", + }) + continue + } + + const command = hookCommand(resource) + if (!command) { + results.push({ + resourceId: resource.id, + name: resource.name, + event, + status: "skipped", + elapsedMs: 0, + error: "Hook has no command.", + }) + continue + } + + results.push( + await runHookCommand({ + resource, + command, + event, + engineId: options.engineId, + projectPath: options.projectPath, + cwd: options.cwd ?? options.projectPath, + payloadJson, + payloadHash, + env: options.env, + timeoutMs, + }), + ) + } + + const executedCount = results.filter((result) => + ["passed", "failed", "timed-out"].includes(result.status), + ).length + const skippedCount = results.filter((result) => result.status === "skipped").length + const failedCount = results.filter((result) => result.status === "failed").length + const timedOutCount = results.filter((result) => result.status === "timed-out").length + const status: MossHookRunStatus = + failedCount > 0 || timedOutCount > 0 + ? "failed" + : executedCount > 0 + ? "passed" + : "skipped" + + return { + status, + event, + engineId: options.engineId, + projectPath: options.projectPath, + matchedCount: matchedHooks.length, + executedCount, + skippedCount, + failedCount, + timedOutCount, + payloadHash, + results, + warnings, + } +} diff --git a/src/main/lib/moss-source/index.ts b/src/main/lib/moss-source/index.ts new file mode 100644 index 000000000..4101c4a83 --- /dev/null +++ b/src/main/lib/moss-source/index.ts @@ -0,0 +1,10 @@ +export * from "./bootstrap" +export * from "./hooks" +export * from "./layout" +export * from "./projection" +export * from "./provider-config" +export * from "./provider-secrets" +export * from "./registry" +export * from "./runtime-materializer" +export * from "./subagents" +export * from "./types" diff --git a/src/main/lib/moss-source/layout.ts b/src/main/lib/moss-source/layout.ts new file mode 100644 index 000000000..a5eee4d61 --- /dev/null +++ b/src/main/lib/moss-source/layout.ts @@ -0,0 +1,47 @@ +import * as path from "path" +import { + MOSS_ROOT_DIR, + MOSS_SOURCE_VERSION, + type MossSourceLayout, +} from "./types" + +export function getMossSourceLayout(projectPath: string): MossSourceLayout { + const root = path.join(projectPath, MOSS_ROOT_DIR) + + return { + version: MOSS_SOURCE_VERSION, + projectPath, + root, + sourceInstruction: path.join(root, "source", "moss.md"), + workspaceConfig: path.join(root, "source", "workspace.yaml"), + memoryRoot: path.join(root, "memory"), + skillsRoot: path.join(root, "skills"), + mcpConfig: path.join(root, "mcp", "config.json"), + pluginsRoot: path.join(root, "plugins"), + hooksRoot: path.join(root, "hooks"), + subagentsRoot: path.join(root, "subagents"), + providersConfig: path.join(root, "providers.yaml"), + } +} + +export function toMossProjectPath( + projectPath: string, + filePath: string, +): string { + if (!path.isAbsolute(filePath)) return filePath + return path.relative(projectPath, filePath) +} + +export function getMossBootstrapDirectories( + layout: MossSourceLayout, +): string[] { + return [ + path.dirname(layout.sourceInstruction), + layout.memoryRoot, + layout.skillsRoot, + path.dirname(layout.mcpConfig), + layout.pluginsRoot, + layout.hooksRoot, + layout.subagentsRoot, + ] +} diff --git a/src/main/lib/moss-source/projection.ts b/src/main/lib/moss-source/projection.ts new file mode 100644 index 000000000..42f79c09e --- /dev/null +++ b/src/main/lib/moss-source/projection.ts @@ -0,0 +1,908 @@ +import * as crypto from "crypto" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import type { + EngineResourceProjection, + ResourcePathMapping, +} from "../shared-resources/types" + +export type MossProjectionMaterializeStatus = + | "created" + | "updated" + | "skipped" + | "conflict" + | "unsupported" + +export interface MossProjectionMaterializeResult { + engineId: EngineResourceProjection["engineId"] + resourceId: string + action: ResourcePathMapping["action"] + sourcePath?: string + targetPath?: string + status: MossProjectionMaterializeStatus + reason?: string +} + +export interface MossProjectionManifestEntry { + engineId: EngineResourceProjection["engineId"] + resourceId: string + action: ResourcePathMapping["action"] + sourcePath?: string + targetPath: string + contentHash?: string + updatedAt: string +} + +export interface MossProjectionManifest { + version: 1 + generatedAt: string + entries: Record +} + +export interface MossProjectionManifestSummary { + status: "found" | "missing" | "parse-error" + sourcePath: string + generatedAt?: string + totalEntries: number + engines: Array<{ + engineId: EngineResourceProjection["engineId"] + entries: number + }> + error?: string +} + +export interface MaterializeMossProjectionOptions { + projectPath: string + projection: EngineResourceProjection + dryRun?: boolean +} + +export interface RemoveMossProjectionResourceOptions { + projectPath: string + resourceId: string + sourcePath?: string + targetPaths?: string[] + removeTargets?: boolean +} + +export interface RemoveMossProjectionResourceResult { + removedEntries: string[] + removedTargets: string[] +} + +const MANIFEST_PATH = path.join(".moss", "projections", "manifest.json") +const ADAPTER_MANIFEST_NAME = ".moss-adapter.json" + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +function normalizePathKey(projectPath: string, filePath: string): string { + const normalizedProjectPath = path.resolve(projectPath) + const normalizedFilePath = path.resolve(filePath) + if ( + normalizedFilePath === normalizedProjectPath || + normalizedFilePath.startsWith(`${normalizedProjectPath}${path.sep}`) + ) { + return path.relative(normalizedProjectPath, normalizedFilePath) + } + return normalizedFilePath +} + +function resolveProjectionPath(projectPath: string, mappingPath: string): string { + if (mappingPath.startsWith("~/")) { + return path.join(os.homedir(), mappingPath.slice(2)) + } + if (path.isAbsolute(mappingPath)) return mappingPath + return path.join(projectPath, mappingPath) +} + +function hashContent(content: string | Buffer): string { + return crypto.createHash("sha256").update(content).digest("hex") +} + +async function readManifest(projectPath: string): Promise { + const manifestPath = path.join(projectPath, MANIFEST_PATH) + try { + const raw = await fs.readFile(manifestPath, "utf-8") + const parsed = JSON.parse(raw) as MossProjectionManifest + if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object") { + return parsed + } + } catch { + // Fall through to a fresh manifest. + } + + return { + version: 1, + generatedAt: new Date().toISOString(), + entries: {}, + } +} + +async function writeManifest( + projectPath: string, + manifest: MossProjectionManifest, +): Promise { + manifest.generatedAt = new Date().toISOString() + const manifestPath = path.join(projectPath, MANIFEST_PATH) + await fs.mkdir(path.dirname(manifestPath), { recursive: true }) + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8") +} + +function summarizeManifest( + manifest: MossProjectionManifest, + sourcePath: string, +): MossProjectionManifestSummary { + const counts = new Map() + for (const entry of Object.values(manifest.entries)) { + counts.set(entry.engineId, (counts.get(entry.engineId) ?? 0) + 1) + } + + return { + status: "found", + sourcePath, + generatedAt: manifest.generatedAt, + totalEntries: Object.keys(manifest.entries).length, + engines: Array.from(counts.entries()) + .map(([engineId, entries]) => ({ engineId, entries })) + .sort((a, b) => a.engineId.localeCompare(b.engineId)), + } +} + +export async function readMossProjectionManifestSummary( + projectPath: string, +): Promise { + const manifestPath = path.join(projectPath, MANIFEST_PATH) + if (!(await pathExists(manifestPath))) { + return { + status: "missing", + sourcePath: manifestPath, + totalEntries: 0, + engines: [], + } + } + + try { + const raw = await fs.readFile(manifestPath, "utf-8") + const parsed = JSON.parse(raw) as MossProjectionManifest + if ( + parsed?.version !== 1 || + !parsed.entries || + typeof parsed.entries !== "object" + ) { + throw new Error("Projection manifest is not a version 1 Moss manifest.") + } + return summarizeManifest(parsed, manifestPath) + } catch (error) { + return { + status: "parse-error", + sourcePath: manifestPath, + totalEntries: 0, + engines: [], + error: error instanceof Error ? error.message : String(error), + } + } +} + +export async function removeMossProjectionResource( + options: RemoveMossProjectionResourceOptions, +): Promise { + const manifestPath = path.join(options.projectPath, MANIFEST_PATH) + if (!(await pathExists(manifestPath))) { + return { + removedEntries: [], + removedTargets: [], + } + } + + const manifest = await readManifest(options.projectPath) + const sourceKey = options.sourcePath + ? normalizePathKey( + options.projectPath, + resolveProjectionPath(options.projectPath, options.sourcePath), + ) + : undefined + const sourceAbs = options.sourcePath + ? resolveProjectionPath(options.projectPath, options.sourcePath) + : undefined + const targetKeys = new Set( + (options.targetPaths ?? []).map((targetPath) => + normalizePathKey( + options.projectPath, + resolveProjectionPath(options.projectPath, targetPath), + ), + ), + ) + const removedEntries: string[] = [] + const removedTargets: string[] = [] + const removedTargetKeys = new Set() + + for (const [entryKey, entry] of Object.entries(manifest.entries)) { + const entryManifestKey = normalizePathKey( + options.projectPath, + resolveProjectionPath(options.projectPath, entryKey), + ) + const entrySourceKey = entry.sourcePath + ? normalizePathKey( + options.projectPath, + resolveProjectionPath(options.projectPath, entry.sourcePath), + ) + : undefined + const entryTargetKey = normalizePathKey( + options.projectPath, + resolveProjectionPath(options.projectPath, entry.targetPath), + ) + const matches = + entry.resourceId === options.resourceId || + (sourceKey !== undefined && entrySourceKey === sourceKey) || + targetKeys.has(entryTargetKey) || + targetKeys.has(entryManifestKey) + + if (!matches) continue + + delete manifest.entries[entryKey] + removedEntries.push(entryKey) + + if (options.removeTargets && targetKeys.has(entryManifestKey)) { + await fs.rm(resolveProjectionPath(options.projectPath, entryKey), { + recursive: true, + force: true, + }) + removedTargets.push(entryKey) + removedTargetKeys.add(entryManifestKey) + } else if (options.removeTargets && targetKeys.has(entryTargetKey)) { + await fs.rm(resolveProjectionPath(options.projectPath, entry.targetPath), { + recursive: true, + force: true, + }) + removedTargets.push(entry.targetPath) + removedTargetKeys.add(entryTargetKey) + } + } + + if (options.removeTargets) { + for (const targetPath of options.targetPaths ?? []) { + const targetAbs = resolveProjectionPath(options.projectPath, targetPath) + const targetKey = normalizePathKey(options.projectPath, targetAbs) + if (removedTargetKeys.has(targetKey)) continue + if (!(await isRemovableProjectionTarget({ + targetAbs, + sourceAbs, + sourcePath: options.sourcePath, + }))) { + continue + } + await fs.rm(targetAbs, { recursive: true, force: true }) + removedTargets.push(targetPath) + removedTargetKeys.add(targetKey) + } + } + + if (removedEntries.length > 0) { + await writeManifest(options.projectPath, manifest) + } + + return { + removedEntries, + removedTargets, + } +} + +function canOverwriteTarget( + targetKey: string, + manifest: MossProjectionManifest, +): boolean { + return Boolean(manifest.entries[targetKey]) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isEmptyRecord(value: unknown): boolean { + return isRecord(value) && Object.keys(value).length === 0 +} + +function isAdoptableEmptyMcpBridge(value: unknown): boolean { + if (!isRecord(value)) return false + + const entries = Object.entries(value) + if (entries.length === 0) return true + + return entries.every(([key, entryValue]) => { + if (key !== "mcpServers" && key !== "servers") return false + return isEmptyRecord(entryValue) + }) +} + +function isAdoptableMossGeneratedBridge(value: unknown): boolean { + if (!isRecord(value)) return false + const moss = value.moss + if (!isRecord(moss)) return false + return moss.generated === true && Array.isArray(moss.sources) +} + +function isAdoptableMossAdapterManifest(value: unknown): boolean { + if (!isRecord(value)) return false + return value.version === 1 && + value.generatedBy === "moss" && + Array.isArray(value.resources) +} + +async function canAdoptExistingTarget(params: { + targetAbs: string + targetKey: string +}): Promise { + try { + const stat = await fs.lstat(params.targetAbs) + if (!stat.isFile()) return false + const raw = await fs.readFile(params.targetAbs, "utf-8") + const parsed = JSON.parse(raw) + if (params.targetKey === ".mcp.json" && isAdoptableEmptyMcpBridge(parsed)) { + return true + } + return isAdoptableMossGeneratedBridge(parsed) || + isAdoptableMossAdapterManifest(parsed) + } catch { + return false + } +} + +async function readLinkTarget(filePath: string): Promise { + try { + return await fs.readlink(filePath) + } catch { + return null + } +} + +async function isSameSymlinkTarget( + linkPath: string, + sourcePath: string, +): Promise { + const existingTarget = await readLinkTarget(linkPath) + if (!existingTarget) return false + const resolvedExisting = path.resolve(path.dirname(linkPath), existingTarget) + return resolvedExisting === path.resolve(sourcePath) +} + +async function isRemovableProjectionTarget(params: { + targetAbs: string + sourceAbs?: string + sourcePath?: string +}): Promise { + try { + const stat = await fs.lstat(params.targetAbs) + if (stat.isSymbolicLink()) { + if (!params.sourceAbs) return true + return isSameSymlinkTarget(params.targetAbs, params.sourceAbs) + } + if (!stat.isFile()) return false + const raw = await fs.readFile(params.targetAbs, "utf-8") + const parsed = JSON.parse(raw) as { + moss?: { + generated?: boolean + sources?: string[] + } + } + if (parsed.moss?.generated !== true) return false + if (!params.sourcePath) return true + return Array.isArray(parsed.moss.sources) && parsed.moss.sources.includes(params.sourcePath) + } catch { + return false + } +} + +async function ensureWritableTarget( + params: { + targetAbs: string + targetKey: string + manifest: MossProjectionManifest + dryRun?: boolean + }, +): Promise<{ ok: true } | { ok: false; reason: string }> { + if (!(await pathExists(params.targetAbs))) return { ok: true } + if (canOverwriteTarget(params.targetKey, params.manifest)) return { ok: true } + if (await canAdoptExistingTarget(params)) return { ok: true } + + return { + ok: false, + reason: "Target exists and is not managed by Moss projection manifest.", + } +} + +async function materializeSymlink(params: { + projectPath: string + engineId: EngineResourceProjection["engineId"] + mapping: ResourcePathMapping + manifest: MossProjectionManifest + dryRun?: boolean +}): Promise { + if (!params.mapping.sourcePath || !params.mapping.targetPath) { + return { + engineId: params.engineId, + resourceId: params.mapping.resourceId, + action: params.mapping.action, + status: "unsupported", + reason: "Symlink projection requires sourcePath and targetPath.", + } + } + + const sourceAbs = resolveProjectionPath(params.projectPath, params.mapping.sourcePath) + const targetAbs = resolveProjectionPath(params.projectPath, params.mapping.targetPath) + const targetKey = normalizePathKey(params.projectPath, targetAbs) + + if (!(await pathExists(sourceAbs))) { + return { + engineId: params.engineId, + resourceId: params.mapping.resourceId, + action: "symlink", + sourcePath: params.mapping.sourcePath, + targetPath: params.mapping.targetPath, + status: "conflict", + reason: "Source path does not exist.", + } + } + + if (await isSameSymlinkTarget(targetAbs, sourceAbs)) { + const wasManaged = canOverwriteTarget(targetKey, params.manifest) + if (!params.dryRun && !wasManaged) { + params.manifest.entries[targetKey] = { + engineId: params.engineId, + resourceId: params.mapping.resourceId, + action: "symlink", + sourcePath: params.mapping.sourcePath, + targetPath: params.mapping.targetPath, + updatedAt: new Date().toISOString(), + } + } + return { + engineId: params.engineId, + resourceId: params.mapping.resourceId, + action: "symlink", + sourcePath: params.mapping.sourcePath, + targetPath: params.mapping.targetPath, + status: wasManaged ? "skipped" : "updated", + reason: wasManaged + ? "Symlink already points at the Moss source." + : "Existing symlink points at the Moss source and was adopted by the projection manifest.", + } + } + + const writable = await ensureWritableTarget({ + targetAbs, + targetKey, + manifest: params.manifest, + dryRun: params.dryRun, + }) + if (!writable.ok) { + return { + engineId: params.engineId, + resourceId: params.mapping.resourceId, + action: "symlink", + sourcePath: params.mapping.sourcePath, + targetPath: params.mapping.targetPath, + status: "conflict", + reason: writable.reason, + } + } + + let sourceStat + try { + sourceStat = await fs.stat(sourceAbs) + } catch { + return { + engineId: params.engineId, + resourceId: params.mapping.resourceId, + action: "symlink", + sourcePath: params.mapping.sourcePath, + targetPath: params.mapping.targetPath, + status: "conflict", + reason: "Unable to stat source path.", + } + } + + const wasManaged = canOverwriteTarget(targetKey, params.manifest) + const targetAlreadyExists = await pathExists(targetAbs) + + if (!params.dryRun) { + await fs.mkdir(path.dirname(targetAbs), { recursive: true }) + if (targetAlreadyExists) { + await fs.rm(targetAbs, { recursive: true, force: true }) + } + const type = sourceStat.isDirectory() + ? process.platform === "win32" ? "junction" : "dir" + : "file" + await fs.symlink(sourceAbs, targetAbs, type) + params.manifest.entries[targetKey] = { + engineId: params.engineId, + resourceId: params.mapping.resourceId, + action: "symlink", + sourcePath: params.mapping.sourcePath, + targetPath: params.mapping.targetPath, + updatedAt: new Date().toISOString(), + } + } + + return { + engineId: params.engineId, + resourceId: params.mapping.resourceId, + action: "symlink", + sourcePath: params.mapping.sourcePath, + targetPath: params.mapping.targetPath, + status: wasManaged || targetAlreadyExists ? "updated" : "created", + } +} + +function quoteTomlString(value: string): string { + return JSON.stringify(value) +} + +function appendTomlValue(lines: string[], key: string, value: unknown): void { + if (typeof value === "string") { + lines.push(`${key} = ${quoteTomlString(value)}`) + return + } + if (typeof value === "boolean" || typeof value === "number") { + lines.push(`${key} = ${String(value)}`) + return + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + lines.push(`${key} = [${value.map(quoteTomlString).join(", ")}]`) + } +} + +async function readMcpServers(sourceAbs: string): Promise> { + const raw = await fs.readFile(sourceAbs, "utf-8") + const parsed = JSON.parse(raw) as { + mcpServers?: Record + servers?: Record + } + return parsed.mcpServers ?? parsed.servers ?? {} +} + +async function buildCodexTomlBridge( + projectPath: string, + mappings: ResourcePathMapping[], +): Promise { + const lines = [ + "# Generated by Moss from .moss Unified Source.", + "# Do not edit this file directly; update .moss instead.", + "", + ] + + const mcpMappings = mappings.filter((mapping) => + mapping.sourcePath?.endsWith(path.join(".moss", "mcp", "config.json")) || + mapping.sourcePath?.endsWith(".moss/mcp/config.json"), + ) + const seenMcpSources = new Set() + for (const mapping of mcpMappings) { + if (!mapping.sourcePath) continue + if (seenMcpSources.has(mapping.sourcePath)) continue + seenMcpSources.add(mapping.sourcePath) + const sourceAbs = resolveProjectionPath(projectPath, mapping.sourcePath) + const servers = await readMcpServers(sourceAbs) + for (const [serverName, serverConfig] of Object.entries(servers)) { + const config = serverConfig && typeof serverConfig === "object" + ? serverConfig as Record + : {} + lines.push(`[mcp_servers.${serverName}]`) + appendTomlValue(lines, "command", config.command) + appendTomlValue(lines, "url", config.url) + appendTomlValue(lines, "args", config.args) + if (config.env && typeof config.env === "object" && !Array.isArray(config.env)) { + lines.push("") + lines.push(`[mcp_servers.${serverName}.env]`) + for (const [envKey, envValue] of Object.entries(config.env)) { + appendTomlValue(lines, envKey, envValue) + } + } + lines.push("") + } + } + + const providerMappings = mappings.filter((mapping) => + mapping.sourcePath?.endsWith(path.join(".moss", "providers.yaml")) || + mapping.sourcePath?.endsWith(".moss/providers.yaml"), + ) + const seenProviderSources = new Set() + for (const mapping of providerMappings) { + if (!mapping.sourcePath) continue + if (seenProviderSources.has(mapping.sourcePath)) continue + seenProviderSources.add(mapping.sourcePath) + lines.push(`# Moss provider routing source: ${mapping.sourcePath}`) + } + + return `${lines.join("\n").trimEnd()}\n` +} + +async function buildManagedBridgeContent( + projectPath: string, + targetAbs: string, + mappings: ResourcePathMapping[], +): Promise { + const targetName = path.basename(targetAbs) + const firstSourcePath = mappings.find((mapping) => mapping.sourcePath)?.sourcePath + const firstSourceAbs = firstSourcePath + ? resolveProjectionPath(projectPath, firstSourcePath) + : undefined + + if (targetName === ".mcp.json" && firstSourceAbs) { + const raw = await fs.readFile(firstSourceAbs, "utf-8") + const parsed = JSON.parse(raw) + return `${JSON.stringify(parsed, null, 2)}\n` + } + + if (targetName.endsWith(".toml")) { + return buildCodexTomlBridge(projectPath, mappings) + } + + return `${JSON.stringify({ + moss: { + generated: true, + sources: mappings.map((mapping) => mapping.sourcePath).filter(Boolean), + note: "Generated by Moss from .moss Unified Source. Update .moss instead.", + }, + }, null, 2)}\n` +} + +async function materializeManagedBridge(params: { + projectPath: string + projection: EngineResourceProjection + mappings: ResourcePathMapping[] + targetPath: string + manifest: MossProjectionManifest + dryRun?: boolean +}): Promise { + const targetAbs = resolveProjectionPath(params.projectPath, params.targetPath) + const targetKey = normalizePathKey(params.projectPath, targetAbs) + const wasManaged = canOverwriteTarget(targetKey, params.manifest) + const targetAlreadyExists = await pathExists(targetAbs) + const writable = await ensureWritableTarget({ + targetAbs, + targetKey, + manifest: params.manifest, + dryRun: params.dryRun, + }) + + if (!writable.ok) { + return params.mappings.map((mapping) => ({ + engineId: params.projection.engineId, + resourceId: mapping.resourceId, + action: mapping.action, + sourcePath: mapping.sourcePath, + targetPath: mapping.targetPath, + status: "conflict", + reason: writable.reason, + })) + } + + const content = await buildManagedBridgeContent( + params.projectPath, + targetAbs, + params.mappings, + ) + const contentHash = hashContent(content) + + if (!params.dryRun) { + await fs.mkdir(path.dirname(targetAbs), { recursive: true }) + await fs.writeFile(targetAbs, content, "utf-8") + } + + const now = new Date().toISOString() + for (const mapping of params.mappings) { + params.manifest.entries[targetKey] = { + engineId: params.projection.engineId, + resourceId: mapping.resourceId, + action: "managed-bridge", + sourcePath: mapping.sourcePath, + targetPath: params.targetPath, + contentHash, + updatedAt: now, + } + } + + const status = wasManaged || targetAlreadyExists ? "updated" : "created" + return params.mappings.map((mapping) => ({ + engineId: params.projection.engineId, + resourceId: mapping.resourceId, + action: "managed-bridge", + sourcePath: mapping.sourcePath, + targetPath: params.targetPath, + status, + })) +} + +async function materializeAdapterInjection(params: { + projectPath: string + projection: EngineResourceProjection + mappings: ResourcePathMapping[] + targetPath: string + manifest: MossProjectionManifest + dryRun?: boolean +}): Promise { + const targetAbs = resolveProjectionPath(params.projectPath, params.targetPath) + const adapterManifestAbs = path.join(targetAbs, ADAPTER_MANIFEST_NAME) + const adapterManifestKey = normalizePathKey(params.projectPath, adapterManifestAbs) + const wasManaged = canOverwriteTarget(adapterManifestKey, params.manifest) + const adapterManifestAlreadyExists = await pathExists(adapterManifestAbs) + + try { + const stat = await fs.stat(targetAbs) + if (!stat.isDirectory()) { + return params.mappings.map((mapping) => ({ + engineId: params.projection.engineId, + resourceId: mapping.resourceId, + action: mapping.action, + sourcePath: mapping.sourcePath, + targetPath: mapping.targetPath, + status: "conflict", + reason: "Adapter injection target exists and is not a directory.", + })) + } + } catch { + // Directory will be created below. + } + + const writable = await ensureWritableTarget({ + targetAbs: adapterManifestAbs, + targetKey: adapterManifestKey, + manifest: params.manifest, + dryRun: params.dryRun, + }) + + if (!writable.ok) { + return params.mappings.map((mapping) => ({ + engineId: params.projection.engineId, + resourceId: mapping.resourceId, + action: mapping.action, + sourcePath: mapping.sourcePath, + targetPath: mapping.targetPath, + status: "conflict", + reason: writable.reason, + })) + } + + const content = `${JSON.stringify({ + version: 1, + engineId: params.projection.engineId, + generatedBy: "moss", + resources: params.mappings.map((mapping) => ({ + resourceId: mapping.resourceId, + sourcePath: mapping.sourcePath, + targetPath: mapping.targetPath, + reason: mapping.reason, + })), + }, null, 2)}\n` + const contentHash = hashContent(content) + + if (!params.dryRun) { + await fs.mkdir(targetAbs, { recursive: true }) + await fs.writeFile(adapterManifestAbs, content, "utf-8") + } + + const now = new Date().toISOString() + for (const mapping of params.mappings) { + params.manifest.entries[adapterManifestKey] = { + engineId: params.projection.engineId, + resourceId: mapping.resourceId, + action: "adapter-inject", + sourcePath: mapping.sourcePath, + targetPath: params.targetPath, + contentHash, + updatedAt: now, + } + } + + const status = wasManaged || adapterManifestAlreadyExists ? "updated" : "created" + return params.mappings.map((mapping) => ({ + engineId: params.projection.engineId, + resourceId: mapping.resourceId, + action: "adapter-inject", + sourcePath: mapping.sourcePath, + targetPath: params.targetPath, + status, + })) +} + +function groupByTarget( + mappings: ResourcePathMapping[], +): Map { + const groups = new Map() + for (const mapping of mappings) { + if (!mapping.targetPath) continue + const group = groups.get(mapping.targetPath) ?? [] + group.push(mapping) + groups.set(mapping.targetPath, group) + } + return groups +} + +export async function materializeMossProjection( + options: MaterializeMossProjectionOptions, +): Promise { + const manifest = await readManifest(options.projectPath) + const results: MossProjectionMaterializeResult[] = [] + + const symlinks = options.projection.mappings.filter( + (mapping) => mapping.action === "symlink", + ) + for (const mapping of symlinks) { + const result = await materializeSymlink({ + projectPath: options.projectPath, + engineId: options.projection.engineId, + mapping, + manifest, + dryRun: options.dryRun, + }) + results.push(result) + } + + const managedBridgeGroups = groupByTarget( + options.projection.mappings.filter( + (mapping) => mapping.action === "managed-bridge", + ), + ) + for (const [targetPath, mappings] of managedBridgeGroups) { + results.push( + ...(await materializeManagedBridge({ + projectPath: options.projectPath, + projection: options.projection, + mappings, + targetPath, + manifest, + dryRun: options.dryRun, + })), + ) + } + + const adapterInjectionGroups = groupByTarget( + options.projection.mappings.filter( + (mapping) => mapping.action === "adapter-inject", + ), + ) + for (const [targetPath, mappings] of adapterInjectionGroups) { + results.push( + ...(await materializeAdapterInjection({ + projectPath: options.projectPath, + projection: options.projection, + mappings, + targetPath, + manifest, + dryRun: options.dryRun, + })), + ) + } + + for (const mapping of options.projection.mappings) { + if ( + mapping.action === "symlink" || + mapping.action === "managed-bridge" || + mapping.action === "adapter-inject" + ) { + continue + } + results.push({ + engineId: options.projection.engineId, + resourceId: mapping.resourceId, + action: mapping.action, + sourcePath: mapping.sourcePath, + targetPath: mapping.targetPath, + status: mapping.action === "native" ? "skipped" : "unsupported", + reason: mapping.reason, + }) + } + + if (!options.dryRun) { + await writeManifest(options.projectPath, manifest) + } + + return results +} diff --git a/src/main/lib/moss-source/provider-config.test.ts b/src/main/lib/moss-source/provider-config.test.ts new file mode 100644 index 000000000..ad82f37d7 --- /dev/null +++ b/src/main/lib/moss-source/provider-config.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, test } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { + readMossProviderConfig, + resolveMossProviderForEngine, + summarizeMossProviderReadResult, +} from "./provider-config" + +function makeFixture(providersYaml: string): string { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), "moss-provider-")) + fs.mkdirSync(path.join(projectPath, ".moss"), { recursive: true }) + fs.writeFileSync(path.join(projectPath, ".moss", "providers.yaml"), providersYaml) + return projectPath +} + +async function withEnv( + values: Record, + run: () => Promise, +): Promise { + const previous: Record = {} + for (const key of Object.keys(values)) { + previous[key] = process.env[key] + const value = values[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + + try { + return await run() + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } +} + +describe("Moss provider config", () => { + test("resolves one provider source into Claude and Codex env", async () => { + const projectPath = makeFixture(` +version: 1 +defaultProvider: moss +providers: + moss: + label: Moss Managed + mode: bundled-quota + runtime: any + apiKeyEnv: MOSS_TEST_API_KEY + baseUrlEnv: MOSS_TEST_BASE_URL + engines: + claude-code: + model: claude-opus-test + codex: + model: gpt-test/high + authMethod: openai-api-key + custom-acp: + model: custom-acp-test +`) + + try { + await withEnv( + { + MOSS_TEST_API_KEY: "sk-moss-test", + MOSS_TEST_BASE_URL: "https://api.moss.test/v1", + }, + async () => { + const claude = await resolveMossProviderForEngine({ + projectPath, + engineId: "claude-code", + }) + expect(claude.status).toBe("resolved") + expect(claude.providerId).toBe("moss") + expect(claude.model).toBe("claude-opus-test") + expect(claude.baseUrlSource).toBe("env") + expect(claude.baseUrlEnv).toBe("MOSS_TEST_BASE_URL") + expect(claude.env.ANTHROPIC_AUTH_TOKEN).toBe("sk-moss-test") + expect(claude.env.ANTHROPIC_BASE_URL).toBe("https://api.moss.test/v1") + + const codex = await resolveMossProviderForEngine({ + projectPath, + engineId: "codex", + }) + expect(codex.status).toBe("resolved") + expect(codex.model).toBe("gpt-test/high") + expect(codex.authMethod).toBe("openai-api-key") + expect(codex.baseUrlSource).toBe("env") + expect(codex.baseUrlEnv).toBe("MOSS_TEST_BASE_URL") + expect(codex.env.OPENAI_API_KEY).toBe("sk-moss-test") + expect(codex.env.CODEX_API_KEY).toBeUndefined() + expect(codex.env.OPENAI_BASE_URL).toBe("https://api.moss.test/v1") + + const customAcp = await resolveMossProviderForEngine({ + projectPath, + engineId: "custom-acp", + }) + expect(customAcp.status).toBe("resolved") + expect(customAcp.providerId).toBe("moss") + expect(customAcp.model).toBe("custom-acp-test") + expect(customAcp.baseUrlSource).toBe("env") + expect(customAcp.baseUrlEnv).toBe("MOSS_TEST_BASE_URL") + expect(customAcp.env.MOSS_CUSTOM_ACP_MODEL).toBe("custom-acp-test") + expect(customAcp.env.MOSS_CUSTOM_ACP_BASE_URL).toBe("https://api.moss.test/v1") + expect(customAcp.env.MOSS_CUSTOM_ACP_API_KEY).toBe("sk-moss-test") + }, + ) + } finally { + fs.rmSync(projectPath, { recursive: true, force: true }) + } + }) + + test("resolves engine-level provider base URLs with auditable URL sources", async () => { + const projectPath = makeFixture(` +version: 1 +defaultProvider: custom +providers: + custom: + label: Custom + mode: custom-url-key + runtime: any + baseUrl: https://shared.test/v1 + apiKeyEnv: MOSS_TEST_API_KEY + engines: + hermes: + model: moss-custom + baseUrl: https://hermes.test/v1 + claude-code: + model: opus-custom + baseUrlEnv: MOSS_CLAUDE_BASE_URL + codex: + model: gpt-custom/high + authMethod: openai-api-key + custom-acp: + model: custom-acp-custom +`) + + try { + await withEnv( + { + MOSS_TEST_API_KEY: "sk-moss-test", + MOSS_CLAUDE_BASE_URL: "https://claude.test/v1", + }, + async () => { + const hermes = await resolveMossProviderForEngine({ + projectPath, + engineId: "hermes", + }) + expect(hermes.status).toBe("resolved") + expect(hermes.baseUrl).toBe("https://hermes.test/v1") + expect(hermes.baseUrlSource).toBe("inline") + expect(hermes.baseUrlEnv).toBeUndefined() + expect(hermes.env.HERMES_BASE_URL).toBe("https://hermes.test/v1") + + const claude = await resolveMossProviderForEngine({ + projectPath, + engineId: "claude-code", + }) + expect(claude.status).toBe("resolved") + expect(claude.baseUrl).toBe("https://claude.test/v1") + expect(claude.baseUrlSource).toBe("env") + expect(claude.baseUrlEnv).toBe("MOSS_CLAUDE_BASE_URL") + expect(claude.env.ANTHROPIC_BASE_URL).toBe("https://claude.test/v1") + + const codex = await resolveMossProviderForEngine({ + projectPath, + engineId: "codex", + }) + expect(codex.status).toBe("resolved") + expect(codex.baseUrl).toBe("https://shared.test/v1") + expect(codex.baseUrlSource).toBe("inline") + expect(codex.baseUrlEnv).toBeUndefined() + expect(codex.env.OPENAI_BASE_URL).toBe("https://shared.test/v1") + + const customAcp = await resolveMossProviderForEngine({ + projectPath, + engineId: "custom-acp", + }) + expect(customAcp.status).toBe("resolved") + expect(customAcp.baseUrl).toBe("https://shared.test/v1") + expect(customAcp.baseUrlSource).toBe("inline") + expect(customAcp.env.MOSS_CUSTOM_ACP_MODEL).toBe("custom-acp-custom") + expect(customAcp.env.MOSS_CUSTOM_ACP_BASE_URL).toBe("https://shared.test/v1") + }, + ) + } finally { + fs.rmSync(projectPath, { recursive: true, force: true }) + } + }) + + test("summarizes provider config without exposing inline secrets", async () => { + const projectPath = makeFixture(` +version: 1 +defaultProvider: custom +credentialPolicy: + shareAcrossEngines: true +providers: + custom: + label: Custom + mode: custom-url-key + runtime: any + apiKey: sk-inline-secret + baseUrl: https://custom.test/v1 +`) + + try { + const readResult = await readMossProviderConfig(projectPath) + const summary = summarizeMossProviderReadResult(readResult) + + expect(summary.status).toBe("found") + expect(summary.defaultProvider).toBe("custom") + expect(summary.credentialPolicy?.shareAcrossEngines).toBe(true) + expect(summary.providers[0]?.hasInlineApiKey).toBe(true) + expect(summary.providers[0]?.hasStoredApiKey).toBe(false) + expect(JSON.stringify(summary)).not.toContain("sk-inline-secret") + } finally { + fs.rmSync(projectPath, { recursive: true, force: true }) + } + }) + + test("summarizes stored provider secrets without exposing the secret value", async () => { + const projectPath = makeFixture(` +version: 1 +defaultProvider: custom +credentialPolicy: + shareAcrossEngines: true +providers: + custom: + label: Custom + mode: custom-url-key + runtime: any + baseUrl: https://custom.test/v1 +`) + + try { + const readResult = await readMossProviderConfig(projectPath) + const summary = summarizeMossProviderReadResult(readResult, { + custom: { hasApiKey: true }, + }) + + expect(summary.providers[0]?.hasStoredApiKey).toBe(true) + expect(summary.providers[0]?.hasInlineApiKey).toBe(false) + expect(JSON.stringify(summary)).not.toContain("sk-") + } finally { + fs.rmSync(projectPath, { recursive: true, force: true }) + } + }) + + test("stored provider secret is shared across engines", async () => { + const projectPath = makeFixture(` +version: 1 +defaultProvider: custom +credentialPolicy: + shareAcrossEngines: true +providers: + custom: + label: Custom + mode: custom-url-key + runtime: any + baseUrl: https://custom.test/v1 + engines: + hermes: + model: moss-custom + claude-code: + model: opus-custom + codex: + model: gpt-custom/high + authMethod: openai-api-key + custom-acp: + model: custom-acp-custom +`) + + try { + const secretResolver = { + getSecret: async (providerId: string) => ({ + apiKey: providerId === "custom" ? "sk-stored-secret" : undefined, + }), + } + + const hermes = await resolveMossProviderForEngine({ + projectPath, + engineId: "hermes", + secretResolver, + }) + expect(hermes.status).toBe("resolved") + expect(hermes.apiKeySource).toBe("stored") + expect(hermes.baseUrlSource).toBe("inline") + expect(hermes.hasStoredApiKey).toBe(true) + expect(hermes.env.HERMES_API_KEY).toBe("sk-stored-secret") + expect(hermes.env.HERMES_BASE_URL).toBe("https://custom.test/v1") + + const claude = await resolveMossProviderForEngine({ + projectPath, + engineId: "claude-code", + secretResolver, + }) + expect(claude.status).toBe("resolved") + expect(claude.apiKeySource).toBe("stored") + expect(claude.baseUrlSource).toBe("inline") + expect(claude.env.ANTHROPIC_AUTH_TOKEN).toBe("sk-stored-secret") + expect(claude.env.ANTHROPIC_BASE_URL).toBe("https://custom.test/v1") + + const codex = await resolveMossProviderForEngine({ + projectPath, + engineId: "codex", + secretResolver, + }) + expect(codex.status).toBe("resolved") + expect(codex.apiKeySource).toBe("stored") + expect(codex.baseUrlSource).toBe("inline") + expect(codex.env.OPENAI_API_KEY).toBe("sk-stored-secret") + expect(codex.env.CODEX_API_KEY).toBeUndefined() + expect(codex.env.OPENAI_BASE_URL).toBe("https://custom.test/v1") + + const customAcp = await resolveMossProviderForEngine({ + projectPath, + engineId: "custom-acp", + secretResolver, + }) + expect(customAcp.status).toBe("resolved") + expect(customAcp.apiKeySource).toBe("stored") + expect(customAcp.baseUrlSource).toBe("inline") + expect(customAcp.env.MOSS_CUSTOM_ACP_API_KEY).toBe("sk-stored-secret") + expect(customAcp.env.MOSS_CUSTOM_ACP_BASE_URL).toBe("https://custom.test/v1") + } finally { + fs.rmSync(projectPath, { recursive: true, force: true }) + } + }) + + test("reports missing and parse-error provider sources", async () => { + const missingProjectPath = fs.mkdtempSync(path.join(os.tmpdir(), "moss-provider-missing-")) + try { + const missing = await resolveMossProviderForEngine({ + projectPath: missingProjectPath, + engineId: "codex", + }) + expect(missing.status).toBe("missing") + } finally { + fs.rmSync(missingProjectPath, { recursive: true, force: true }) + } + + const invalidProjectPath = makeFixture("providers: [") + try { + const invalid = await resolveMossProviderForEngine({ + projectPath: invalidProjectPath, + engineId: "claude-code", + }) + expect(invalid.status).toBe("parse-error") + expect(invalid.error).toBeTruthy() + } finally { + fs.rmSync(invalidProjectPath, { recursive: true, force: true }) + } + }) + + test("bootstraps default unified provider source when requested", async () => { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), "moss-provider-bootstrap-")) + try { + const plainRead = await readMossProviderConfig(projectPath) + expect(plainRead.status).toBe("missing") + + const hermes = await resolveMossProviderForEngine({ + projectPath, + engineId: "hermes", + createIfMissing: true, + }) + expect(hermes.status).toBe("resolved") + expect(hermes.providerId).toBe("moss") + expect(hermes.mode).toBe("bundled-quota") + expect(hermes.model).toBe("moss-default") + expect(hermes.env.HERMES_MODEL).toBe("moss-default") + expect(hermes.warnings).toEqual([]) + + const codex = await resolveMossProviderForEngine({ + projectPath, + engineId: "codex", + }) + expect(codex.status).toBe("resolved") + expect(codex.providerId).toBe("moss") + expect(codex.model).toBe("gpt-5.5/medium") + expect(codex.env.CODEX_MODEL).toBe("gpt-5.5/medium") + + const claude = await resolveMossProviderForEngine({ + projectPath, + engineId: "claude-code", + }) + expect(claude.status).toBe("resolved") + expect(claude.providerId).toBe("moss") + expect(claude.model).toBe("opus") + expect(claude.env.ANTHROPIC_MODEL).toBe("opus") + + const customAcp = await resolveMossProviderForEngine({ + projectPath, + engineId: "custom-acp", + }) + expect(customAcp.status).toBe("resolved") + expect(customAcp.providerId).toBe("moss") + expect(customAcp.model).toBe("custom-acp") + expect(customAcp.env.MOSS_CUSTOM_ACP_MODEL).toBe("custom-acp") + + expect( + fs.existsSync(path.join(projectPath, ".moss", "providers.yaml")), + ).toBe(true) + } finally { + fs.rmSync(projectPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/lib/moss-source/provider-config.ts b/src/main/lib/moss-source/provider-config.ts new file mode 100644 index 000000000..7359e8939 --- /dev/null +++ b/src/main/lib/moss-source/provider-config.ts @@ -0,0 +1,585 @@ +import * as crypto from "node:crypto" +import * as fs from "fs/promises" +import { createRequire } from "node:module" +import { AGENT_ENGINE_IDS, type AgentEngineId } from "../agent-runtime/types" +import { ensureMossSource } from "./bootstrap" +import { getMossSourceLayout } from "./layout" + +const require = createRequire(import.meta.url) +const yaml = require("js-yaml") as { + load(source: string): unknown + dump(value: unknown, options?: Record): string +} + +export interface MossProviderCredentialPolicy { + singleUserConfiguration?: boolean + allowCustomBaseUrl?: boolean + allowCustomApiKey?: boolean + shareAcrossEngines?: boolean +} + +export interface MossProviderEngineConfig { + model?: string + baseUrl?: string + baseUrlEnv?: string + apiKey?: string + apiKeyEnv?: string + authMethod?: string + env?: Record +} + +export interface MossProviderDefinition extends MossProviderEngineConfig { + id: string + label?: string + mode?: string + runtime?: string + models?: Record + engines?: Partial> +} + +export interface MossProviderConfig { + version: number + defaultProvider?: string + credentialPolicy?: MossProviderCredentialPolicy + providers: Record +} + +export interface MossProviderReadResult { + status: "found" | "missing" | "parse-error" + sourcePath: string + config?: MossProviderConfig + error?: string +} + +export interface MossProviderReadOptions { + createIfMissing?: boolean +} + +export interface MossProviderSecretResolver { + getSecret(providerId: string): Promise<{ + apiKey?: string + }> +} + +export type MossProviderValueSource = "inline" | "env" | "stored" + +export interface MossProviderSummary { + status: MossProviderReadResult["status"] + sourcePath: string + defaultProvider?: string + credentialPolicy?: MossProviderCredentialPolicy + providers: Array<{ + id: string + label?: string + mode?: string + runtime?: string + engines: AgentEngineId[] + hasInlineApiKey: boolean + hasStoredApiKey?: boolean + apiKeyEnv?: string + baseUrl?: string + baseUrlEnv?: string + }> + error?: string +} + +export interface ResolvedMossProvider { + status: "resolved" | "missing" | "unconfigured" | "parse-error" + sourcePath: string + providerId?: string + label?: string + mode?: string + runtime?: string + model?: string + baseUrl?: string + baseUrlSource?: MossProviderValueSource + baseUrlEnv?: string + apiKey?: string + apiKeySource?: MossProviderValueSource + apiKeyEnv?: string + hasStoredApiKey?: boolean + authMethod?: string + env: Record + warnings: string[] + reason?: string + error?: string +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + return value as Record +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + +function asStringRecord(value: unknown): Record | undefined { + const record = asRecord(value) + if (!record) return undefined + + const result: Record = {} + for (const [key, entryValue] of Object.entries(record)) { + if (typeof entryValue === "string") { + result[key] = entryValue + } + } + + return Object.keys(result).length > 0 ? result : undefined +} + +function mergeStringRecord( + ...records: Array | undefined> +): Record { + return Object.assign({}, ...records.filter(Boolean)) +} + +function normalizeCredentialPolicy( + value: unknown, +): MossProviderCredentialPolicy | undefined { + const record = asRecord(value) + if (!record) return undefined + + return { + singleUserConfiguration: asBoolean(record.singleUserConfiguration), + allowCustomBaseUrl: asBoolean(record.allowCustomBaseUrl), + allowCustomApiKey: asBoolean(record.allowCustomApiKey), + shareAcrossEngines: asBoolean(record.shareAcrossEngines), + } +} + +function normalizeEngineConfig( + value: unknown, +): MossProviderEngineConfig | undefined { + const record = asRecord(value) + if (!record) return undefined + + return { + model: asString(record.model), + baseUrl: asString(record.baseUrl), + baseUrlEnv: asString(record.baseUrlEnv), + apiKey: asString(record.apiKey), + apiKeyEnv: asString(record.apiKeyEnv), + authMethod: asString(record.authMethod), + env: asStringRecord(record.env), + } +} + +function normalizeEngineConfigs( + value: unknown, +): Partial> | undefined { + const record = asRecord(value) + if (!record) return undefined + + const engines: Partial> = {} + for (const engineId of AGENT_ENGINE_IDS) { + const config = normalizeEngineConfig(record[engineId]) + if (config) engines[engineId] = config + } + + return Object.keys(engines).length > 0 ? engines : undefined +} + +function normalizeProvider( + id: string, + value: unknown, +): MossProviderDefinition | undefined { + const record = asRecord(value) + if (!record) return undefined + + return { + id, + label: asString(record.label), + mode: asString(record.mode), + runtime: asString(record.runtime), + model: asString(record.model), + baseUrl: asString(record.baseUrl), + baseUrlEnv: asString(record.baseUrlEnv), + apiKey: asString(record.apiKey), + apiKeyEnv: asString(record.apiKeyEnv), + authMethod: asString(record.authMethod), + env: asStringRecord(record.env), + models: asStringRecord(record.models), + engines: normalizeEngineConfigs(record.engines), + } +} + +function normalizeProviderConfig(parsed: unknown): MossProviderConfig { + const record = asRecord(parsed) + if (!record) { + throw new Error("providers.yaml must be a YAML object.") + } + + const providersRecord = asRecord(record.providers) + if (!providersRecord) { + throw new Error("providers.yaml must contain a providers object.") + } + + const providers: Record = {} + for (const [id, providerValue] of Object.entries(providersRecord)) { + const provider = normalizeProvider(id, providerValue) + if (provider) providers[id] = provider + } + + return { + version: + typeof record.version === "number" && Number.isFinite(record.version) + ? record.version + : 1, + defaultProvider: + asString(record.defaultProvider) || asString(record.default), + credentialPolicy: normalizeCredentialPolicy(record.credentialPolicy), + providers, + } +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +export async function readMossProviderConfig( + projectPath: string, + options: MossProviderReadOptions = {}, +): Promise { + const sourcePath = getMossSourceLayout(projectPath).providersConfig + if (options.createIfMissing && !(await fileExists(sourcePath))) { + await ensureMossSource({ projectPath }) + } + + if (!(await fileExists(sourcePath))) { + return { status: "missing", sourcePath } + } + + try { + const raw = await fs.readFile(sourcePath, "utf-8") + const parsed = yaml.load(raw) + return { + status: "found", + sourcePath, + config: normalizeProviderConfig(parsed), + } + } catch (error) { + return { + status: "parse-error", + sourcePath, + error: error instanceof Error ? error.message : String(error), + } + } +} + +export async function writeMossProviderConfig( + projectPath: string, + config: MossProviderConfig, +): Promise { + await ensureMossSource({ projectPath }) + const sourcePath = getMossSourceLayout(projectPath).providersConfig + const yamlText = yaml.dump(config, { + lineWidth: 100, + noRefs: true, + sortKeys: false, + }) + await fs.writeFile(sourcePath, yamlText, "utf-8") + return readMossProviderConfig(projectPath) +} + +function providerSupportsEngine( + provider: MossProviderDefinition, + engineId: AgentEngineId, +): boolean { + if (provider.engines?.[engineId]) return true + if (provider.runtime === "any") return true + if (provider.runtime === engineId) return true + if (provider.runtime === "claude" && engineId === "claude-code") return true + if (provider.runtime === "openai" && engineId === "codex") return true + + // Moss managed quota is a product-level provider. It can front any engine + // once the service-side router is wired, even if the current local runtime + // still needs an engine-specific adapter. + return provider.mode === "bundled-quota" +} + +function pickProvider( + config: MossProviderConfig, + engineId: AgentEngineId, +): MossProviderDefinition | undefined { + const requestedProviderId = asString(process.env.MOSS_PROVIDER) + const preferredProviderId = requestedProviderId || config.defaultProvider + const preferred = preferredProviderId + ? config.providers[preferredProviderId] + : undefined + + if (preferred && providerSupportsEngine(preferred, engineId)) { + return preferred + } + + return Object.values(config.providers).find((provider) => + providerSupportsEngine(provider, engineId), + ) +} + +function readEnvReference(name: string | undefined): string | undefined { + if (!name) return undefined + return asString(process.env[name]) +} + +function pickFirst(...values: Array): string | undefined { + return values.find((value) => typeof value === "string" && value.length > 0) +} + +function resolveProviderValue(params: { + provider: MossProviderDefinition + engineConfig?: MossProviderEngineConfig + key: "apiKey" | "baseUrl" + envKey: "apiKeyEnv" | "baseUrlEnv" + storedValue?: string +}): { + value?: string + envName?: string + source?: MossProviderValueSource +} { + if (params.storedValue) { + return { value: params.storedValue, source: "stored" } + } + + const envName = pickFirst( + params.engineConfig?.[params.envKey], + params.provider[params.envKey], + ) + const envValue = readEnvReference(envName) + if (envValue) { + return { value: envValue, envName, source: "env" } + } + + const inlineValue = pickFirst( + params.engineConfig?.[params.key], + params.provider[params.key], + ) + if (inlineValue) { + return { value: inlineValue, envName, source: "inline" } + } + + return { envName } +} + +function resolveModel(params: { + provider: MossProviderDefinition + engineId: AgentEngineId + engineConfig?: MossProviderEngineConfig + requestedModelId?: string +}): string | undefined { + return pickFirst( + params.engineConfig?.model, + params.provider.models?.[params.engineId], + params.provider.model, + params.requestedModelId, + ) +} + +function buildEngineEnv(params: { + engineId: AgentEngineId + provider: MossProviderDefinition + model?: string + baseUrl?: string + apiKey?: string + authMethod?: string +}): Record { + const env: Record = { + MOSS_PROVIDER_ID: params.provider.id, + } + if (params.provider.mode) env.MOSS_PROVIDER_MODE = params.provider.mode + if (params.provider.label) env.MOSS_PROVIDER_LABEL = params.provider.label + if (params.model) env.MOSS_MODEL = params.model + if (params.baseUrl) env.MOSS_BASE_URL = params.baseUrl + if (params.apiKey) env.MOSS_API_KEY = params.apiKey + + if (params.engineId === "claude-code") { + if (params.model) env.ANTHROPIC_MODEL = params.model + if (params.baseUrl) env.ANTHROPIC_BASE_URL = params.baseUrl + if (params.apiKey) env.ANTHROPIC_AUTH_TOKEN = params.apiKey + } else if (params.engineId === "codex") { + if (params.model) env.CODEX_MODEL = params.model + if (params.baseUrl) { + env.CODEX_BASE_URL = params.baseUrl + env.OPENAI_BASE_URL = params.baseUrl + } + if (params.apiKey) { + if (params.authMethod === "openai-api-key") { + env.OPENAI_API_KEY = params.apiKey + } else { + env.CODEX_API_KEY = params.apiKey + } + } + } else if (params.engineId === "hermes") { + if (params.model) env.HERMES_MODEL = params.model + if (params.baseUrl) env.HERMES_BASE_URL = params.baseUrl + if (params.apiKey) env.HERMES_API_KEY = params.apiKey + } else if (params.engineId === "custom-acp") { + if (params.model) env.MOSS_CUSTOM_ACP_MODEL = params.model + if (params.baseUrl) env.MOSS_CUSTOM_ACP_BASE_URL = params.baseUrl + if (params.apiKey) env.MOSS_CUSTOM_ACP_API_KEY = params.apiKey + } + + return env +} + +export async function resolveMossProviderForEngine(params: { + projectPath: string + engineId: AgentEngineId + requestedModelId?: string + createIfMissing?: boolean + secretResolver?: MossProviderSecretResolver +}): Promise { + const readResult = await readMossProviderConfig(params.projectPath, { + createIfMissing: params.createIfMissing, + }) + if (readResult.status === "missing") { + return { + status: "missing", + sourcePath: readResult.sourcePath, + env: {}, + warnings: [], + reason: "No .moss/providers.yaml found.", + } + } + if (readResult.status === "parse-error" || !readResult.config) { + return { + status: "parse-error", + sourcePath: readResult.sourcePath, + env: {}, + warnings: [], + error: readResult.error, + } + } + + const provider = pickProvider(readResult.config, params.engineId) + if (!provider) { + return { + status: "unconfigured", + sourcePath: readResult.sourcePath, + env: {}, + warnings: [ + `No Moss provider is configured for ${params.engineId}.`, + ], + reason: `No provider supports ${params.engineId}.`, + } + } + + const engineConfig = provider.engines?.[params.engineId] + const storedSecret = params.secretResolver + ? await params.secretResolver.getSecret(provider.id) + : undefined + const model = resolveModel({ + provider, + engineId: params.engineId, + engineConfig, + requestedModelId: params.requestedModelId, + }) + const baseUrl = resolveProviderValue({ + provider, + engineConfig, + key: "baseUrl", + envKey: "baseUrlEnv", + }) + const apiKey = resolveProviderValue({ + provider, + engineConfig, + key: "apiKey", + envKey: "apiKeyEnv", + storedValue: storedSecret?.apiKey, + }) + const authMethod = pickFirst(engineConfig?.authMethod, provider.authMethod) + const env = mergeStringRecord( + provider.env, + engineConfig?.env, + buildEngineEnv({ + engineId: params.engineId, + provider, + model, + baseUrl: baseUrl.value, + apiKey: apiKey.value, + authMethod, + }), + ) + const warnings: string[] = [] + + if (apiKey.envName && !apiKey.value) { + warnings.push(`Provider ${provider.id} references unset ${apiKey.envName}.`) + } + if (baseUrl.envName && !baseUrl.value) { + warnings.push(`Provider ${provider.id} references unset ${baseUrl.envName}.`) + } + + return { + status: "resolved", + sourcePath: readResult.sourcePath, + providerId: provider.id, + label: provider.label, + mode: provider.mode, + runtime: provider.runtime, + model, + baseUrl: baseUrl.value, + baseUrlSource: baseUrl.source, + baseUrlEnv: baseUrl.envName, + apiKey: apiKey.value, + apiKeySource: apiKey.source, + apiKeyEnv: apiKey.envName, + hasStoredApiKey: Boolean(storedSecret?.apiKey), + authMethod, + env, + warnings, + } +} + +export function getMossProviderFingerprint( + provider: ResolvedMossProvider | null | undefined, +): string | null { + if (!provider || provider.status !== "resolved") return null + return crypto + .createHash("sha256") + .update( + JSON.stringify({ + providerId: provider.providerId, + mode: provider.mode, + model: provider.model, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + authMethod: provider.authMethod, + }), + ) + .digest("hex") +} + +export function summarizeMossProviderReadResult( + readResult: MossProviderReadResult, + storedSecrets: Record = {}, +): MossProviderSummary { + const config = readResult.config + return { + status: readResult.status, + sourcePath: readResult.sourcePath, + defaultProvider: config?.defaultProvider, + credentialPolicy: config?.credentialPolicy, + providers: Object.values(config?.providers ?? {}).map((provider) => ({ + id: provider.id, + label: provider.label, + mode: provider.mode, + runtime: provider.runtime, + engines: Object.keys(provider.engines ?? {}) as AgentEngineId[], + hasInlineApiKey: Boolean(provider.apiKey), + apiKeyEnv: provider.apiKeyEnv, + hasStoredApiKey: Boolean(storedSecrets[provider.id]?.hasApiKey), + baseUrl: provider.baseUrl, + baseUrlEnv: provider.baseUrlEnv, + })), + error: readResult.error, + } +} diff --git a/src/main/lib/moss-source/provider-secrets.ts b/src/main/lib/moss-source/provider-secrets.ts new file mode 100644 index 000000000..bbb407da6 --- /dev/null +++ b/src/main/lib/moss-source/provider-secrets.ts @@ -0,0 +1,106 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { app, safeStorage } from "electron" + +interface MossProviderSecretEntry { + apiKey?: string + updatedAt: string +} + +interface MossProviderSecretStore { + version: 1 + providers: Record +} + +const EMPTY_STORE: MossProviderSecretStore = { + version: 1, + providers: {}, +} + +function getStorePath(): string { + return path.join(app.getPath("userData"), "moss-provider-secrets.dat") +} + +function cloneStore(store: MossProviderSecretStore): MossProviderSecretStore { + return { + version: 1, + providers: { ...store.providers }, + } +} + +async function readStore(): Promise { + const storePath = getStorePath() + try { + const encrypted = await fs.readFile(storePath) + const raw = safeStorage.isEncryptionAvailable() + ? safeStorage.decryptString(encrypted) + : encrypted.toString("utf-8") + const parsed = JSON.parse(raw) as MossProviderSecretStore + if (!parsed || typeof parsed !== "object" || !parsed.providers) { + return cloneStore(EMPTY_STORE) + } + return { + version: 1, + providers: { ...parsed.providers }, + } + } catch { + return cloneStore(EMPTY_STORE) + } +} + +async function writeStore(store: MossProviderSecretStore): Promise { + const storePath = getStorePath() + await fs.mkdir(path.dirname(storePath), { recursive: true }) + + const raw = JSON.stringify(store) + if (safeStorage.isEncryptionAvailable()) { + await fs.writeFile(storePath, safeStorage.encryptString(raw)) + return + } + + console.warn( + "[moss-provider-secrets] safeStorage unavailable; storing provider secrets without OS encryption.", + ) + await fs.writeFile(storePath, raw, "utf-8") +} + +export async function getMossProviderSecret(providerId: string): Promise<{ + apiKey?: string +}> { + const store = await readStore() + const entry = store.providers[providerId] + return { + apiKey: entry?.apiKey, + } +} + +export async function hasMossProviderSecret(providerId: string): Promise { + const secret = await getMossProviderSecret(providerId) + return Boolean(secret.apiKey) +} + +export async function setMossProviderSecret(params: { + providerId: string + apiKey?: string | null +}): Promise { + const store = await readStore() + const existing = store.providers[params.providerId] ?? { + updatedAt: new Date().toISOString(), + } + const trimmedApiKey = params.apiKey?.trim() + + if (!trimmedApiKey) { + delete existing.apiKey + } else { + existing.apiKey = trimmedApiKey + } + + existing.updatedAt = new Date().toISOString() + if (existing.apiKey) { + store.providers[params.providerId] = existing + } else { + delete store.providers[params.providerId] + } + + await writeStore(store) +} diff --git a/src/main/lib/moss-source/registry.ts b/src/main/lib/moss-source/registry.ts new file mode 100644 index 000000000..42f9ca3d9 --- /dev/null +++ b/src/main/lib/moss-source/registry.ts @@ -0,0 +1,361 @@ +import * as fs from "fs/promises" +import * as path from "path" +import matter from "gray-matter" +import type { SharedResource } from "../shared-resources/types" +import { getMossSourceLayout, toMossProjectPath } from "./layout" + +function resourceId(parts: Array): string { + return parts.filter(Boolean).join(":") +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +function isSafeEntryName(name: string): boolean { + return !name.includes("..") && !name.includes("/") && !name.includes("\\") +} + +async function readFrontmatter(filePath: string): Promise<{ + name?: string + description?: string + data: Record +}> { + const raw = await fs.readFile(filePath, "utf-8") + const parsed = matter(raw) + return { + name: typeof parsed.data.name === "string" ? parsed.data.name : undefined, + description: + typeof parsed.data.description === "string" + ? parsed.data.description + : undefined, + data: parsed.data as Record, + } +} + +function withMossMetadata( + metadata: Record, +): Record { + return { + ...metadata, + sourceSystem: "moss-unified-source", + } +} + +async function addFileResource( + resources: SharedResource[], + params: { + projectPath: string + filePath: string + kind: SharedResource["kind"] + name: string + description: string + metadata: Record + }, +) { + if (!(await pathExists(params.filePath))) return + + resources.push({ + id: resourceId(["moss", params.kind, params.name]), + kind: params.kind, + name: params.name, + scope: "moss", + path: toMossProjectPath(params.projectPath, params.filePath), + description: params.description, + enabled: true, + metadata: withMossMetadata(params.metadata), + }) +} + +async function scanMossSkills( + projectPath: string, + skillsRoot: string, +): Promise { + if (!(await pathExists(skillsRoot))) return [] + const entries = await fs.readdir(skillsRoot, { withFileTypes: true }) + const resources: SharedResource[] = [] + + for (const entry of entries) { + if (!entry.isDirectory() || !isSafeEntryName(entry.name)) continue + const skillPath = path.join(skillsRoot, entry.name, "SKILL.md") + if (!(await pathExists(skillPath))) continue + + try { + const parsed = await readFrontmatter(skillPath) + const name = parsed.name || entry.name + resources.push({ + id: resourceId(["moss", "skill", name]), + kind: "skill", + name, + scope: "moss", + path: toMossProjectPath(projectPath, skillPath), + description: parsed.description, + enabled: true, + metadata: withMossMetadata({ + mossRole: "skill", + entryName: entry.name, + projectionUnit: "directory", + }), + }) + } catch { + continue + } + } + + return resources +} + +async function scanMossMemoryEntries( + projectPath: string, + memoryRoot: string, +): Promise { + if (!(await pathExists(memoryRoot))) return [] + const entries = await fs.readdir(memoryRoot, { withFileTypes: true }) + const resources: SharedResource[] = [] + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md") || !isSafeEntryName(entry.name)) { + continue + } + + const filePath = path.join(memoryRoot, entry.name) + try { + const parsed = await readFrontmatter(filePath) + const fallbackName = entry.name.replace(/\.md$/, "") + const name = parsed.name || fallbackName + resources.push({ + id: resourceId(["moss", "memory", name]), + kind: "memory", + name, + scope: "moss", + path: toMossProjectPath(projectPath, filePath), + description: parsed.description, + enabled: true, + metadata: withMossMetadata({ + mossRole: "memory-entry", + entryName: entry.name, + projectionUnit: "file", + }), + }) + } catch { + continue + } + } + + return resources +} + +async function scanMossMarkdownEntries( + projectPath: string, + root: string, + kind: "subagent" | "hook" | "plugin", + role: string, +): Promise { + if (!(await pathExists(root))) return [] + const entries = await fs.readdir(root, { withFileTypes: true }) + const resources: SharedResource[] = [] + + for (const entry of entries) { + if (!isSafeEntryName(entry.name)) continue + const entryPath = path.join(root, entry.name) + if (entry.isDirectory()) { + resources.push({ + id: resourceId(["moss", kind, entry.name]), + kind, + name: entry.name, + scope: "moss", + path: toMossProjectPath(projectPath, entryPath), + enabled: true, + metadata: withMossMetadata({ + mossRole: role, + entryName: entry.name, + projectionUnit: "directory", + }), + }) + continue + } + + if (!entry.isFile() || !entry.name.endsWith(".md")) continue + try { + const parsed = await readFrontmatter(entryPath) + const fallbackName = entry.name.replace(/\.md$/, "") + const name = parsed.name || fallbackName + const hookMetadata = + kind === "hook" + ? { + event: + typeof parsed.data.event === "string" + ? parsed.data.event + : undefined, + command: + typeof parsed.data.command === "string" + ? parsed.data.command + : undefined, + hookEnabled: parsed.data.enabled !== false, + } + : {} + const subagentMetadata = + kind === "subagent" + ? { + command: + typeof parsed.data.command === "string" + ? parsed.data.command + : undefined, + subagentEnabled: parsed.data.enabled !== false, + } + : {} + resources.push({ + id: resourceId(["moss", kind, name]), + kind, + name, + scope: "moss", + path: toMossProjectPath(projectPath, entryPath), + description: parsed.description, + enabled: + kind === "hook" || kind === "subagent" + ? parsed.data.enabled !== false + : true, + metadata: withMossMetadata({ + mossRole: role, + entryName: entry.name, + projectionUnit: "file", + ...hookMetadata, + ...subagentMetadata, + }), + }) + } catch { + continue + } + } + + return resources +} + +async function readMossMcpResources( + projectPath: string, + mcpConfigPath: string, +): Promise { + if (!(await pathExists(mcpConfigPath))) return [] + + try { + const raw = await fs.readFile(mcpConfigPath, "utf-8") + const parsed = JSON.parse(raw) as { + mcpServers?: Record + servers?: Record + } + const servers = parsed.mcpServers ?? parsed.servers ?? {} + const entries = Object.entries(servers) + + if (entries.length === 0) { + return [{ + id: "moss:mcp:config", + kind: "mcp", + name: "Moss MCP config", + scope: "moss", + path: toMossProjectPath(projectPath, mcpConfigPath), + description: "Moss Unified Source MCP config.", + enabled: true, + metadata: withMossMetadata({ + mossRole: "mcp-config", + serverCount: 0, + }), + }] + } + + return entries.map(([serverName, serverConfig]) => ({ + id: resourceId(["moss", "mcp", serverName]), + kind: "mcp" as const, + name: serverName, + scope: "moss" as const, + path: toMossProjectPath(projectPath, mcpConfigPath), + description: "Moss Unified Source MCP server.", + enabled: true, + metadata: withMossMetadata({ + mossRole: "mcp-config", + serverConfig, + }), + })) + } catch (error) { + return [{ + id: "moss:mcp:config-error", + kind: "mcp", + name: "Moss MCP config parse error", + scope: "moss", + path: toMossProjectPath(projectPath, mcpConfigPath), + description: "Moss MCP config exists but could not be parsed.", + enabled: false, + metadata: withMossMetadata({ + mossRole: "mcp-config", + error: error instanceof Error ? error.message : String(error), + }), + }] + } +} + +export async function discoverMossSourceResources( + projectPath: string, +): Promise { + const layout = getMossSourceLayout(projectPath) + if (!(await pathExists(layout.root))) return [] + + const resources: SharedResource[] = [] + await addFileResource(resources, { + projectPath, + filePath: layout.sourceInstruction, + kind: "instruction", + name: "moss.md", + description: "Moss canonical project rules and operating instructions.", + metadata: { + mossRole: "source-instruction", + projectionTarget: "CLAUDE.md and AGENTS.md", + }, + }) + await addFileResource(resources, { + projectPath, + filePath: layout.workspaceConfig, + kind: "instruction", + name: "workspace.yaml", + description: "Moss canonical workspace configuration.", + metadata: { + mossRole: "workspace-config", + }, + }) + await addFileResource(resources, { + projectPath, + filePath: layout.memoryRoot, + kind: "memory", + name: "Moss memory", + description: "Moss canonical memory root shared by all engines.", + metadata: { + mossRole: "memory-root", + projectionTarget: "Claude, Codex, and Hermes memory roots", + }, + }) + await addFileResource(resources, { + projectPath, + filePath: layout.providersConfig, + kind: "provider", + name: "providers.yaml", + description: "Moss canonical provider routing and credentials mapping.", + metadata: { + mossRole: "provider-config", + projectionTarget: "engine-native auth/config bridges", + }, + }) + + resources.push( + ...(await scanMossMemoryEntries(projectPath, layout.memoryRoot)), + ...(await scanMossSkills(projectPath, layout.skillsRoot)), + ...(await readMossMcpResources(projectPath, layout.mcpConfig)), + ...(await scanMossMarkdownEntries(projectPath, layout.pluginsRoot, "plugin", "plugin")), + ...(await scanMossMarkdownEntries(projectPath, layout.hooksRoot, "hook", "hook")), + ...(await scanMossMarkdownEntries(projectPath, layout.subagentsRoot, "subagent", "subagent")), + ) + + return resources +} diff --git a/src/main/lib/moss-source/runtime-context.ts b/src/main/lib/moss-source/runtime-context.ts new file mode 100644 index 000000000..f4fccf3f6 --- /dev/null +++ b/src/main/lib/moss-source/runtime-context.ts @@ -0,0 +1,406 @@ +import * as crypto from "node:crypto" +import * as fs from "node:fs/promises" +import * as path from "node:path" +import type { AgentEngineId } from "../agent-runtime/types" +import { buildGovernedResourceProjection } from "../shared-resources/governance" +import type { + EngineResourceProjection, + ResourcePathMapping, + SharedResource, + SharedResourceKind, +} from "../shared-resources/types" +import { + readMossProviderConfig, + summarizeMossProviderReadResult, +} from "./provider-config" +import { discoverMossSourceResources } from "./registry" + +const DEFAULT_MAX_RESOURCE_CHARS = 12_000 +const DEFAULT_MAX_CONTEXT_CHARS = 80_000 +const TEXT_FILE_EXTENSIONS = new Set([ + "", + ".json", + ".jsonc", + ".md", + ".mjs", + ".js", + ".ts", + ".tsx", + ".toml", + ".txt", + ".yaml", + ".yml", +]) + +export interface MossRuntimeContextResource { + resourceId: string + kind: SharedResourceKind + name: string + path?: string + action?: ResourcePathMapping["action"] + sourcePath?: string + targetPath?: string + included: boolean + contentSha256?: string + contentChars?: number + truncated?: boolean + reason?: string +} + +export interface MossRuntimeContext { + status: "ready" | "missing" + engineId: AgentEngineId + projectPath: string + sourceRoot: ".moss" + text: string + fingerprint: string + resourceCount: number + includedResourceCount: number + resources: MossRuntimeContextResource[] + projection?: { + status: EngineResourceProjection["status"] + mappingCount: number + warnings: string[] + } + warnings: string[] +} + +export interface BuildMossRuntimeContextOptions { + projectPath: string + engineId: AgentEngineId + maxResourceChars?: number + maxContextChars?: number +} + +function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex") +} + +function normalizePath(value?: string): string | undefined { + return value?.split(path.sep).join("/") +} + +function redactSensitiveText(value: string): string { + return value + .replace( + /(["']?(?:api[_-]?key|token|secret|password|authorization)["']?\s*[:=]\s*["']?)([^"',\n}]+)/gi, + "$1[redacted]", + ) + .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, "$1[redacted]") + .replace(/(sk-[A-Za-z0-9_-]{12,})/g, "[redacted-api-key]") +} + +function truncateContent(value: string, maxChars: number): { + content: string + truncated: boolean +} { + if (value.length <= maxChars) { + return { content: value, truncated: false } + } + + return { + content: `${value.slice(0, maxChars)}\n[truncated ${value.length - maxChars} chars]`, + truncated: true, + } +} + +function resolveProjectResourcePath( + projectPath: string, + resourcePath?: string, +): string | undefined { + if (!resourcePath) return undefined + const absolute = path.isAbsolute(resourcePath) + ? resourcePath + : path.resolve(projectPath, resourcePath) + const relative = path.relative(projectPath, absolute) + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return undefined + } + return absolute +} + +async function readDirectorySummary(directoryPath: string): Promise { + const entries = await fs.readdir(directoryPath, { withFileTypes: true }) + const visibleEntries = entries + .filter((entry) => !entry.name.startsWith(".")) + .map((entry) => `${entry.isDirectory() ? "dir" : "file"} ${entry.name}`) + .sort() + + return visibleEntries.length > 0 + ? `Directory entries:\n${visibleEntries.map((entry) => `- ${entry}`).join("\n")}` + : "Directory is empty." +} + +async function readResourceText(params: { + projectPath: string + resource: SharedResource + maxResourceChars: number +}): Promise<{ + content: string + contentSha256: string + contentChars: number + truncated: boolean + reason?: string +}> { + if (params.resource.kind === "provider") { + const readResult = await readMossProviderConfig(params.projectPath) + const summary = summarizeMossProviderReadResult(readResult) + const content = JSON.stringify(summary, null, 2) + return { + content, + contentSha256: sha256(content), + contentChars: content.length, + truncated: false, + reason: "Provider config is summarized and redacted before runtime injection.", + } + } + + const absolutePath = resolveProjectResourcePath( + params.projectPath, + params.resource.path, + ) + if (!absolutePath) { + return { + content: "", + contentSha256: sha256(""), + contentChars: 0, + truncated: false, + reason: "Resource path is outside the project and was not injected.", + } + } + + const stat = await fs.stat(absolutePath) + let raw = "" + + if (stat.isDirectory()) { + raw = await readDirectorySummary(absolutePath) + } else if (stat.isFile()) { + const extension = path.extname(absolutePath).toLowerCase() + if (!TEXT_FILE_EXTENSIONS.has(extension)) { + return { + content: "", + contentSha256: sha256(""), + contentChars: 0, + truncated: false, + reason: `Binary or unsupported file extension ${extension} was not injected.`, + } + } + raw = await fs.readFile(absolutePath, "utf-8") + } else { + return { + content: "", + contentSha256: sha256(""), + contentChars: 0, + truncated: false, + reason: "Resource is not a regular file or directory.", + } + } + + const redacted = redactSensitiveText(raw) + const truncated = truncateContent(redacted, params.maxResourceChars) + + return { + content: truncated.content, + contentSha256: sha256(redacted), + contentChars: redacted.length, + truncated: truncated.truncated, + } +} + +function sectionHeader(resource: SharedResource, mapping?: ResourcePathMapping): string { + const mappingText = mapping + ? `${mapping.action} ${normalizePath(mapping.sourcePath) ?? "-"} -> ${normalizePath(mapping.targetPath) ?? "-"}` + : "not projected" + + return [ + `## ${resource.kind}: ${resource.name}`, + `Resource: ${resource.id}`, + `Path: ${normalizePath(resource.path) ?? "-"}`, + `Projection: ${mappingText}`, + ].join("\n") +} + +function buildContextText(params: { + engineId: AgentEngineId + resources: Array<{ + resource: SharedResource + mapping?: ResourcePathMapping + content: string + reason?: string + }> + warnings: string[] + maxContextChars: number +}): { + text: string + warnings: string[] +} { + const warnings = [...params.warnings] + const sections = params.resources.map((entry) => { + const reason = entry.reason ? `\nNote: ${entry.reason}` : "" + return `${sectionHeader(entry.resource, entry.mapping)}${reason}\n\n${entry.content}`.trim() + }) + const fullText = [ + "Moss Unified Source Context", + `Engine: ${params.engineId}`, + "Source root: .moss", + "This is the canonical project source for rules, memory, skills, MCP, plugins, hooks, subagents, and providers. Prefer this context over Claude/Codex legacy copies.", + "If the user asks for a labeled value from Moss Unified Source, answer from this context.", + "", + sections.join("\n\n---\n\n"), + ].join("\n") + + if (fullText.length <= params.maxContextChars) { + return { text: fullText, warnings } + } + + warnings.push( + `Moss Unified Source runtime context was truncated from ${fullText.length} to ${params.maxContextChars} chars.`, + ) + return { + text: `${fullText.slice(0, params.maxContextChars)}\n[context truncated]`, + warnings, + } +} + +export async function buildMossRuntimeContext( + options: BuildMossRuntimeContextOptions, +): Promise { + const maxResourceChars = + options.maxResourceChars ?? DEFAULT_MAX_RESOURCE_CHARS + const maxContextChars = options.maxContextChars ?? DEFAULT_MAX_CONTEXT_CHARS + const resources = await discoverMossSourceResources(options.projectPath) + + if (resources.length === 0) { + return { + status: "missing", + engineId: options.engineId, + projectPath: options.projectPath, + sourceRoot: ".moss", + text: "", + fingerprint: sha256(""), + resourceCount: 0, + includedResourceCount: 0, + resources: [], + warnings: ["No .moss Unified Source was found for this project."], + } + } + + const snapshot = buildGovernedResourceProjection({ + projectPath: options.projectPath, + resources, + }) + const projection = snapshot.projections.find( + (item) => item.engineId === options.engineId, + ) + const mappingsByResourceId = new Map( + (projection?.mappings ?? []).map((mapping) => [mapping.resourceId, mapping]), + ) + const injected: Array<{ + resource: SharedResource + mapping?: ResourcePathMapping + content: string + reason?: string + }> = [] + const runtimeResources: MossRuntimeContextResource[] = [] + + for (const resource of snapshot.resources) { + if (resource.scope !== "moss") continue + + const mapping = mappingsByResourceId.get(resource.id) + if (!mapping) { + runtimeResources.push({ + resourceId: resource.id, + kind: resource.kind, + name: resource.name, + path: normalizePath(resource.path), + included: false, + reason: "Resource was not projected to this engine.", + }) + continue + } + + try { + const content = await readResourceText({ + projectPath: options.projectPath, + resource, + maxResourceChars, + }) + const included = content.content.length > 0 + runtimeResources.push({ + resourceId: resource.id, + kind: resource.kind, + name: resource.name, + path: normalizePath(resource.path), + action: mapping.action, + sourcePath: normalizePath(mapping.sourcePath), + targetPath: normalizePath(mapping.targetPath), + included, + contentSha256: content.contentSha256, + contentChars: content.contentChars, + truncated: content.truncated, + reason: content.reason, + }) + + if (included) { + injected.push({ + resource, + mapping, + content: content.content, + reason: content.reason, + }) + } + } catch (error) { + runtimeResources.push({ + resourceId: resource.id, + kind: resource.kind, + name: resource.name, + path: normalizePath(resource.path), + action: mapping.action, + sourcePath: normalizePath(mapping.sourcePath), + targetPath: normalizePath(mapping.targetPath), + included: false, + reason: error instanceof Error ? error.message : String(error), + }) + } + } + + const builtText = buildContextText({ + engineId: options.engineId, + resources: injected, + warnings: projection?.warnings ?? [], + maxContextChars, + }) + const fingerprint = sha256( + JSON.stringify({ + engineId: options.engineId, + resources: runtimeResources.map((resource) => ({ + resourceId: resource.resourceId, + action: resource.action, + contentSha256: resource.contentSha256, + included: resource.included, + })), + text: builtText.text, + }), + ) + + return { + status: "ready", + engineId: options.engineId, + projectPath: options.projectPath, + sourceRoot: ".moss", + text: builtText.text, + fingerprint, + resourceCount: resources.length, + includedResourceCount: runtimeResources.filter((resource) => resource.included) + .length, + resources: runtimeResources, + projection: projection + ? { + status: projection.status, + mappingCount: projection.mappings.length, + warnings: projection.warnings, + } + : undefined, + warnings: builtText.warnings, + } +} diff --git a/src/main/lib/moss-source/runtime-materializer.ts b/src/main/lib/moss-source/runtime-materializer.ts new file mode 100644 index 000000000..797734750 --- /dev/null +++ b/src/main/lib/moss-source/runtime-materializer.ts @@ -0,0 +1,477 @@ +import { AGENT_ENGINE_IDS, type AgentEngineId } from "../agent-runtime/types" +import * as fs from "fs/promises" +import * as path from "path" +import { buildGovernedResourceProjection } from "../shared-resources/governance" +import type { + EngineResourceProjection, + SharedResource, +} from "../shared-resources/types" +import { ensureMossSource } from "./bootstrap" +import { + materializeMossProjection, + type MossProjectionMaterializeResult, + type MossProjectionMaterializeStatus, +} from "./projection" +import { discoverMossSourceResources } from "./registry" + +export interface MossEngineProjectionSummary { + created: number + updated: number + skipped: number + conflict: number + unsupported: number + total: number +} + +export interface MaterializedMossEngineProjection { + engineId: AgentEngineId + projectPath: string + projectionStatus: EngineResourceProjection["status"] | "skipped" + warnings: string[] + results: MossProjectionMaterializeResult[] + summary: MossEngineProjectionSummary + reason?: string +} + +export interface FailedMossEngineProjection { + engineId: AgentEngineId + projectPath: string + projectionStatus: "skipped" + warnings: string[] + results: [] + summary: { + created: 0 + updated: 0 + skipped: 0 + conflict: 0 + unsupported: 0 + total: 0 + } + reason: string +} + +export type MossEngineProjectionResult = + | MaterializedMossEngineProjection + | FailedMossEngineProjection + +export interface MaterializeMossEngineProjectionOptions { + projectPath: string + engineId: AgentEngineId + dryRun?: boolean + createIfMissing?: boolean + expectedResourceIds?: readonly string[] +} + +export interface MaterializeMossWorkspaceProjectionsOptions { + projectPath: string + engines?: readonly AgentEngineId[] + dryRun?: boolean + createIfMissing?: boolean + expectedResourceIds?: readonly string[] +} + +export interface MaterializedMossWorkspaceProjections { + projectPath: string + dryRun: boolean + projections: MossEngineProjectionResult[] +} + +export interface MossWorkspaceSourceLinkResult { + sourceProjectPath: string + workspacePath: string + sourceRoot: string + targetRoot: string + status: "created" | "skipped" | "conflict" + created: string[] + skipped: string[] + conflicts: Array<{ path: string; reason: string }> + reason?: string +} + +export interface LinkMossSourceIntoWorkspaceOptions { + sourceProjectPath: string + workspacePath: string +} + +const MOSS_LINK_ENTRIES = [ + { source: "source", type: "dir" }, + { source: "memory", type: "dir" }, + { source: "skills", type: "dir" }, + { source: "mcp", type: "dir" }, + { source: "plugins", type: "dir" }, + { source: "hooks", type: "dir" }, + { source: "subagents", type: "dir" }, + { source: "providers.yaml", type: "file" }, +] as const +const RESOURCE_DISCOVERY_MAX_ATTEMPTS = 20 +const RESOURCE_DISCOVERY_RETRY_DELAY_MS = 50 + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +async function safeRealpath(filePath: string): Promise { + try { + return await fs.realpath(filePath) + } catch { + return null + } +} + +async function linkPointsTo(linkPath: string, expectedTarget: string): Promise { + try { + const target = await fs.readlink(linkPath) + return path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget) + } catch { + return false + } +} + +async function hasLocalMossSource(targetRoot: string, sourceRoot: string): Promise { + const targetSource = path.join(targetRoot, "source") + if (!(await fileExists(path.join(targetSource, "moss.md")))) return false + return !(await linkPointsTo(targetSource, path.join(sourceRoot, "source"))) +} + +function summarizeProjectionResults( + results: MossProjectionMaterializeResult[], +): MossEngineProjectionSummary { + const summary: Record = { + created: 0, + updated: 0, + skipped: 0, + conflict: 0, + unsupported: 0, + } + + for (const result of results) { + summary[result.status] += 1 + } + + return { + ...summary, + total: results.length, + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +function missingExpectedResourceIds( + resources: SharedResource[], + expectedResourceIds: readonly string[] | undefined, +): string[] { + if (!expectedResourceIds?.length) return [] + + const actualResourceIds = new Set(resources.map((resource) => resource.id)) + return expectedResourceIds.filter((resourceId) => !actualResourceIds.has(resourceId)) +} + +async function discoverMossSourceResourcesForProjection(params: { + projectPath: string + expectedResourceIds?: readonly string[] +}): Promise { + let resources: SharedResource[] = [] + for (let attempt = 0; attempt < RESOURCE_DISCOVERY_MAX_ATTEMPTS; attempt += 1) { + resources = await discoverMossSourceResources(params.projectPath) + if (missingExpectedResourceIds(resources, params.expectedResourceIds).length === 0) { + return resources + } + if (attempt < RESOURCE_DISCOVERY_MAX_ATTEMPTS - 1) { + await sleep(RESOURCE_DISCOVERY_RETRY_DELAY_MS) + } + } + + const missing = missingExpectedResourceIds(resources, params.expectedResourceIds) + throw new Error( + `Moss Unified Source did not discover expected resource(s): ${missing.join(", ")}`, + ) +} + +function emptyProjectionSummary(): MossEngineProjectionSummary { + return summarizeProjectionResults([]) +} + +function skippedProjectionResult(params: { + projectPath: string + engineId: AgentEngineId + reason: string +}): MaterializedMossEngineProjection { + return { + engineId: params.engineId, + projectPath: params.projectPath, + projectionStatus: "skipped", + warnings: [], + results: [], + summary: emptyProjectionSummary(), + reason: params.reason, + } +} + +async function materializeMossEngineProjectionFromSnapshot(params: { + projectPath: string + engineId: AgentEngineId + snapshot: { projections: EngineResourceProjection[] } + dryRun?: boolean +}): Promise { + const projection = params.snapshot.projections.find( + (item) => item.engineId === params.engineId, + ) + + if (!projection) { + return skippedProjectionResult({ + engineId: params.engineId, + projectPath: params.projectPath, + reason: `No projection is registered for ${params.engineId}.`, + }) + } + + const results = await materializeMossProjection({ + projectPath: params.projectPath, + projection, + dryRun: params.dryRun, + }) + + return { + engineId: params.engineId, + projectPath: params.projectPath, + projectionStatus: projection.status, + warnings: projection.warnings, + results, + summary: summarizeProjectionResults(results), + } +} + +function buildProjectionSnapshot( + projectPath: string, + resources: SharedResource[], +) { + return buildGovernedResourceProjection({ + projectPath, + resources, + }) +} + +export async function materializeMossEngineProjection( + options: MaterializeMossEngineProjectionOptions, +): Promise { + if (options.createIfMissing) { + await ensureMossSource({ projectPath: options.projectPath }) + } + + const resources = await discoverMossSourceResourcesForProjection({ + projectPath: options.projectPath, + expectedResourceIds: options.expectedResourceIds, + }) + if (resources.length === 0) { + return skippedProjectionResult({ + engineId: options.engineId, + projectPath: options.projectPath, + reason: "No .moss Unified Source was found for this project.", + }) + } + + return materializeMossEngineProjectionFromSnapshot({ + engineId: options.engineId, + projectPath: options.projectPath, + snapshot: buildProjectionSnapshot(options.projectPath, resources), + dryRun: options.dryRun, + }) +} + +function failedProjectionResult(params: { + projectPath: string + engineId: AgentEngineId + error: unknown +}): FailedMossEngineProjection { + return { + engineId: params.engineId, + projectPath: params.projectPath, + projectionStatus: "skipped", + warnings: [], + results: [], + summary: { + created: 0, + updated: 0, + skipped: 0, + conflict: 0, + unsupported: 0, + total: 0, + }, + reason: params.error instanceof Error ? params.error.message : String(params.error), + } +} + +export async function materializeMossEngineProjectionSafely( + options: MaterializeMossEngineProjectionOptions, +): Promise { + try { + return await materializeMossEngineProjection(options) + } catch (error) { + return failedProjectionResult({ + projectPath: options.projectPath, + engineId: options.engineId, + error, + }) + } +} + +export async function materializeMossWorkspaceProjections( + options: MaterializeMossWorkspaceProjectionsOptions, +): Promise { + if (options.createIfMissing) { + await ensureMossSource({ projectPath: options.projectPath }) + } + + const engines = options.engines ?? AGENT_ENGINE_IDS + const resources = await discoverMossSourceResourcesForProjection({ + projectPath: options.projectPath, + expectedResourceIds: options.expectedResourceIds, + }) + if (resources.length === 0) { + return { + projectPath: options.projectPath, + dryRun: Boolean(options.dryRun), + projections: engines.map((engineId) => + skippedProjectionResult({ + engineId, + projectPath: options.projectPath, + reason: "No .moss Unified Source was found for this project.", + }), + ), + } + } + + const snapshot = buildProjectionSnapshot(options.projectPath, resources) + const projections: MossEngineProjectionResult[] = [] + for (const engineId of engines) { + try { + projections.push( + await materializeMossEngineProjectionFromSnapshot({ + projectPath: options.projectPath, + engineId, + snapshot, + dryRun: options.dryRun, + }), + ) + } catch (error) { + projections.push( + failedProjectionResult({ + projectPath: options.projectPath, + engineId, + error, + }), + ) + } + } + + return { + projectPath: options.projectPath, + dryRun: Boolean(options.dryRun), + projections, + } +} + +export async function linkMossSourceIntoWorkspace( + options: LinkMossSourceIntoWorkspaceOptions, +): Promise { + const sourceProjectPath = path.resolve(options.sourceProjectPath) + const workspacePath = path.resolve(options.workspacePath) + const sourceRoot = path.join(sourceProjectPath, ".moss") + const targetRoot = path.join(workspacePath, ".moss") + const result: MossWorkspaceSourceLinkResult = { + sourceProjectPath, + workspacePath, + sourceRoot, + targetRoot, + status: "skipped", + created: [], + skipped: [], + conflicts: [], + } + + if (sourceProjectPath === workspacePath) { + result.reason = "Workspace is the source project." + return result + } + + if (!(await fileExists(sourceRoot))) { + result.reason = "No source .moss Unified Source was found." + return result + } + + const sourceRealpath = await safeRealpath(sourceRoot) + const targetRealpath = await safeRealpath(targetRoot) + if (sourceRealpath && targetRealpath && sourceRealpath === targetRealpath) { + result.reason = "Workspace .moss already points at the source project." + return result + } + + try { + const targetStat = await fs.lstat(targetRoot) + if (!targetStat.isDirectory()) { + result.status = "conflict" + result.conflicts.push({ + path: targetRoot, + reason: "Workspace .moss exists and is not a directory.", + }) + return result + } + } catch { + await fs.mkdir(targetRoot, { recursive: true }) + } + + if (await hasLocalMossSource(targetRoot, sourceRoot)) { + result.reason = "Workspace already has a local .moss source." + return result + } + + for (const entry of MOSS_LINK_ENTRIES) { + const sourcePath = path.join(sourceRoot, entry.source) + const targetPath = path.join(targetRoot, entry.source) + + if (!(await fileExists(sourcePath))) { + result.skipped.push(entry.source) + continue + } + + if (await linkPointsTo(targetPath, sourcePath)) { + result.skipped.push(entry.source) + continue + } + + if (await fileExists(targetPath)) { + result.conflicts.push({ + path: targetPath, + reason: "Target exists and is not linked to the source .moss entry.", + }) + continue + } + + await fs.symlink( + sourcePath, + targetPath, + entry.type === "dir" && process.platform === "win32" ? "junction" : undefined, + ) + result.created.push(entry.source) + } + + if (result.conflicts.length > 0) { + result.status = "conflict" + return result + } + + if (result.created.length > 0) { + result.status = "created" + return result + } + + result.reason = "Workspace .moss source links are already present." + return result +} diff --git a/src/main/lib/moss-source/subagents.ts b/src/main/lib/moss-source/subagents.ts new file mode 100644 index 000000000..cdc074815 --- /dev/null +++ b/src/main/lib/moss-source/subagents.ts @@ -0,0 +1,271 @@ +import { spawn } from "node:child_process" +import { createHash, randomUUID } from "node:crypto" +import type { AgentEngineId } from "../agent-runtime/types" +import type { SharedResource } from "../shared-resources/types" +import { discoverMossSourceResources } from "./registry" + +export type MossSubagentInvocationStatus = + | "passed" + | "failed" + | "skipped" + | "timed-out" + +export interface MossSubagentInvocationResult { + status: MossSubagentInvocationStatus + invocationId: string + engineId: AgentEngineId + projectPath: string + resourceId?: string + name: string + taskHash: string + payloadHash: string + command?: string + commandHash?: string + exitCode?: number | null + elapsedMs: number + stdout?: string + stderr?: string + error?: string + timedOut?: boolean + warnings: string[] +} + +export interface InvokeMossSubagentOptions { + projectPath: string + engineId: AgentEngineId + name: string + task: string + cwd?: string + payload?: Record + env?: Record + timeoutMs?: number +} + +const DEFAULT_SUBAGENT_TIMEOUT_MS = 30_000 + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex") +} + +function redactSubagentOutput(value: string): string { + return value + .replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-REDACTED") + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer REDACTED") + .replace( + /(["']?(?:api[_-]?key|token|secret|password|authorization)["']?\s*[:=]\s*["']?)([^"',\n}]+)/gi, + "$1REDACTED", + ) + .slice(0, 4000) +} + +function subagentCommand(resource: SharedResource): string | undefined { + const command = resource.metadata?.command + return typeof command === "string" && command.trim() + ? command.trim() + : undefined +} + +function isSubagentEnabled(resource: SharedResource): boolean { + if (resource.enabled === false) return false + if (resource.metadata?.subagentEnabled === false) return false + return true +} + +function visibleResult( + result: MossSubagentInvocationResult, +): MossSubagentInvocationResult { + return { + ...result, + command: result.command ? redactSubagentOutput(result.command) : undefined, + stdout: result.stdout ? redactSubagentOutput(result.stdout) : undefined, + stderr: result.stderr ? redactSubagentOutput(result.stderr) : undefined, + error: result.error ? redactSubagentOutput(result.error) : undefined, + } +} + +function selectMossSubagent( + resources: SharedResource[], + name: string, +): SharedResource | undefined { + const normalizedName = name.trim().toLowerCase() + return resources.find( + (resource) => + resource.kind === "subagent" && + resource.scope === "moss" && + (resource.name.toLowerCase() === normalizedName || + resource.id.toLowerCase() === normalizedName), + ) +} + +export async function invokeMossSubagent( + options: InvokeMossSubagentOptions, +): Promise { + const startedAt = Date.now() + const invocationId = randomUUID() + const payloadJson = JSON.stringify(options.payload ?? {}) + const payloadHash = sha256(payloadJson) + const taskHash = sha256(options.task) + const warnings: string[] = [] + const name = options.name.trim() + + let resources: SharedResource[] = [] + try { + resources = await discoverMossSourceResources(options.projectPath) + } catch (error) { + return { + status: "failed", + invocationId, + engineId: options.engineId, + projectPath: options.projectPath, + name, + taskHash, + payloadHash, + elapsedMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + warnings, + } + } + + const resource = selectMossSubagent(resources, name) + if (!resource) { + return { + status: "failed", + invocationId, + engineId: options.engineId, + projectPath: options.projectPath, + name, + taskHash, + payloadHash, + elapsedMs: Date.now() - startedAt, + error: `Moss subagent ${name} was not found.`, + warnings, + } + } + + if (!isSubagentEnabled(resource)) { + return { + status: "skipped", + invocationId, + engineId: options.engineId, + projectPath: options.projectPath, + resourceId: resource.id, + name: resource.name, + taskHash, + payloadHash, + elapsedMs: Date.now() - startedAt, + error: "Subagent is disabled.", + warnings, + } + } + + const command = subagentCommand(resource) + if (!command) { + return { + status: "skipped", + invocationId, + engineId: options.engineId, + projectPath: options.projectPath, + resourceId: resource.id, + name: resource.name, + taskHash, + payloadHash, + elapsedMs: Date.now() - startedAt, + error: "Subagent has no Moss invocation command.", + warnings, + } + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS + const cwd = options.cwd ?? options.projectPath + + return new Promise((resolve) => { + const child = spawn(command, { + cwd, + env: { + ...process.env, + ...options.env, + MOSS_SUBAGENT_INVOCATION_ID: invocationId, + MOSS_SUBAGENT_ENGINE: options.engineId, + MOSS_SUBAGENT_RESOURCE_ID: resource.id, + MOSS_SUBAGENT_NAME: resource.name, + MOSS_SUBAGENT_PROJECT_PATH: options.projectPath, + MOSS_SUBAGENT_CWD: cwd, + MOSS_SUBAGENT_TASK: options.task, + MOSS_SUBAGENT_TASK_SHA256: taskHash, + MOSS_SUBAGENT_PAYLOAD_JSON: payloadJson, + MOSS_SUBAGENT_PAYLOAD_SHA256: payloadHash, + }, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }) + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + let timedOut = false + let forceKillTimer: ReturnType | null = null + + const timeout = setTimeout(() => { + timedOut = true + child.kill("SIGTERM") + forceKillTimer = setTimeout(() => child.kill("SIGKILL"), 2000) + }, timeoutMs) + + child.stdout.on("data", (chunk) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + }) + child.stderr.on("data", (chunk) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + }) + child.once("error", (error) => { + clearTimeout(timeout) + if (forceKillTimer) clearTimeout(forceKillTimer) + resolve( + visibleResult({ + status: "failed", + invocationId, + engineId: options.engineId, + projectPath: options.projectPath, + resourceId: resource.id, + name: resource.name, + taskHash, + payloadHash, + command, + commandHash: sha256(command), + elapsedMs: Date.now() - startedAt, + error: error.message, + warnings, + }), + ) + }) + child.once("close", (exitCode) => { + clearTimeout(timeout) + if (forceKillTimer) clearTimeout(forceKillTimer) + const status: MossSubagentInvocationStatus = timedOut + ? "timed-out" + : exitCode === 0 + ? "passed" + : "failed" + + resolve( + visibleResult({ + status, + invocationId, + engineId: options.engineId, + projectPath: options.projectPath, + resourceId: resource.id, + name: resource.name, + taskHash, + payloadHash, + command, + commandHash: sha256(command), + exitCode, + elapsedMs: Date.now() - startedAt, + stdout: Buffer.concat(stdoutChunks).toString("utf-8").trim(), + stderr: Buffer.concat(stderrChunks).toString("utf-8").trim(), + timedOut, + warnings, + }), + ) + }) + }) +} diff --git a/src/main/lib/moss-source/types.ts b/src/main/lib/moss-source/types.ts new file mode 100644 index 000000000..33724dc44 --- /dev/null +++ b/src/main/lib/moss-source/types.ts @@ -0,0 +1,39 @@ +import type { AgentEngineId } from "../agent-runtime" + +export const MOSS_SOURCE_VERSION = 1 +export const MOSS_ROOT_DIR = ".moss" + +export type MossSourceFileRole = + | "source-instruction" + | "workspace-config" + | "memory-root" + | "skill" + | "mcp-config" + | "plugin" + | "hook" + | "subagent" + | "provider-config" + +export interface MossSourceLayout { + version: typeof MOSS_SOURCE_VERSION + projectPath: string + root: string + sourceInstruction: string + workspaceConfig: string + memoryRoot: string + skillsRoot: string + mcpConfig: string + pluginsRoot: string + hooksRoot: string + subagentsRoot: string + providersConfig: string +} + +export interface MossEnginePathTarget { + engineId: AgentEngineId + sourceRole: MossSourceFileRole + sourcePath: string + targetPath: string + action: "native" | "symlink" | "adapter-inject" | "managed-bridge" + reason: string +} diff --git a/src/main/lib/plugins/index.ts b/src/main/lib/plugins/index.ts index 1c849e7ab..4c6356cb5 100644 --- a/src/main/lib/plugins/index.ts +++ b/src/main/lib/plugins/index.ts @@ -42,6 +42,29 @@ let pluginCache: { plugins: PluginInfo[]; timestamp: number } | null = null let mcpCache: { configs: PluginMcpConfig[]; timestamp: number } | null = null const CACHE_TTL_MS = 30000 // 30 seconds - plugins don't change often during a session +function normalizePluginMcpServerConfig( + config: McpServerConfig, + pluginPath: string, +): McpServerConfig { + const command = (config as { command?: unknown }).command + if (typeof command !== "string" || command.trim() === "") { + return config + } + + const rawCwd = (config as { cwd?: unknown }).cwd + const cwd = + typeof rawCwd === "string" && rawCwd.trim() !== "" + ? path.isAbsolute(rawCwd) + ? rawCwd + : path.resolve(pluginPath, rawCwd) + : pluginPath + + return { + ...config, + cwd, + } +} + /** * Clear plugin caches (for testing/manual invalidation) */ @@ -190,7 +213,10 @@ export async function discoverPluginMcpServers(): Promise { const validServers: Record = {} for (const [name, config] of Object.entries(serversObj)) { if (config && typeof config === "object" && !Array.isArray(config)) { - validServers[name] = config as McpServerConfig + validServers[name] = normalizePluginMcpServerConfig( + config as McpServerConfig, + plugin.path, + ) } } diff --git a/src/main/lib/shared-resources/codex-native-resources.test.ts b/src/main/lib/shared-resources/codex-native-resources.test.ts new file mode 100644 index 000000000..ef6d8d970 --- /dev/null +++ b/src/main/lib/shared-resources/codex-native-resources.test.ts @@ -0,0 +1,494 @@ +import { describe, expect, test } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { collectCodexNativeResources } from "./codex-native-resources" +import { buildGovernedResourceProjection } from "./governance" + +function makeTempRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "onecode-codex-resources-")) +} + +function writeFile(filePath: string, contents: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, contents, "utf-8") +} + +describe("collectCodexNativeResources", () => { + test("discovers Codex user skills, plugin manifests, and plugin skills as native resources", async () => { + const root = makeTempRoot() + try { + const codexRoot = path.join(root, ".codex") + const pluginRoot = path.join( + codexRoot, + "plugins", + "cache", + "openai-bundled", + "record-and-replay", + "1.0.829", + ) + const inlinePluginRoot = path.join( + codexRoot, + "plugins", + "cache", + "personal", + "inline-mcp", + "0.1.0", + ) + const codexCacheRoot = path.join(codexRoot, "cache") + + writeFile( + path.join(codexRoot, "skills", ".system", "imagegen", "SKILL.md"), + `---\nname: imagegen\ndescription: Generate images.\n---\n\n# Imagegen\n`, + ) + writeFile(path.join(codexRoot, "config.toml"), `model = "gpt-5.5"\n`) + writeFile(path.join(codexRoot, "browser", "config.toml"), `enabled = true\n`) + writeFile(path.join(codexRoot, "auth.json"), JSON.stringify({ redacted: true })) + writeFile(path.join(codexRoot, "hooks.json"), JSON.stringify({ Stop: [] })) + writeFile( + path.join(codexRoot, "automations", "parity-loop", "automation.toml"), + [ + `version = 1`, + `id = "parity-loop"`, + `kind = "cron"`, + `name = "Parity Loop"`, + `prompt = "Check parity"`, + `status = "ACTIVE"`, + `rrule = "FREQ=HOURLY;INTERVAL=1"`, + `model = "moss-default"`, + `engine = "hermes"`, + `reasoning_effort = "high"`, + `execution_environment = "worktree"`, + `updated_at = 1800000000000`, + ].join("\n"), + ) + writeFile( + path.join(pluginRoot, ".codex-plugin", "plugin.json"), + JSON.stringify( + { + name: "record-and-replay", + version: "1.0.829", + description: "Record what I'm doing on my Mac", + skills: "./skills/", + mcpServers: "./.mcp.json", + apps: "./.app.json", + interface: { + displayName: "Record & Replay", + shortDescription: "Record workflows", + category: "Productivity", + capabilities: ["Read", "Write"], + }, + }, + null, + 2, + ), + ) + writeFile( + path.join(pluginRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + event_stream: { + command: "event-stream", + args: ["mcp"], + cwd: ".", + env: { RECORD_TOKEN: "redacted" }, + approved: true, + }, + }, + }), + ) + writeFile( + path.join(pluginRoot, ".app.json"), + JSON.stringify({ apps: { replay: { id: "connector_replay", required: true } } }), + ) + writeFile( + path.join(pluginRoot, "skills", "record-workflow", "SKILL.md"), + `---\nname: record-workflow\ndescription: Turn recordings into skills.\n---\n\n# Record\n`, + ) + writeFile( + path.join(pluginRoot, "skills", "nested", "review", "SKILL.md"), + `# Nested review\n`, + ) + writeFile( + path.join(inlinePluginRoot, ".codex-plugin", "plugin.json"), + JSON.stringify( + { + name: "inline-mcp", + version: "0.1.0", + mcpServers: { + inline_server: { + url: "http://127.0.0.1:1234/mcp", + description: "Inline MCP server", + approved: true, + }, + }, + interface: { + displayName: "Inline MCP", + shortDescription: "Inline plugin MCP server", + }, + }, + null, + 2, + ), + ) + writeFile( + path.join(codexCacheRoot, "codex_app_directory", "directory.json"), + JSON.stringify({ + schema_version: 1, + connectors: [ + { + id: "connector_demo", + name: "Demo Connector", + description: "Use the demo connector.", + distributionChannel: "ECOSYSTEM_DIRECTORY", + labels: { interactive: "true", consequential: "false" }, + installUrl: "https://chatgpt.com/apps/demo/connector_demo", + isAccessible: true, + isEnabled: true, + pluginDisplayNames: ["Demo Plugin"], + appMetadata: { + review: { status: "APPROVED" }, + categories: ["DEVELOPER_TOOLS"], + developer: "Demo Inc", + version: "1.2.3", + }, + }, + ], + }), + ) + writeFile( + path.join(codexCacheRoot, "codex_apps_server_info", "server.json"), + JSON.stringify({ + schema_version: 1, + server_info: { + name: "codex-connectors-mcp", + version: "0.1.0", + }, + }), + ) + writeFile( + path.join(codexCacheRoot, "codex_apps_tools", "tools.json"), + JSON.stringify({ + schema_version: 3, + tools: [ + { + server_name: "codex_apps", + supports_parallel_tool_calls: false, + tool_name: "search_demo", + tool_namespace: "codex_apps__demo", + namespace_description: "Demo tools", + connector_id: "connector_demo", + connector_name: "Demo Connector", + plugin_display_names: ["Demo Plugin"], + tool: { + title: "search_demo", + description: "Search demo data.", + annotations: { readOnlyHint: true }, + _meta: { + resource_name: "Demo_search_demo", + connector_id: "connector_demo", + connector_name: "Demo Connector", + connector_description: "Use the demo connector.", + link_id: "link_demo", + _codex_apps: { + resource_uri: "/connector_demo/search_demo", + contains_mcp_source: false, + }, + }, + }, + }, + ], + }), + ) + writeFile( + path.join(codexCacheRoot, "remote_plugin_catalog", "catalog.json"), + JSON.stringify({ + schema_version: 1, + plugins: [ + { + id: "plugin_demo", + name: "demo", + discoverability: "LISTED", + installation_policy: "AVAILABLE", + authentication_policy: "ON_INSTALL", + status: "AVAILABLE", + release: { + version: "1.0.0", + display_name: "Demo Plugin", + description: "Demo app plugin.", + app_manifest: { + apps: { + demo: { id: "asdk_demo", required: true }, + }, + }, + interface: { + short_description: "Demo app", + category: "Developer Tools", + developer_name: "Demo Inc", + default_prompt: "Search demo data.", + }, + }, + }, + ], + }), + ) + + const resources = await collectCodexNativeResources({ + codexRoot, + pluginCacheRoot: path.join(codexRoot, "plugins", "cache"), + codexCacheRoot, + }) + + expect(resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "skill:codex:user:.system/imagegen", + kind: "skill", + name: "imagegen", + scope: "user", + engine: "codex", + metadata: expect.objectContaining({ + codexResourceRole: "user-skill", + relativeDir: ".system/imagegen", + }), + }), + expect.objectContaining({ + id: "codex:config:config", + kind: "config", + name: "Codex config.toml", + scope: "engine", + engine: "codex", + metadata: expect.objectContaining({ + codexResourceRole: "config", + }), + }), + expect.objectContaining({ + id: "codex:provider:auth", + kind: "provider", + name: "Codex auth.json", + metadata: expect.objectContaining({ + codexResourceRole: "auth", + containsSecrets: true, + }), + }), + expect.objectContaining({ + id: "codex:hook:hooks", + kind: "hook", + name: "Codex hooks.json", + metadata: expect.objectContaining({ + codexResourceRole: "hooks", + }), + }), + expect.objectContaining({ + id: "codex:automation:parity-loop", + kind: "automation", + name: "Parity Loop", + metadata: expect.objectContaining({ + codexResourceRole: "automation", + rrule: "FREQ=HOURLY;INTERVAL=1", + updatedAt: 1800000000000, + }), + }), + expect.objectContaining({ + id: "codex:connector:connector_demo", + kind: "connector", + name: "Demo Connector", + metadata: expect.objectContaining({ + codexResourceRole: "connector", + isAccessible: true, + labels: { interactive: "true", consequential: "false" }, + }), + }), + expect.objectContaining({ + id: "codex:tool:codex_apps__demo:search_demo:connector_demo", + kind: "tool", + name: "search_demo", + metadata: expect.objectContaining({ + codexResourceRole: "codex-app-tool", + connectorId: "connector_demo", + resourceUri: "/connector_demo/search_demo", + }), + }), + expect.objectContaining({ + id: "codex:app:asdk_demo", + kind: "app", + name: "Demo Plugin / demo", + metadata: expect.objectContaining({ + codexResourceRole: "remote-plugin-app", + authenticationPolicy: "ON_INSTALL", + }), + }), + expect.objectContaining({ + id: "codex:app:plugin:codex:openai-bundled:record-and-replay:replay", + kind: "app", + name: "replay", + scope: "plugin", + pluginSource: "codex:openai-bundled:record-and-replay", + metadata: expect.objectContaining({ + codexResourceRole: "plugin-app", + appId: "connector_replay", + required: true, + }), + }), + expect.objectContaining({ + id: "codex:mcp:plugin:codex:openai-bundled:record-and-replay:event_stream", + kind: "mcp", + name: "event_stream", + scope: "plugin", + engine: "codex", + pluginSource: "codex:openai-bundled:record-and-replay", + path: path.join(pluginRoot, ".mcp.json"), + metadata: expect.objectContaining({ + codexResourceRole: "plugin-mcp-server", + serverName: "event_stream", + pluginName: "Record & Replay", + pluginVersion: "1.0.829", + transport: "stdio", + command: "event-stream", + args: ["mcp"], + cwd: ".", + hasEnv: true, + envKeys: ["RECORD_TOKEN"], + approved: true, + manifestPath: path.join(pluginRoot, ".mcp.json"), + }), + }), + expect.objectContaining({ + id: "codex:mcp:plugin:codex:personal:inline-mcp:inline_server", + kind: "mcp", + name: "inline_server", + scope: "plugin", + engine: "codex", + pluginSource: "codex:personal:inline-mcp", + path: path.join(inlinePluginRoot, ".codex-plugin", "plugin.json"), + metadata: expect.objectContaining({ + codexResourceRole: "plugin-mcp-server", + pluginName: "Inline MCP", + pluginVersion: "0.1.0", + transport: "http", + url: "http://127.0.0.1:1234/mcp", + approved: true, + manifestPath: path.join(inlinePluginRoot, ".codex-plugin", "plugin.json"), + }), + }), + expect.objectContaining({ + id: "plugin:codex:openai-bundled:record-and-replay", + kind: "plugin", + name: "Record & Replay", + scope: "plugin", + engine: "codex", + metadata: expect.objectContaining({ + codexResourceRole: "plugin-manifest", + mcpManifestPath: path.join(pluginRoot, ".mcp.json"), + }), + }), + expect.objectContaining({ + id: "skill:codex:plugin:codex:openai-bundled:record-and-replay:record-workflow", + kind: "skill", + name: "record-workflow", + scope: "plugin", + engine: "codex", + pluginSource: "codex:openai-bundled:record-and-replay", + metadata: expect.objectContaining({ + codexResourceRole: "plugin-skill", + pluginName: "Record & Replay", + }), + }), + expect.objectContaining({ + id: "skill:codex:plugin:codex:openai-bundled:record-and-replay:nested/review", + name: "nested/review", + }), + ]), + ) + + const governed = buildGovernedResourceProjection({ resources }) + const codexProjection = governed.projections.find( + (projection) => projection.engineId === "codex", + ) + expect(codexProjection?.mappings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: "skill:codex:user:.system/imagegen", + action: "native", + }), + expect.objectContaining({ + resourceId: "plugin:codex:openai-bundled:record-and-replay", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:config:config", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:provider:auth", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:hook:hooks", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:automation:parity-loop", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:connector:connector_demo", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:tool:codex_apps__demo:search_demo:connector_demo", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:app:asdk_demo", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:app:plugin:codex:openai-bundled:record-and-replay:replay", + action: "native", + }), + expect.objectContaining({ + resourceId: "codex:mcp:plugin:codex:openai-bundled:record-and-replay:event_stream", + action: "native", + targetPath: path.join(pluginRoot, ".mcp.json"), + }), + expect.objectContaining({ + resourceId: "codex:mcp:plugin:codex:personal:inline-mcp:inline_server", + action: "native", + targetPath: path.join(inlinePluginRoot, ".codex-plugin", "plugin.json"), + }), + expect.objectContaining({ + resourceId: + "skill:codex:plugin:codex:openai-bundled:record-and-replay:record-workflow", + action: "native", + }), + ]), + ) + expect( + governed.resources.find( + (resource) => + resource.id === + "skill:codex:plugin:codex:openai-bundled:record-and-replay:record-workflow", + )?.provenance?.discoveredBy, + ).toBe("Codex plugin cache skill") + const governedPluginMcp = governed.resources.find( + (resource) => + resource.id === + "codex:mcp:plugin:codex:openai-bundled:record-and-replay:event_stream", + ) + expect(governedPluginMcp?.approval).toMatchObject({ + required: true, + approved: true, + }) + expect(governedPluginMcp?.provenance).toMatchObject({ + source: "plugin", + sourceId: "codex:openai-bundled:record-and-replay", + engine: "codex", + displayPath: path.join(pluginRoot, ".mcp.json"), + discoveredBy: "Codex plugin MCP manifest", + }) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/lib/shared-resources/codex-native-resources.ts b/src/main/lib/shared-resources/codex-native-resources.ts new file mode 100644 index 000000000..a1dae2a9f --- /dev/null +++ b/src/main/lib/shared-resources/codex-native-resources.ts @@ -0,0 +1,885 @@ +import * as fs from "fs/promises" +import type { Dirent } from "fs" +import * as os from "os" +import * as path from "path" +import matter from "gray-matter" +import { parseTOML } from "confbox/toml" +import type { SharedResource } from "./types" + +const home = os.homedir() +const SKIPPED_SCAN_DIRS = new Set([ + ".git", + ".hg", + ".svn", + "node_modules", + "dist", + "build", + "out", +]) + +interface CodexPluginManifest { + name?: unknown + version?: unknown + description?: unknown + homepage?: unknown + repository?: unknown + keywords?: unknown + skills?: unknown + mcpServers?: unknown + apps?: unknown + interface?: unknown +} + +interface CodexPluginInterface { + displayName?: unknown + shortDescription?: unknown + longDescription?: unknown + developerName?: unknown + category?: unknown + capabilities?: unknown +} + +interface CodexPluginMcpServerConfig { + command?: unknown + args?: unknown + cwd?: unknown + url?: unknown + env?: unknown + authType?: unknown + _oauth?: unknown + disabled?: unknown + approved?: unknown + description?: unknown +} + +interface CodexNativeFileSpec { + relativePath: string + kind: "config" | "provider" | "hook" + name: string + description: string + role: string + containsSecrets?: boolean +} + +export interface CollectCodexNativeResourcesParams { + codexRoot?: string + pluginCacheRoot?: string + codexCacheRoot?: string + projectPath?: string +} + +function resourceId(parts: Array): string { + return parts.filter(Boolean).join(":") +} + +function toDisplayPath(filePath: string, projectPath?: string): string { + if (projectPath && filePath.startsWith(projectPath)) { + return path.relative(projectPath, filePath) + } + return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +function isSafePathSegment(name: string): boolean { + return name !== "." && name !== ".." && !name.includes("/") && !name.includes("\\") +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined +} + +function stringArrayValue(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const strings = value.filter((item): item is string => + typeof item === "string" && item.trim().length > 0 + ) + return strings.length > 0 ? strings : undefined +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function booleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + +function stringRecordValue(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ) + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +function stringRecordKeys(value: unknown): string[] | undefined { + if (!isRecord(value)) return undefined + const keys = Object.keys(value).filter((key) => key.trim().length > 0).sort() + return keys.length > 0 ? keys : undefined +} + +function displayRelativeDir(root: string, filePath: string): string { + return path.relative(root, path.dirname(filePath)).split(path.sep).filter(Boolean).join("/") +} + +async function readFrontmatter(filePath: string): Promise<{ + name?: string + description?: string +}> { + const raw = await fs.readFile(filePath, "utf-8") + const parsed = matter(raw) + return { + name: stringValue(parsed.data.name), + description: stringValue(parsed.data.description), + } +} + +async function readTomlRecord(filePath: string): Promise | null> { + try { + const raw = await fs.readFile(filePath, "utf-8") + const parsed = parseTOML>(raw) + return isRecord(parsed) ? parsed : null + } catch { + return null + } +} + +async function findSkillFiles(root: string, maxDepth = 8): Promise { + if (!(await pathExists(root))) return [] + const files: string[] = [] + + async function walk(dir: string, depth: number) { + if (depth > maxDepth) return + + let entries: Dirent[] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + if (!isSafePathSegment(entry.name)) continue + const entryPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + if (SKIPPED_SCAN_DIRS.has(entry.name)) continue + await walk(entryPath, depth + 1) + continue + } + + if (entry.isFile() && entry.name === "SKILL.md") { + files.push(entryPath) + } + } + } + + await walk(root, 0) + return files.sort((left, right) => left.localeCompare(right)) +} + +async function findCodexPluginManifests(root: string, maxDepth = 8): Promise { + if (!(await pathExists(root))) return [] + const manifests: string[] = [] + + async function walk(dir: string, depth: number) { + if (depth > maxDepth) return + + let entries: Dirent[] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + if (!isSafePathSegment(entry.name)) continue + if (!entry.isDirectory()) continue + + const entryPath = path.join(dir, entry.name) + if (entry.name === ".codex-plugin") { + const manifestPath = path.join(entryPath, "plugin.json") + if (await pathExists(manifestPath)) manifests.push(manifestPath) + continue + } + + if (SKIPPED_SCAN_DIRS.has(entry.name)) continue + await walk(entryPath, depth + 1) + } + } + + await walk(root, 0) + return manifests.sort((left, right) => left.localeCompare(right)) +} + +async function listJsonFiles(root: string): Promise { + if (!(await pathExists(root))) return [] + let entries: Dirent[] + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + return [] + } + + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json") && isSafePathSegment(entry.name)) + .map((entry) => path.join(root, entry.name)) + .sort((left, right) => left.localeCompare(right)) +} + +function resolvePluginRelativePath(pluginPath: string, value: unknown): string | undefined { + const relativePath = stringValue(value) + if (!relativePath) return undefined + return path.resolve(pluginPath, relativePath) +} + +async function scanCodexSkillRoot(params: { + root: string + scope: "user" | "plugin" + projectPath?: string + pluginSource?: string + pluginName?: string + pluginVersion?: string +}): Promise { + const skillFiles = await findSkillFiles(params.root) + const resources: SharedResource[] = [] + + for (const filePath of skillFiles) { + const relativeDir = displayRelativeDir(params.root, filePath) + try { + const parsed = await readFrontmatter(filePath) + const name = parsed.name || relativeDir || path.basename(path.dirname(filePath)) + resources.push({ + id: resourceId([ + "skill", + "codex", + params.scope, + params.pluginSource, + relativeDir || name, + ]), + kind: "skill", + name, + scope: params.scope, + engine: "codex", + pluginSource: params.pluginSource, + path: toDisplayPath(filePath, params.projectPath), + description: parsed.description, + enabled: true, + metadata: { + codexResourceRole: params.scope === "plugin" ? "plugin-skill" : "user-skill", + entryName: relativeDir || name, + relativeDir, + skillRoot: toDisplayPath(params.root, params.projectPath), + pluginName: params.pluginName, + pluginVersion: params.pluginVersion, + }, + }) + } catch { + continue + } + } + + return resources +} + +async function readCodexPluginManifest(manifestPath: string): Promise { + try { + const raw = await fs.readFile(manifestPath, "utf-8") + const parsed = JSON.parse(raw) + return isRecord(parsed) ? parsed : null + } catch { + return null + } +} + +async function readJsonRecord(filePath: string): Promise | null> { + try { + const raw = await fs.readFile(filePath, "utf-8") + const parsed = JSON.parse(raw) + return isRecord(parsed) ? parsed : null + } catch { + return null + } +} + +async function collectCodexNativeFiles(params: { + codexRoot: string + projectPath?: string +}): Promise { + const specs: CodexNativeFileSpec[] = [ + { + relativePath: "config.toml", + kind: "config", + name: "Codex config.toml", + description: "Codex user configuration, model routing, sandbox, MCP, and UI defaults.", + role: "config", + }, + { + relativePath: "auth.json", + kind: "provider", + name: "Codex auth.json", + description: "Codex authentication state and account routing. Secret values are not read into the resource snapshot.", + role: "auth", + containsSecrets: true, + }, + { + relativePath: "hooks.json", + kind: "hook", + name: "Codex hooks.json", + description: "Codex user hook configuration.", + role: "hooks", + }, + { + relativePath: path.join("browser", "config.toml"), + kind: "config", + name: "Codex browser config.toml", + description: "Codex browser tool configuration.", + role: "browser-config", + }, + ] + + const resources: SharedResource[] = [] + for (const spec of specs) { + const filePath = path.join(params.codexRoot, spec.relativePath) + if (!(await pathExists(filePath))) continue + + resources.push({ + id: resourceId(["codex", spec.kind, spec.role]), + kind: spec.kind, + name: spec.name, + scope: "engine", + engine: "codex", + path: toDisplayPath(filePath, params.projectPath), + description: spec.description, + enabled: true, + metadata: { + codexResourceRole: spec.role, + relativePath: spec.relativePath.split(path.sep).join("/"), + containsSecrets: spec.containsSecrets === true, + }, + }) + } + + return resources +} + +async function collectCodexAutomations(params: { + codexRoot: string + projectPath?: string +}): Promise { + const automationsRoot = path.join(params.codexRoot, "automations") + if (!(await pathExists(automationsRoot))) return [] + + let entries: Dirent[] + try { + entries = await fs.readdir(automationsRoot, { withFileTypes: true }) + } catch { + return [] + } + + const resources: SharedResource[] = [] + for (const entry of entries) { + if (!entry.isDirectory() || !isSafePathSegment(entry.name)) continue + const filePath = path.join(automationsRoot, entry.name, "automation.toml") + if (!(await pathExists(filePath))) continue + + const parsed = await readTomlRecord(filePath) + const id = stringValue(parsed?.id) || entry.name + const name = stringValue(parsed?.name) || id + const status = stringValue(parsed?.status) + + resources.push({ + id: resourceId(["codex", "automation", id]), + kind: "automation", + name, + scope: "engine", + engine: "codex", + path: toDisplayPath(filePath, params.projectPath), + description: stringValue(parsed?.prompt), + enabled: status ? status.toLowerCase() !== "paused" : true, + metadata: { + codexResourceRole: "automation", + automationId: id, + kind: stringValue(parsed?.kind), + status, + rrule: stringValue(parsed?.rrule), + model: stringValue(parsed?.model), + engine: stringValue(parsed?.engine), + reasoningEffort: stringValue(parsed?.reasoning_effort), + executionEnvironment: stringValue(parsed?.execution_environment), + lastRunAt: numberValue(parsed?.last_run_at), + createdAt: numberValue(parsed?.created_at), + updatedAt: numberValue(parsed?.updated_at), + hasTargetThread: Boolean(stringValue(parsed?.target_thread_id)), + hasCwds: Array.isArray(parsed?.cwds) && parsed.cwds.length > 0, + isEnabled: booleanValue(parsed?.is_enabled), + }, + }) + } + + return resources.sort((left, right) => left.id.localeCompare(right.id)) +} + +async function collectCodexConnectorResources(params: { + cacheRoot: string + projectPath?: string +}): Promise { + const files = await listJsonFiles(path.join(params.cacheRoot, "codex_app_directory")) + const byId = new Map() + + for (const filePath of files) { + const parsed = await readJsonRecord(filePath) + const connectors = Array.isArray(parsed?.connectors) ? parsed.connectors : [] + for (const connector of connectors) { + if (!isRecord(connector)) continue + const id = stringValue(connector.id) + const name = stringValue(connector.name) + if (!id || !name) continue + + const appMetadata = isRecord(connector.appMetadata) ? connector.appMetadata : {} + const review = isRecord(appMetadata.review) ? appMetadata.review : {} + const categories = stringArrayValue(appMetadata.categories) + byId.set(id, { + id: resourceId(["codex", "connector", id]), + kind: "connector", + name, + scope: "engine", + engine: "codex", + path: toDisplayPath(filePath, params.projectPath), + description: stringValue(connector.description), + enabled: booleanValue(connector.isEnabled) ?? true, + metadata: { + codexResourceRole: "connector", + connectorId: id, + distributionChannel: stringValue(connector.distributionChannel), + installUrl: stringValue(connector.installUrl), + isAccessible: booleanValue(connector.isAccessible), + isEnabled: booleanValue(connector.isEnabled), + labels: stringRecordValue(connector.labels), + categories, + reviewStatus: stringValue(review.status), + developer: stringValue(appMetadata.developer), + version: stringValue(appMetadata.version), + pluginDisplayNames: stringArrayValue(connector.pluginDisplayNames), + logoUrl: stringValue(connector.logoUrl), + logoUrlDark: stringValue(connector.logoUrlDark), + cacheFile: toDisplayPath(filePath, params.projectPath), + }, + }) + } + } + + return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id)) +} + +async function collectCodexAppsToolsResources(params: { + cacheRoot: string + projectPath?: string +}): Promise { + const resources = new Map() + + for (const filePath of await listJsonFiles(path.join(params.cacheRoot, "codex_apps_server_info"))) { + const parsed = await readJsonRecord(filePath) + const serverInfo = isRecord(parsed?.server_info) ? parsed.server_info : null + const name = stringValue(serverInfo?.name) + if (!serverInfo || !name) continue + resources.set(`server:${name}:${path.basename(filePath)}`, { + id: resourceId(["codex", "mcp", "apps-server", name, path.basename(filePath, ".json")]), + kind: "mcp", + name, + scope: "engine", + engine: "codex", + path: toDisplayPath(filePath, params.projectPath), + description: stringValue(serverInfo.description), + enabled: true, + metadata: { + codexResourceRole: "codex-apps-server", + title: stringValue(serverInfo.title), + version: stringValue(serverInfo.version), + websiteUrl: stringValue(serverInfo.websiteUrl), + cacheFile: toDisplayPath(filePath, params.projectPath), + }, + }) + } + + for (const filePath of await listJsonFiles(path.join(params.cacheRoot, "codex_apps_tools"))) { + const parsed = await readJsonRecord(filePath) + const tools = Array.isArray(parsed?.tools) ? parsed.tools : [] + for (const item of tools) { + if (!isRecord(item)) continue + const tool = isRecord(item.tool) ? item.tool : {} + const meta = isRecord(tool._meta) ? tool._meta : {} + const codexAppsMeta = isRecord(meta._codex_apps) ? meta._codex_apps : {} + const namespace = stringValue(item.tool_namespace) + const toolName = stringValue(item.tool_name) || stringValue(tool.name) + if (!namespace || !toolName) continue + + const connectorId = + stringValue(item.connector_id) || + stringValue(meta.connector_id) + const id = resourceId(["codex", "tool", namespace, toolName, connectorId]) + resources.set(id, { + id, + kind: "tool", + name: stringValue(tool.title) || toolName, + scope: "engine", + engine: "codex", + path: toDisplayPath(filePath, params.projectPath), + description: stringValue(tool.description), + enabled: true, + metadata: { + codexResourceRole: "codex-app-tool", + serverName: stringValue(item.server_name), + serverOrigin: stringValue(item.server_origin), + supportsParallelToolCalls: booleanValue(item.supports_parallel_tool_calls), + toolNamespace: namespace, + toolName, + namespaceDescription: stringValue(item.namespace_description), + connectorId, + connectorName: + stringValue(item.connector_name) || + stringValue(meta.connector_name), + connectorDescription: stringValue(meta.connector_description), + linkId: stringValue(meta.link_id), + resourceName: stringValue(meta.resource_name), + resourceUri: stringValue(codexAppsMeta.resource_uri), + containsMcpSource: booleanValue(codexAppsMeta.contains_mcp_source), + annotations: isRecord(tool.annotations) ? tool.annotations : undefined, + pluginDisplayNames: stringArrayValue(item.plugin_display_names), + cacheFile: toDisplayPath(filePath, params.projectPath), + }, + }) + } + } + + return [...resources.values()].sort((left, right) => left.id.localeCompare(right.id)) +} + +async function collectCodexRemoteAppResources(params: { + cacheRoot: string + projectPath?: string +}): Promise { + const resources = new Map() + + for (const filePath of await listJsonFiles(path.join(params.cacheRoot, "remote_plugin_catalog"))) { + const parsed = await readJsonRecord(filePath) + const plugins = Array.isArray(parsed?.plugins) ? parsed.plugins : [] + for (const plugin of plugins) { + if (!isRecord(plugin)) continue + const release = isRecord(plugin.release) ? plugin.release : {} + const appManifest = isRecord(release.app_manifest) ? release.app_manifest : {} + const apps = isRecord(appManifest.apps) ? appManifest.apps : {} + const pluginId = stringValue(plugin.id) + const pluginName = stringValue(release.display_name) || stringValue(plugin.name) + const interfaceMeta = isRecord(release.interface) ? release.interface : {} + + for (const [appName, appValue] of Object.entries(apps)) { + if (!isRecord(appValue)) continue + const appId = stringValue(appValue.id) + if (!appId) continue + const id = resourceId(["codex", "app", appId]) + resources.set(id, { + id, + kind: "app", + name: pluginName ? `${pluginName} / ${appName}` : appName, + scope: "engine", + engine: "codex", + path: toDisplayPath(filePath, params.projectPath), + description: + stringValue(interfaceMeta.short_description) || + stringValue(release.description), + enabled: stringValue(plugin.status) !== "UNAVAILABLE", + metadata: { + codexResourceRole: "remote-plugin-app", + appId, + appName, + pluginId, + pluginName: stringValue(plugin.name), + displayName: pluginName, + pluginStatus: stringValue(plugin.status), + installationPolicy: stringValue(plugin.installation_policy), + authenticationPolicy: stringValue(plugin.authentication_policy), + discoverability: stringValue(plugin.discoverability), + releaseVersion: stringValue(release.version), + category: stringValue(interfaceMeta.category), + developerName: stringValue(interfaceMeta.developer_name), + defaultPrompt: stringValue(interfaceMeta.default_prompt), + defaultPrompts: stringArrayValue(interfaceMeta.default_prompts), + websiteUrl: stringValue(interfaceMeta.website_url), + privacyPolicyUrl: stringValue(interfaceMeta.privacy_policy_url), + termsOfServiceUrl: stringValue(interfaceMeta.terms_of_service_url), + cacheFile: toDisplayPath(filePath, params.projectPath), + }, + }) + } + } + } + + return [...resources.values()].sort((left, right) => left.id.localeCompare(right.id)) +} + +async function collectCodexPluginAppResources(params: { + appManifestPath?: string + pluginSource: string + pluginName: string + pluginVersion?: string + projectPath?: string +}): Promise { + if (!params.appManifestPath || !(await pathExists(params.appManifestPath))) return [] + const parsed = await readJsonRecord(params.appManifestPath) + const apps = isRecord(parsed?.apps) ? parsed.apps : {} + const resources: SharedResource[] = [] + + for (const [name, value] of Object.entries(apps)) { + if (!isRecord(value)) continue + const appId = stringValue(value.id) + if (!appId) continue + resources.push({ + id: resourceId(["codex", "app", "plugin", params.pluginSource, name]), + kind: "app", + name, + scope: "plugin", + engine: "codex", + pluginSource: params.pluginSource, + path: toDisplayPath(params.appManifestPath, params.projectPath), + enabled: true, + metadata: { + codexResourceRole: "plugin-app", + appId, + appName: name, + pluginName: params.pluginName, + pluginVersion: params.pluginVersion, + required: booleanValue(value.required), + manifestPath: toDisplayPath(params.appManifestPath, params.projectPath), + }, + }) + } + + return resources +} + +function mcpServersFromRecord(value: unknown): Record { + if (!isRecord(value)) return {} + if (isRecord(value.mcpServers)) return value.mcpServers + if (isRecord(value.servers)) return value.servers + return value +} + +async function collectCodexPluginMcpResources(params: { + mcpServers: unknown + mcpManifestPath?: string + manifestPath: string + pluginSource: string + pluginName: string + pluginVersion?: string + projectPath?: string +}): Promise { + const manifestFile = + params.mcpManifestPath && await pathExists(params.mcpManifestPath) + ? params.mcpManifestPath + : params.manifestPath + const parsed = + manifestFile === params.mcpManifestPath + ? await readJsonRecord(manifestFile) + : isRecord(params.mcpServers) + ? { mcpServers: params.mcpServers } + : null + const servers = mcpServersFromRecord(parsed) + const resources: SharedResource[] = [] + + for (const [serverName, value] of Object.entries(servers)) { + if (!isRecord(value)) continue + const config = value as CodexPluginMcpServerConfig + const command = stringValue(config.command) + const url = stringValue(config.url) + const transport = command ? "stdio" : url ? "http" : "unknown" + + resources.push({ + id: resourceId(["codex", "mcp", "plugin", params.pluginSource, serverName]), + kind: "mcp", + name: serverName, + scope: "plugin", + engine: "codex", + pluginSource: params.pluginSource, + path: toDisplayPath(manifestFile, params.projectPath), + description: stringValue(config.description), + enabled: booleanValue(config.disabled) === true ? false : true, + metadata: { + codexResourceRole: "plugin-mcp-server", + serverName, + pluginName: params.pluginName, + pluginVersion: params.pluginVersion, + transport, + command, + args: stringArrayValue(config.args), + cwd: stringValue(config.cwd), + url, + authType: stringValue(config.authType), + hasOAuth: Boolean(config._oauth), + hasEnv: isRecord(config.env) && Object.keys(config.env).length > 0, + envKeys: stringRecordKeys(config.env), + approved: booleanValue(config.approved), + manifestPath: toDisplayPath(manifestFile, params.projectPath), + }, + }) + } + + return resources +} + +function pluginCacheParts(pluginCacheRoot: string, pluginPath: string): { + marketplace?: string + slug?: string + cacheVersion?: string +} { + const relative = path.relative(pluginCacheRoot, pluginPath) + if (relative.startsWith("..")) return {} + const [marketplace, slug, cacheVersion] = relative.split(path.sep) + return { marketplace, slug, cacheVersion } +} + +export async function collectCodexNativeResources( + params: CollectCodexNativeResourcesParams = {}, +): Promise { + const codexRoot = params.codexRoot ?? path.join(home, ".codex") + const pluginCacheRoot = + params.pluginCacheRoot ?? path.join(codexRoot, "plugins", "cache") + const codexCacheRoot = params.codexCacheRoot ?? path.join(codexRoot, "cache") + const resources: SharedResource[] = [] + + resources.push( + ...(await collectCodexNativeFiles({ + codexRoot, + projectPath: params.projectPath, + })), + ...(await collectCodexAutomations({ + codexRoot, + projectPath: params.projectPath, + })), + ...(await collectCodexConnectorResources({ + cacheRoot: codexCacheRoot, + projectPath: params.projectPath, + })), + ...(await collectCodexAppsToolsResources({ + cacheRoot: codexCacheRoot, + projectPath: params.projectPath, + })), + ...(await collectCodexRemoteAppResources({ + cacheRoot: codexCacheRoot, + projectPath: params.projectPath, + })), + ...(await scanCodexSkillRoot({ + root: path.join(codexRoot, "skills"), + scope: "user", + projectPath: params.projectPath, + })), + ) + + const manifests = await findCodexPluginManifests(pluginCacheRoot) + for (const manifestPath of manifests) { + const pluginPath = path.dirname(path.dirname(manifestPath)) + const manifest = await readCodexPluginManifest(manifestPath) + if (!manifest) continue + + const interfaceMeta = isRecord(manifest.interface) + ? (manifest.interface as CodexPluginInterface) + : {} + const cacheParts = pluginCacheParts(pluginCacheRoot, pluginPath) + const manifestName = stringValue(manifest.name) || cacheParts.slug || path.basename(pluginPath) + const displayName = stringValue(interfaceMeta.displayName) || manifestName + const version = stringValue(manifest.version) || cacheParts.cacheVersion + const pluginSource = resourceId([ + "codex", + cacheParts.marketplace || "cache", + manifestName, + ]) + const mcpManifestPath = resolvePluginRelativePath(pluginPath, manifest.mcpServers) + const appManifestPath = resolvePluginRelativePath(pluginPath, manifest.apps) + const skillsRoot = + resolvePluginRelativePath(pluginPath, manifest.skills) ?? path.join(pluginPath, "skills") + + resources.push({ + id: resourceId(["plugin", pluginSource]), + kind: "plugin", + name: displayName, + scope: "plugin", + engine: "codex", + pluginSource, + path: toDisplayPath(pluginPath, params.projectPath), + description: + stringValue(interfaceMeta.shortDescription) || + stringValue(manifest.description) || + stringValue(interfaceMeta.longDescription), + enabled: true, + metadata: { + codexResourceRole: "plugin-manifest", + manifestName, + displayName, + version, + marketplace: cacheParts.marketplace, + cacheSlug: cacheParts.slug, + cacheVersion: cacheParts.cacheVersion, + category: stringValue(interfaceMeta.category), + developerName: stringValue(interfaceMeta.developerName), + homepage: stringValue(manifest.homepage), + repository: stringValue(manifest.repository), + keywords: stringArrayValue(manifest.keywords), + capabilities: stringArrayValue(interfaceMeta.capabilities), + manifestPath: toDisplayPath(manifestPath, params.projectPath), + mcpManifestPath: + mcpManifestPath && await pathExists(mcpManifestPath) + ? toDisplayPath(mcpManifestPath, params.projectPath) + : undefined, + appManifestPath: + appManifestPath && await pathExists(appManifestPath) + ? toDisplayPath(appManifestPath, params.projectPath) + : undefined, + skillsPath: + await pathExists(skillsRoot) + ? toDisplayPath(skillsRoot, params.projectPath) + : undefined, + }, + }) + + resources.push( + ...(await scanCodexSkillRoot({ + root: skillsRoot, + scope: "plugin", + projectPath: params.projectPath, + pluginSource, + pluginName: displayName, + pluginVersion: version, + })), + ...(await collectCodexPluginAppResources({ + appManifestPath, + pluginSource, + pluginName: displayName, + pluginVersion: version, + projectPath: params.projectPath, + })), + ...(await collectCodexPluginMcpResources({ + mcpServers: manifest.mcpServers, + mcpManifestPath, + manifestPath, + pluginSource, + pluginName: displayName, + pluginVersion: version, + projectPath: params.projectPath, + })), + ) + } + + return resources +} diff --git a/src/main/lib/shared-resources/governance.test.ts b/src/main/lib/shared-resources/governance.test.ts new file mode 100644 index 000000000..6d985a77c --- /dev/null +++ b/src/main/lib/shared-resources/governance.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, test } from "bun:test" +import { buildGovernedResourceProjection } from "./governance" +import type { SharedResource } from "./types" + +function resource(overrides: Partial & Pick): SharedResource { + return { + enabled: true, + ...overrides, + } +} + +describe("buildGovernedResourceProjection", () => { + test("resolves same-name conflicts by scope precedence and projects only the winner", () => { + const snapshot = buildGovernedResourceProjection({ + projectPath: "/workspace/app", + resources: [ + resource({ + id: "agent:project:reviewer", + kind: "agent", + name: "Reviewer", + scope: "project", + path: ".claude/agents/reviewer.md", + }), + resource({ + id: "agent:user:reviewer", + kind: "agent", + name: "reviewer", + scope: "user", + path: "~/.claude/agents/reviewer.md", + }), + ], + }) + + expect(snapshot.conflicts).toHaveLength(1) + expect(snapshot.conflicts[0]).toMatchObject({ + key: "agent:shared:reviewer", + winnerResourceId: "agent:project:reviewer", + resolution: "winner-by-precedence", + }) + + const userAgent = snapshot.resources.find( + (item) => item.id === "agent:user:reviewer", + ) + expect(userAgent?.conflict?.winnerResourceId).toBe("agent:project:reviewer") + + const claudeProjection = snapshot.projections.find( + (projection) => projection.engineId === "claude-code", + ) + expect(claudeProjection?.mappings.map((mapping) => mapping.resourceId)).toEqual([ + "agent:project:reviewer", + ]) + + const codexProjection = snapshot.projections.find( + (projection) => projection.engineId === "codex", + ) + expect(codexProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "agent:project:reviewer", + action: "prompt-inject", + }), + ) + expect(codexProjection?.warnings).toContain( + "reviewer is shadowed by a higher precedence resource.", + ) + }) + + test("tracks plugin MCP approval state and keeps unapproved resources out of projections", () => { + const snapshot = buildGovernedResourceProjection({ + resources: [ + resource({ + id: "mcp:claude-code:plugin:demo:browser", + kind: "mcp", + name: "browser", + scope: "plugin", + engine: "claude-code", + pluginSource: "demo", + path: "plugins/demo/mcp/browser.json", + metadata: { approved: false }, + enabled: false, + }), + ], + }) + + const pluginMcp = snapshot.resources[0] + expect(pluginMcp?.approval).toMatchObject({ + required: true, + approved: false, + }) + expect(pluginMcp?.provenance).toMatchObject({ + source: "plugin", + sourceId: "demo", + discoveredBy: "plugin MCP manifest", + }) + + for (const projection of snapshot.projections) { + expect(projection.mappings).toHaveLength(0) + } + }) + + test("projects Codex-native MCP resources natively for Codex", () => { + const snapshot = buildGovernedResourceProjection({ + resources: [ + resource({ + id: "mcp:codex:global:node_repl", + kind: "mcp", + name: "node_repl", + scope: "engine", + engine: "codex", + path: "~/.codex/config.toml", + }), + ], + }) + + const codexProjection = snapshot.projections.find( + (projection) => projection.engineId === "codex", + ) + expect(codexProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "mcp:codex:global:node_repl", + action: "native", + targetPath: "~/.codex/config.toml", + }), + ) + }) + + test("treats Moss Unified Source as canonical over legacy project files", () => { + const snapshot = buildGovernedResourceProjection({ + projectPath: "/workspace/app", + resources: [ + resource({ + id: "moss:instruction:moss.md", + kind: "instruction", + name: "AGENTS.md", + scope: "moss", + path: ".moss/source/moss.md", + metadata: { + mossRole: "source-instruction", + }, + }), + resource({ + id: "instruction:project:AGENTS.md", + kind: "instruction", + name: "AGENTS.md", + scope: "project", + path: "AGENTS.md", + }), + ], + }) + + expect(snapshot.conflicts[0]).toMatchObject({ + key: "instruction:shared:agents.md", + winnerResourceId: "moss:instruction:moss.md", + resolution: "winner-by-precedence", + }) + + const mossInstruction = snapshot.resources.find( + (item) => item.id === "moss:instruction:moss.md", + ) + expect(mossInstruction?.provenance).toMatchObject({ + source: "moss", + discoveredBy: "Moss Unified Source", + precedenceLabel: "Moss Unified Source is canonical", + }) + }) + + test("projects Moss resources to Claude, Codex, Hermes, and Custom ACP without duplicating source data", () => { + const snapshot = buildGovernedResourceProjection({ + projectPath: "/workspace/app", + resources: [ + resource({ + id: "moss:instruction:moss.md", + kind: "instruction", + name: "moss.md", + scope: "moss", + path: ".moss/source/moss.md", + metadata: { + mossRole: "source-instruction", + }, + }), + resource({ + id: "moss:skill:review", + kind: "skill", + name: "review", + scope: "moss", + path: ".moss/skills/review/SKILL.md", + metadata: { + mossRole: "skill", + entryName: "review", + }, + }), + resource({ + id: "moss:mcp:browser", + kind: "mcp", + name: "browser", + scope: "moss", + path: ".moss/mcp/config.json", + metadata: { + mossRole: "mcp-config", + }, + }), + resource({ + id: "moss:provider:providers.yaml", + kind: "provider", + name: "providers.yaml", + scope: "moss", + path: ".moss/providers.yaml", + metadata: { + mossRole: "provider-config", + }, + }), + resource({ + id: "moss:hook:on-stop", + kind: "hook", + name: "on-stop", + scope: "moss", + path: ".moss/hooks/on-stop.md", + metadata: { + mossRole: "hook", + entryName: "on-stop.md", + }, + }), + resource({ + id: "moss:plugin:moss-starter", + kind: "plugin", + name: "moss-starter", + scope: "moss", + path: ".moss/plugins/moss-starter.md", + metadata: { + mossRole: "plugin", + entryName: "moss-starter.md", + }, + }), + resource({ + id: "moss:subagent:reviewer", + kind: "subagent", + name: "reviewer", + scope: "moss", + path: ".moss/subagents/reviewer.md", + metadata: { + mossRole: "subagent", + entryName: "reviewer.md", + }, + }), + ], + }) + + const claudeProjection = snapshot.projections.find( + (projection) => projection.engineId === "claude-code", + ) + expect(claudeProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:instruction:moss.md", + action: "symlink", + targetPath: "CLAUDE.md", + }), + ) + expect(claudeProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:mcp:browser", + action: "managed-bridge", + targetPath: ".mcp.json", + }), + ) + expect(claudeProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:hook:on-stop", + action: "adapter-inject", + sourcePath: ".moss/hooks/on-stop.md", + targetPath: ".claude/hooks", + }), + ) + + const codexProjection = snapshot.projections.find( + (projection) => projection.engineId === "codex", + ) + expect(codexProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:instruction:moss.md", + action: "symlink", + targetPath: "AGENTS.md", + }), + ) + expect(codexProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:provider:providers.yaml", + action: "managed-bridge", + targetPath: ".codex/config.toml", + }), + ) + expect(codexProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:hook:on-stop", + action: "adapter-inject", + sourcePath: ".moss/hooks/on-stop.md", + targetPath: ".codex/hooks", + }), + ) + expect(codexProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:plugin:moss-starter", + action: "adapter-inject", + sourcePath: ".moss/plugins/moss-starter.md", + targetPath: ".codex/plugins", + }), + ) + expect(codexProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:subagent:reviewer", + action: "symlink", + sourcePath: ".moss/subagents/reviewer.md", + targetPath: ".codex/agents/reviewer.md", + }), + ) + + const hermesProjection = snapshot.projections.find( + (projection) => projection.engineId === "hermes", + ) + expect(hermesProjection?.status).toBe("ready") + expect(hermesProjection?.warnings).toEqual([]) + expect(hermesProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:skill:review", + action: "native", + sourcePath: ".moss/skills/review", + targetPath: ".moss/skills/review", + }), + ) + expect(hermesProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:hook:on-stop", + action: "native", + sourcePath: ".moss/hooks/on-stop.md", + targetPath: ".moss/hooks/on-stop.md", + }), + ) + + const customAcpProjection = snapshot.projections.find( + (projection) => projection.engineId === "custom-acp", + ) + expect(customAcpProjection?.status).toBe("unsupported") + expect(customAcpProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:instruction:moss.md", + action: "prompt-inject", + sourcePath: ".moss/source/moss.md", + }), + ) + expect(customAcpProjection?.mappings).toContainEqual( + expect.objectContaining({ + resourceId: "moss:provider:providers.yaml", + action: "prompt-inject", + sourcePath: ".moss/providers.yaml", + }), + ) + }) +}) diff --git a/src/main/lib/shared-resources/governance.ts b/src/main/lib/shared-resources/governance.ts new file mode 100644 index 000000000..169dac472 --- /dev/null +++ b/src/main/lib/shared-resources/governance.ts @@ -0,0 +1,579 @@ +import * as path from "path" +import { AGENT_RUNTIME_MANIFESTS } from "../agent-runtime/manifests" +import type { AgentEngineId } from "../agent-runtime/types" +import type { + EngineResourceProjection, + ResourcePathMapping, + SharedResource, + SharedResourceApproval, + SharedResourceConflict, + SharedResourceKind, + SharedResourceSnapshot, +} from "./types" + +const GOVERNED_RESOURCE_KINDS = new Set([ + "agent", + "subagent", + "skill", + "command", + "mcp", + "memory", + "instruction", + "hook", + "provider", + "config", + "automation", + "connector", + "app", + "tool", +]) + +const SCOPE_PRECEDENCE: Record = { + moss: 0, + project: 10, + user: 20, + engine: 30, + plugin: 40, +} + +function normalizeResourceName(name: string): string { + return name.trim().toLowerCase() +} + +function buildConflictKey(resource: SharedResource): string | undefined { + if (!GOVERNED_RESOURCE_KINDS.has(resource.kind)) return undefined + + const engineKey = ["mcp", "memory", "config", "automation", "connector", "app", "tool"].includes(resource.kind) + ? resource.engine ?? "shared" + : "shared" + return `${resource.kind}:${engineKey}:${normalizeResourceName(resource.name)}` +} + +function getPrecedenceRank(resource: SharedResource): number { + const scopeRank = SCOPE_PRECEDENCE[resource.scope] ?? 90 + const disabledPenalty = resource.enabled === false ? 100 : 0 + const approvalPenalty = + resource.approval?.required && !resource.approval.approved ? 50 : 0 + return scopeRank + disabledPenalty + approvalPenalty +} + +function getPrecedenceLabel(resource: SharedResource): string { + if (resource.scope === "moss") return "Moss Unified Source is canonical" + if (resource.scope === "project") return "project overrides user, engine, and plugin" + if (resource.scope === "user") return "user overrides engine and plugin" + if (resource.scope === "engine") return "engine-native config overrides plugin" + return "plugin is lowest precedence and may require approval" +} + +function getDiscoverySource(resource: SharedResource): string { + if (resource.scope === "moss") return "Moss Unified Source" + + const codexResourceRole = resource.metadata?.codexResourceRole + if (resource.engine === "codex" && codexResourceRole === "plugin-manifest") { + return "Codex plugin manifest" + } + if (resource.engine === "codex" && codexResourceRole === "plugin-skill") { + return "Codex plugin cache skill" + } + if (resource.engine === "codex" && codexResourceRole === "user-skill") { + return "Codex skill directory" + } + if (resource.engine === "codex" && codexResourceRole === "config") { + return "Codex config.toml" + } + if (resource.engine === "codex" && codexResourceRole === "browser-config") { + return "Codex browser config.toml" + } + if (resource.engine === "codex" && codexResourceRole === "auth") { + return "Codex auth state" + } + if (resource.engine === "codex" && codexResourceRole === "hooks") { + return "Codex hooks.json" + } + if (resource.engine === "codex" && codexResourceRole === "automation") { + return "Codex automation.toml" + } + if (resource.engine === "codex" && codexResourceRole === "connector") { + return "Codex connector directory cache" + } + if (resource.engine === "codex" && codexResourceRole === "codex-apps-server") { + return "Codex apps MCP server cache" + } + if (resource.engine === "codex" && codexResourceRole === "codex-app-tool") { + return "Codex apps tool cache" + } + if (resource.engine === "codex" && codexResourceRole === "remote-plugin-app") { + return "Codex remote plugin app catalog" + } + if (resource.engine === "codex" && codexResourceRole === "plugin-app") { + return "Codex plugin app manifest" + } + if (resource.engine === "codex" && codexResourceRole === "plugin-mcp-server") { + return "Codex plugin MCP manifest" + } + + if (resource.kind === "mcp") { + if (resource.scope === "plugin") return "plugin MCP manifest" + if (resource.engine === "codex") return "Codex MCP config" + if (resource.engine === "claude-code") return "Claude MCP config" + return "MCP config" + } + + if (resource.scope === "plugin") return "plugin component directory" + if (resource.kind === "memory") return "memory file" + if (resource.kind === "instruction") return "project instruction file" + return `${resource.scope} ${resource.kind} directory` +} + +function getMossEntryName(resource: SharedResource): string { + const entryName = resource.metadata?.entryName + if (typeof entryName === "string" && entryName.trim()) { + return entryName + } + + if (resource.path) return path.basename(resource.path) + return resource.name +} + +function getMossRole(resource: SharedResource): string | undefined { + const role = resource.metadata?.mossRole + return typeof role === "string" ? role : undefined +} + +function getMossProjectionSourcePath(resource: SharedResource): string | undefined { + const sourcePath = resource.path + if (!sourcePath) return undefined + + if ( + resource.kind === "skill" && + path.basename(sourcePath) === "SKILL.md" + ) { + return path.dirname(sourcePath) + } + + return sourcePath +} + +function buildMossProjectionMapping( + engineId: AgentEngineId, + resource: SharedResource, +): ResourcePathMapping | null { + const sourcePath = getMossProjectionSourcePath(resource) + if (!sourcePath) return null + + const role = getMossRole(resource) + const entryName = getMossEntryName(resource) + + if (engineId === "hermes") { + return { + resourceId: resource.id, + action: "native", + sourcePath, + targetPath: sourcePath, + reason: "Hermes/Moss consumes the .moss Unified Source natively.", + } + } + + if (engineId === "claude-code") { + if (resource.kind === "instruction" && role === "source-instruction") { + return { + resourceId: resource.id, + action: "symlink", + sourcePath, + targetPath: "CLAUDE.md", + reason: "Claude Code reads project instructions from CLAUDE.md.", + } + } + if (resource.kind === "memory") { + if (role === "memory-entry") { + return { + resourceId: resource.id, + action: "native", + sourcePath, + targetPath: path.join(".claude", "memory", entryName), + reason: "Claude Code sees this entry through the projected Moss memory root.", + } + } + return { + resourceId: resource.id, + action: "symlink", + sourcePath, + targetPath: ".claude/memory", + reason: "Claude Code project memory is mapped to Moss memory.", + } + } + if (resource.kind === "skill") { + return { + resourceId: resource.id, + action: "symlink", + sourcePath, + targetPath: path.join(".claude", "skills", entryName.replace(/\/SKILL\.md$/, "")), + reason: "Claude Code skills can be projected from Moss skills.", + } + } + if (resource.kind === "subagent") { + return { + resourceId: resource.id, + action: "symlink", + sourcePath, + targetPath: path.join(".claude", "agents", entryName), + reason: "Claude Code subagents are projected from Moss subagents.", + } + } + if (resource.kind === "mcp") { + return { + resourceId: resource.id, + action: "managed-bridge", + sourcePath, + targetPath: ".mcp.json", + reason: "Moss writes Claude-compatible MCP config from the canonical Moss MCP config.", + } + } + if (resource.kind === "hook") { + return { + resourceId: resource.id, + action: "adapter-inject", + sourcePath, + targetPath: ".claude/hooks", + reason: "Moss adapts hook events to Claude-compatible hooks without duplicating user config.", + } + } + if (resource.kind === "provider") { + return { + resourceId: resource.id, + action: "managed-bridge", + sourcePath, + targetPath: ".claude/settings.local.json", + reason: "Moss bridges provider credentials and routing into Claude Code native config.", + } + } + if (resource.kind === "plugin") { + return { + resourceId: resource.id, + action: "adapter-inject", + sourcePath, + targetPath: ".claude/plugins", + reason: "Moss exposes installed plugins through a Claude-compatible adapter.", + } + } + } + + if (engineId === "codex") { + if (resource.kind === "instruction" && role === "source-instruction") { + return { + resourceId: resource.id, + action: "symlink", + sourcePath, + targetPath: "AGENTS.md", + reason: "Codex reads project instructions from AGENTS.md.", + } + } + if (resource.kind === "memory") { + if (role === "memory-entry") { + return { + resourceId: resource.id, + action: "native", + sourcePath, + targetPath: path.join(".codex", "memories", entryName), + reason: "Codex sees this entry through the projected Moss memory root.", + } + } + return { + resourceId: resource.id, + action: "symlink", + sourcePath, + targetPath: ".codex/memories", + reason: "Codex project memory is mapped to Moss memory.", + } + } + if (resource.kind === "skill") { + return { + resourceId: resource.id, + action: "symlink", + sourcePath, + targetPath: path.join(".codex", "skills", entryName.replace(/\/SKILL\.md$/, "")), + reason: "Codex skills are projected from Moss skills.", + } + } + if (resource.kind === "subagent") { + return { + resourceId: resource.id, + action: "symlink", + sourcePath, + targetPath: path.join(".codex", "agents", entryName), + reason: "Codex subagents are projected from Moss subagents without a second real definition.", + } + } + if (resource.kind === "mcp") { + return { + resourceId: resource.id, + action: "managed-bridge", + sourcePath, + targetPath: ".codex/config.toml", + reason: "Moss writes Codex MCP TOML from the canonical Moss MCP config.", + } + } + if (resource.kind === "hook") { + return { + resourceId: resource.id, + action: "adapter-inject", + sourcePath, + targetPath: ".codex/hooks", + reason: "Moss adapts hook events to Codex-compatible hooks without duplicating user config.", + } + } + if (resource.kind === "provider") { + return { + resourceId: resource.id, + action: "managed-bridge", + sourcePath, + targetPath: ".codex/config.toml", + reason: "Moss bridges provider credentials and routing into Codex native config.", + } + } + if (resource.kind === "plugin") { + return { + resourceId: resource.id, + action: "adapter-inject", + sourcePath, + targetPath: ".codex/plugins", + reason: "Moss exposes installed plugins through a Codex-compatible adapter without duplicating user config.", + } + } + } + + return { + resourceId: resource.id, + action: "prompt-inject", + sourcePath, + reason: "Moss source is injected as shared prompt/context for this engine.", + } +} + +function getApproval(resource: SharedResource): SharedResourceApproval { + const metadataApproved = resource.metadata?.approved + + if (resource.kind === "mcp" && resource.scope === "plugin") { + const approved = metadataApproved === true + return { + required: true, + approved, + reason: approved + ? "Approved plugin MCP server." + : "Plugin MCP server must be approved before projection.", + } + } + + if (resource.scope === "plugin" && resource.enabled === false) { + return { + required: false, + approved: false, + reason: "Plugin is installed but disabled.", + } + } + + return { + required: false, + approved: resource.enabled !== false, + } +} + +function applyResourceGovernance(resources: SharedResource[]): { + resources: SharedResource[] + conflicts: SharedResourceConflict[] +} { + const governedResources = resources.map((resource) => { + const approval = getApproval(resource) + const withApproval: SharedResource = { + ...resource, + approval, + } + const precedenceRank = getPrecedenceRank(withApproval) + const conflictKey = buildConflictKey(resource) + + return { + ...withApproval, + precedenceRank, + conflictKey, + provenance: { + source: resource.scope, + sourceId: resource.pluginSource ?? resource.engine ?? resource.scope, + engine: resource.engine, + displayPath: resource.path, + discoveredBy: getDiscoverySource(resource), + precedenceRank, + precedenceLabel: getPrecedenceLabel(resource), + }, + } + }) + + const groups = new Map() + for (const resource of governedResources) { + if (!resource.conflictKey) continue + const group = groups.get(resource.conflictKey) ?? [] + group.push(resource) + groups.set(resource.conflictKey, group) + } + + const conflicts: SharedResourceConflict[] = [] + const conflictByResourceId = new Map() + + for (const [key, group] of groups) { + if (group.length < 2) continue + + const ordered = [...group].sort((left, right) => { + const rankDelta = (left.precedenceRank ?? 90) - (right.precedenceRank ?? 90) + if (rankDelta !== 0) return rankDelta + return left.id.localeCompare(right.id) + }) + const winner = ordered[0] + const conflict: SharedResourceConflict = { + key, + kind: winner.kind, + name: winner.name, + winnerResourceId: winner.id, + resourceIds: ordered.map((resource) => resource.id), + reason: `${winner.scope} resource wins by precedence.`, + resolution: "winner-by-precedence", + } + conflicts.push(conflict) + for (const resource of ordered) { + conflictByResourceId.set(resource.id, conflict) + } + } + + return { + resources: governedResources.map((resource) => ({ + ...resource, + conflict: conflictByResourceId.get(resource.id), + })), + conflicts, + } +} + +function canProjectResource( + resource: SharedResource, + warnings: string[], +): boolean { + if (resource.enabled === false) { + return false + } + + if (resource.approval?.required && !resource.approval.approved) { + warnings.push(`${resource.name} is withheld because approval is pending.`) + return false + } + + if ( + resource.conflict && + resource.conflict.resolution === "winner-by-precedence" && + resource.conflict.winnerResourceId !== resource.id + ) { + warnings.push(`${resource.name} is shadowed by a higher precedence resource.`) + return false + } + + return true +} + +function buildEngineProjection( + engineId: AgentEngineId, + projectPath: string | undefined, + resources: SharedResource[], +): EngineResourceProjection { + const manifest = AGENT_RUNTIME_MANIFESTS[engineId] + const mappings: ResourcePathMapping[] = [] + const warnings: string[] = [] + + for (const resource of resources) { + if (!resource.path) continue + if (!canProjectResource(resource, warnings)) continue + + if (resource.scope === "moss") { + const mossMapping = buildMossProjectionMapping(engineId, resource) + if (mossMapping) mappings.push(mossMapping) + continue + } + + if (engineId === "claude-code") { + if (["agent", "subagent", "skill", "command", "plugin", "mcp", "memory", "instruction", "hook", "provider"].includes(resource.kind)) { + mappings.push({ + resourceId: resource.id, + action: "native", + sourcePath: resource.path, + targetPath: resource.path, + }) + } + continue + } + + if (engineId === "codex") { + if ( + resource.engine === "codex" && + ["skill", "plugin", "config", "provider", "hook", "automation", "connector", "app", "tool"].includes(resource.kind) + ) { + mappings.push({ + resourceId: resource.id, + action: "native", + sourcePath: resource.path, + targetPath: resource.path, + }) + } else if (resource.kind === "mcp" && resource.engine === "codex") { + mappings.push({ + resourceId: resource.id, + action: "native", + sourcePath: resource.path, + targetPath: resource.path, + }) + } else if (["agent", "subagent", "skill", "command", "memory", "instruction", "hook", "provider"].includes(resource.kind)) { + mappings.push({ + resourceId: resource.id, + action: "prompt-inject", + sourcePath: resource.path, + reason: "Codex ACP does not consume Claude-native resource directories directly yet.", + }) + } + continue + } + + mappings.push({ + resourceId: resource.id, + action: "unsupported", + sourcePath: resource.path, + reason: "This engine does not have native resource projection implemented yet.", + }) + } + + if (engineId === "hermes" && manifest.availability === "unsupported") { + warnings.push("Hermes runtime is unavailable; Moss source remains canonical but cannot be launched natively.") + } + + if (engineId === "codex") { + warnings.push("Codex receives agents, skills, commands, and memory by prompt/context projection until native projection is implemented.") + } + + return { + engineId, + status: manifest.availability === "unsupported" ? "unsupported" : warnings.length > 0 ? "partial" : "ready", + userRoot: manifest.configRoots.user, + projectRoot: projectPath ? path.join(projectPath, manifest.configRoots.project || "") : manifest.configRoots.project, + mappings, + warnings, + } +} + +export function buildGovernedResourceProjection(params: { + resources: SharedResource[] + projectPath?: string +}): Pick { + const governed = applyResourceGovernance(params.resources) + const projections = (Object.keys(AGENT_RUNTIME_MANIFESTS) as AgentEngineId[]).map((engineId) => + buildEngineProjection(engineId, params.projectPath, governed.resources), + ) + + return { + resources: governed.resources, + conflicts: governed.conflicts, + projections, + } +} diff --git a/src/main/lib/shared-resources/index.ts b/src/main/lib/shared-resources/index.ts new file mode 100644 index 000000000..b35c59aaa --- /dev/null +++ b/src/main/lib/shared-resources/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./registry" diff --git a/src/main/lib/shared-resources/registry.test.ts b/src/main/lib/shared-resources/registry.test.ts new file mode 100644 index 000000000..215d33878 --- /dev/null +++ b/src/main/lib/shared-resources/registry.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { buildSharedResourceSnapshot } from "./registry" + +function makeTempRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "onecode-shared-registry-")) +} + +function writeFile(filePath: string, contents: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, contents, "utf-8") +} + +describe("buildSharedResourceSnapshot", () => { + test("loads in non-Electron automation scripts and includes Codex-native resources", async () => { + const root = makeTempRoot() + try { + const codexRoot = path.join(root, ".codex") + const pluginRoot = path.join( + codexRoot, + "plugins", + "cache", + "openai-bundled", + "record-and-replay", + "1.0.829", + ) + + writeFile( + path.join(codexRoot, "skills", "parity-audit", "SKILL.md"), + `---\nname: parity-audit\ndescription: Compare local UI against Codex Desktop.\n---\n\n# Parity Audit\n`, + ) + writeFile( + path.join(pluginRoot, ".codex-plugin", "plugin.json"), + JSON.stringify( + { + name: "record-and-replay", + version: "1.0.829", + description: "Record what I'm doing on my Mac", + skills: "./skills/", + interface: { + displayName: "Record & Replay", + shortDescription: "Record workflows", + }, + }, + null, + 2, + ), + ) + writeFile( + path.join(pluginRoot, "skills", "record-and-replay", "SKILL.md"), + `---\nname: record-and-replay\ndescription: Convert recordings into reusable skills.\n---\n\n# Record\n`, + ) + + const snapshot = await buildSharedResourceSnapshot({ + codexRoot, + codexPluginCacheRoot: path.join(codexRoot, "plugins", "cache"), + }) + + expect(snapshot.resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "skill:codex:user:parity-audit", + kind: "skill", + name: "parity-audit", + engine: "codex", + provenance: expect.objectContaining({ + discoveredBy: "Codex skill directory", + }), + }), + expect.objectContaining({ + id: "plugin:codex:openai-bundled:record-and-replay", + kind: "plugin", + name: "Record & Replay", + engine: "codex", + provenance: expect.objectContaining({ + discoveredBy: "Codex plugin manifest", + }), + }), + expect.objectContaining({ + id: "skill:codex:plugin:codex:openai-bundled:record-and-replay:record-and-replay", + kind: "skill", + name: "record-and-replay", + engine: "codex", + provenance: expect.objectContaining({ + discoveredBy: "Codex plugin cache skill", + }), + }), + ]), + ) + + const codexProjection = snapshot.projections.find( + (projection) => projection.engineId === "codex", + ) + expect(codexProjection?.mappings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: "skill:codex:user:parity-audit", + action: "native", + }), + expect.objectContaining({ + resourceId: "plugin:codex:openai-bundled:record-and-replay", + action: "native", + }), + ]), + ) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/lib/shared-resources/registry.ts b/src/main/lib/shared-resources/registry.ts new file mode 100644 index 000000000..792510966 --- /dev/null +++ b/src/main/lib/shared-resources/registry.ts @@ -0,0 +1,591 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import matter from "gray-matter" +import type { AgentEngineId } from "../agent-runtime" +import { ensureMossSource } from "../moss-source/bootstrap" +import { discoverMossSourceResources } from "../moss-source/registry" +import type { McpServerConfig } from "../claude-config" +import { + discoverInstalledPlugins, + discoverPluginMcpServers, + getPluginComponentPaths, +} from "../plugins" +import { + getApprovedPluginMcpServers, + getEnabledPlugins, +} from "../claude-plugin-settings" +import { + type SharedResource, + type SharedResourceSnapshot, +} from "./types" +import { buildGovernedResourceProjection } from "./governance" +import { collectCodexNativeResources } from "./codex-native-resources" + +const home = os.homedir() + +function toDisplayPath(filePath: string, projectPath?: string): string { + if (projectPath && filePath.startsWith(projectPath)) { + return path.relative(projectPath, filePath) + } + return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath +} + +function resourceId(parts: Array): string { + return parts.filter(Boolean).join(":") +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +function isSafeEntryName(name: string): boolean { + return !name.includes("..") && !name.includes("/") && !name.includes("\\") +} + +async function readFrontmatter(filePath: string): Promise<{ + name?: string + description?: string + body: string +}> { + const raw = await fs.readFile(filePath, "utf-8") + const parsed = matter(raw) + return { + name: typeof parsed.data.name === "string" ? parsed.data.name : undefined, + description: + typeof parsed.data.description === "string" + ? parsed.data.description + : undefined, + body: parsed.content.trim(), + } +} + +async function scanAgentFiles( + dir: string, + scope: "project" | "user" | "plugin", + projectPath?: string, + pluginSource?: string, +): Promise { + if (!(await pathExists(dir))) return [] + const entries = await fs.readdir(dir, { withFileTypes: true }) + const resources: SharedResource[] = [] + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md") || !isSafeEntryName(entry.name)) { + continue + } + + const filePath = path.join(dir, entry.name) + try { + const parsed = await readFrontmatter(filePath) + const name = parsed.name || entry.name.replace(/\.md$/, "") + resources.push({ + id: resourceId(["agent", scope, pluginSource, name]), + kind: "agent", + name, + scope, + pluginSource, + path: toDisplayPath(filePath, projectPath), + description: parsed.description, + enabled: scope !== "plugin" || Boolean(pluginSource), + }) + } catch { + continue + } + } + + return resources +} + +async function scanSkillDirs( + dir: string, + scope: "project" | "user" | "plugin", + projectPath?: string, + pluginSource?: string, +): Promise { + if (!(await pathExists(dir))) return [] + const entries = await fs.readdir(dir, { withFileTypes: true }) + const resources: SharedResource[] = [] + + for (const entry of entries) { + if (!entry.isDirectory() || !isSafeEntryName(entry.name)) continue + const filePath = path.join(dir, entry.name, "SKILL.md") + if (!(await pathExists(filePath))) continue + + try { + const parsed = await readFrontmatter(filePath) + const name = parsed.name || entry.name + resources.push({ + id: resourceId(["skill", scope, pluginSource, name]), + kind: "skill", + name, + scope, + pluginSource, + path: toDisplayPath(filePath, projectPath), + description: parsed.description, + enabled: scope !== "plugin" || Boolean(pluginSource), + }) + } catch { + continue + } + } + + return resources +} + +async function scanCommandFiles( + dir: string, + scope: "project" | "user" | "plugin", + projectPath?: string, + pluginSource?: string, + prefix = "", +): Promise { + if (!(await pathExists(dir))) return [] + const entries = await fs.readdir(dir, { withFileTypes: true }) + const resources: SharedResource[] = [] + + for (const entry of entries) { + if (!isSafeEntryName(entry.name)) continue + const filePath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + const nextPrefix = prefix ? `${prefix}:${entry.name}` : entry.name + resources.push( + ...(await scanCommandFiles( + filePath, + scope, + projectPath, + pluginSource, + nextPrefix, + )), + ) + continue + } + + if (!entry.isFile() || !entry.name.endsWith(".md")) continue + try { + const parsed = await readFrontmatter(filePath) + const fallback = entry.name.replace(/\.md$/, "") + const name = parsed.name || (prefix ? `${prefix}:${fallback}` : fallback) + resources.push({ + id: resourceId(["command", scope, pluginSource, name]), + kind: "command", + name, + scope, + pluginSource, + path: toDisplayPath(filePath, projectPath), + description: parsed.description, + enabled: scope !== "plugin" || Boolean(pluginSource), + }) + } catch { + continue + } + } + + return resources +} + +function mcpResourcesFromRecord(params: { + servers: Record + scope: "project" | "user" | "plugin" | "engine" + engine?: AgentEngineId + group: string + pluginSource?: string +}): SharedResource[] { + return Object.entries(params.servers).map(([name, config]) => ({ + id: resourceId(["mcp", params.engine, params.scope, params.pluginSource, params.group, name]), + kind: "mcp" as const, + name, + scope: params.scope, + engine: params.engine, + pluginSource: params.pluginSource, + enabled: true, + metadata: { + group: params.group, + transport: config.command ? "stdio" : config.url ? "http" : "unknown", + hasOAuth: Boolean(config._oauth), + authType: config.authType, + }, + })) +} + +async function collectClaudeMcpResources(projectPath?: string): Promise { + try { + const { + getMergedGlobalMcpServers, + getMergedLocalProjectMcpServers, + readClaudeConfig, + readClaudeDirConfig, + readProjectMcpJson, + resolveProjectPathFromWorktree, + } = await import("../claude-config") + const [claudeConfig, claudeDirConfig] = await Promise.all([ + readClaudeConfig(), + readClaudeDirConfig(), + ]) + const globalServers = await getMergedGlobalMcpServers( + claudeConfig, + claudeDirConfig, + ) + const resources = mcpResourcesFromRecord({ + servers: globalServers, + scope: "user", + engine: "claude-code", + group: "global", + }) + + if (projectPath) { + const resolvedProjectPath = resolveProjectPathFromWorktree(projectPath) || projectPath + const projectServers = await getMergedLocalProjectMcpServers( + resolvedProjectPath, + claudeConfig, + claudeDirConfig, + ) + const projectMcpJsonServers = await readProjectMcpJson(resolvedProjectPath) + resources.push( + ...mcpResourcesFromRecord({ + servers: { ...projectMcpJsonServers, ...projectServers }, + scope: "project", + engine: "claude-code", + group: resolvedProjectPath, + }), + ) + } + + return resources + } catch (error) { + return [{ + id: "mcp:claude-code:error", + kind: "mcp", + name: "Claude MCP discovery failed", + scope: "engine", + engine: "claude-code", + enabled: false, + metadata: { + error: error instanceof Error ? error.message : String(error), + }, + }] + } +} + +async function collectCodexMcpResources(): Promise { + try { + const { getAllCodexMcpConfigHandler } = await import("../trpc/routers/codex") + const snapshot = await getAllCodexMcpConfigHandler() + return snapshot.groups.flatMap((group) => + group.mcpServers.map((server) => ({ + id: resourceId(["mcp", "codex", group.projectPath || "global", server.name]), + kind: "mcp" as const, + name: server.name, + scope: group.projectPath ? "project" : "engine", + engine: "codex" as const, + enabled: server.status === "connected", + metadata: { + group: group.groupName, + projectPath: group.projectPath, + status: server.status, + needsAuth: server.needsAuth, + toolCount: server.tools.length, + config: server.config, + }, + })), + ) + } catch (error) { + return [{ + id: "mcp:codex:error", + kind: "mcp", + name: "Codex MCP discovery failed", + scope: "engine", + engine: "codex", + enabled: false, + metadata: { + error: error instanceof Error ? error.message : String(error), + }, + }] + } +} + +async function collectMemoryResources(projectPath?: string): Promise { + const resources: SharedResource[] = [] + + const addMemoryResource = async (params: { + filePath: string | null + name: string + scope: "project" | "user" + engine?: AgentEngineId + description: string + memoryRole: string + }) => { + if (!params.filePath || !(await pathExists(params.filePath))) return + + let entryType: "file" | "directory" = "file" + try { + const stat = await fs.stat(params.filePath) + entryType = stat.isDirectory() ? "directory" : "file" + } catch { + return + } + + resources.push({ + id: resourceId(["memory", params.engine, params.scope, params.filePath]), + kind: "memory", + name: params.name, + scope: params.scope, + engine: params.engine, + path: toDisplayPath(params.filePath, projectPath), + description: params.description, + enabled: true, + metadata: { + entryType, + memoryRole: params.memoryRole, + }, + }) + } + + const codexMemoryRoot = path.join(home, ".codex", "memories") + await addMemoryResource({ + filePath: codexMemoryRoot, + name: "Codex memories", + scope: "user", + engine: "codex", + description: "Codex user memory root.", + memoryRole: "codex-user-root", + }) + await addMemoryResource({ + filePath: path.join(codexMemoryRoot, "MEMORY.md"), + name: "MEMORY.md", + scope: "user", + engine: "codex", + description: "Codex memory registry.", + memoryRole: "codex-registry", + }) + await addMemoryResource({ + filePath: path.join(codexMemoryRoot, "memory_summary.md"), + name: "memory_summary.md", + scope: "user", + engine: "codex", + description: "Codex compact memory summary.", + memoryRole: "codex-summary", + }) + await addMemoryResource({ + filePath: path.join(codexMemoryRoot, "raw_memories.md"), + name: "raw_memories.md", + scope: "user", + engine: "codex", + description: "Codex raw memory notes.", + memoryRole: "codex-raw", + }) + await addMemoryResource({ + filePath: path.join(codexMemoryRoot, "extensions"), + name: "memory extensions", + scope: "user", + engine: "codex", + description: "Codex memory extension queue.", + memoryRole: "codex-extensions", + }) + await addMemoryResource({ + filePath: path.join(codexMemoryRoot, "rollout_summaries"), + name: "rollout_summaries", + scope: "user", + engine: "codex", + description: "Codex memory evidence summaries.", + memoryRole: "codex-rollouts", + }) + + await addMemoryResource({ + filePath: path.join(home, ".claude", "CLAUDE.md"), + name: "CLAUDE.md", + scope: "user", + engine: "claude-code", + description: "Claude Code user memory file.", + memoryRole: "claude-user-memory", + }) + await addMemoryResource({ + filePath: path.join(home, ".claude", "memory"), + name: "Claude memory", + scope: "user", + engine: "claude-code", + description: "Claude Code user memory directory.", + memoryRole: "claude-user-memory-dir", + }) + await addMemoryResource({ + filePath: path.join(home, ".claude", "memories"), + name: "Claude memories", + scope: "user", + engine: "claude-code", + description: "Claude Code user memories directory.", + memoryRole: "claude-user-memories-dir", + }) + + if (projectPath) { + await addMemoryResource({ + filePath: path.join(projectPath, "CLAUDE.md"), + name: "CLAUDE.md", + scope: "project", + engine: "claude-code", + description: "Claude Code project memory and instruction file.", + memoryRole: "claude-project-memory", + }) + await addMemoryResource({ + filePath: path.join(projectPath, ".claude", "memory"), + name: "Claude project memory", + scope: "project", + engine: "claude-code", + description: "Claude Code project memory directory.", + memoryRole: "claude-project-memory-dir", + }) + await addMemoryResource({ + filePath: path.join(projectPath, ".claude", "memories"), + name: "Claude project memories", + scope: "project", + engine: "claude-code", + description: "Claude Code project memories directory.", + memoryRole: "claude-project-memories-dir", + }) + await addMemoryResource({ + filePath: path.join(projectPath, ".codex", "memories"), + name: "Codex project memories", + scope: "project", + engine: "codex", + description: "Codex project memory directory.", + memoryRole: "codex-project-memory-dir", + }) + await addMemoryResource({ + filePath: path.join(projectPath, ".1code", "memory"), + name: "1Code shared memory", + scope: "project", + description: "1Code shared memory projection root.", + memoryRole: "onecode-shared-memory", + }) + + const agentsPath = path.join(projectPath, "AGENTS.md") + if (await pathExists(agentsPath)) { + resources.push({ + id: "instruction:project:AGENTS.md", + kind: "instruction", + name: "AGENTS.md", + scope: "project", + path: "AGENTS.md", + enabled: true, + }) + } + } + + return resources +} + +export async function buildSharedResourceSnapshot(params: { + projectPath?: string + codexRoot?: string + codexPluginCacheRoot?: string + codexCacheRoot?: string +} = {}): Promise { + const projectPath = params.projectPath + if (projectPath) { + await ensureMossSource({ projectPath }) + } + + const enabledPluginSources = await getEnabledPlugins() + const [installedPlugins, pluginMcpConfigs] = await Promise.all([ + discoverInstalledPlugins(), + discoverPluginMcpServers(), + ]) + const approvedPluginMcpServers = new Set(await getApprovedPluginMcpServers()) + const enabledPlugins = installedPlugins.filter((plugin) => + enabledPluginSources.includes(plugin.source), + ) + + const userClaudeRoot = path.join(home, ".claude") + const projectClaudeRoot = projectPath ? path.join(projectPath, ".claude") : null + + const resources: SharedResource[] = [ + ...(projectPath ? await discoverMossSourceResources(projectPath) : []), + ...(await scanAgentFiles(path.join(userClaudeRoot, "agents"), "user")), + ...(await scanSkillDirs(path.join(userClaudeRoot, "skills"), "user")), + ...(await scanCommandFiles(path.join(userClaudeRoot, "commands"), "user")), + ...(projectClaudeRoot + ? [ + ...(await scanAgentFiles(path.join(projectClaudeRoot, "agents"), "project", projectPath)), + ...(await scanSkillDirs(path.join(projectClaudeRoot, "skills"), "project", projectPath)), + ...(await scanCommandFiles(path.join(projectClaudeRoot, "commands"), "project", projectPath)), + ] + : []), + ...(await collectClaudeMcpResources(projectPath)), + ...(await collectCodexNativeResources({ + projectPath, + codexRoot: params.codexRoot, + pluginCacheRoot: params.codexPluginCacheRoot, + codexCacheRoot: params.codexCacheRoot, + })), + ...(await collectCodexMcpResources()), + ...(await collectMemoryResources(projectPath)), + ] + + for (const plugin of installedPlugins) { + const enabled = enabledPluginSources.includes(plugin.source) + resources.push({ + id: resourceId(["plugin", plugin.source]), + kind: "plugin", + name: plugin.name, + scope: "plugin", + pluginSource: plugin.source, + path: toDisplayPath(plugin.path), + description: plugin.description, + enabled, + metadata: { + marketplace: plugin.marketplace, + version: plugin.version, + category: plugin.category, + homepage: plugin.homepage, + tags: plugin.tags, + }, + }) + } + + for (const plugin of enabledPlugins) { + const paths = getPluginComponentPaths(plugin) + resources.push( + ...(await scanAgentFiles(paths.agents, "plugin", undefined, plugin.source)), + ...(await scanSkillDirs(paths.skills, "plugin", undefined, plugin.source)), + ...(await scanCommandFiles(paths.commands, "plugin", undefined, plugin.source)), + ) + } + + for (const pluginConfig of pluginMcpConfigs) { + for (const [serverName, serverConfig] of Object.entries(pluginConfig.mcpServers)) { + const approved = approvedPluginMcpServers.has(`${pluginConfig.pluginSource}:${serverName}`) + resources.push(...mcpResourcesFromRecord({ + servers: { [serverName]: serverConfig }, + scope: "plugin", + engine: "claude-code", + group: pluginConfig.pluginSource, + pluginSource: pluginConfig.pluginSource, + }).map((resource) => ({ + ...resource, + enabled: enabledPluginSources.includes(pluginConfig.pluginSource) && approved, + metadata: { + ...resource.metadata, + approved, + }, + }))) + } + } + + const governed = buildGovernedResourceProjection({ + resources, + projectPath, + }) + + return { + generatedAt: new Date().toISOString(), + projectPath, + resources: governed.resources, + conflicts: governed.conflicts, + projections: governed.projections, + } +} diff --git a/src/main/lib/shared-resources/types.ts b/src/main/lib/shared-resources/types.ts new file mode 100644 index 000000000..d91bf1dd2 --- /dev/null +++ b/src/main/lib/shared-resources/types.ts @@ -0,0 +1,96 @@ +import type { AgentEngineId } from "../agent-runtime" + +export type SharedResourceKind = + | "agent" + | "subagent" + | "skill" + | "command" + | "plugin" + | "mcp" + | "memory" + | "instruction" + | "hook" + | "provider" + | "config" + | "automation" + | "connector" + | "app" + | "tool" + +export type SharedResourceScope = "moss" | "project" | "user" | "plugin" | "engine" + +export interface SharedResourceProvenance { + source: SharedResourceScope + sourceId?: string + engine?: AgentEngineId + displayPath?: string + discoveredBy: string + precedenceRank: number + precedenceLabel: string +} + +export interface SharedResourceApproval { + required: boolean + approved: boolean + reason?: string +} + +export interface SharedResourceConflict { + key: string + kind: SharedResourceKind + name: string + winnerResourceId: string + resourceIds: string[] + reason: string + resolution: "unique" | "winner-by-precedence" | "manual-review" +} + +export interface SharedResource { + id: string + kind: SharedResourceKind + name: string + scope: SharedResourceScope + path?: string + engine?: AgentEngineId + pluginSource?: string + description?: string + enabled?: boolean + provenance?: SharedResourceProvenance + approval?: SharedResourceApproval + precedenceRank?: number + conflictKey?: string + conflict?: SharedResourceConflict + metadata?: Record +} + +export interface ResourcePathMapping { + resourceId: string + action: + | "native" + | "symlink" + | "copy" + | "prompt-inject" + | "adapter-inject" + | "managed-bridge" + | "unsupported" + sourcePath?: string + targetPath?: string + reason?: string +} + +export interface EngineResourceProjection { + engineId: AgentEngineId + status: "ready" | "partial" | "unsupported" + userRoot?: string + projectRoot?: string + mappings: ResourcePathMapping[] + warnings: string[] +} + +export interface SharedResourceSnapshot { + generatedAt: string + projectPath?: string + resources: SharedResource[] + conflicts: SharedResourceConflict[] + projections: EngineResourceProjection[] +} diff --git a/src/main/lib/trpc/routers/agent-runtime.ts b/src/main/lib/trpc/routers/agent-runtime.ts new file mode 100644 index 000000000..14a974287 --- /dev/null +++ b/src/main/lib/trpc/routers/agent-runtime.ts @@ -0,0 +1,828 @@ +import { eq } from "drizzle-orm" +import * as fs from "fs/promises" +import * as path from "path" +import { z } from "zod" +import { + AGENT_ENGINE_IDS, + DEFAULT_AGENT_ENGINE_ID, + buildMossForkSubChatRecord, + buildMossRollbackSubChatUpdate, + buildMossSessionActionPlan, + buildCodexNativeSessionBridgePlan, + buildHermesNativeSessionBridgePlan, + getMossSessionControlPlane, + getAgentRuntimeAdapter, + getAgentRuntimeManifest, + listAgentRuntimeManifests, + mergeMossSessionControlMetadata, + persistAgentRuntimeSession, + type AgentEngineId, + type AgentPermissionMode, +} from "../../agent-runtime" +import { getDatabase, chats, projects, subChats } from "../../db" +import { createId } from "../../db/utils" +import { applyRollbackStash } from "../../git/stash" +import { + materializeMossEngineProjectionSafely, + materializeMossWorkspaceProjections, + readMossProviderConfig, + setMossProviderSecret, + getMossProviderSecret, + hasMossProviderSecret, + summarizeMossProviderReadResult, + writeMossProviderConfig, + type MossProviderConfig, + type MossProviderDefinition, +} from "../../moss-source" +import { publicProcedure, router } from "../index" + +const agentEngineSchema = z.enum(AGENT_ENGINE_IDS) +const permissionModeValues = [ + "plan", + "agent", + "bypass", + "read-only", + "ask-approval", + "full-access", + "custom", +] as const +const permissionModeSchema = z.enum(permissionModeValues) +const providerModelSettingsSchema = z.object({ + hermes: z.string().optional(), + claudeCode: z.string().optional(), + codex: z.string().optional(), + customAcp: z.string().optional(), +}) + +function cleanString(value: string | undefined | null): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +async function buildStoredSecretSummary( + config: MossProviderConfig | undefined, +): Promise> { + const providerIds = Object.keys(config?.providers ?? {}) + const entries = await Promise.all( + providerIds.map(async (providerId) => [ + providerId, + { hasApiKey: await hasMossProviderSecret(providerId) }, + ] as const), + ) + return Object.fromEntries(entries) +} + +function getOrCreateCustomProvider( + config: MossProviderConfig, +): MossProviderDefinition { + const existing = config.providers.custom + return { + ...existing, + id: "custom", + label: existing?.label ?? "Custom OpenAI-Compatible", + mode: existing?.mode ?? "custom-url-key", + runtime: existing?.runtime ?? "any", + apiKeyEnv: existing?.apiKeyEnv ?? "MOSS_CUSTOM_API_KEY", + baseUrlEnv: existing?.baseUrlEnv ?? "MOSS_CUSTOM_BASE_URL", + engines: { + ...existing?.engines, + hermes: { + ...existing?.engines?.hermes, + model: existing?.engines?.hermes?.model ?? "moss-custom", + }, + "claude-code": { + ...existing?.engines?.["claude-code"], + model: existing?.engines?.["claude-code"]?.model ?? "opus", + }, + codex: { + ...existing?.engines?.codex, + model: existing?.engines?.codex?.model ?? "gpt-5.5/medium", + authMethod: + existing?.engines?.codex?.authMethod ?? "openai-api-key", + }, + "custom-acp": { + ...existing?.engines?.["custom-acp"], + model: existing?.engines?.["custom-acp"]?.model ?? "custom-acp", + }, + }, + } +} + +function parseRuntimeMetadata(value: string | null): Record { + if (!value) return {} + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +function isAgentPermissionMode(value: unknown): value is AgentPermissionMode { + return ( + typeof value === "string" && + permissionModeValues.includes(value as AgentPermissionMode) + ) +} + +function resolveSubChatPermissionMode(subChat: { + mode: string | null + runtimeMetadata: string | null +}): AgentPermissionMode { + const metadata = parseRuntimeMetadata(subChat.runtimeMetadata) + return isAgentPermissionMode(metadata.permissionMode) + ? metadata.permissionMode + : subChat.mode as AgentPermissionMode +} + +function normalizeEngineId(value: string | null | undefined): AgentEngineId { + return AGENT_ENGINE_IDS.includes(value as AgentEngineId) + ? value as AgentEngineId + : DEFAULT_AGENT_ENGINE_ID +} + +function getProjectPathForSubChat(subChatId: string): string { + const db = getDatabase() + const subChat = db + .select() + .from(subChats) + .where(eq(subChats.id, subChatId)) + .get() + if (!subChat) throw new Error("Sub-chat not found") + + const chat = db + .select() + .from(chats) + .where(eq(chats.id, subChat.chatId)) + .get() + if (!chat) throw new Error("Chat not found") + + const project = db + .select() + .from(projects) + .where(eq(projects.id, chat.projectId)) + .get() + if (!project) throw new Error("Project not found") + + return chat.worktreePath || project.path +} + +function getSubChatOrThrow(subChatId: string) { + const subChat = getDatabase() + .select() + .from(subChats) + .where(eq(subChats.id, subChatId)) + .get() + if (!subChat) throw new Error("Sub-chat not found") + return subChat +} + +function buildForkName(params: { + sourceSubChatId: string + chatId: string + sourceName: string | null + requestedName?: string +}): string { + if (params.requestedName?.trim()) return params.requestedName.trim() + + const baseName = (params.sourceName || "Chat").replace(/^\[\d+\]\s*/, "") + const siblings = getDatabase() + .select({ name: subChats.name }) + .from(subChats) + .where(eq(subChats.chatId, params.chatId)) + .all() + + let maxN = 0 + for (const sibling of siblings) { + const match = sibling.name?.match(/^\[(\d+)\]/) + if (match) maxN = Math.max(maxN, Number.parseInt(match[1], 10)) + } + + return `[${maxN + 1}] ${baseName}` +} + +async function copyClaudeForkSessionFiles(params: { + sourceSubChatId: string + targetSubChatId: string +}): Promise { + try { + const { app } = await import("electron") + const userDataPath = app.getPath("userData") + const sourceDir = path.join( + userDataPath, + "claude-sessions", + params.sourceSubChatId, + "projects", + ) + const targetDir = path.join( + userDataPath, + "claude-sessions", + params.targetSubChatId, + "projects", + ) + const sourceDirExists = await fs + .stat(sourceDir) + .then(() => true) + .catch(() => false) + if (!sourceDirExists) return false + + await fs.cp(sourceDir, targetDir, { recursive: true }) + return true + } catch (error) { + console.warn("[agentRuntime.forkSession] Failed to copy Claude session files:", error) + return false + } +} + +function buildActionPlanForSubChat(subChatId: string) { + const subChat = getSubChatOrThrow(subChatId) + const engine = normalizeEngineId(subChat.engine) + const manifest = getAgentRuntimeManifest(engine) + const nativeSessionId = + subChat.engineSessionId ?? + (engine === "claude-code" ? subChat.sessionId : null) + return { + subChat, + engine, + manifest, + plan: buildMossSessionActionPlan({ + subChatId, + engine, + nativeSessionId, + messages: subChat.messages, + features: manifest.features, + }), + } +} + +function updateSessionControlMetadata(params: { + subChatId: string + runtimeMetadata: string | null + metadata: Record +}) { + return getDatabase() + .update(subChats) + .set({ + runtimeMetadata: mergeMossSessionControlMetadata( + params.runtimeMetadata, + params.metadata, + ), + updatedAt: new Date(), + }) + .where(eq(subChats.id, params.subChatId)) + .returning() + .get() +} + +export const agentRuntimeRouter = router({ + listEngines: publicProcedure.query(async () => { + const manifests = listAgentRuntimeManifests() + const healthEntries = await Promise.all( + manifests.map(async (manifest) => { + const adapter = getAgentRuntimeAdapter(manifest.id) + const session = { + subChatId: "", + chatId: "", + engineId: manifest.id, + permissionMode: "agent", + cwd: "", + } as const + const health = adapter.inspect + ? await adapter.inspect(session) + : { + availability: await adapter.canStart(session), + } + return [manifest.id, health] as const + }), + ) + + const healthByEngine = Object.fromEntries(healthEntries) + + return manifests.map((manifest) => ({ + ...manifest, + availability: healthByEngine[manifest.id]?.availability ?? manifest.availability, + statusReason: healthByEngine[manifest.id]?.statusReason, + authMethod: healthByEngine[manifest.id]?.authMethod, + models: healthByEngine[manifest.id]?.models ?? manifest.models, + })) + }), + + getSession: publicProcedure + .input(z.object({ subChatId: z.string() })) + .query(({ input }) => { + const db = getDatabase() + const subChat = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + + if (!subChat) { + return null + } + + const metadata = parseRuntimeMetadata(subChat.runtimeMetadata) + + return { + subChatId: subChat.id, + chatId: subChat.chatId, + engine: subChat.engine as AgentEngineId, + legacySessionId: subChat.sessionId, + nativeSessionId: subChat.engineSessionId, + configDir: subChat.engineConfigDir, + modelId: subChat.modelId, + permissionMode: isAgentPermissionMode(metadata.permissionMode) + ? metadata.permissionMode + : subChat.mode as AgentPermissionMode, + metadata, + updatedAt: subChat.updatedAt, + } + }), + + getProviderConfig: publicProcedure + .input( + z.object({ + projectPath: z.string().optional(), + }), + ) + .query(async ({ input }) => { + if (!input.projectPath) { + return { + status: "missing" as const, + sourcePath: "", + providers: [], + } + } + + const readResult = await readMossProviderConfig(input.projectPath, { + createIfMissing: true, + }) + const storedSecrets = await buildStoredSecretSummary(readResult.config) + return summarizeMossProviderReadResult(readResult, storedSecrets) + }), + + getControlPlane: publicProcedure + .input( + z.object({ + projectPath: z.string().optional(), + }), + ) + .query(({ input }) => + getMossSessionControlPlane({ + projectPath: input.projectPath, + secretResolver: { getSecret: getMossProviderSecret }, + }), + ), + + getSessionActionPlan: publicProcedure + .input(z.object({ subChatId: z.string() })) + .query(({ input }) => buildActionPlanForSubChat(input.subChatId).plan), + + prepareSessionResume: publicProcedure + .input(z.object({ subChatId: z.string() })) + .mutation(({ input }) => { + const { subChat, plan } = buildActionPlanForSubChat(input.subChatId) + const action = plan.actions.resume + if (action.status !== "ready") { + throw new Error(action.reason || "Session is not ready to resume.") + } + const engine = normalizeEngineId(subChat.engine) + const nativeSessionId = + subChat.engineSessionId ?? + (engine === "claude-code" ? subChat.sessionId : null) + const permissionMode = resolveSubChatPermissionMode(subChat) + const nativeBridgePlan = + engine === "codex" && nativeSessionId + ? buildCodexNativeSessionBridgePlan({ + action: "resume", + sessionId: nativeSessionId, + cwd: getProjectPathForSubChat(input.subChatId), + modelId: subChat.modelId, + permissionMode, + }) + : engine === "hermes" && nativeSessionId + ? buildHermesNativeSessionBridgePlan({ + action: "resume", + sessionId: nativeSessionId, + cwd: getProjectPathForSubChat(input.subChatId), + modelId: subChat.modelId, + permissionMode, + }) + : undefined + const nativeBridgeRunner = + nativeBridgePlan?.bridge === "codex-exec-resume" + ? { + kind: "codex-exec-resume", + runner: "runCodexExecResumeBridge", + promptSource: "stdin", + canRunHeadless: nativeBridgePlan.canRunHeadless, + } + : nativeBridgePlan?.bridge === "hermes-cli-resume" + ? { + kind: "hermes-cli-resume", + runner: "runHermesCliResumeBridge", + promptSource: nativeBridgePlan.promptSource, + canRunHeadless: nativeBridgePlan.canRunHeadless, + } + : undefined + + const updated = updateSessionControlMetadata({ + subChatId: input.subChatId, + runtimeMetadata: subChat.runtimeMetadata, + metadata: { + action: "resume", + mode: action.mode, + status: action.status, + nativeSessionLinked: action.mode === "native", + nativeSessionId, + nativeBridgePlan, + nativeBridgeRunner, + }, + }) + + return { + success: true as const, + action: "resume" as const, + mode: action.mode, + nativeBridgePlan, + nativeBridgeRunner, + subChat: updated, + } + }), + + forkSession: publicProcedure + .input( + z.object({ + subChatId: z.string(), + messageId: z.string().optional(), + messageIndex: z.number().int().nonnegative().optional(), + name: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const { subChat: sourceSubChat, engine } = + buildActionPlanForSubChat(input.subChatId) + const sourceNativeSessionId = + sourceSubChat.engineSessionId ?? + (engine === "claude-code" ? sourceSubChat.sessionId : null) + const permissionMode = resolveSubChatPermissionMode(sourceSubChat) + const nativeBridgePlan = + engine === "codex" && sourceNativeSessionId + ? buildCodexNativeSessionBridgePlan({ + action: "fork", + sessionId: sourceNativeSessionId, + cwd: getProjectPathForSubChat(input.subChatId), + modelId: sourceSubChat.modelId, + permissionMode, + }) + : engine === "hermes" && sourceNativeSessionId + ? buildHermesNativeSessionBridgePlan({ + action: "fork", + sessionId: sourceNativeSessionId, + cwd: getProjectPathForSubChat(input.subChatId), + modelId: sourceSubChat.modelId, + permissionMode, + targetMessageId: input.messageId, + }) + : undefined + const forkName = buildForkName({ + sourceSubChatId: input.subChatId, + chatId: sourceSubChat.chatId, + sourceName: sourceSubChat.name, + requestedName: input.name, + }) + const targetSubChatId = createId() + let forkRecord = buildMossForkSubChatRecord({ + sourceSubChat, + targetSubChatId, + targetName: forkName, + targetMessageId: input.messageId, + targetMessageIndex: input.messageIndex, + nativeBridgePlan, + }) + let snapshot = forkRecord.snapshot + let nativeSessionLinked = forkRecord.nativeSessionLinked + + let newSubChat = getDatabase() + .insert(subChats) + .values(forkRecord.insertValues) + .returning() + .get() + + if (nativeSessionLinked && engine === "claude-code") { + const copied = sourceSubChat.sessionId + ? await copyClaudeForkSessionFiles({ + sourceSubChatId: input.subChatId, + targetSubChatId, + }) + : false + + if (!copied) { + forkRecord = buildMossForkSubChatRecord({ + sourceSubChat, + targetSubChatId, + targetName: forkName, + targetMessageId: input.messageId, + targetMessageIndex: input.messageIndex, + nativeBridgePlan, + forceTranscript: true, + fallbackReason: "Claude session files were not available to copy.", + }) + snapshot = forkRecord.snapshot + nativeSessionLinked = forkRecord.nativeSessionLinked + newSubChat = getDatabase() + .update(subChats) + .set({ + messages: forkRecord.insertValues.messages, + sessionId: forkRecord.insertValues.sessionId, + engineSessionId: forkRecord.insertValues.engineSessionId, + runtimeMetadata: forkRecord.insertValues.runtimeMetadata, + updatedAt: new Date(), + }) + .where(eq(subChats.id, targetSubChatId)) + .returning() + .get() + } + } + + return { + success: true as const, + action: "fork" as const, + mode: snapshot.mode, + nativeSessionLinked, + subChat: newSubChat, + messageCount: snapshot.messageCount, + forkAtSdkUuid: snapshot.forkAtSdkUuid, + nativeBridgePlan, + } + }), + + rollbackSession: publicProcedure + .input( + z.object({ + subChatId: z.string(), + targetMessageId: z.string().optional(), + targetSdkMessageUuid: z.string().optional(), + applyGitCheckpoint: z.boolean().optional(), + }), + ) + .mutation(async ({ input }) => { + const { subChat, engine } = buildActionPlanForSubChat(input.subChatId) + const sourceNativeSessionId = + subChat.engineSessionId ?? + (engine === "claude-code" ? subChat.sessionId : null) + const permissionMode = resolveSubChatPermissionMode(subChat) + const nativeBridgePlan = + engine === "hermes" && sourceNativeSessionId + ? buildHermesNativeSessionBridgePlan({ + action: "rollback", + sessionId: sourceNativeSessionId, + cwd: getProjectPathForSubChat(input.subChatId), + modelId: subChat.modelId, + permissionMode, + targetMessageId: input.targetMessageId, + targetSdkMessageUuid: input.targetSdkMessageUuid, + }) + : undefined + const rollbackRecord = buildMossRollbackSubChatUpdate({ + subChat, + targetMessageId: input.targetMessageId, + targetSdkMessageUuid: input.targetSdkMessageUuid, + appliedGitCheckpoint: Boolean(input.applyGitCheckpoint), + nativeBridgePlan, + }) + const snapshot = rollbackRecord.snapshot + + if (input.applyGitCheckpoint) { + if (!snapshot.targetSdkMessageUuid) { + throw new Error("A target SDK message UUID is required for git rollback.") + } + + const chat = getDatabase() + .select() + .from(chats) + .where(eq(chats.id, subChat.chatId)) + .get() + if (!chat?.worktreePath) { + throw new Error("A worktree path is required for git rollback.") + } + + const rollback = await applyRollbackStash( + chat.worktreePath, + snapshot.targetSdkMessageUuid, + ) + if (!rollback.success) { + throw new Error(`Git rollback failed: ${rollback.error}`) + } + if (!rollback.checkpointFound) { + throw new Error("Checkpoint not found - cannot rollback git state.") + } + } + + const updated = getDatabase() + .update(subChats) + .set(rollbackRecord.updateValues) + .where(eq(subChats.id, input.subChatId)) + .returning() + .get() + + return { + success: true as const, + action: "rollback" as const, + mode: snapshot.mode, + nativeSessionLinked: snapshot.nativeSessionLinked, + subChat: updated, + messageCount: snapshot.messageCount, + targetMessageId: snapshot.targetMessageId, + targetSdkMessageUuid: snapshot.targetSdkMessageUuid, + nativeBridgePlan, + } + }), + + getProviderSettings: publicProcedure + .input( + z.object({ + projectPath: z.string().optional(), + }), + ) + .query(async ({ input }) => { + if (!input.projectPath) { + return { + status: "missing" as const, + sourcePath: "", + defaultProvider: "moss", + useCustomProvider: false, + customProvider: null, + } + } + + const readResult = await readMossProviderConfig(input.projectPath, { + createIfMissing: true, + }) + if (readResult.status !== "found" || !readResult.config) { + return { + status: readResult.status, + sourcePath: readResult.sourcePath, + defaultProvider: "moss", + useCustomProvider: false, + customProvider: null, + error: readResult.error, + } + } + + const custom = getOrCreateCustomProvider(readResult.config) + const hasApiKey = await hasMossProviderSecret(custom.id) + + return { + status: "found" as const, + sourcePath: readResult.sourcePath, + defaultProvider: readResult.config.defaultProvider ?? "moss", + useCustomProvider: readResult.config.defaultProvider === "custom", + customProvider: { + id: custom.id, + label: custom.label, + mode: custom.mode, + baseUrl: custom.baseUrl ?? "", + hasApiKey, + models: { + hermes: custom.engines?.hermes?.model ?? "", + claudeCode: custom.engines?.["claude-code"]?.model ?? "", + codex: custom.engines?.codex?.model ?? "", + customAcp: custom.engines?.["custom-acp"]?.model ?? "", + }, + }, + } + }), + + saveProviderSettings: publicProcedure + .input( + z.object({ + projectPath: z.string(), + useCustomProvider: z.boolean(), + customProvider: z.object({ + apiKey: z.string().optional(), + clearApiKey: z.boolean().optional(), + baseUrl: z.string().optional(), + models: providerModelSettingsSchema.optional(), + }), + }), + ) + .mutation(async ({ input }) => { + const readResult = await readMossProviderConfig(input.projectPath, { + createIfMissing: true, + }) + if (readResult.status !== "found" || !readResult.config) { + throw new Error(readResult.error || "Unable to read Moss provider config") + } + + const config = readResult.config + const custom = getOrCreateCustomProvider(config) + const models = input.customProvider.models + + custom.baseUrl = cleanString(input.customProvider.baseUrl) + custom.engines = { + ...custom.engines, + hermes: { + ...custom.engines?.hermes, + model: cleanString(models?.hermes) ?? "moss-custom", + }, + "claude-code": { + ...custom.engines?.["claude-code"], + model: cleanString(models?.claudeCode) ?? "opus", + }, + codex: { + ...custom.engines?.codex, + model: cleanString(models?.codex) ?? "gpt-5.5/medium", + authMethod: custom.engines?.codex?.authMethod ?? "openai-api-key", + }, + "custom-acp": { + ...custom.engines?.["custom-acp"], + model: cleanString(models?.customAcp) ?? "custom-acp", + }, + } + delete custom.apiKey + + config.defaultProvider = input.useCustomProvider ? "custom" : "moss" + config.credentialPolicy = { + ...config.credentialPolicy, + singleUserConfiguration: true, + allowCustomBaseUrl: true, + allowCustomApiKey: true, + shareAcrossEngines: true, + } + config.providers.custom = custom + + if (input.customProvider.clearApiKey) { + await setMossProviderSecret({ providerId: "custom", apiKey: null }) + } else if (typeof input.customProvider.apiKey === "string") { + await setMossProviderSecret({ + providerId: "custom", + apiKey: input.customProvider.apiKey, + }) + } + + const updated = await writeMossProviderConfig(input.projectPath, config) + const storedSecrets = await buildStoredSecretSummary(updated.config) + return summarizeMossProviderReadResult(updated, storedSecrets) + }), + + setSessionEngine: publicProcedure + .input( + z.object({ + subChatId: z.string(), + engine: agentEngineSchema, + nativeSessionId: z.string().nullable().optional(), + configDir: z.string().nullable().optional(), + modelId: z.string().nullable().optional(), + permissionMode: permissionModeSchema.optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }), + ) + .mutation(async ({ input }) => { + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: input.engine, + nativeSessionId: input.nativeSessionId, + configDir: input.configDir, + modelId: input.modelId, + permissionMode: input.permissionMode, + metadata: input.metadata, + updateLegacySessionId: input.engine === "claude-code", + }) + + const projectPath = getProjectPathForSubChat(input.subChatId) + const projection = await materializeMossEngineProjectionSafely({ + projectPath, + engineId: input.engine, + createIfMissing: true, + }) + + return { success: true as const, projection } + }), + + materializeProjection: publicProcedure + .input( + z.object({ + subChatId: z.string().optional(), + projectPath: z.string().optional(), + engine: agentEngineSchema.optional(), + dryRun: z.boolean().optional(), + createIfMissing: z.boolean().optional(), + }), + ) + .mutation(async ({ input }) => { + const projectPath = input.projectPath || + (input.subChatId ? getProjectPathForSubChat(input.subChatId) : null) + + if (!projectPath) { + throw new Error("projectPath or subChatId is required") + } + + return materializeMossWorkspaceProjections({ + projectPath, + engines: input.engine ? [input.engine] : AGENT_ENGINE_IDS, + dryRun: input.dryRun, + createIfMissing: input.createIfMissing ?? true, + }) + }), +}) diff --git a/src/main/lib/trpc/routers/agent-utils.ts b/src/main/lib/trpc/routers/agent-utils.ts index ababf03b9..bacd837d6 100644 --- a/src/main/lib/trpc/routers/agent-utils.ts +++ b/src/main/lib/trpc/routers/agent-utils.ts @@ -1,10 +1,13 @@ import * as fs from "fs/promises" import * as path from "path" import * as os from "os" -import matter from "gray-matter" import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" import { resolveDirentType } from "../../fs/dirent" import { getEnabledPlugins } from "./claude-settings" +import { + parseMossFrontmatter, + stringifyMossFrontmatter, +} from "../../moss-source/frontmatter" // Valid model values for agents export const VALID_AGENT_MODELS = ["sonnet", "opus", "haiku", "inherit"] as const @@ -44,7 +47,7 @@ export function parseAgentMd( filename: string ): Partial { try { - const { data, content: body } = matter(content) + const { data, content: body } = parseMossFrontmatter(content) // Parse tools - can be comma-separated string or array let tools: string[] | undefined @@ -70,7 +73,8 @@ export function parseAgentMd( // Validate model const model = - data.model && VALID_AGENT_MODELS.includes(data.model) + typeof data.model === "string" && + VALID_AGENT_MODELS.includes(data.model as AgentModel) ? (data.model as AgentModel) : undefined @@ -100,20 +104,21 @@ export function generateAgentMd(agent: { disallowedTools?: string[] model?: AgentModel }): string { - const frontmatter: string[] = [] - frontmatter.push(`name: ${agent.name}`) - frontmatter.push(`description: ${agent.description}`) + const frontmatter: Record = { + name: agent.name, + description: agent.description, + } if (agent.tools && agent.tools.length > 0) { - frontmatter.push(`tools: ${agent.tools.join(", ")}`) + frontmatter.tools = agent.tools.join(", ") } if (agent.disallowedTools && agent.disallowedTools.length > 0) { - frontmatter.push(`disallowedTools: ${agent.disallowedTools.join(", ")}`) + frontmatter.disallowedTools = agent.disallowedTools.join(", ") } if (agent.model && agent.model !== "inherit") { - frontmatter.push(`model: ${agent.model}`) + frontmatter.model = agent.model } - return `---\n${frontmatter.join("\n")}\n---\n\n${agent.prompt}` + return stringifyMossFrontmatter(`\n\n${agent.prompt}`, frontmatter) } /** diff --git a/src/main/lib/trpc/routers/agents.ts b/src/main/lib/trpc/routers/agents.ts index a2b19cedf..5cba3757c 100644 --- a/src/main/lib/trpc/routers/agents.ts +++ b/src/main/lib/trpc/routers/agents.ts @@ -12,6 +12,7 @@ import { } from "./agent-utils" import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" import { getEnabledPlugins } from "./claude-settings" +import { removeMossProjectionResource } from "../../moss-source" // Shared procedure for listing agents const listAgentsProcedure = publicProcedure @@ -62,6 +63,28 @@ const listAgentsProcedure = publicProcedure return [...projectAgents, ...userAgents, ...pluginAgents] }) +async function resolveMossSubagentSymlinkSource( + agentPath: string, + projectPath: string, +): Promise { + try { + const stat = await fs.lstat(agentPath) + if (!stat.isSymbolicLink()) return null + const linkTarget = await fs.readlink(agentPath) + const resolvedTarget = path.resolve(path.dirname(agentPath), linkTarget) + const mossSubagentsRoot = path.resolve(projectPath, ".moss", "subagents") + if ( + resolvedTarget === mossSubagentsRoot || + resolvedTarget.startsWith(`${mossSubagentsRoot}${path.sep}`) + ) { + return resolvedTarget + } + } catch { + return null + } + return null +} + export const agentsRouter = router({ /** * List all agents from filesystem @@ -306,10 +329,12 @@ export const agentsRouter = router({ } let targetDir: string + let projectPath: string | undefined if (input.source === "project") { if (!input.cwd) { throw new Error("Project path (cwd) required for project agents") } + projectPath = input.cwd targetDir = path.join(input.cwd, ".claude", "agents") } else { targetDir = path.join(os.homedir(), ".claude", "agents") @@ -317,6 +342,30 @@ export const agentsRouter = router({ const agentPath = path.join(targetDir, `${safeName}.md`) + const mossSourcePath = projectPath + ? await resolveMossSubagentSymlinkSource(agentPath, projectPath) + : null + + if (mossSourcePath && projectPath) { + try { + await fs.unlink(mossSourcePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + await removeMossProjectionResource({ + projectPath, + resourceId: `moss:subagent:${safeName}`, + sourcePath: path.relative(projectPath, mossSourcePath), + targetPaths: [ + path.join(".claude", "agents", `${safeName}.md`), + path.join(".codex", "agents", `${safeName}.md`), + ], + removeTargets: true, + }) + await fs.rm(agentPath, { force: true }) + return { deleted: true } + } + await fs.unlink(agentPath) return { deleted: true } diff --git a/src/main/lib/trpc/routers/chat-runtime-selection.test.ts b/src/main/lib/trpc/routers/chat-runtime-selection.test.ts new file mode 100644 index 000000000..703227c4b --- /dev/null +++ b/src/main/lib/trpc/routers/chat-runtime-selection.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { + buildEmptySubChatValues, + buildInitialSubChatValues, + resolveChatRuntimeSelection, +} from "./chat-runtime-selection" + +describe("chat runtime selection persistence", () => { + test("defaults new chat sessions to Hermes instead of Claude", () => { + expect(resolveChatRuntimeSelection({})).toEqual({ + engine: "hermes", + modelId: undefined, + }) + }) + + test("preserves selected Hermes engine and model for initial sub-chat creation", () => { + expect( + buildInitialSubChatValues({ + chatId: "chat-1", + engine: "hermes", + model: "gpt-5.5", + mode: "agent", + messages: "[]", + }), + ).toMatchObject({ + chatId: "chat-1", + engine: "hermes", + modelId: "gpt-5.5", + mode: "agent", + messages: "[]", + }) + }) + + test("preserves selected provider for follow-up sub-chat creation", () => { + expect( + buildEmptySubChatValues({ + chatId: "chat-1", + name: "Continue with Codex", + engine: "codex", + model: "gpt-5.5/high", + mode: "plan", + }), + ).toEqual({ + chatId: "chat-1", + name: "Continue with Codex", + engine: "codex", + modelId: "gpt-5.5/high", + mode: "plan", + messages: "[]", + }) + }) +}) diff --git a/src/main/lib/trpc/routers/chat-runtime-selection.ts b/src/main/lib/trpc/routers/chat-runtime-selection.ts new file mode 100644 index 000000000..a572cdb23 --- /dev/null +++ b/src/main/lib/trpc/routers/chat-runtime-selection.ts @@ -0,0 +1,55 @@ +import { + DEFAULT_AGENT_ENGINE_ID, + type AgentEngineId, +} from "../../agent-runtime/types" + +export type ChatMode = "plan" | "agent" + +export interface ChatRuntimeSelectionInput { + engine?: AgentEngineId + model?: string +} + +export function resolveChatRuntimeSelection( + input: ChatRuntimeSelectionInput, +): { engine: AgentEngineId; modelId?: string } { + return { + engine: input.engine ?? DEFAULT_AGENT_ENGINE_ID, + modelId: input.model, + } +} + +export function buildInitialSubChatValues(input: { + chatId: string + engine?: AgentEngineId + model?: string + mode: ChatMode + messages: string + runtimeMetadata?: string +}) { + return { + chatId: input.chatId, + ...resolveChatRuntimeSelection(input), + mode: input.mode, + messages: input.messages, + ...(input.runtimeMetadata + ? { runtimeMetadata: input.runtimeMetadata } + : {}), + } +} + +export function buildEmptySubChatValues(input: { + chatId: string + name?: string + engine?: AgentEngineId + model?: string + mode: ChatMode +}) { + return { + chatId: input.chatId, + name: input.name, + ...resolveChatRuntimeSelection(input), + mode: input.mode, + messages: "[]", + } +} diff --git a/src/main/lib/trpc/routers/chats.ts b/src/main/lib/trpc/routers/chats.ts index a699b445d..8f61133a1 100644 --- a/src/main/lib/trpc/routers/chats.ts +++ b/src/main/lib/trpc/routers/chats.ts @@ -4,6 +4,11 @@ import * as fs from "fs/promises" import * as path from "path" import simpleGit from "simple-git" import { z } from "zod" +import { + getCodexOutputArtifactsFromBlocks, + normalizeCodexConversationBlocksFromMessage, + type CodexOutputArtifact, +} from "../../../../shared/codex-tool-normalizer" import { getAuthManager } from "../../../index" import { trackPRCreated, @@ -11,6 +16,21 @@ import { trackWorkspaceCreated, trackWorkspaceDeleted, } from "../../analytics" +import { + AGENT_ENGINE_IDS, + DEFAULT_AGENT_ENGINE_ID, + buildMossForkSnapshot, + getAgentRuntimeManifest, + mergeMossSessionControlMetadata, + type AgentEngineId, + type AgentPermissionMode, +} from "../../agent-runtime" +import { + readCodexNativeSessionEventsById, + recoverCodexNativeMessagesFromSessionEvents, + type CodexNativeRecoveredMessage, +} from "../../agent-runtime/codex-native-recovery" +import { shouldClearStaleAgentStreamId } from "../../agent-runtime/stale-stream-state" import { chats, getDatabase, projects, subChats } from "../../db" import { createWorktreeForChat, @@ -24,9 +44,19 @@ import { computeContentHash, gitCache } from "../../git/cache" import { splitUnifiedDiffByFile } from "../../git/diff-parser" import { execWithShellEnv } from "../../git/shell-env" import { applyRollbackStash } from "../../git/stash" +import { + linkMossSourceIntoWorkspace, + materializeMossWorkspaceProjections, +} from "../../moss-source" import { checkInternetConnection, checkOllamaStatus } from "../../ollama" import { terminalManager } from "../../terminal/manager" import { publicProcedure, router } from "../index" +import { + buildEmptySubChatValues, + buildInitialSubChatValues, +} from "./chat-runtime-selection" +import { hasActiveCodexStreamForSubChat } from "./codex" +import { hasActiveHermesStreamForSubChat } from "./hermes" type WorktreeSetupFailurePayload = { kind: "create-failed" | "setup-failed" @@ -34,6 +64,121 @@ type WorktreeSetupFailurePayload = { projectId: string } +const permissionModeSchema = z.enum([ + "plan", + "agent", + "bypass", + "read-only", + "ask-approval", + "full-access", + "custom", +]) + +function buildInitialRuntimeMetadata( + permissionMode: AgentPermissionMode | undefined, +): string | undefined { + return permissionMode + ? JSON.stringify({ permissionMode }) + : undefined +} + +type ChatOutputArtifactRow = { + id: string + artifact: CodexOutputArtifact + chatId: string + chatName: string | null + subChatId: string + subChatName: string | null + projectId: string | null + projectName: string | null + projectPath: string | null + engine: string | null + createdAt: Date | null + updatedAt: Date | null +} + +type ArtifactCandidateSubChat = { + chatId: string + chatName: string | null + chatCreatedAt: Date | null + chatUpdatedAt: Date | null + projectId: string | null + projectName: string | null + projectPath: string | null + subChatId: string + subChatName: string | null + subChatCreatedAt: Date | null + subChatUpdatedAt: Date | null + engine: string | null + messages: string | null +} + +function safeParseMessageList(value: string | null): unknown[] { + if (!value) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function extractLibraryArtifactsFromSubChat( + row: ArtifactCandidateSubChat, +): ChatOutputArtifactRow[] { + const messages = safeParseMessageList(row.messages) + const outputArtifacts: ChatOutputArtifactRow[] = [] + + messages.forEach((message, messageIndex) => { + const messageRecord = + typeof message === "object" && message !== null + ? (message as Record) + : null + if (!messageRecord || messageRecord.role !== "assistant") return + + const messageId = + typeof messageRecord.id === "string" && messageRecord.id.trim() + ? messageRecord.id + : `message-${messageIndex}` + const blocks = normalizeCodexConversationBlocksFromMessage(messageRecord, { + chatStatus: "idle", + turnId: messageId, + }) + + getCodexOutputArtifactsFromBlocks(blocks).forEach((artifact, artifactIndex) => { + outputArtifacts.push({ + id: [ + row.chatId, + row.subChatId, + messageId, + artifact.id || `artifact-${artifactIndex}`, + ].join(":"), + artifact: { + ...artifact, + id: [ + row.chatId, + row.subChatId, + messageId, + artifact.id || `artifact-${artifactIndex}`, + ].join(":"), + }, + chatId: row.chatId, + chatName: row.chatName, + subChatId: row.subChatId, + subChatName: row.subChatName, + projectId: row.projectId, + projectName: row.projectName, + projectPath: row.projectPath, + engine: row.engine, + createdAt: row.subChatCreatedAt ?? row.chatCreatedAt, + updatedAt: row.subChatUpdatedAt ?? row.chatUpdatedAt, + }) + }) + }) + + return outputArtifacts +} + function sendWorktreeSetupFailure( windowId: number | null, payload: WorktreeSetupFailurePayload, @@ -57,6 +202,163 @@ function sendWorktreeSetupFailure( } } +async function recoverCodexNativeSubChatMessagesIfNeeded< + TSubChat extends { + id: string + engine: string + engineSessionId?: string | null + messages: string + }, +>(subChat: TSubChat): Promise { + if (subChat.engine !== "codex" || !subChat.engineSessionId) { + return subChat + } + + let messages: CodexNativeRecoveredMessage[] + try { + const parsedMessages = JSON.parse(subChat.messages || "[]") + if (!Array.isArray(parsedMessages)) return subChat + messages = parsedMessages + } catch { + return subChat + } + + if (!messages.some((message) => message.role === "assistant")) { + return subChat + } + + const sessionEvents = await readCodexNativeSessionEventsById( + subChat.engineSessionId, + ) + if (sessionEvents.length === 0) return subChat + + const recovery = recoverCodexNativeMessagesFromSessionEvents( + messages, + sessionEvents, + ) + if (!recovery.changed) return subChat + + const nextMessages = JSON.stringify(recovery.messages) + try { + getDatabase() + .update(subChats) + .set({ messages: nextMessages }) + .where(eq(subChats.id, subChat.id)) + .run() + } catch (error) { + console.warn("[chats] Failed to persist recovered Codex native messages:", error) + } + + return { + ...subChat, + messages: nextMessages, + } +} + +async function clearStaleAgentStreamIdIfNeeded< + TSubChat extends { + id: string + engine: string + streamId?: string | null + }, +>(subChat: TSubChat): Promise { + if ( + !shouldClearStaleAgentStreamId(subChat, { + isActiveCodexStream: hasActiveCodexStreamForSubChat, + isActiveHermesStream: hasActiveHermesStreamForSubChat, + }) + ) { + return subChat + } + + try { + getDatabase() + .update(subChats) + .set({ streamId: null }) + .where(eq(subChats.id, subChat.id)) + .run() + } catch (error) { + console.warn("[chats] Failed to clear stale agent stream id:", error) + } + + return { + ...subChat, + streamId: null, + } +} + +async function normalizeLoadedSubChatRuntimeState< + TSubChat extends { + id: string + engine: string + engineSessionId?: string | null + messages: string + streamId?: string | null + }, +>(subChat: TSubChat): Promise { + const recoveredSubChat = await recoverCodexNativeSubChatMessagesIfNeeded(subChat) + return clearStaleAgentStreamIdIfNeeded(recoveredSubChat) +} + +async function materializeMossWorkspaceForChat(params: { + sourceProjectPath: string + workspacePath: string + chatId: string +}): Promise { + try { + const sourceLink = await linkMossSourceIntoWorkspace({ + sourceProjectPath: params.sourceProjectPath, + workspacePath: params.workspacePath, + }) + const projections = await materializeMossWorkspaceProjections({ + projectPath: params.workspacePath, + createIfMissing: true, + }) + const summary = projections.projections.reduce( + (acc, projection) => ({ + created: acc.created + projection.summary.created, + updated: acc.updated + projection.summary.updated, + skipped: acc.skipped + projection.summary.skipped, + conflict: acc.conflict + projection.summary.conflict, + unsupported: acc.unsupported + projection.summary.unsupported, + total: acc.total + projection.summary.total, + }), + { + created: 0, + updated: 0, + skipped: 0, + conflict: 0, + unsupported: 0, + total: 0, + }, + ) + + if (sourceLink.status === "conflict" || summary.conflict > 0) { + console.warn("[moss-source] Workspace projection conflicts:", { + chatId: params.chatId, + workspacePath: params.workspacePath, + sourceLink, + summary, + }) + return + } + + console.log("[moss-source] Workspace projections refreshed:", { + chatId: params.chatId, + workspacePath: params.workspacePath, + sourceLinkStatus: sourceLink.status, + sourceLinkReason: sourceLink.reason, + summary, + }) + } catch (error) { + console.warn("[moss-source] Workspace projection refresh failed:", { + chatId: params.chatId, + workspacePath: params.workspacePath, + error: error instanceof Error ? error.message : String(error), + }) + } +} + // Fallback to truncated user message if AI generation fails function getFallbackName(userMessage: string): string { const trimmed = userMessage.trim() @@ -229,6 +531,65 @@ export const chatsRouter = router({ .all() }), + /** + * List local output artifacts across non-archived chats. + * This backs the desktop Library route without coupling it to a single agent engine. + */ + listOutputArtifacts: publicProcedure + .input( + z + .object({ + projectId: z.string().optional(), + limit: z.number().int().min(1).max(1000).optional(), + }) + .optional(), + ) + .query(({ input }): ChatOutputArtifactRow[] => { + const db = getDatabase() + const conditions = [ + isNull(chats.archivedAt), + sql`( + ${subChats.messages} LIKE '%generated-image%' OR + ${subChats.messages} LIKE '%data-output%' OR + ${subChats.messages} LIKE '%resource_link%' OR + ${subChats.messages} LIKE '%embedded_resource%' OR + ${subChats.messages} LIKE '%tool-Write%' OR + ${subChats.messages} LIKE '%tool-Edit%' OR + ${subChats.messages} LIKE '%tool-write:%' OR + ${subChats.messages} LIKE '%tool-edit:%' + )`, + ] + if (input?.projectId) { + conditions.push(eq(chats.projectId, input.projectId)) + } + + const rows = db + .select({ + chatId: chats.id, + chatName: chats.name, + chatCreatedAt: chats.createdAt, + chatUpdatedAt: chats.updatedAt, + projectId: chats.projectId, + projectName: projects.name, + projectPath: projects.path, + subChatId: subChats.id, + subChatName: subChats.name, + subChatCreatedAt: subChats.createdAt, + subChatUpdatedAt: subChats.updatedAt, + engine: subChats.engine, + messages: subChats.messages, + }) + .from(subChats) + .innerJoin(chats, eq(subChats.chatId, chats.id)) + .leftJoin(projects, eq(chats.projectId, projects.id)) + .where(and(...conditions)) + .orderBy(desc(subChats.updatedAt)) + .limit(input?.limit ?? 500) + .all() + + return rows.flatMap((row) => extractLibraryArtifactsFromSubChat(row)) + }), + /** * List archived chats (optionally filter by project) */ @@ -253,17 +614,22 @@ export const chatsRouter = router({ */ get: publicProcedure .input(z.object({ id: z.string() })) - .query(({ input }) => { + .query(async ({ input }) => { const db = getDatabase() const chat = db.select().from(chats).where(eq(chats.id, input.id)).get() if (!chat) return null - const chatSubChats = db + const loadedSubChats = db .select() .from(subChats) .where(eq(subChats.chatId, input.id)) .orderBy(subChats.createdAt) .all() + const chatSubChats = await Promise.all( + loadedSubChats.map((subChat) => + normalizeLoadedSubChatRuntimeState(subChat), + ), + ) const project = db .select() @@ -282,6 +648,7 @@ export const chatsRouter = router({ z.object({ projectId: z.string(), name: z.string().optional(), + engine: z.enum(AGENT_ENGINE_IDS).optional(), model: z.string().optional(), initialMessage: z.string().optional(), initialMessageParts: z @@ -310,6 +677,7 @@ export const chatsRouter = router({ branchType: z.enum(["local", "remote"]).optional(), // Whether baseBranch is local or remote useWorktree: z.boolean().default(true), // If false, work directly in project dir mode: z.enum(["plan", "agent"]).default("agent"), + permissionMode: permissionModeSchema.optional(), }), ) .mutation(async ({ input, ctx }) => { @@ -364,11 +732,14 @@ export const chatsRouter = router({ const subChat = db .insert(subChats) - .values({ + .values(buildInitialSubChatValues({ chatId: chat.id, + engine: input.engine, + model: input.model, mode: input.mode, messages: initialMessages, - }) + runtimeMetadata: buildInitialRuntimeMetadata(input.permissionMode), + })) .returning() .get() console.log("[chats.create] created subChat:", subChat) @@ -395,6 +766,12 @@ export const chatsRouter = router({ input.baseBranch, input.branchType, { + onCreated: ({ worktreePath }) => + materializeMossWorkspaceForChat({ + sourceProjectPath: project.path, + workspacePath: worktreePath, + chatId: chat.id, + }), onSetupComplete: (setupResult: WorktreeSetupResult) => { if (setupResult.success) return const message = @@ -437,6 +814,11 @@ export const chatsRouter = router({ .where(eq(chats.id, chat.id)) .run() worktreeResult = { worktreePath: project.path } + await materializeMossWorkspaceForChat({ + sourceProjectPath: project.path, + workspacePath: project.path, + chatId: chat.id, + }) } } else { // Local mode: use project path directly, no branch info @@ -446,6 +828,11 @@ export const chatsRouter = router({ .where(eq(chats.id, chat.id)) .run() worktreeResult = { worktreePath: project.path } + await materializeMossWorkspaceForChat({ + sourceProjectPath: project.path, + workspacePath: project.path, + chatId: chat.id, + }) } const response = { @@ -683,15 +1070,17 @@ export const chatsRouter = router({ */ getSubChat: publicProcedure .input(z.object({ id: z.string() })) - .query(({ input }) => { + .query(async ({ input }) => { const db = getDatabase() - const subChat = db + const loadedSubChat = db .select() .from(subChats) .where(eq(subChats.id, input.id)) .get() - if (!subChat) return null + if (!loadedSubChat) return null + const subChat = + await normalizeLoadedSubChatRuntimeState(loadedSubChat) const chat = db .select() @@ -718,6 +1107,8 @@ export const chatsRouter = router({ z.object({ chatId: z.string(), name: z.string().optional(), + engine: z.enum(AGENT_ENGINE_IDS).optional(), + model: z.string().optional(), mode: z.enum(["plan", "agent"]).default("agent"), }), ) @@ -725,12 +1116,13 @@ export const chatsRouter = router({ const db = getDatabase() return db .insert(subChats) - .values({ + .values(buildEmptySubChatValues({ chatId: input.chatId, name: input.name, + engine: input.engine, + model: input.model, mode: input.mode, - messages: "[]", - }) + })) .returning() .get() }), @@ -773,28 +1165,22 @@ export const chatsRouter = router({ } if (cutoffIndex === -1) throw new Error("Message not found") - // 3. Slice messages up to and including the target - const messagesToFork = allMessages.slice(0, cutoffIndex + 1) - - // 4. Find sdkMessageUuid of last assistant message (for resumeSessionAt) - const lastAssistant = [...messagesToFork] - .reverse() - .find((m: any) => m.role === "assistant") - const forkAtSdkUuid = lastAssistant?.metadata?.sdkMessageUuid || null - - // 5. Generate new IDs for all messages + set shouldForkResume on last assistant - const forkedMessages = messagesToFork.map((msg: any, i: number) => ({ - ...msg, - id: `fork-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 7)}`, - metadata: { - ...msg.metadata, - shouldResume: undefined, - ...(msg === lastAssistant && - forkAtSdkUuid && { - shouldForkResume: true, - }), - }, - })) + const sourceEngine = AGENT_ENGINE_IDS.includes( + sourceSubChat.engine as AgentEngineId, + ) + ? sourceSubChat.engine as AgentEngineId + : DEFAULT_AGENT_ENGINE_ID + const manifest = getAgentRuntimeManifest(sourceEngine) + const sourceNativeSessionId = + sourceSubChat.engineSessionId ?? + (sourceEngine === "claude-code" ? sourceSubChat.sessionId : null) + let snapshot = buildMossForkSnapshot({ + engine: sourceEngine, + nativeSessionId: sourceNativeSessionId, + messages: allMessages, + features: manifest.features, + targetMessageIndex: cutoffIndex, + }) // 6. Generate fork name: [N] originalName let forkName = input.name @@ -821,21 +1207,49 @@ export const chatsRouter = router({ forkName = `[${maxN + 1}] ${baseName}` } - // 7. Insert new sub-chat with sessionId from original (needed for resume) - const newSubChat = db + const buildForkRuntimeMetadata = ( + mode: typeof snapshot.mode, + extra?: Record, + ) => + mergeMossSessionControlMetadata(sourceSubChat.runtimeMetadata, { + action: "fork", + mode, + source: "fork-sub-chat", + sourceSubChatId: input.subChatId, + sourceEngineSessionId: + sourceSubChat.engineSessionId ?? sourceSubChat.sessionId ?? null, + nativeSessionLinked, + targetMessageId: input.messageId, + targetMessageIndex: cutoffIndex, + forkAtSdkUuid: snapshot.forkAtSdkUuid, + ...(extra ?? {}), + }) + + let nativeSessionLinked = snapshot.nativeSessionLinked + + // 7. Insert new sub-chat. Only engines with a verified native bridge keep + // native session identity; all others fork the transcript and start fresh. + let newSubChat = db .insert(subChats) .values({ chatId: sourceSubChat.chatId, name: forkName, mode: sourceSubChat.mode, - messages: JSON.stringify(forkedMessages), - sessionId: sourceSubChat.sessionId, + messages: JSON.stringify(snapshot.messages), + sessionId: nativeSessionLinked ? sourceSubChat.sessionId : null, + engine: sourceEngine, + engineSessionId: nativeSessionLinked + ? sourceSubChat.engineSessionId ?? sourceSubChat.sessionId + : null, + engineConfigDir: sourceSubChat.engineConfigDir, + modelId: sourceSubChat.modelId, + runtimeMetadata: buildForkRuntimeMetadata(snapshot.mode), }) .returning() .get() - // 8. Copy .jsonl session files to the new isolated config dir - if (sourceSubChat.sessionId) { + // 8. Copy Claude .jsonl session files to the new isolated config dir + if (nativeSessionLinked && sourceEngine === "claude-code") { try { const { app } = await import("electron") const userDataPath = app.getPath("userData") @@ -859,28 +1273,50 @@ export const chatsRouter = router({ if (sourceDirExists) { await fs.cp(sourceDir, targetDir, { recursive: true }) + } else { + throw new Error("Claude session files were not available to copy") } } catch (err) { console.warn("[forkSubChat] Failed to copy session files:", err) - // Clear shouldForkResume since there's no .jsonl to fork from - for (const m of forkedMessages) { - if (m.metadata?.shouldForkResume) { - delete m.metadata.shouldForkResume - } - } - db.update(subChats) - .set({ messages: JSON.stringify(forkedMessages) }) + snapshot = buildMossForkSnapshot({ + engine: sourceEngine, + nativeSessionId: null, + messages: allMessages, + features: manifest.features, + targetMessageIndex: cutoffIndex, + }) + nativeSessionLinked = false + newSubChat = db + .update(subChats) + .set({ + messages: JSON.stringify(snapshot.messages), + sessionId: null, + engineSessionId: null, + runtimeMetadata: buildForkRuntimeMetadata(snapshot.mode, { + nativeSessionLinked, + fallbackReason: "Claude session files were not available to copy.", + }), + updatedAt: new Date(), + }) .where(eq(subChats.id, newSubChat.id)) - .run() + .returning() + .get() } } - console.log("[forkSubChat] Created", { id: newSubChat.id, name: forkName, messages: forkedMessages.length }) + console.log("[forkSubChat] Created", { + id: newSubChat.id, + name: forkName, + messages: snapshot.messageCount, + mode: snapshot.mode, + }) return { subChat: newSubChat, - messageCount: forkedMessages.length, - forkAtSdkUuid, + messageCount: snapshot.messageCount, + forkAtSdkUuid: snapshot.forkAtSdkUuid, + mode: snapshot.mode, + nativeSessionLinked, } }), diff --git a/src/main/lib/trpc/routers/claude-settings.ts b/src/main/lib/trpc/routers/claude-settings.ts index 6ab649059..19cce290b 100644 --- a/src/main/lib/trpc/routers/claude-settings.ts +++ b/src/main/lib/trpc/routers/claude-settings.ts @@ -1,107 +1,20 @@ -import * as fs from "fs/promises" -import * as path from "path" -import * as os from "os" import { z } from "zod" import { router, publicProcedure } from "../index" - -const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json") - -// Cache for enabled plugins to avoid repeated filesystem reads -let enabledPluginsCache: { plugins: string[]; timestamp: number } | null = null -const ENABLED_PLUGINS_CACHE_TTL_MS = 5000 // 5 seconds - -// Cache for approved plugin MCP servers -let approvedMcpCache: { servers: string[]; timestamp: number } | null = null -const APPROVED_MCP_CACHE_TTL_MS = 5000 // 5 seconds - -/** - * Invalidate the enabled plugins cache - * Call this when enabledPlugins setting changes - */ -export function invalidateEnabledPluginsCache(): void { - enabledPluginsCache = null -} - -/** - * Invalidate the approved MCP servers cache - * Call this when approvedPluginMcpServers setting changes - */ -export function invalidateApprovedMcpCache(): void { - approvedMcpCache = null -} - -/** - * Read Claude settings.json file - * Returns empty object if file doesn't exist - */ -async function readClaudeSettings(): Promise> { - try { - const content = await fs.readFile(CLAUDE_SETTINGS_PATH, "utf-8") - return JSON.parse(content) - } catch (error) { - // File doesn't exist or is invalid JSON - return {} - } -} - -/** - * Get list of enabled plugin identifiers from settings.json - * Plugins are DISABLED by default — only plugins explicitly in this list are active. - * Returns empty array if no plugins have been enabled. - * Results are cached for 5 seconds to reduce filesystem reads. - */ -export async function getEnabledPlugins(): Promise { - // Return cached result if still valid - if (enabledPluginsCache && Date.now() - enabledPluginsCache.timestamp < ENABLED_PLUGINS_CACHE_TTL_MS) { - return enabledPluginsCache.plugins - } - - const settings = await readClaudeSettings() - const plugins = Array.isArray(settings.enabledPlugins) ? settings.enabledPlugins as string[] : [] - - enabledPluginsCache = { plugins, timestamp: Date.now() } - return plugins -} - -/** - * Get list of approved plugin MCP server identifiers from settings.json - * Format: "{pluginSource}:{serverName}" e.g., "ccsetup:ccsetup:context7" - * Returns empty array if no approved servers - * Results are cached for 5 seconds to reduce filesystem reads - */ -export async function getApprovedPluginMcpServers(): Promise { - // Return cached result if still valid - if (approvedMcpCache && Date.now() - approvedMcpCache.timestamp < APPROVED_MCP_CACHE_TTL_MS) { - return approvedMcpCache.servers - } - - const settings = await readClaudeSettings() - const servers = Array.isArray(settings.approvedPluginMcpServers) - ? settings.approvedPluginMcpServers as string[] - : [] - - approvedMcpCache = { servers, timestamp: Date.now() } - return servers -} - -/** - * Check if a plugin MCP server is approved - */ -export async function isPluginMcpApproved(pluginSource: string, serverName: string): Promise { - const approved = await getApprovedPluginMcpServers() - const identifier = `${pluginSource}:${serverName}` - return approved.includes(identifier) -} - -/** - * Write Claude settings.json file - * Creates the .claude directory if it doesn't exist - */ -async function writeClaudeSettings(settings: Record): Promise { - const dir = path.dirname(CLAUDE_SETTINGS_PATH) - await fs.mkdir(dir, { recursive: true }) - await fs.writeFile(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8") -} +export { + getApprovedPluginMcpServers, + getEnabledPlugins, + invalidateApprovedMcpCache, + invalidateEnabledPluginsCache, + isPluginMcpApproved, +} from "../../claude-plugin-settings" +import { + getApprovedPluginMcpServers, + getEnabledPlugins, + invalidateApprovedMcpCache, + invalidateEnabledPluginsCache, + readClaudeSettings, + writeClaudeSettings, +} from "../../claude-plugin-settings" export const claudeSettingsRouter = router({ /** diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index 9e5eadffe..6cdd7ea60 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -10,7 +10,7 @@ import { buildClaudeEnv, checkOfflineFallback, createTransformer, - getBundledClaudeBinaryPath, + resolveClaudeCodeExecutable, logClaudeEnv, logRawClaudeMessage, type UIMessageChunk, @@ -41,6 +41,12 @@ import { } from "../../mcp-auth" import { fetchOAuthMetadata, getMcpBaseUrl } from "../../oauth" import { discoverPluginMcpServers } from "../../plugins" +import { persistAgentRuntimeSession } from "../../agent-runtime/session-store" +import { + getMossProviderSecret, + resolveMossProviderForEngine, + type ResolvedMossProvider, +} from "../../moss-source" import { publicProcedure, router } from "../index" import { buildAgentsOption } from "./agent-utils" import { @@ -146,6 +152,19 @@ function parseMentions(prompt: string): { } } +function buildClaudeCustomConfigFromMossProvider( + provider: ResolvedMossProvider, + fallbackModel?: string, +): { model: string; token: string; baseUrl: string } | undefined { + if (provider.status !== "resolved" || !provider.apiKey) return undefined + + return { + model: provider.model || fallbackModel || "opus", + token: provider.apiKey, + baseUrl: provider.baseUrl || "https://api.anthropic.com", + } +} + /** * Decrypt token using Electron's safeStorage */ @@ -424,6 +443,7 @@ const MCP_FETCH_TIMEOUT_MS = 40_000 */ async function fetchToolsForServer( serverConfig: McpServerConfig, + options: { sourcePath?: string | null } = {}, ): Promise { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), MCP_FETCH_TIMEOUT_MS), @@ -448,6 +468,8 @@ async function fetchToolsForServer( command, args: (serverConfig as any).args, env: (serverConfig as any).env, + cwd: (serverConfig as any).cwd, + sourcePath: options.sourcePath, }) } catch { return [] @@ -494,7 +516,7 @@ export async function getAllMcpConfigHandler() { let needsAuth = false try { - tools = await fetchToolsForServer(serverConfig) + tools = await fetchToolsForServer(serverConfig, { sourcePath: scope }) } catch (error) { console.error(`[MCP] Failed to fetch tools for ${name}:`, error) } @@ -980,6 +1002,20 @@ export const claudeRouter = router({ // 2.5. AUTO-FALLBACK: Check internet and switch to Ollama if offline // Only check if offline mode is enabled in settings const claudeCodeToken = getClaudeCodeToken() + const mossProviderLookupPath = + input.projectPath || + resolveProjectPathFromWorktree(input.cwd) || + input.cwd + const mossProvider = await resolveMossProviderForEngine({ + projectPath: mossProviderLookupPath, + engineId: "claude-code", + requestedModelId: input.model, + createIfMissing: true, + secretResolver: { getSecret: getMossProviderSecret }, + }) + if (mossProvider.warnings.length > 0) { + console.warn("[claude] Moss provider warnings:", mossProvider.warnings) + } const offlineResult = await checkOfflineFallback( input.customConfig, claudeCodeToken, @@ -998,13 +1034,25 @@ export const claudeRouter = router({ } // Use offline config if available - const finalCustomConfig = offlineResult.config || input.customConfig + const mossCustomConfig = + input.customConfig + ? undefined + : buildClaudeCustomConfigFromMossProvider( + mossProvider, + input.model, + ) + const finalCustomConfig = + offlineResult.config || input.customConfig || mossCustomConfig + const isUsingMossProviderConfig = + Boolean(mossCustomConfig) && finalCustomConfig === mossCustomConfig const isUsingOllama = offlineResult.isUsingOllama // Track connection method for analytics let connectionMethod = "claude-subscription" // default (Claude Code OAuth) if (isUsingOllama) { connectionMethod = "offline-ollama" + } else if (isUsingMossProviderConfig) { + connectionMethod = "moss-provider" } else if (finalCustomConfig) { // Has custom config = either API key or custom model const isDefaultAnthropicUrl = @@ -1126,13 +1174,22 @@ export const claudeRouter = router({ prompt = createPromptWithImages() } + const mossProviderEnv = + mossProvider.status === "resolved" ? mossProvider.env : {} + const customClaudeEnv = { + ...mossProviderEnv, + ...(finalCustomConfig + ? { + ANTHROPIC_AUTH_TOKEN: finalCustomConfig.token, + ANTHROPIC_BASE_URL: finalCustomConfig.baseUrl, + } + : {}), + } + // Build full environment for Claude SDK (includes HOME, PATH, etc.) const claudeEnv = buildClaudeEnv({ - ...(finalCustomConfig && { - customEnv: { - ANTHROPIC_AUTH_TOKEN: finalCustomConfig.token, - ANTHROPIC_BASE_URL: finalCustomConfig.baseUrl, - }, + ...(Object.keys(customClaudeEnv).length > 0 && { + customEnv: customClaudeEnv, }), enableTasks: input.enableTasks ?? true, }) @@ -1399,7 +1456,7 @@ export const claudeRouter = router({ // Build final env - only add OAuth token if we have one AND no existing API config // Existing CLI config takes precedence over OAuth - const finalEnv = { + const finalEnv: Record = { ...claudeEnv, ...(claudeCodeToken && !hasExistingApiConfig && { @@ -1439,8 +1496,11 @@ export const claudeRouter = router({ "[claude-auth] ============================================", ) - // Get bundled Claude binary path - const claudeBinaryPath = getBundledClaudeBinaryPath() + const claudeExecutable = resolveClaudeCodeExecutable() + const claudeBinaryPath = claudeExecutable.path + console.log( + `[claude-binary] Using ${claudeExecutable.source} Claude Code executable: ${claudeBinaryPath}`, + ) const resumeSessionId = input.sessionId || existingSessionId || undefined @@ -1864,19 +1924,19 @@ ${prompt} : "" if (!/\.md$/i.test(filePath)) { return { - behavior: "deny", + behavior: "deny" as const, message: 'Only ".md" files can be modified in plan mode.', } } } else if (toolName == "ExitPlanMode") { return { - behavior: "deny", + behavior: "deny" as const, message: `IMPORTANT: DONT IMPLEMENT THE PLAN UNTIL THE EXPLIT COMMAND. THE PLAN WAS **ONLY** PRESENTED TO USER, FINISH CURRENT MESSAGE AS SOON AS POSSIBLE`, } } else if (PLAN_MODE_BLOCKED_TOOLS.has(toolName)) { return { - behavior: "deny", + behavior: "deny" as const, message: `Tool "${toolName}" blocked in plan mode.`, } } @@ -1937,7 +1997,7 @@ ${prompt} result: errorMessage, } as UIMessageChunk) return { - behavior: "deny", + behavior: "deny" as const, message: errorMessage, } } @@ -1945,6 +2005,12 @@ ${prompt} // Update the tool part with answers result for approved const answers = (response.updatedInput as any)?.answers const answerResult = { answers } + const updatedInput = + response.updatedInput && + typeof response.updatedInput === "object" && + !Array.isArray(response.updatedInput) + ? (response.updatedInput as Record) + : toolInput if (askToolPart) { askToolPart.result = answerResult askToolPart.state = "result" @@ -1956,12 +2022,12 @@ ${prompt} result: answerResult, } as UIMessageChunk) return { - behavior: "allow", - updatedInput: response.updatedInput, + behavior: "allow" as const, + updatedInput, } } return { - behavior: "allow", + behavior: "allow" as const, updatedInput: toolInput, } }, @@ -2006,6 +2072,22 @@ ${prompt} let policyRetryNeeded = false let messageCount = 0 let pendingFinishChunk: UIMessageChunk | null = null + const runtimeMetadataBase = { + cwd: input.cwd, + projectPath: input.projectPath ?? null, + offlineMode: isUsingOllama, + sdkPermissionMode: + input.mode === "plan" ? "plan" : "bypassPermissions", + mcpServerCount: mcpServersFiltered + ? Object.keys(mcpServersFiltered).length + : 0, + historyEnabled, + hasImages: Boolean(input.images?.length), + mossProviderStatus: mossProvider.status, + mossProviderId: mossProvider.providerId ?? null, + mossProviderMode: mossProvider.mode ?? null, + usingMossProviderConfig: isUsingMossProviderConfig, + } // eslint-disable-next-line no-constant-condition while (true) { @@ -2505,7 +2587,7 @@ ${prompt} `[claude] Session not found - clearing invalid sessionId from database`, ) db.update(subChats) - .set({ sessionId: null }) + .set({ sessionId: null, engineSessionId: null }) .where(eq(subChats.id, input.subChatId)) .run() @@ -2669,6 +2751,22 @@ ${prompt} } const savedSessionId = metadata.sessionId + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "claude-code", + nativeSessionId: savedSessionId ?? null, + configDir: isolatedConfigDir, + modelId: resolvedModel ?? null, + permissionMode: input.mode, + metadata: { + ...runtimeMetadataBase, + resultSubtype: abortController.signal.aborted + ? "cancelled" + : "success", + sdkMessageUuid: metadata.sdkMessageUuid ?? null, + }, + updateLegacySessionId: true, + }) if (parts.length > 0) { const assistantMessage = { @@ -2939,7 +3037,7 @@ ${prompt} transport: z.enum(["stdio", "http"]), command: z.string().optional(), args: z.array(z.string()).optional(), - env: z.record(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), url: z.string().url().optional(), authType: z.enum(["none", "oauth", "bearer"]).optional(), bearerToken: z.string().optional(), @@ -3017,7 +3115,7 @@ ${prompt} .optional(), command: z.string().optional(), args: z.array(z.string()).optional(), - env: z.record(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), url: z.string().url().optional(), authType: z.enum(["none", "oauth", "bearer"]).optional(), bearerToken: z.string().optional(), diff --git a/src/main/lib/trpc/routers/codex-mcp-session.test.ts b/src/main/lib/trpc/routers/codex-mcp-session.test.ts new file mode 100644 index 000000000..4da551094 --- /dev/null +++ b/src/main/lib/trpc/routers/codex-mcp-session.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { shouldAttachCodexMcpServerToSession } from "./codex-mcp-session" + +const sessionServer = { name: "local-tools" } + +describe("Codex MCP session eligibility", () => { + test("keeps healthy tool-bearing servers in the session config", () => { + expect( + shouldAttachCodexMcpServerToSession({ + sessionServer, + settingsServer: { + status: "connected", + needsAuth: false, + tools: [{ name: "search" }], + }, + toolsWereResolved: true, + }), + ).toBe(true) + }) + + test("keeps failed settings entries visible but out of the Codex session", () => { + expect( + shouldAttachCodexMcpServerToSession({ + sessionServer, + settingsServer: { + status: "failed", + needsAuth: false, + tools: [], + }, + toolsWereResolved: true, + }), + ).toBe(false) + }) + + test("does not pass auth-blocked servers to the Codex runtime", () => { + expect( + shouldAttachCodexMcpServerToSession({ + sessionServer, + settingsServer: { + status: "needs-auth", + needsAuth: true, + tools: [], + }, + toolsWereResolved: true, + }), + ).toBe(false) + }) + + test("does not pass unverified empty tool probes into native Codex", () => { + expect( + shouldAttachCodexMcpServerToSession({ + sessionServer, + settingsServer: { + status: "connected", + needsAuth: false, + tools: [], + }, + toolsWereResolved: true, + }), + ).toBe(false) + }) +}) diff --git a/src/main/lib/trpc/routers/codex-mcp-session.ts b/src/main/lib/trpc/routers/codex-mcp-session.ts new file mode 100644 index 000000000..43068bd97 --- /dev/null +++ b/src/main/lib/trpc/routers/codex-mcp-session.ts @@ -0,0 +1,25 @@ +export type CodexMcpSessionServerCandidate = { + name: string +} | null + +export type CodexMcpSettingsServerCandidate = { + status: "connected" | "failed" | "pending" | "needs-auth" + needsAuth?: boolean + tools?: unknown[] +} + +export function shouldAttachCodexMcpServerToSession(params: { + sessionServer: CodexMcpSessionServerCandidate + settingsServer: CodexMcpSettingsServerCandidate + toolsWereResolved: boolean +}): boolean { + if (!params.sessionServer) return false + if (params.settingsServer.needsAuth) return false + if (params.settingsServer.status !== "connected") return false + + if (params.toolsWereResolved && (params.settingsServer.tools?.length ?? 0) === 0) { + return false + } + + return true +} diff --git a/src/main/lib/trpc/routers/codex.ts b/src/main/lib/trpc/routers/codex.ts index 0bc355eb9..2c690821c 100644 --- a/src/main/lib/trpc/routers/codex.ts +++ b/src/main/lib/trpc/routers/codex.ts @@ -3,12 +3,12 @@ import { observable } from "@trpc/server/observable" import { streamText } from "ai" import { eq } from "drizzle-orm" import { app } from "electron" -import { spawn, type ChildProcess } from "node:child_process" +import { spawn, spawnSync, type ChildProcess } from "node:child_process" import { createHash } from "node:crypto" import { existsSync } from "node:fs" import { readdir, readFile } from "node:fs/promises" import { homedir } from "node:os" -import { basename, dirname, join, sep } from "node:path" +import { basename, delimiter, dirname, join, sep } from "node:path" import { z } from "zod" import { normalizeCodexAssistantMessage, @@ -16,13 +16,51 @@ import { } from "../../../../shared/codex-tool-normalizer" import { getClaudeShellEnvironment } from "../../claude/env" import { resolveProjectPathFromWorktree } from "../../claude-config" +import { shouldIgnoreMossStoredMessageSessionIds } from "../../agent-runtime/session-actions" +import { + codexJsonlEventToNativeToolEvent, + extractCodexJsonlEventSessionId, + extractCodexJsonlEventText, + isCodexJsonlCommentaryTextEvent, + isCodexJsonlDeltaTextEvent, + isCodexJsonlFinalTextEvent, + isCodexNativeRepeatedFinalText, + parseCodexJsonlEventLine, + reconcileCodexNativeTextAppend, + runCodexExecResumeBridge, + runCodexExecStartBridge, + splitCodexTextForStreamingDeltas, + type CodexExecResumeBridgeResult, + type CodexJsonlEvent, +} from "../../agent-runtime/codex-native-session" +import { + buildNativePartsFromCodexEvents, + getCodexNativePartsRichness, + isCodexJsonlUserEvent, +} from "../../agent-runtime/codex-native-recovery" +import { + createCodexNativeMessagePartsAccumulator, + isCodexNativeRuntimeNoticeText, +} from "../../agent-runtime/codex-native-message-parts" +import { + shouldStartFreshCodexNativeSession, + stripCodexNativeRuntimeNoticeMessages, +} from "../../agent-runtime/codex-native-resume" import { getDatabase, projects as projectsTable, subChats } from "../../db" +import { persistAgentRuntimeSession } from "../../agent-runtime/session-store" +import { + getMossProviderSecret, + getMossProviderFingerprint, + resolveMossProviderForEngine, + type ResolvedMossProvider, +} from "../../moss-source" import { fetchMcpTools, fetchMcpToolsStdio, type McpToolInfo, } from "../../mcp-auth" import { publicProcedure, router } from "../index" +import { shouldAttachCodexMcpServerToSession } from "./codex-mcp-session" const imageAttachmentSchema = z.object({ base64Data: z.string(), @@ -30,13 +68,31 @@ const imageAttachmentSchema = z.object({ filename: z.string().optional(), }) +const permissionModeSchema = z.enum([ + "plan", + "agent", + "bypass", + "read-only", + "ask-approval", + "full-access", + "custom", +]) + type CodexProviderSession = { provider: ACPProvider cwd: string authFingerprint: string | null + mossProviderFingerprint: string | null mcpFingerprint: string } +type CodexAuthConfig = { + apiKey: string + authMethodId?: "codex-api-key" | "openai-api-key" + baseUrl?: string + providerId?: string +} + type CodexLoginSessionState = | "running" | "success" @@ -53,7 +109,7 @@ type CodexLoginSession = { exitCode: number | null } -type CodexIntegrationState = +export type CodexIntegrationState = | "connected_chatgpt" | "connected_api_key" | "not_logged_in" @@ -66,6 +122,7 @@ type CodexMcpServerForSession = command: string args: string[] env: Array<{ name: string; value: string }> + cwd?: string } | { name: string @@ -80,6 +137,17 @@ type CodexMcpServerForSettings = { tools: McpToolInfo[] needsAuth: boolean config: Record + serverInfo?: { + name: string + version: string + icons?: Array<{ + src: string + mimeType?: string + sizes?: string[] + theme?: "light" | "dark" + }> + } + error?: string } type CodexMcpSnapshot = { @@ -108,6 +176,15 @@ export function hasActiveCodexStreams(): boolean { return activeStreams.size > 0 } +export function hasActiveCodexStreamForSubChat( + subChatId: string, + runId?: string | null, +): boolean { + const stream = activeStreams.get(subChatId) + if (!stream) return false + return !runId || stream.runId === runId +} + /** Abort all active Codex streams so their cleanup saves partial state */ export function abortAllCodexStreams(): void { for (const [subChatId, stream] of activeStreams) { @@ -136,10 +213,16 @@ const AUTH_HINTS = [ "401", "403", ] -const DEFAULT_CODEX_MODEL = "gpt-5.3-codex/high" +const DEFAULT_CODEX_MODEL = "gpt-5.5/medium" +const MIN_DEFAULT_CODEX_CLI_VERSION = "0.133.0" const CODEX_MCP_TOOLS_FETCH_TIMEOUT_MS = 40_000 const CODEX_USAGE_POLL_ATTEMPTS = 3 const CODEX_USAGE_POLL_INTERVAL_MS = 200 +const NATIVE_TEXT_REPLAY_CHUNK_LENGTH = 12 +const NATIVE_TEXT_REPLAY_MIN_INTERVAL_MS = 16 +const NATIVE_TEXT_REPLAY_MAX_INTERVAL_MS = 50 +const NATIVE_TEXT_REPLAY_MAX_DURATION_MS = 2400 +let loggedCodexCliPath: string | null = null type CodexTokenUsage = { input_tokens?: number @@ -170,13 +253,13 @@ const codexMcpListEntrySchema = z type: z.string(), command: z.string().nullable().optional(), args: z.array(z.string()).nullable().optional(), - env: z.record(z.string()).nullable().optional(), + env: z.record(z.string(), z.string()).nullable().optional(), env_vars: z.array(z.string()).nullable().optional(), cwd: z.string().nullable().optional(), url: z.string().nullable().optional(), bearer_token_env_var: z.string().nullable().optional(), - http_headers: z.record(z.string()).nullable().optional(), - env_http_headers: z.record(z.string()).nullable().optional(), + http_headers: z.record(z.string(), z.string()).nullable().optional(), + env_http_headers: z.record(z.string(), z.string()).nullable().optional(), }) .passthrough(), auth_status: z.string().nullable().optional(), @@ -234,7 +317,7 @@ function resolveCodexAcpBinaryPath(): string { return toUnpackedAsarPath(resolvedPath) } -function resolveBundledCodexCliPath(): string { +function getBundledCodexCliPath(): string { const binaryName = process.platform === "win32" ? "codex.exe" : "codex" const resourcesDir = app.isPackaged ? join(process.resourcesPath, "bin") @@ -245,20 +328,133 @@ function resolveBundledCodexCliPath(): string { `${process.platform}-${process.arch}`, ) - const binaryPath = join(resourcesDir, binaryName) - if (existsSync(binaryPath)) { - return binaryPath + return join(resourcesDir, binaryName) +} + +function parseCodexCliVersion(output: string): string | null { + const match = output.match(/\bcodex-cli\s+(\d+\.\d+\.\d+)\b/i) + return match?.[1] ?? null +} + +function compareSemver(a: string | null, b: string | null): number { + if (!a && !b) return 0 + if (!a) return -1 + if (!b) return 1 + + const aParts = a.split(".").map((part) => Number.parseInt(part, 10) || 0) + const bParts = b.split(".").map((part) => Number.parseInt(part, 10) || 0) + const length = Math.max(aParts.length, bParts.length) + + for (let index = 0; index < length; index += 1) { + const diff = (aParts[index] ?? 0) - (bParts[index] ?? 0) + if (diff !== 0) return diff + } + + return 0 +} + +function getCodexCliVersion(binaryPath: string): string | null { + const result = spawnSync(binaryPath, ["--version"], { + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + encoding: "utf8", + timeout: 5_000, + windowsHide: true, + }) + + if (result.error || result.status !== 0) { + return null } - const hint = app.isPackaged - ? "Binary is missing from bundled resources." - : "Run `bun run codex:download` to download it for local dev." + return parseCodexCliVersion(`${result.stdout}\n${result.stderr}`) +} - throw new Error( - `[codex] Bundled Codex CLI not found at ${binaryPath}. ${hint}`, +function getSystemCodexCliCandidates(): string[] { + const binaryName = process.platform === "win32" ? "codex.exe" : "codex" + const candidates = [ + process.env.MOSS_CODEX_CLI_PATH, + process.env.CODEX_CLI_PATH, + join(homedir(), ".local", "bin", binaryName), + ] + + if (process.platform === "darwin") { + candidates.push( + join("/opt/homebrew/bin", binaryName), + join("/usr/local/bin", binaryName), + ) + } + + for (const pathEntry of process.env.PATH?.split(delimiter) ?? []) { + if (pathEntry.trim().length > 0) { + candidates.push(join(pathEntry, binaryName)) + } + } + + return Array.from( + new Set(candidates.filter((candidate): candidate is string => Boolean(candidate))), ) } +function resolveCodexCliPath(): string { + const bundledPath = getBundledCodexCliPath() + const explicitPath = process.env.MOSS_CODEX_CLI_PATH || process.env.CODEX_CLI_PATH + + if (explicitPath) { + if (!existsSync(explicitPath)) { + throw new Error(`[codex] Configured Codex CLI not found at ${explicitPath}.`) + } + + return explicitPath + } + + const candidates = [ + bundledPath, + ...getSystemCodexCliCandidates().filter((candidate) => candidate !== bundledPath), + ] + .filter((candidate) => existsSync(candidate)) + .map((path) => ({ + path, + isBundled: path === bundledPath, + version: getCodexCliVersion(path), + })) + + if (candidates.length === 0) { + const hint = app.isPackaged + ? "Binary is missing from bundled resources." + : "Run `bun run codex:download` to download it for local dev or install a current `codex` CLI." + + throw new Error( + `[codex] Codex CLI not found at ${bundledPath} or on PATH. ${hint}`, + ) + } + + const selected = candidates.reduce((best, candidate) => { + const versionDiff = compareSemver(candidate.version, best.version) + if (versionDiff > 0) return candidate + if (versionDiff === 0 && best.isBundled && !candidate.isBundled) return candidate + return best + }) + + if ( + loggedCodexCliPath !== selected.path && + selected.version && + compareSemver(selected.version, MIN_DEFAULT_CODEX_CLI_VERSION) < 0 + ) { + console.warn( + `[codex] Using Codex CLI ${selected.version} at ${selected.path}; default model ${DEFAULT_CODEX_MODEL} expects ${MIN_DEFAULT_CODEX_CLI_VERSION} or newer.`, + ) + } + + if (loggedCodexCliPath !== selected.path && !selected.isBundled) { + console.info( + `[codex] Using Codex CLI ${selected.version ?? "unknown"} at ${selected.path}.`, + ) + } + loggedCodexCliPath = selected.path + + return selected.path +} + function stripAnsi(input: string): string { return input.replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "") } @@ -360,7 +556,7 @@ async function runCodexCli( stderr: string exitCode: number | null }> { - const codexCliPath = resolveBundledCodexCliPath() + const codexCliPath = resolveCodexCliPath() const cwd = options?.cwd?.trim() return await new Promise((resolvePromise, rejectPromise) => { @@ -501,6 +697,155 @@ async function findSessionFileById(sessionId: string): Promise { return null } +async function readSessionEventsForCurrentRun( + sessionId: string, + options: { notBeforeTimestampMs: number }, +): Promise { + const sessionFile = await findSessionFileById(sessionId) + if (!sessionFile) return [] + + let rawContent = "" + try { + rawContent = await readFile(sessionFile, "utf8") + } catch { + return [] + } + + return parseSessionEventsForCurrentRun(rawContent, options) +} + +function parseSessionEventsForCurrentRun( + rawContent: string, + options: { notBeforeTimestampMs: number }, +): CodexJsonlEvent[] { + const events: CodexJsonlEvent[] = [] + const notBeforeTimestampMs = Math.max(0, options.notBeforeTimestampMs - 2_000) + for (const line of rawContent.split("\n")) { + const event = parseCodexJsonlEventLine(line) + if (!event) continue + + const timestampMs = toTimestampMs((event as any).timestamp) + if (timestampMs !== undefined && timestampMs < notBeforeTimestampMs) { + continue + } + events.push(event) + } + + return events +} + +type NativeSessionEventTailer = { + start: (sessionId: string | null | undefined) => void + stop: () => Promise + getForwardedCount: () => number +} + +function createNativeSessionEventTailer(options: { + notBeforeTimestampMs: number + onEvent: (event: CodexJsonlEvent) => void + intervalMs?: number +}): NativeSessionEventTailer { + const intervalMs = options.intervalMs ?? 250 + const forwardedEventHashes = new Set() + let currentSessionId: string | null = null + let currentSessionFile: string | null = null + let stopped = false + let polling = false + let pendingPoll = false + let timer: ReturnType | null = null + let forwardedCount = 0 + + const getEventHash = (event: CodexJsonlEvent): string => { + try { + return createHash("sha1").update(JSON.stringify(event)).digest("hex") + } catch { + return createHash("sha1").update(String(event)).digest("hex") + } + } + + const poll = async (): Promise => { + if (!currentSessionId) return 0 + if (polling) { + pendingPoll = true + return 0 + } + + polling = true + let forwardedInPoll = 0 + try { + currentSessionFile = + currentSessionFile ?? (await findSessionFileById(currentSessionId)) + if (!currentSessionFile) { + return 0 + } + + let rawContent = "" + try { + rawContent = await readFile(currentSessionFile, "utf8") + } catch { + return 0 + } + + const events = parseSessionEventsForCurrentRun(rawContent, { + notBeforeTimestampMs: options.notBeforeTimestampMs, + }) + for (const event of events) { + const eventHash = getEventHash(event) + if (forwardedEventHashes.has(eventHash)) continue + forwardedEventHashes.add(eventHash) + forwardedInPoll += 1 + forwardedCount += 1 + try { + options.onEvent(event) + } catch (error) { + console.warn("[codex] Ignoring tailed native JSONL event:", error) + } + } + } finally { + polling = false + } + + if (pendingPoll && !stopped) { + pendingPoll = false + forwardedInPoll += await poll() + } + return forwardedInPoll + } + + const schedule = () => { + if (stopped || !currentSessionId || timer) return + timer = setTimeout(() => { + timer = null + void poll().finally(schedule) + }, intervalMs) + } + + return { + start(sessionId) { + const cleanedSessionId = sessionId?.trim() + if (!cleanedSessionId || stopped) return + if (currentSessionId === cleanedSessionId && timer) return + if (currentSessionId !== cleanedSessionId) { + currentSessionFile = null + } + currentSessionId = cleanedSessionId + void poll() + schedule() + }, + async stop() { + stopped = true + if (timer) { + clearTimeout(timer) + timer = null + } + return poll() + }, + getForwardedCount() { + return forwardedCount + }, + } +} + async function readLatestTokenCountInfo( filePath: string, options?: { notBeforeTimestampMs?: number }, @@ -607,6 +952,39 @@ function mapToUsageMetadata(info: CodexTokenCountInfo): CodexUsageMetadata | nul return Object.keys(usageMetadata).length > 0 ? usageMetadata : null } +function mapNativeUsageMetadata( + usage: Record | undefined, +): CodexUsageMetadata | null { + if (!usage) return null + + const rawInputTokens = + toNonNegativeInt(usage.input_tokens) ?? toNonNegativeInt(usage.inputTokens) + const rawCachedInputTokens = + toNonNegativeInt(usage.cached_input_tokens) ?? + toNonNegativeInt(usage.cachedInputTokens) + const outputTokens = + toNonNegativeInt(usage.output_tokens) ?? toNonNegativeInt(usage.outputTokens) + const totalTokens = + toNonNegativeInt(usage.total_tokens) ?? toNonNegativeInt(usage.totalTokens) + const modelContextWindow = + toNonNegativeInt(usage.model_context_window) ?? + toNonNegativeInt(usage.modelContextWindow) + const inputTokens = + rawInputTokens !== undefined + ? Math.max(0, rawInputTokens - (rawCachedInputTokens ?? 0)) + : undefined + + const usageMetadata: CodexUsageMetadata = {} + if (inputTokens !== undefined) usageMetadata.inputTokens = inputTokens + if (outputTokens !== undefined) usageMetadata.outputTokens = outputTokens + if (totalTokens !== undefined) usageMetadata.totalTokens = totalTokens + if (modelContextWindow !== undefined) { + usageMetadata.modelContextWindow = modelContextWindow + } + + return Object.keys(usageMetadata).length > 0 ? usageMetadata : null +} + async function pollUsage( sessionId: string, options?: { notBeforeTimestampMs?: number }, @@ -745,7 +1123,10 @@ function normalizeCodexTools(tools: McpToolInfo[]): McpToolInfo[] { return [...unique.values()] } -async function fetchCodexMcpTools(entry: CodexMcpListEntry): Promise { +async function fetchCodexMcpTools( + entry: CodexMcpListEntry, + sourcePath?: string | null, +): Promise { const transportType = entry.transport.type.trim().toLowerCase() const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), CODEX_MCP_TOOLS_FETCH_TIMEOUT_MS), @@ -759,6 +1140,8 @@ async function fetchCodexMcpTools(entry: CodexMcpListEntry): Promise 0 ? trimmed : undefined +} + function getCodexMcpFingerprint(servers: CodexMcpServerForSession[]): string { return createHash("sha256").update(JSON.stringify(servers)).digest("hex") } @@ -829,6 +1219,7 @@ async function resolveCodexMcpSnapshot(params: { const includeInSession = entry.enabled const resolvedStdioEnv = resolveCodexStdioEnv(entry.transport) const resolvedHttpHeaders = resolveCodexHttpHeaders(entry.transport) + const transportCwd = normalizeCodexTransportCwd(entry.transport.cwd) let status: CodexMcpServerForSettings["status"] = !entry.enabled ? "failed" : authState.needsAuth @@ -854,11 +1245,13 @@ async function resolveCodexMcpSnapshot(params: { command, args: Array.isArray(args) ? args : [], env: envPairs, + ...(transportCwd ? { cwd: transportCwd } : {}), } } settingsConfig.command = command settingsConfig.args = args + settingsConfig.cwd = transportCwd settingsConfig.env = entry.transport.env || undefined settingsConfig.envVars = entry.transport.env_vars || undefined } else if ( @@ -895,7 +1288,12 @@ async function resolveCodexMcpSnapshot(params: { // For auth-capable HTTP, only probe if explicit auth header is available. Boolean(resolvedHttpHeaders?.Authorization) ) - const tools = shouldProbeTools ? await fetchCodexMcpTools(entry) : [] + const tools = shouldProbeTools + ? await fetchCodexMcpTools( + entry, + lookupPath === "__global__" ? undefined : lookupPath, + ) + : [] if (shouldProbeTools && tools.length === 0) { status = "failed" } @@ -914,8 +1312,16 @@ async function resolveCodexMcpSnapshot(params: { ) for (const converted of convertedEntries) { - if (converted.sessionServer) { - mcpServersForSession.push(converted.sessionServer) + const { sessionServer } = converted + if ( + sessionServer && + shouldAttachCodexMcpServerToSession({ + sessionServer, + settingsServer: converted.settingsServer, + toolsWereResolved: shouldIncludeTools, + }) + ) { + mcpServersForSession.push(sessionServer) } mcpServersForSettings.push(converted.settingsServer) } @@ -952,6 +1358,7 @@ function getCodexServerIdentity( transportType: config.transportType ?? null, command: config.command ?? null, args: config.args ?? null, + cwd: config.cwd ?? null, env: config.env ?? null, envVars: config.envVars ?? null, url: config.url ?? null, @@ -1047,6 +1454,28 @@ function normalizeCodexIntegrationState(rawOutput: string): CodexIntegrationStat return "unknown" } +export async function getCodexIntegrationStatus(): Promise<{ + state: CodexIntegrationState + isConnected: boolean + rawOutput: string + exitCode: number | null +}> { + const result = await runCodexCli(["login", "status"]) + const combinedOutput = [result.stdout, result.stderr] + .filter((chunk) => chunk.trim().length > 0) + .join("\n") + .trim() + const state = normalizeCodexIntegrationState(combinedOutput) + + return { + state, + isConnected: + state === "connected_chatgpt" || state === "connected_api_key", + rawOutput: combinedOutput, + exitCode: result.exitCode, + } +} + function parseStoredMessages(raw: string | null | undefined): any[] { if (!raw) return [] try { @@ -1100,7 +1529,7 @@ function extractCodexModelId(rawModel: unknown): string | undefined { function preprocessCodexModelName(params: { modelId: string - authConfig?: { apiKey: string } + authConfig?: CodexAuthConfig }): string { const hasAppManagedApiKey = Boolean(params.authConfig?.apiKey?.trim()) if (!hasAppManagedApiKey) { @@ -1111,13 +1540,25 @@ function preprocessCodexModelName(params: { return params.modelId } -function getAuthFingerprint(authConfig?: { apiKey: string }): string | null { +function getAuthFingerprint(authConfig?: CodexAuthConfig): string | null { const apiKey = authConfig?.apiKey?.trim() - if (!apiKey) return null - return createHash("sha256").update(apiKey).digest("hex") + if (!apiKey && !authConfig?.baseUrl && !authConfig?.authMethodId) return null + return createHash("sha256") + .update( + JSON.stringify({ + apiKey, + baseUrl: authConfig?.baseUrl, + authMethodId: authConfig?.authMethodId, + providerId: authConfig?.providerId, + }), + ) + .digest("hex") } -function buildCodexProviderEnv(authConfig?: { apiKey: string }): Record { +function buildCodexProviderEnv(params?: { + authConfig?: CodexAuthConfig + mossProvider?: ResolvedMossProvider | null +}): Record { // Prefer shell-derived values (notably PATH) so stdio MCP dependencies // like pipx/npx resolve the same way as in MCP tool probing. const env: Record = {} @@ -1135,25 +1576,42 @@ function buildCodexProviderEnv(authConfig?: { apiKey: string }): Record +}): any { + const parts = [...(params.parts ?? [])] + if (params.text) { + parts.push({ + type: "text", + text: params.text, + state: "done", + }) + } + + return { + id: params.id ?? crypto.randomUUID(), + role: "assistant", + parts, + metadata: params.metadata, + } +} + +function emitNativeCodexUiChunks(params: { + result: CodexExecResumeBridgeResult + metadata: Record + messageId: string + emit: (chunk: any) => void +}): void { + const textPartId = crypto.randomUUID() + const text = params.result.lastText || "" + + params.emit({ + type: "start", + messageId: params.messageId, + messageMetadata: params.metadata, + }) + params.emit({ type: "start-step" }) + + if (text) { + params.emit({ type: "text-start", id: textPartId }) + params.emit({ type: "text-delta", id: textPartId, delta: text }) + params.emit({ type: "text-end", id: textPartId }) + } + + params.emit({ type: "finish-step" }) + params.emit({ + type: "finish", + finishReason: params.result.success ? "stop" : "error", + messageMetadata: params.metadata, + }) +} + function getOrCreateProvider(params: { subChatId: string cwd: string mcpServers: CodexMcpServerForSession[] mcpFingerprint: string existingSessionId?: string - authConfig?: { - apiKey: string - } + authConfig?: CodexAuthConfig + mossProvider?: ResolvedMossProvider | null }): ACPProvider { const authFingerprint = getAuthFingerprint(params.authConfig) + const mossProviderFingerprint = getMossProviderFingerprint(params.mossProvider) const existing = providerSessions.get(params.subChatId) if ( existing && existing.cwd === params.cwd && existing.authFingerprint === authFingerprint && + existing.mossProviderFingerprint === mossProviderFingerprint && existing.mcpFingerprint === params.mcpFingerprint ) { return existing.provider @@ -1254,7 +1786,10 @@ function getOrCreateProvider(params: { const provider = createACPProvider({ command: resolveCodexAcpBinaryPath(), - env: buildCodexProviderEnv(params.authConfig), + env: buildCodexProviderEnv({ + authConfig: params.authConfig, + mossProvider: params.mossProvider, + }), authMethodId: getCodexAuthMethodId(params.authConfig), session: { cwd: params.cwd, @@ -1270,6 +1805,7 @@ function getOrCreateProvider(params: { provider, cwd: params.cwd, authFingerprint, + mossProviderFingerprint, mcpFingerprint: params.mcpFingerprint, }) @@ -1286,21 +1822,7 @@ function cleanupProvider(subChatId: string): void { export const codexRouter = router({ getIntegration: publicProcedure.query(async () => { - const result = await runCodexCli(["login", "status"]) - const combinedOutput = [result.stdout, result.stderr] - .filter((chunk) => chunk.trim().length > 0) - .join("\n") - .trim() - - const state = normalizeCodexIntegrationState(combinedOutput) - - return { - state, - isConnected: - state === "connected_chatgpt" || state === "connected_api_key", - rawOutput: combinedOutput, - exitCode: result.exitCode, - } + return getCodexIntegrationStatus() }), logout: publicProcedure.mutation(async () => { @@ -1341,7 +1863,7 @@ export const codexRouter = router({ return toLoginSessionResponse(existingSession) } - const codexCliPath = resolveBundledCodexCliPath() + const codexCliPath = resolveCodexCliPath() const sessionId = crypto.randomUUID() const child = spawn(codexCliPath, ["login"], { @@ -1566,18 +2088,23 @@ export const codexRouter = router({ cwd: z.string(), projectPath: z.string().optional(), mode: z.enum(["plan", "agent"]).default("agent"), + permissionMode: permissionModeSchema.optional(), sessionId: z.string().optional(), forceNewSession: z.boolean().optional(), images: z.array(imageAttachmentSchema).optional(), authConfig: z .object({ apiKey: z.string().min(1), + authMethodId: z.enum(["codex-api-key", "openai-api-key"]).optional(), + baseUrl: z.string().min(1).optional(), + providerId: z.string().min(1).optional(), }) .optional(), }), ) .subscription(({ input }) => { return observable((emit) => { + const runtimePermissionMode = input.permissionMode ?? input.mode const existingStream = activeStreams.get(input.subChatId) if (existingStream) { existingStream.cancelRequested = true @@ -1594,6 +2121,8 @@ export const codexRouter = router({ }) let isActive = true + let clearActiveStreamId: (() => void) | null = null + let latestKnownCodexSessionId: string | null = input.sessionId ?? null const safeEmit = (chunk: any) => { if (!isActive) return @@ -1628,12 +2157,57 @@ export const codexRouter = router({ throw new Error("Sub-chat not found") } - const existingMessages = parseStoredMessages(existingSubChat.messages) + const parsedExistingMessages = parseStoredMessages(existingSubChat.messages) + const nativeMessageHygiene = + stripCodexNativeRuntimeNoticeMessages(parsedExistingMessages) + const existingMessages = nativeMessageHygiene.messages as any[] + const ignoreStoredSessionIds = shouldIgnoreMossStoredMessageSessionIds( + existingSubChat.runtimeMetadata, + ) + const existingRunSessionIdCandidate = input.forceNewSession + ? undefined + : (ignoreStoredSessionIds ? undefined : input.sessionId) ?? + existingSubChat.engineSessionId ?? + (ignoreStoredSessionIds + ? undefined + : getLastSessionId(existingMessages)) + const nativeResumeDecision = shouldStartFreshCodexNativeSession({ + storedMessagesByteLength: Buffer.byteLength( + existingSubChat.messages ?? "", + "utf8", + ), + runtimeMetadata: existingSubChat.runtimeMetadata, + candidateSessionId: existingRunSessionIdCandidate, + forceNewSession: input.forceNewSession, + messages: existingMessages, + }) + const existingRunSessionId = nativeResumeDecision.startFresh + ? undefined + : existingRunSessionIdCandidate + latestKnownCodexSessionId = existingRunSessionId ?? null + const requestedModelIdFromInput = extractCodexModelId(input.model) + const resolvedProjectPathFromCwd = resolveProjectPathFromWorktree( + input.cwd, + ) + const providerLookupPath = + input.projectPath || resolvedProjectPathFromCwd || input.cwd + const mossProvider = await resolveMossProviderForEngine({ + projectPath: providerLookupPath, + engineId: "codex", + requestedModelId: requestedModelIdFromInput, + createIfMissing: true, + secretResolver: { getSecret: getMossProviderSecret }, + }) + if (mossProvider.warnings.length > 0) { + console.warn("[codex] Moss provider warnings:", mossProvider.warnings) + } + const effectiveAuthConfig = + input.authConfig || buildCodexAuthConfigFromMossProvider(mossProvider) const requestedModelId = - extractCodexModelId(input.model) || DEFAULT_CODEX_MODEL + requestedModelIdFromInput || mossProvider.model || DEFAULT_CODEX_MODEL const selectedModelId = preprocessCodexModelName({ modelId: requestedModelId, - authConfig: input.authConfig, + authConfig: effectiveAuthConfig, }) const metadataModel = selectedModelId @@ -1663,15 +2237,66 @@ export const codexRouter = router({ return true } - const cleanAssistantMessageForPersistence = (message: any) => { - if (!message || message.role !== "assistant") return message - if (!Array.isArray(message.parts)) return message - - const cleanedParts = message.parts.filter( - (part: any) => part?.state !== "input-streaming", - ) + const persistSubChatStreamId = (streamId: string | null) => { + if (!isAuthoritativeRun()) { + return false + } - if (cleanedParts.length === 0) { + db.update(subChats) + .set({ + streamId, + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .run() + return true + } + clearActiveStreamId = () => { + persistSubChatStreamId(null) + } + + const shouldDedupeCodexNativeTextParts = (message: any) => + message?.metadata?.transport === "codex-native-exec" || + typeof message?.metadata?.nativeBridge === "string" + + const getTextPartDedupeKey = (text: unknown) => { + if (typeof text !== "string") return null + const trimmed = text.trim() + return trimmed ? trimmed.replace(/\r\n/g, "\n") : null + } + + const dedupeCodexNativeTextParts = ( + message: any, + parts: any[], + ) => { + if (!shouldDedupeCodexNativeTextParts(message)) return parts + const seenTextParts = new Set() + const dedupedParts: any[] = [] + for (const part of parts) { + if (part?.type !== "text") { + dedupedParts.push(part) + continue + } + const textKey = getTextPartDedupeKey(part.text) + if (textKey && seenTextParts.has(textKey)) continue + if (textKey) seenTextParts.add(textKey) + dedupedParts.push(part) + } + return dedupedParts + } + + const cleanAssistantMessageForPersistence = (message: any) => { + if (!message || message.role !== "assistant") return message + if (!Array.isArray(message.parts)) return message + + const cleanedParts = dedupeCodexNativeTextParts( + message, + message.parts.filter( + (part: any) => part?.state !== "input-streaming", + ), + ) + + if (cleanedParts.length === 0) { return null } @@ -1698,10 +2323,19 @@ export const codexRouter = router({ db.update(subChats) .set({ messages: JSON.stringify(messagesForStream), + streamId: input.runId, updatedAt: new Date(), }) .where(eq(subChats.id, input.subChatId)) .run() + } else { + if ( + nativeMessageHygiene.removedCount > 0 || + nativeMessageHygiene.removedPartCount > 0 + ) { + persistSubChatMessages(messagesForStream) + } + persistSubChatStreamId(input.runId) } if (input.forceNewSession) { @@ -1716,36 +2350,842 @@ export const codexRouter = router({ toolsResolved: false, } try { - const resolvedProjectPathFromCwd = resolveProjectPathFromWorktree( - input.cwd, - ) const mcpLookupPath = input.projectPath || resolvedProjectPathFromCwd || input.cwd mcpSnapshot = await resolveCodexMcpSnapshot({ lookupPath: mcpLookupPath, + includeTools: true, }) } catch (mcpError) { console.error("[codex] Failed to resolve MCP servers:", mcpError) } + if (shouldUseNativeCodexExec()) { + const startedAt = Date.now() + const responseMessageId = crypto.randomUUID() + let didEmitNativeStart = false + let didEmitNativeStartStep = false + let didEmitNativeFinishStep = false + let activeNativeTextPartId: string | null = null + let nativeTextSequence = 0 + let nativeFinalTextPartId: string | null = null + let didEmitNativeFinalTextStart = false + let emittedNativeText = "" + let emittedNativeFinalText = "" + let latestNativeSessionId = existingRunSessionId ?? null + let pendingNativeSnapshotTimer: ReturnType | null = + null + let lastNativeSnapshotPersistedAt = 0 + let lastNativeSnapshotKey = "" + let nativeVisualTextQueue: Promise = Promise.resolve() + const handledNativeEventHashes = new Set() + const nativeMessageParts = + createCodexNativeMessagePartsAccumulator() + const runningNativeBridge = existingRunSessionId + ? "codex-exec-resume" + : "codex-exec-start" + let nativeSessionEventTailer: NativeSessionEventTailer | null = null + let tailedNativeSessionEventCount = 0 + const nativeRuntimeNoticeCleanupMetadata: Record = { + ...(nativeMessageHygiene.removedCount > 0 + ? { + nativeRuntimeNoticeMessagesRemoved: + nativeMessageHygiene.removedCount, + } + : {}), + ...(nativeMessageHygiene.removedPartCount > 0 + ? { + nativeRuntimeNoticePartsRemoved: + nativeMessageHygiene.removedPartCount, + } + : {}), + } + + const buildNativeResponseMetadata = ( + resultSubtype: "running" | "success" | "error" | "cancelled", + options?: { + durationMs?: number + nativeBridge?: string + imageCount?: number + usageMetadata?: CodexUsageMetadata | null + error?: string | null + }, + ): Record => ({ + model: metadataModel, + sessionId: latestNativeSessionId ?? undefined, + durationMs: options?.durationMs ?? Date.now() - startedAt, + resultSubtype, + nativeBridge: options?.nativeBridge ?? runningNativeBridge, + transport: "codex-native-exec", + imageCount: options?.imageCount ?? input.images?.length ?? 0, + ...(nativeResumeDecision.reason + ? { nativeResumeSkipReason: nativeResumeDecision.reason } + : {}), + ...nativeRuntimeNoticeCleanupMetadata, + ...(options?.usageMetadata || {}), + ...(options?.error ? { error: options.error } : {}), + }) + + const persistNativeRuntimeSession = ( + resultSubtype: "running" | "success" | "error" | "cancelled", + options?: { + nativeBridge?: string + imageCount?: number + eventCount?: number + exitCode?: number | null + error?: string | null + }, + ) => { + if (!isAuthoritativeRun()) return false + + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "codex", + nativeSessionId: latestNativeSessionId, + configDir: join(homedir(), ".codex"), + modelId: metadataModel, + permissionMode: runtimePermissionMode, + metadata: { + cwd: input.cwd, + projectPath: input.projectPath ?? null, + transport: "codex-native-exec", + nativeBridge: options?.nativeBridge ?? runningNativeBridge, + authMode: input.authConfig + ? "api-key" + : effectiveAuthConfig + ? "moss-provider" + : "codex-cli", + mossProviderStatus: mossProvider.status, + mossProviderId: mossProvider.providerId ?? null, + mossProviderMode: mossProvider.mode ?? null, + forceNewSession: Boolean(input.forceNewSession), + ignoredStoredSessionIds: ignoreStoredSessionIds, + ...(nativeResumeDecision.reason + ? { nativeResumeSkipReason: nativeResumeDecision.reason } + : {}), + ...nativeRuntimeNoticeCleanupMetadata, + mcpFingerprint: mcpSnapshot.fingerprint, + mcpFetchedAt: mcpSnapshot.fetchedAt, + mcpToolsResolved: mcpSnapshot.toolsResolved, + mcpServerCount: mcpSnapshot.mcpServersForSession.length, + hasImages: Boolean(input.images?.length), + imageCount: options?.imageCount ?? input.images?.length ?? 0, + resultSubtype, + ...(typeof options?.eventCount === "number" + ? { eventCount: options.eventCount } + : {}), + ...(options?.exitCode !== undefined + ? { exitCode: options.exitCode } + : {}), + ...(options?.error ? { error: options.error } : {}), + }, + }) + return true + } + + persistNativeRuntimeSession("running") + + const persistNativeSnapshot = ( + resultSubtype: "running" | "success" | "error" | "cancelled" = + "running", + options?: { + force?: boolean + nativeBridge?: string + imageCount?: number + usageMetadata?: CodexUsageMetadata | null + error?: string | null + }, + ) => { + if (nativeMessageParts.parts.length === 0) { + return false + } + + const snapshotKey = JSON.stringify({ + resultSubtype, + sessionId: latestNativeSessionId, + textLength: emittedNativeText.length, + parts: nativeMessageParts.parts.map((part) => ({ + type: part.type, + id: part.toolCallId, + state: part.state, + textLength: + typeof part.text === "string" ? part.text.length : undefined, + hasResult: part.result !== undefined || part.output !== undefined, + })), + tools: nativeMessageParts.toolParts.map((part) => ({ + type: part.type, + id: part.toolCallId, + state: part.state, + textLength: + typeof part.text === "string" ? part.text.length : undefined, + hasResult: part.result !== undefined || part.output !== undefined, + })), + }) + if (!options?.force && snapshotKey === lastNativeSnapshotKey) { + return false + } + + const responseMessage = buildNativeCodexAssistantMessage({ + id: responseMessageId, + parts: nativeMessageParts.parts, + metadata: buildNativeResponseMetadata(resultSubtype, options), + }) + const cleanedResponseMessage = + cleanAssistantMessageForPersistence(responseMessage) + const messagesToPersist = cleanedResponseMessage + ? [...messagesForStream, cleanedResponseMessage] + : messagesForStream + + if (persistSubChatMessages(messagesToPersist)) { + lastNativeSnapshotKey = snapshotKey + lastNativeSnapshotPersistedAt = Date.now() + return true + } + return false + } + + const flushNativeSnapshot = ( + resultSubtype: "running" | "success" | "error" | "cancelled" = + "running", + options?: Parameters[1], + ) => { + if (pendingNativeSnapshotTimer) { + clearTimeout(pendingNativeSnapshotTimer) + pendingNativeSnapshotTimer = null + } + return persistNativeSnapshot(resultSubtype, { + ...options, + force: true, + }) + } + + const scheduleNativeSnapshotPersist = () => { + const now = Date.now() + const elapsed = now - lastNativeSnapshotPersistedAt + if (elapsed >= 500) { + persistNativeSnapshot() + return + } + + if (pendingNativeSnapshotTimer) return + pendingNativeSnapshotTimer = setTimeout(() => { + pendingNativeSnapshotTimer = null + persistNativeSnapshot() + }, Math.max(50, 500 - elapsed)) + } + + const getNativeEventHash = (event: CodexJsonlEvent): string => { + try { + return createHash("sha1") + .update(JSON.stringify(event)) + .digest("hex") + } catch { + return createHash("sha1") + .update(String(event)) + .digest("hex") + } + } + + const emitNativeStart = () => { + if (didEmitNativeStart) return + didEmitNativeStart = true + safeEmit({ + type: "start", + messageId: responseMessageId, + messageMetadata: { + model: metadataModel, + sessionId: latestNativeSessionId ?? undefined, + transport: "codex-native-exec", + }, + }) + safeEmit({ type: "start-step" }) + didEmitNativeStartStep = true + } + + const enqueueNativeVisualTextDelta = ( + emitChunk: () => void, + delayAfterMs: number, + ) => { + nativeVisualTextQueue = nativeVisualTextQueue + .catch(() => undefined) + .then(async () => { + if (!isActive) return + emitChunk() + await sleep(delayAfterMs) + }) + return nativeVisualTextQueue + } + + const drainNativeVisualTextQueue = async () => { + try { + await nativeVisualTextQueue + } catch { + // Keep stream completion authoritative even if a late UI emit races + // with teardown. + } + } + + const closeNativeTextPart = () => { + if (!activeNativeTextPartId) { + nativeMessageParts.closeActiveTextPart() + return + } + safeEmit({ type: "text-end", id: activeNativeTextPartId }) + activeNativeTextPartId = null + nativeMessageParts.closeActiveTextPart() + } + + const closeNativeFinalTextPart = () => { + if (!didEmitNativeFinalTextStart || !nativeFinalTextPartId) { + nativeMessageParts.closeFinalTextPart() + return + } + safeEmit({ type: "text-end", id: nativeFinalTextPartId }) + didEmitNativeFinalTextStart = false + nativeFinalTextPartId = null + nativeMessageParts.closeFinalTextPart() + } + + const closeNativeTextParts = () => { + closeNativeTextPart() + closeNativeFinalTextPart() + } + + const emitNativeFinishStep = () => { + if (!didEmitNativeStartStep || didEmitNativeFinishStep) return + closeNativeTextParts() + safeEmit({ type: "finish-step" }) + didEmitNativeFinishStep = true + } + + const emitNativeTextDelta = (delta: string) => { + if (!delta) return + emitNativeStart() + const textChange = nativeMessageParts.appendTextDelta(delta) + if (!textChange) return + if (textChange.didStart || !activeNativeTextPartId) { + activeNativeTextPartId = `native-text-${responseMessageId}-${nativeTextSequence++}` + safeEmit({ type: "text-start", id: activeNativeTextPartId }) + } + safeEmit({ type: "text-delta", id: activeNativeTextPartId, delta }) + emittedNativeText += delta + scheduleNativeSnapshotPersist() + } + + const emitNativeTextDeltaChunks = (delta: string) => { + for (const chunk of splitCodexTextForStreamingDeltas(delta)) { + emitNativeTextDelta(chunk) + } + } + + const reconcileNativeText = (text: string) => { + if (!text) return + if (!emittedNativeText) { + emitNativeTextDeltaChunks(text) + return + } + const textAppend = reconcileCodexNativeTextAppend( + emittedNativeText, + text, + ) + if (textAppend.kind !== "separate") { + emitNativeTextDeltaChunks(textAppend.appendText) + } + } + + const emitNativeFinalText = (text: string) => { + if (!text) return + closeNativeTextPart() + if (!emittedNativeText) { + emitNativeFinalTextDeltaChunks(text, false) + return + } + if (emittedNativeFinalText.includes(text)) { + return + } + if ( + isCodexNativeRepeatedFinalText(emittedNativeFinalText, text) || + isCodexNativeRepeatedFinalText(emittedNativeText, text) + ) { + return + } + + const textAppend = reconcileCodexNativeTextAppend( + emittedNativeText, + text, + ) + if (!textAppend.appendText) return + + emitNativeFinalTextDeltaChunks( + textAppend.appendText, + textAppend.kind === "separate", + ) + } + + const emitNativeFinalTextDelta = ( + delta: string, + separateFromPreviousText: boolean, + ) => { + if (!delta) return + emitNativeStart() + const finalTextChange = nativeMessageParts.appendFinalTextDelta(delta) + if (!finalTextChange) return + if (finalTextChange.didStart || !nativeFinalTextPartId) { + nativeFinalTextPartId = `native-final-${responseMessageId}` + safeEmit({ type: "text-start", id: nativeFinalTextPartId }) + didEmitNativeFinalTextStart = true + } + safeEmit({ type: "text-delta", id: nativeFinalTextPartId, delta }) + emittedNativeFinalText += delta + emittedNativeText += + separateFromPreviousText && emittedNativeText + ? `\n\n${delta}` + : delta + scheduleNativeSnapshotPersist() + } + + const emitNativeFinalTextDeltaChunks = ( + delta: string, + separateFromPreviousText: boolean, + ) => { + const chunks = splitCodexTextForStreamingDeltas( + delta, + NATIVE_TEXT_REPLAY_CHUNK_LENGTH, + ) + const replayDelayMs = Math.max( + NATIVE_TEXT_REPLAY_MIN_INTERVAL_MS, + Math.min( + NATIVE_TEXT_REPLAY_MAX_INTERVAL_MS, + Math.floor( + NATIVE_TEXT_REPLAY_MAX_DURATION_MS / + Math.max(chunks.length, 1), + ), + ), + ) + chunks.forEach((chunk, index) => { + enqueueNativeVisualTextDelta( + () => { + emitNativeFinalTextDelta( + chunk, + separateFromPreviousText && index === 0, + ) + }, + replayDelayMs, + ) + }) + } + + const emitNativeCommentaryText = (text: string) => { + const commentaryText = text.trim() + if (!commentaryText) return + + emitNativeStart() + closeNativeTextPart() + + const commentaryPart = nativeMessageParts.appendCommentaryText(text) + if (!commentaryPart) return + const commentaryTextPartId = `native-commentary-${responseMessageId}-${nativeTextSequence++}` + + safeEmit({ type: "text-start", id: commentaryTextPartId }) + for (const chunk of splitCodexTextForStreamingDeltas(commentaryText)) { + safeEmit({ + type: "text-delta", + id: commentaryTextPartId, + delta: chunk, + }) + } + safeEmit({ type: "text-end", id: commentaryTextPartId }) + emittedNativeText += emittedNativeText + ? `\n\n${commentaryText}` + : commentaryText + scheduleNativeSnapshotPersist() + } + + const emitNativeToolInput = (params: { + callId: string + toolName: string + input: unknown + title?: string + }) => { + emitNativeStart() + closeNativeTextPart() + closeNativeFinalTextPart() + const toolChange = nativeMessageParts.startTool({ + callId: params.callId, + toolName: params.toolName, + input: params.input, + ...(params.title ? { title: params.title } : {}), + }) + + if (toolChange.didStart) { + safeEmit({ + type: "tool-input-available", + toolCallId: toolChange.part.toolCallId ?? params.callId, + toolName: params.toolName, + input: params.input, + ...(params.title ? { title: params.title } : {}), + }) + } + scheduleNativeSnapshotPersist() + return toolChange.part + } + + const emitNativeToolOutput = (params: { + callId: string + output: unknown + toolName?: string + input?: unknown + title?: string + isError?: boolean + }) => { + emitNativeStart() + let updatedToolPart = nativeMessageParts.updateToolResult( + params.callId, + { + output: params.output, + ...(params.input !== undefined ? { input: params.input } : {}), + ...(params.isError ? { isError: true } : {}), + }, + ) + if (!updatedToolPart && params.toolName) { + emitNativeToolInput({ + callId: params.callId, + toolName: params.toolName, + input: params.input ?? {}, + ...(params.title ? { title: params.title } : {}), + }) + updatedToolPart = nativeMessageParts.updateToolResult( + params.callId, + { + output: params.output, + ...(params.input !== undefined ? { input: params.input } : {}), + ...(params.isError ? { isError: true } : {}), + }, + ) + } + + safeEmit({ + type: params.isError ? "tool-output-error" : "tool-output-available", + toolCallId: updatedToolPart?.toolCallId ?? params.callId, + ...(params.isError + ? { errorText: String(params.output ?? "Codex tool failed") } + : { output: params.output }), + }) + scheduleNativeSnapshotPersist() + } + + const handleNativeJsonlEvent = (event: CodexJsonlEvent) => { + const eventHash = getNativeEventHash(event) + if (handledNativeEventHashes.has(eventHash)) return + handledNativeEventHashes.add(eventHash) + + const eventSessionId = extractCodexJsonlEventSessionId(event) + if (eventSessionId && eventSessionId !== latestNativeSessionId) { + latestNativeSessionId = eventSessionId + latestKnownCodexSessionId = eventSessionId + nativeSessionEventTailer?.start(eventSessionId) + persistNativeRuntimeSession("running") + safeEmit({ + type: "message-metadata", + messageMetadata: buildNativeResponseMetadata("running"), + }) + scheduleNativeSnapshotPersist() + } + + if (isCodexJsonlUserEvent(event)) return + + const text = extractCodexJsonlEventText(event) + if (text && isCodexNativeRuntimeNoticeText(text)) return + if (text && isCodexJsonlCommentaryTextEvent(event)) { + emitNativeCommentaryText(text) + return + } + + const toolEvent = codexJsonlEventToNativeToolEvent(event) + if (toolEvent?.kind === "tool-input") { + emitNativeToolInput({ + callId: toolEvent.callId, + toolName: toolEvent.toolName, + input: toolEvent.input, + ...(toolEvent.title ? { title: toolEvent.title } : {}), + }) + } else if (toolEvent?.kind === "tool-output") { + emitNativeToolOutput({ + callId: toolEvent.callId, + output: toolEvent.output, + ...(toolEvent.toolName ? { toolName: toolEvent.toolName } : {}), + ...(toolEvent.input !== undefined ? { input: toolEvent.input } : {}), + ...(toolEvent.title ? { title: toolEvent.title } : {}), + ...(toolEvent.isError ? { isError: true } : {}), + }) + } + + if (!text) return + + if (isCodexJsonlFinalTextEvent(event)) { + emitNativeFinalText(text) + return + } + + if (isCodexJsonlDeltaTextEvent(event)) { + emitNativeTextDelta(text) + return + } + + reconcileNativeText(text) + } + + nativeSessionEventTailer = createNativeSessionEventTailer({ + notBeforeTimestampMs: startedAt, + onEvent: handleNativeJsonlEvent, + }) + nativeSessionEventTailer.start(existingRunSessionId) + + try { + emitNativeStart() + const nativeEnv = buildCodexProviderEnv({ + authConfig: effectiveAuthConfig, + mossProvider, + }) + const runNativeCodex = existingRunSessionId + ? runCodexExecResumeBridge({ + sessionId: existingRunSessionId, + cwd: input.cwd, + prompt: input.prompt, + modelId: selectedModelId, + permissionMode: runtimePermissionMode, + command: resolveCodexCliPath(), + images: input.images, + env: nativeEnv, + abortSignal: abortController.signal, + onEvent: handleNativeJsonlEvent, + }) + : runCodexExecStartBridge({ + cwd: input.cwd, + prompt: input.prompt, + modelId: selectedModelId, + permissionMode: runtimePermissionMode, + command: resolveCodexCliPath(), + images: input.images, + env: nativeEnv, + abortSignal: abortController.signal, + onEvent: handleNativeJsonlEvent, + }) + + const nativeResult = await runNativeCodex + for (const event of nativeResult.events) { + handleNativeJsonlEvent(event) + await drainNativeVisualTextQueue() + } + tailedNativeSessionEventCount += await nativeSessionEventTailer.stop() + const replaySessionId = + nativeResult.nativeSessionId || + latestNativeSessionId || + existingRunSessionId + const replayedNativeSessionEvents = replaySessionId + ? await readSessionEventsForCurrentRun(replaySessionId, { + notBeforeTimestampMs: startedAt, + }) + : [] + if (replayedNativeSessionEvents.length > 0) { + const replaySnapshot = buildNativePartsFromCodexEvents( + replayedNativeSessionEvents, + ) + if ( + getCodexNativePartsRichness(replaySnapshot) > + getCodexNativePartsRichness(nativeMessageParts.snapshot()) + ) { + nativeMessageParts.replaceWith(replaySnapshot) + scheduleNativeSnapshotPersist() + } + } + await drainNativeVisualTextQueue() + const activeStream = activeStreams.get(input.subChatId) + const wasCancelled = + abortController.signal.aborted || activeStream?.cancelRequested + latestNativeSessionId = + nativeResult.nativeSessionId || + latestNativeSessionId || + existingRunSessionId || + null + latestKnownCodexSessionId = latestNativeSessionId + const usageMetadata = mapNativeUsageMetadata(nativeResult.usage) + const resultSubtype = wasCancelled + ? "cancelled" + : nativeResult.success + ? "success" + : "error" + const finalText = nativeResult.lastText || emittedNativeText + + const finalTextAlreadyPresent = nativeMessageParts.parts.some( + (part) => + part.type === "text" && + typeof part.text === "string" && + part.text.trim() === finalText.trim(), + ) + if (!finalTextAlreadyPresent) { + emitNativeFinalText(finalText) + } + await drainNativeVisualTextQueue() + + persistNativeRuntimeSession(resultSubtype, { + nativeBridge: nativeResult.plan.bridge, + imageCount: nativeResult.plan.imageCount, + eventCount: + nativeResult.events.length + + tailedNativeSessionEventCount + + replayedNativeSessionEvents.length, + exitCode: nativeResult.exitCode, + ...(nativeResult.error ? { error: nativeResult.error } : {}), + }) + + if (wasCancelled) { + const responseMetadata = buildNativeResponseMetadata("cancelled", { + nativeBridge: nativeResult.plan.bridge, + imageCount: nativeResult.plan.imageCount, + usageMetadata, + }) + flushNativeSnapshot("cancelled", { + nativeBridge: nativeResult.plan.bridge, + imageCount: nativeResult.plan.imageCount, + usageMetadata, + }) + emitNativeFinishStep() + persistSubChatStreamId(null) + safeEmit({ + type: "message-metadata", + messageMetadata: responseMetadata, + }) + safeEmit({ + type: "finish", + finishReason: "stop", + messageMetadata: responseMetadata, + }) + safeComplete() + return + } + + if (!nativeResult.success) { + flushNativeSnapshot("error", { + nativeBridge: nativeResult.plan.bridge, + imageCount: nativeResult.plan.imageCount, + usageMetadata, + error: nativeResult.error ?? null, + }) + emitNativeFinishStep() + throw new Error( + nativeResult.error || + `Codex native exec failed with exit code ${nativeResult.exitCode ?? "unknown"}.`, + ) + } + + const responseMetadata: Record = { + model: metadataModel, + sessionId: latestNativeSessionId ?? undefined, + durationMs: Date.now() - startedAt, + resultSubtype, + nativeBridge: nativeResult.plan.bridge, + transport: "codex-native-exec", + imageCount: nativeResult.plan.imageCount, + ...(nativeResumeDecision.reason + ? { nativeResumeSkipReason: nativeResumeDecision.reason } + : {}), + ...nativeRuntimeNoticeCleanupMetadata, + ...(usageMetadata || {}), + } + flushNativeSnapshot("success", { + nativeBridge: nativeResult.plan.bridge, + imageCount: nativeResult.plan.imageCount, + usageMetadata, + }) + persistSubChatStreamId(null) + emitNativeFinishStep() + safeEmit({ + type: "message-metadata", + messageMetadata: responseMetadata, + }) + safeEmit({ + type: "finish", + finishReason: "stop", + messageMetadata: responseMetadata, + }) + safeComplete() + return + } catch (nativeError) { + tailedNativeSessionEventCount += + (await nativeSessionEventTailer?.stop().catch((tailError) => { + console.warn("[codex] Failed to stop native session tailer:", tailError) + return 0 + })) ?? 0 + flushNativeSnapshot( + abortController.signal.aborted ? "cancelled" : "error", + { + error: + nativeError instanceof Error + ? nativeError.message + : String(nativeError), + }, + ) + emitNativeFinishStep() + throw nativeError + } + } + const provider = getOrCreateProvider({ subChatId: input.subChatId, cwd: input.cwd, mcpServers: mcpSnapshot.mcpServersForSession, mcpFingerprint: mcpSnapshot.fingerprint, - existingSessionId: - input.forceNewSession - ? undefined - : input.sessionId ?? getLastSessionId(existingMessages), - authConfig: input.authConfig, + existingSessionId: existingRunSessionId, + authConfig: effectiveAuthConfig, + mossProvider, }) const startedAt = Date.now() - let latestSessionId = - provider.getSessionId() || - input.sessionId || - getLastSessionId(existingMessages) + let latestSessionId = provider.getSessionId() || existingRunSessionId + if (latestSessionId) { + latestKnownCodexSessionId = latestSessionId + } let usagePromise: Promise | null = null + const persistCodexRuntimeSession = ( + resultSubtype: "success" | "error" | "cancelled" = "success", + ) => { + if (!isAuthoritativeRun()) return false + + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "codex", + nativeSessionId: + provider.getSessionId() || latestSessionId || null, + configDir: join(homedir(), ".codex"), + modelId: metadataModel, + permissionMode: runtimePermissionMode, + metadata: { + cwd: input.cwd, + projectPath: input.projectPath ?? null, + authMode: input.authConfig + ? "api-key" + : effectiveAuthConfig + ? "moss-provider" + : "codex-cli", + mossProviderStatus: mossProvider.status, + mossProviderId: mossProvider.providerId ?? null, + mossProviderMode: mossProvider.mode ?? null, + forceNewSession: Boolean(input.forceNewSession), + ignoredStoredSessionIds: ignoreStoredSessionIds, + mcpFingerprint: mcpSnapshot.fingerprint, + mcpFetchedAt: mcpSnapshot.fetchedAt, + mcpToolsResolved: mcpSnapshot.toolsResolved, + mcpServerCount: mcpSnapshot.mcpServersForSession.length, + hasImages: Boolean(input.images?.length), + resultSubtype, + }, + }) + + return true + } + + persistCodexRuntimeSession() const resolveUsageOnce = (): Promise => { if (usagePromise) return usagePromise @@ -1780,6 +3220,7 @@ export const codexRouter = router({ const sessionId = provider.getSessionId() || undefined if (sessionId) { latestSessionId = sessionId + latestKnownCodexSessionId = sessionId } if (part.type === "finish") { @@ -1816,7 +3257,9 @@ export const codexRouter = router({ cleanAssistantMessageForPersistence(responseWithUsage) if (!cleanedResponseMessage) { - persistSubChatMessages(messagesForStream) + if (persistSubChatMessages(messagesForStream)) { + persistCodexRuntimeSession() + } return } @@ -1827,9 +3270,12 @@ export const codexRouter = router({ cleanedResponseMessage, ] - persistSubChatMessages(messagesToPersist) + if (persistSubChatMessages(messagesToPersist)) { + persistCodexRuntimeSession() + } } catch (error) { console.error("[codex] Failed to persist messages:", error) + persistCodexRuntimeSession("error") } }, onError: (error) => extractCodexError(error).message, @@ -1873,11 +3319,30 @@ export const codexRouter = router({ safeEmit({ type: "finish" }) } + persistSubChatStreamId(null) safeComplete() } catch (error) { const normalized = extractCodexError(error) console.error("[codex] chat stream error:", error) + try { + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "codex", + nativeSessionId: latestKnownCodexSessionId, + configDir: join(homedir(), ".codex"), + modelId: input.model ?? DEFAULT_CODEX_MODEL, + permissionMode: runtimePermissionMode, + metadata: { + cwd: input.cwd, + projectPath: input.projectPath ?? null, + resultSubtype: "error", + error: normalized.message, + }, + }) + } catch { + // Best-effort runtime index update only. + } if (isCodexAuthError(normalized)) { safeEmit({ type: "auth-error", errorText: normalized.message }) } else { @@ -1893,6 +3358,7 @@ export const codexRouter = router({ if (shouldCleanupProvider) { cleanupProvider(input.subChatId) } + clearActiveStreamId?.() activeStreams.delete(input.subChatId) } } diff --git a/src/main/lib/trpc/routers/files.ts b/src/main/lib/trpc/routers/files.ts index d4081f9ae..18621531b 100644 --- a/src/main/lib/trpc/routers/files.ts +++ b/src/main/lib/trpc/routers/files.ts @@ -29,6 +29,15 @@ const IGNORED_DIRS = new Set([ ".astro", ]) +const ALLOWED_HIDDEN_DIRS = new Set([ + ".1code", + ".moss", + ".claude", + ".codex", + ".github", + ".vscode", +]) + // Files to ignore const IGNORED_FILES = new Set([ ".DS_Store", @@ -125,8 +134,8 @@ async function scanDirectory( if (entry.isDirectory()) { // Skip ignored directories if (IGNORED_DIRS.has(entry.name)) continue - // Skip hidden directories (except .github, .vscode, etc.) - if (entry.name.startsWith(".") && !entry.name.startsWith(".github") && !entry.name.startsWith(".vscode")) continue + // Keep product-owned agent/config directories visible in the right panel. + if (entry.name.startsWith(".") && !ALLOWED_HIDDEN_DIRS.has(entry.name)) continue // Add the folder itself to results entries.push({ path: relativePath, type: "folder" }) @@ -391,6 +400,13 @@ export const filesRouter = router({ ".webp": "image/webp", ".ico": "image/x-icon", ".bmp": "image/bmp", + ".pdf": "application/pdf", + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".ogv": "video/ogg", + ".ogg": "video/ogg", } const mimeType = mimeMap[ext] || "application/octet-stream" diff --git a/src/main/lib/trpc/routers/hermes.ts b/src/main/lib/trpc/routers/hermes.ts new file mode 100644 index 000000000..bba163d0e --- /dev/null +++ b/src/main/lib/trpc/routers/hermes.ts @@ -0,0 +1,811 @@ +import { createACPProvider, type ACPProvider } from "@mcpc-tech/acp-ai-provider" +import { observable } from "@trpc/server/observable" +import { streamText } from "ai" +import { eq } from "drizzle-orm" +import { createHash } from "node:crypto" +import { homedir } from "node:os" +import { join } from "node:path" +import { z } from "zod" +import { shouldIgnoreMossStoredMessageSessionIds } from "../../agent-runtime/session-actions" +import { persistAgentRuntimeSession } from "../../agent-runtime/session-store" +import { getClaudeShellEnvironment } from "../../claude/env" +import { resolveProjectPathFromWorktree } from "../../claude-config" +import { getDatabase, subChats } from "../../db" +import { resolveHermesAcpLaunch } from "../../hermes/runtime" +import { + getMossProviderSecret, + getMossProviderFingerprint, + resolveMossProviderForEngine, + runMossHooks, + type ResolvedMossProvider, +} from "../../moss-source" +import { buildMossRuntimeContext } from "../../moss-source/runtime-context" +import { publicProcedure, router } from "../index" + +const imageAttachmentSchema = z.object({ + base64Data: z.string(), + mediaType: z.string(), + filename: z.string().optional(), +}) + +const permissionModeSchema = z.enum([ + "plan", + "agent", + "bypass", + "read-only", + "ask-approval", + "full-access", + "custom", +]) + +type HermesProviderSession = { + provider: ACPProvider + cwd: string + mossProviderFingerprint: string | null + launchFingerprint: string +} + +type ActiveHermesStream = { + runId: string + controller: AbortController + cancelRequested: boolean +} + +const providerSessions = new Map() +const activeStreams = new Map() + +const DEFAULT_HERMES_MODEL = "moss-default" + +export function hasActiveHermesStreams(): boolean { + return activeStreams.size > 0 +} + +export function hasActiveHermesStreamForSubChat( + subChatId: string, + runId?: string | null, +): boolean { + const stream = activeStreams.get(subChatId) + if (!stream) return false + return !runId || stream.runId === runId +} + +export function abortAllHermesStreams(): void { + for (const [subChatId, stream] of activeStreams) { + console.log(`[hermes] Aborting stream ${subChatId} before reload`) + stream.cancelRequested = true + stream.controller.abort() + } + activeStreams.clear() +} + +function parseStoredMessages(raw: string | null | undefined): any[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function extractPromptFromStoredMessage(message: any): string { + if (!message || !Array.isArray(message.parts)) return "" + + const textParts: string[] = [] + const fileContents: string[] = [] + + for (const part of message.parts) { + if (part?.type === "text" && typeof part.text === "string") { + textParts.push(part.text) + } else if (part?.type === "file-content") { + const filePath = + typeof part.filePath === "string" ? part.filePath : undefined + const fileName = filePath?.split("/").pop() || filePath || "file" + const content = typeof part.content === "string" ? part.content : "" + fileContents.push(`\n--- ${fileName} ---\n${content}`) + } + } + + return textParts.join("\n") + fileContents.join("") +} + +function getLastSessionId(messages: any[]): string | undefined { + const lastAssistant = [...messages] + .reverse() + .find((message) => message?.role === "assistant") + const sessionId = lastAssistant?.metadata?.sessionId + return typeof sessionId === "string" ? sessionId : undefined +} + +function buildUserParts( + prompt: string, + images: + | Array<{ + base64Data?: string + mediaType?: string + filename?: string + }> + | undefined, +): any[] { + const parts: any[] = [{ type: "text", text: prompt }] + + if (images && images.length > 0) { + for (const image of images) { + if (!image.base64Data || !image.mediaType) continue + parts.push({ + type: "data-image", + data: { + base64Data: image.base64Data, + mediaType: image.mediaType, + filename: image.filename, + }, + }) + } + } + + return parts +} + +function buildModelMessageContent( + prompt: string, + images: + | Array<{ + base64Data?: string + mediaType?: string + filename?: string + }> + | undefined, +): any[] { + const content: any[] = [{ type: "text", text: prompt }] + + if (images && images.length > 0) { + for (const image of images) { + if (!image.base64Data || !image.mediaType) continue + content.push({ + type: "file", + mediaType: image.mediaType, + data: image.base64Data, + ...(image.filename ? { filename: image.filename } : {}), + }) + } + } + + return content +} + +function resourcesForSessionInfo( + resources: Awaited>["resources"], + kind: "mcp" | "plugin" | "skill", +): Array<{ name: string; path?: string; source: "moss" }> { + return resources + .filter((resource) => resource.kind === kind && resource.included) + .map((resource) => ({ + name: resource.name, + path: resource.path, + source: "moss" as const, + })) +} + +type MossHookRunSummaryForSession = Awaited> + +function summarizeMossHookRunForSession(run: MossHookRunSummaryForSession) { + return { + status: run.status, + event: run.event, + matchedCount: run.matchedCount, + executedCount: run.executedCount, + skippedCount: run.skippedCount, + failedCount: run.failedCount, + timedOutCount: run.timedOutCount, + warnings: run.warnings, + results: run.results.map((result) => ({ + resourceId: result.resourceId, + name: result.name, + status: result.status, + exitCode: result.exitCode ?? null, + elapsedMs: result.elapsedMs, + timedOut: Boolean(result.timedOut), + })), + } +} + +function extractHermesError(error: unknown): { message: string; code?: string } { + const anyError = error as any + const message = + anyError?.data?.message || + anyError?.errorText || + anyError?.message || + anyError?.error || + String(error) + const code = anyError?.data?.code || anyError?.code + + return { + message: typeof message === "string" ? message : String(message), + code: typeof code === "string" ? code : undefined, + } +} + +function buildHermesProviderEnv(params?: { + mossProvider?: ResolvedMossProvider | null +}): Record { + const env: Record = {} + + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + env[key] = value + } + } + + const shellEnv = getClaudeShellEnvironment() + for (const [key, value] of Object.entries(shellEnv)) { + if (typeof value === "string") { + env[key] = value + } + } + + if (!env.HERMES_HOME) { + env.HERMES_HOME = join(homedir(), ".hermes") + } + + if (params?.mossProvider?.status === "resolved") { + Object.assign(env, params.mossProvider.env) + } + + return env +} + +function getLaunchFingerprint(): { + launch: { command: string; args: string[] } + fingerprint: string +} { + const launch = resolveHermesAcpLaunch() + return { + launch, + fingerprint: createHash("sha256") + .update(JSON.stringify(launch)) + .digest("hex"), + } +} + +function getOrCreateProvider(params: { + subChatId: string + cwd: string + existingSessionId?: string + mossProvider?: ResolvedMossProvider | null +}): ACPProvider { + const mossProviderFingerprint = getMossProviderFingerprint(params.mossProvider) + const { launch, fingerprint: launchFingerprint } = getLaunchFingerprint() + const existing = providerSessions.get(params.subChatId) + + if ( + existing && + existing.cwd === params.cwd && + existing.mossProviderFingerprint === mossProviderFingerprint && + existing.launchFingerprint === launchFingerprint + ) { + return existing.provider + } + + if (existing) { + existing.provider.cleanup() + providerSessions.delete(params.subChatId) + } + + const provider = createACPProvider({ + command: launch.command, + args: launch.args, + env: buildHermesProviderEnv({ mossProvider: params.mossProvider }), + ...(process.env.HERMES_ACP_AUTH_METHOD + ? { authMethodId: process.env.HERMES_ACP_AUTH_METHOD } + : {}), + session: { + cwd: params.cwd, + mcpServers: [], + }, + ...(params.existingSessionId + ? { existingSessionId: params.existingSessionId } + : {}), + persistSession: true, + }) + + providerSessions.set(params.subChatId, { + provider, + cwd: params.cwd, + mossProviderFingerprint, + launchFingerprint, + }) + + return provider +} + +function cleanupProvider(subChatId: string): void { + const existing = providerSessions.get(subChatId) + if (!existing) return + + existing.provider.cleanup() + providerSessions.delete(subChatId) +} + +function resolveHermesMode(mode: "plan" | "agent"): string { + return mode === "agent" ? "accept_edits" : "default" +} + +function resolveHermesModelForCall(model: string | undefined): string | undefined { + const normalized = model?.trim() + if (!normalized || normalized === DEFAULT_HERMES_MODEL || normalized === "hermes") { + return undefined + } + + return normalized +} + +function cleanAssistantMessageForPersistence(message: any): any | null { + if (!message || message.role !== "assistant") return message + if (!Array.isArray(message.parts)) return message + + const cleanedParts = message.parts.filter( + (part: any) => part?.state !== "input-streaming", + ) + + if (cleanedParts.length === 0) { + return null + } + + return { + ...message, + parts: cleanedParts, + } +} + +export const hermesRouter = router({ + chat: publicProcedure + .input( + z.object({ + subChatId: z.string(), + chatId: z.string(), + runId: z.string(), + prompt: z.string(), + model: z.string().optional(), + cwd: z.string(), + projectPath: z.string().optional(), + mode: z.enum(["plan", "agent"]).default("agent"), + permissionMode: permissionModeSchema.optional(), + sessionId: z.string().optional(), + forceNewSession: z.boolean().optional(), + images: z.array(imageAttachmentSchema).optional(), + }), + ) + .subscription(({ input }) => { + return observable((emit) => { + const runtimePermissionMode = input.permissionMode ?? input.mode + const existingStream = activeStreams.get(input.subChatId) + if (existingStream) { + existingStream.cancelRequested = true + existingStream.controller.abort() + cleanupProvider(input.subChatId) + } + + const abortController = new AbortController() + activeStreams.set(input.subChatId, { + runId: input.runId, + controller: abortController, + cancelRequested: false, + }) + + let isActive = true + + const safeEmit = (chunk: any) => { + if (!isActive) return + try { + emit.next(chunk) + } catch { + isActive = false + } + } + + const safeComplete = () => { + if (!isActive) return + isActive = false + try { + emit.complete() + } catch { + // Ignore double completion + } + } + + ;(async () => { + try { + const db = getDatabase() + const existingSubChat = db + .select() + .from(subChats) + .where(eq(subChats.id, input.subChatId)) + .get() + + if (!existingSubChat) { + throw new Error("Sub-chat not found") + } + + const existingMessages = parseStoredMessages(existingSubChat.messages) + const ignoreStoredSessionIds = shouldIgnoreMossStoredMessageSessionIds( + existingSubChat.runtimeMetadata, + ) + const existingRunSessionId = input.forceNewSession + ? undefined + : (ignoreStoredSessionIds ? undefined : input.sessionId) ?? + existingSubChat.engineSessionId ?? + (ignoreStoredSessionIds + ? undefined + : getLastSessionId(existingMessages)) + const requestedModelId = input.model?.trim() || DEFAULT_HERMES_MODEL + const modelForCall = resolveHermesModelForCall(requestedModelId) + const resolvedProjectPathFromCwd = resolveProjectPathFromWorktree( + input.cwd, + ) + const providerLookupPath = + input.projectPath || resolvedProjectPathFromCwd || input.cwd + const mossProvider = await resolveMossProviderForEngine({ + projectPath: providerLookupPath, + engineId: "hermes", + requestedModelId, + createIfMissing: true, + secretResolver: { getSecret: getMossProviderSecret }, + }) + if (mossProvider.warnings.length > 0) { + console.warn("[hermes] Moss provider warnings:", mossProvider.warnings) + } + const mossRuntimeContext = await buildMossRuntimeContext({ + projectPath: providerLookupPath, + engineId: "hermes", + }) + if (mossRuntimeContext.warnings.length > 0) { + console.warn( + "[hermes] Moss runtime context warnings:", + mossRuntimeContext.warnings, + ) + } + const mossHookRun = await runMossHooks({ + projectPath: providerLookupPath, + cwd: input.cwd, + event: "HermesSessionStart", + engineId: "hermes", + payload: { + subChatId: input.subChatId, + chatId: input.chatId, + runId: input.runId, + mode: input.mode, + requestedModelId, + runtimeContextFingerprint: mossRuntimeContext.fingerprint, + promptSha256: createHash("sha256").update(input.prompt).digest("hex"), + }, + }) + const mossHookSummary = + summarizeMossHookRunForSession(mossHookRun) + if (mossHookRun.status === "failed") { + console.warn("[hermes] Moss hook run failed:", mossHookSummary) + } + + const metadataModel = + modelForCall || mossProvider.model || requestedModelId + const lastMessage = existingMessages[existingMessages.length - 1] + const isDuplicatePrompt = + lastMessage?.role === "user" && + extractPromptFromStoredMessage(lastMessage) === input.prompt + + let messagesForStream = existingMessages + const isAuthoritativeRun = () => { + const currentStream = activeStreams.get(input.subChatId) + return !currentStream || currentStream.runId === input.runId + } + + const persistSubChatMessages = (messages: any[]) => { + if (!isAuthoritativeRun()) { + return false + } + + db.update(subChats) + .set({ + messages: JSON.stringify(messages), + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .run() + return true + } + + if (!isDuplicatePrompt) { + const userMessage = { + id: crypto.randomUUID(), + role: "user", + parts: buildUserParts(input.prompt, input.images), + metadata: { model: metadataModel }, + } + + messagesForStream = [...existingMessages, userMessage] + + db.update(subChats) + .set({ + messages: JSON.stringify(messagesForStream), + updatedAt: new Date(), + }) + .where(eq(subChats.id, input.subChatId)) + .run() + } + + if (input.forceNewSession) { + cleanupProvider(input.subChatId) + } + + const provider = getOrCreateProvider({ + subChatId: input.subChatId, + cwd: input.cwd, + existingSessionId: existingRunSessionId, + mossProvider, + }) + + const startedAt = Date.now() + let latestSessionId = provider.getSessionId() || existingRunSessionId + const modeForCall = resolveHermesMode(input.mode) + const languageModel = provider.languageModel(modelForCall, modeForCall) + const sessionInfo = await provider.initSession() + latestSessionId = + provider.getSessionId() || + latestSessionId || + sessionInfo?.sessionId || + undefined + + safeEmit({ + type: "session-init", + engine: "hermes", + sessionId: latestSessionId, + models: sessionInfo?.models, + modes: sessionInfo?.modes, + tools: [], + mcpServers: resourcesForSessionInfo( + mossRuntimeContext.resources, + "mcp", + ), + plugins: resourcesForSessionInfo( + mossRuntimeContext.resources, + "plugin", + ), + skills: resourcesForSessionInfo( + mossRuntimeContext.resources, + "skill", + ), + mossUnifiedSourceContext: { + status: mossRuntimeContext.status, + fingerprint: mossRuntimeContext.fingerprint, + resourceCount: mossRuntimeContext.resourceCount, + includedResourceCount: + mossRuntimeContext.includedResourceCount, + warnings: mossRuntimeContext.warnings, + }, + mossHooks: mossHookSummary, + }) + + const persistHermesRuntimeSession = ( + resultSubtype: "success" | "error" | "cancelled" = "success", + ) => { + if (!isAuthoritativeRun()) return false + + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "hermes", + nativeSessionId: + provider.getSessionId() || latestSessionId || null, + configDir: join(homedir(), ".hermes"), + modelId: metadataModel, + permissionMode: runtimePermissionMode, + metadata: { + cwd: input.cwd, + projectPath: input.projectPath ?? null, + mossProviderStatus: mossProvider.status, + mossProviderId: mossProvider.providerId ?? null, + mossProviderMode: mossProvider.mode ?? null, + mossUnifiedSourceContext: { + status: mossRuntimeContext.status, + fingerprint: mossRuntimeContext.fingerprint, + resourceCount: mossRuntimeContext.resourceCount, + includedResourceCount: + mossRuntimeContext.includedResourceCount, + }, + mossHooks: mossHookSummary, + forceNewSession: Boolean(input.forceNewSession), + ignoredStoredSessionIds: ignoreStoredSessionIds, + hasImages: Boolean(input.images?.length), + resultSubtype, + }, + }) + + return true + } + + persistHermesRuntimeSession() + + const result = streamText({ + model: languageModel, + messages: [ + ...(mossRuntimeContext.status === "ready" + ? ([ + { + role: "system" as const, + content: mossRuntimeContext.text, + }, + ] as const) + : []), + { + role: "user", + content: buildModelMessageContent(input.prompt, input.images), + }, + ], + tools: provider.tools, + abortSignal: abortController.signal, + }) + + const uiStream = result.toUIMessageStream({ + originalMessages: messagesForStream, + generateMessageId: () => crypto.randomUUID(), + messageMetadata: ({ part }) => { + const sessionId = provider.getSessionId() || undefined + if (sessionId) { + latestSessionId = sessionId + } + + if (part.type === "finish") { + return { + model: metadataModel, + sessionId, + durationMs: Date.now() - startedAt, + resultSubtype: part.finishReason === "error" ? "error" : "success", + } + } + + if (sessionId) { + return { + model: metadataModel, + sessionId, + } + } + + return { model: metadataModel } + }, + onFinish: async ({ responseMessage, isContinuation }) => { + try { + const cleanedResponseMessage = + cleanAssistantMessageForPersistence(responseMessage) + + if (!cleanedResponseMessage) { + if (persistSubChatMessages(messagesForStream)) { + persistHermesRuntimeSession() + } + return + } + + const messagesToPersist = [ + ...(isContinuation + ? messagesForStream.slice(0, -1) + : messagesForStream), + cleanedResponseMessage, + ] + + if (persistSubChatMessages(messagesToPersist)) { + persistHermesRuntimeSession() + } + } catch (error) { + console.error("[hermes] Failed to persist messages:", error) + persistHermesRuntimeSession("error") + } + }, + onError: (error) => extractHermesError(error).message, + }) + + const reader = uiStream.getReader() + let pendingFinishChunk: any | null = null + while (true) { + const { done, value } = await reader.read() + if (done) break + + if (value?.type === "error") { + const normalized = extractHermesError(value) + safeEmit({ ...value, errorText: normalized.message }) + continue + } + + if (value?.type === "finish") { + pendingFinishChunk = value + continue + } + + safeEmit(value) + } + + safeEmit(pendingFinishChunk || { type: "finish" }) + safeComplete() + } catch (error) { + const normalized = extractHermesError(error) + + console.error("[hermes] chat stream error:", error) + try { + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "hermes", + nativeSessionId: null, + configDir: join(homedir(), ".hermes"), + modelId: input.model ?? DEFAULT_HERMES_MODEL, + permissionMode: runtimePermissionMode, + metadata: { + cwd: input.cwd, + projectPath: input.projectPath ?? null, + resultSubtype: "error", + error: normalized.message, + }, + }) + } catch { + // Best-effort runtime index update only. + } + safeEmit({ type: "error", errorText: normalized.message }) + safeEmit({ type: "finish" }) + safeComplete() + } finally { + const activeStream = activeStreams.get(input.subChatId) + if (activeStream?.runId === input.runId) { + const shouldCleanupProvider = + abortController.signal.aborted || activeStream.cancelRequested + if (shouldCleanupProvider) { + cleanupProvider(input.subChatId) + } + activeStreams.delete(input.subChatId) + } + } + })() + + return () => { + isActive = false + abortController.abort() + + const activeStream = activeStreams.get(input.subChatId) + if (activeStream?.runId === input.runId) { + activeStream.cancelRequested = true + } + } + }) + }), + + cancel: publicProcedure + .input( + z.object({ + subChatId: z.string(), + runId: z.string(), + }), + ) + .mutation(({ input }) => { + const activeStream = activeStreams.get(input.subChatId) + if (!activeStream) { + return { cancelled: false, ignoredStale: false } + } + + if (activeStream.runId !== input.runId) { + return { cancelled: false, ignoredStale: true } + } + + activeStream.cancelRequested = true + activeStream.controller.abort() + + return { cancelled: true, ignoredStale: false } + }), + + cleanup: publicProcedure + .input(z.object({ subChatId: z.string() })) + .mutation(({ input }) => { + cleanupProvider(input.subChatId) + + const activeStream = activeStreams.get(input.subChatId) + if (activeStream) { + activeStream.controller.abort() + activeStreams.delete(input.subChatId) + } + + return { success: true } + }), +}) diff --git a/src/main/lib/trpc/routers/index.ts b/src/main/lib/trpc/routers/index.ts index b98b18264..2499ad400 100644 --- a/src/main/lib/trpc/routers/index.ts +++ b/src/main/lib/trpc/routers/index.ts @@ -7,6 +7,7 @@ import { claudeSettingsRouter } from "./claude-settings" import { anthropicAccountsRouter } from "./anthropic-accounts" import { ollamaRouter } from "./ollama" import { codexRouter } from "./codex" +import { hermesRouter } from "./hermes" import { terminalRouter } from "./terminal" import { externalRouter } from "./external" import { filesRouter } from "./files" @@ -18,6 +19,10 @@ import { sandboxImportRouter } from "./sandbox-import" import { commandsRouter } from "./commands" import { voiceRouter } from "./voice" import { pluginsRouter } from "./plugins" +import { sharedResourcesRouter } from "./shared-resources" +import { agentRuntimeRouter } from "./agent-runtime" +import { releaseReadinessRouter } from "./release-readiness" +import { mossAccountRouter } from "./moss-account" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -35,6 +40,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { anthropicAccounts: anthropicAccountsRouter, ollama: ollamaRouter, codex: codexRouter, + hermes: hermesRouter, terminal: terminalRouter, external: externalRouter, files: filesRouter, @@ -46,6 +52,10 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { commands: commandsRouter, voice: voiceRouter, plugins: pluginsRouter, + sharedResources: sharedResourcesRouter, + agentRuntime: agentRuntimeRouter, + releaseReadiness: releaseReadinessRouter, + mossAccount: mossAccountRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/lib/trpc/routers/moss-account.ts b/src/main/lib/trpc/routers/moss-account.ts new file mode 100644 index 000000000..e747b20cf --- /dev/null +++ b/src/main/lib/trpc/routers/moss-account.ts @@ -0,0 +1,118 @@ +import { z } from "zod" +import { getAuthManager } from "../../../auth-manager" +import { + buildMossAccountEntitlement, + type MossAccountPlanQuota, +} from "../../moss-account" +import { + hasMossProviderSecret, + readMossProviderConfig, + type MossProviderConfig, +} from "../../moss-source" +import { publicProcedure, router } from "../index" + +const PLAN_FETCH_TIMEOUT_MS = 2500 + +async function buildStoredSecretSummary( + config: MossProviderConfig | undefined, +): Promise> { + const providerIds = Object.keys(config?.providers ?? {}) + const entries = await Promise.all( + providerIds.map(async (providerId) => [ + providerId, + { hasApiKey: await hasMossProviderSecret(providerId) }, + ] as const), + ) + return Object.fromEntries(entries) +} + +function normalizePlanQuota(planData: { + quota?: MossAccountPlanQuota | null + includedCredits?: number | null + usedCredits?: number | null + remainingCredits?: number | null + resetAt?: string | null + quotaUnit?: string | null +}): MossAccountPlanQuota | null { + const quota = planData.quota ?? null + if (quota) return { ...quota, source: "backend" } + + if ( + planData.includedCredits === undefined && + planData.usedCredits === undefined && + planData.remainingCredits === undefined && + planData.resetAt === undefined && + planData.quotaUnit === undefined + ) { + return null + } + + return { + includedCredits: planData.includedCredits, + usedCredits: planData.usedCredits, + remainingCredits: planData.remainingCredits, + resetAt: planData.resetAt, + unit: planData.quotaUnit, + source: "backend", + } +} + +async function fetchMossPlanSafely() { + const authManager = getAuthManager() + if (!authManager?.isAuthenticated()) return null + + let timeout: ReturnType | undefined + try { + return await Promise.race([ + authManager.fetchUserPlan().then((planData) => + planData + ? { + plan: planData.plan, + status: planData.status, + source: "backend" as const, + quota: normalizePlanQuota(planData), + } + : null, + ), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), PLAN_FETCH_TIMEOUT_MS) + }), + ]) + } catch { + return null + } finally { + if (timeout) clearTimeout(timeout) + } +} + +export const mossAccountRouter = router({ + getEntitlement: publicProcedure + .input( + z + .object({ + projectPath: z.string().optional(), + }) + .optional(), + ) + .query(async ({ input }) => { + const authManager = getAuthManager() + const user = authManager?.getUser() ?? null + const plan = await fetchMossPlanSafely() + + const providerReadResult = input?.projectPath + ? await readMossProviderConfig(input.projectPath, { + createIfMissing: true, + }) + : null + const storedSecrets = await buildStoredSecretSummary( + providerReadResult?.config, + ) + + return buildMossAccountEntitlement({ + user, + plan, + providerReadResult, + storedSecrets, + }) + }), +}) diff --git a/src/main/lib/trpc/routers/plugins.ts b/src/main/lib/trpc/routers/plugins.ts index 710cc0557..064866cad 100644 --- a/src/main/lib/trpc/routers/plugins.ts +++ b/src/main/lib/trpc/routers/plugins.ts @@ -11,12 +11,12 @@ import { } from "../../plugins" import { getEnabledPlugins } from "./claude-settings" -interface PluginComponent { +export interface PluginComponent { name: string description?: string } -interface PluginWithComponents { +export interface PluginWithComponents { name: string version: string description?: string diff --git a/src/main/lib/trpc/routers/release-readiness.ts b/src/main/lib/trpc/routers/release-readiness.ts new file mode 100644 index 000000000..9e99cad35 --- /dev/null +++ b/src/main/lib/trpc/routers/release-readiness.ts @@ -0,0 +1,435 @@ +import path from "node:path" +import { promises as fs } from "node:fs" +import { z } from "zod" +import { publicProcedure, router } from "../index" + +type GateStatus = "passed" | "pending" | "blocked" | "missing" | "failed" + +type ReleaseReport = { + status?: string + generatedAt?: string + scripts?: { + build?: string + packageMac?: string + distManifest?: string + distUpload?: string + distUploadDryRun?: string + releaseNotarize?: string + releaseEvidenceAudit?: string + } + mac?: { + targets?: Array<{ + target?: string + arch?: string[] + }> + hardenedRuntime?: boolean + entitlements?: string + entitlementsInherit?: string + publish?: { + provider?: string + url?: string + } + } + signing?: { + appleIdentityEnv?: string + electronBuilderIdentity?: string + notarizationMode?: string + electronBuilderNotarize?: boolean + releaseWorkflow?: { + workflowPath?: string + present?: boolean + uploadsEvidence?: boolean + } + credentialPreflight?: { + report?: string + status?: string + credentialsComplete?: boolean + missingCredentials?: string[] + toolsComplete?: boolean + missingTools?: string[] + blockers?: string[] + } | null + evidenceAudit?: { + report?: string + status?: string + requireNotarization?: boolean + distributable?: boolean + blockerCount?: number + validNotarizationReports?: string[] + acceptedSubmissions?: number + } | null + validNotarizationReports?: string[] + } + artifacts?: { + releaseDir?: string + files?: string[] + macArtifacts?: string[] + updateManifests?: string[] + notarizationEvidence?: string[] + } + distribution?: { + uploadScript?: string + uploadPlan?: { + manifest?: string + status?: string + dryRun?: boolean + provider?: string + channel?: string + artifactCount?: number + target?: { + baseUrl?: string + uploadPrefix?: string + } + } | null + } + warnings?: string[] + failures?: string[] +} + +const releaseLatestRelativePath = path.join( + ".1code", + "program", + "release-packaging", + "latest.json", +) + +async function readJson(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf-8") + return JSON.parse(raw) as T + } catch { + return null + } +} + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +async function listReleaseFiles(rootPath: string) { + const releaseDir = path.join(rootPath, "release") + try { + const entries = await fs.readdir(releaseDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile()) + .map((entry) => path.join("release", entry.name).split(path.sep).join("/")) + .sort() + } catch { + return [] + } +} + +function normalizeRootPath(projectPath?: string) { + if (projectPath && path.isAbsolute(projectPath)) return projectPath + return process.cwd() +} + +async function resolveReleaseRootPath(projectPath?: string) { + const candidateRootPath = normalizeRootPath(projectPath) + const cwdRootPath = process.cwd() + if (candidateRootPath === cwdRootPath) return candidateRootPath + + if (await exists(path.join(candidateRootPath, releaseLatestRelativePath))) { + return candidateRootPath + } + + if (await exists(path.join(cwdRootPath, releaseLatestRelativePath))) { + return cwdRootPath + } + + return candidateRootPath +} + +function gate( + id: string, + title: string, + status: GateStatus, + detail: string, + command: string, + evidence?: string | null, +) { + return { + id, + title, + status, + detail, + command, + evidence: evidence ?? null, + } +} + +export const releaseReadinessRouter = router({ + snapshot: publicProcedure + .input( + z + .object({ + projectPath: z.string().optional(), + }) + .optional(), + ) + .query(async ({ input }) => { + const rootPath = await resolveReleaseRootPath(input?.projectPath) + const latestPath = path.join(rootPath, releaseLatestRelativePath) + const latest = await readJson<{ + report?: string + generatedAt?: string + status?: string + }>(latestPath) + const reportPath = latest?.report + ? path.join(rootPath, latest.report) + : path.join( + rootPath, + ".1code", + "program", + "release-packaging", + "report.json", + ) + const report = await readJson(reportPath) + const releaseFiles = await listReleaseFiles(rootPath) + const reportArtifacts = report?.artifacts ?? {} + const macArtifacts = + reportArtifacts.macArtifacts?.length + ? reportArtifacts.macArtifacts + : releaseFiles.filter((filePath) => /\.(dmg|zip)$/i.test(filePath)) + const updateManifests = + reportArtifacts.updateManifests?.length + ? reportArtifacts.updateManifests + : releaseFiles.filter((filePath) => + /(?:latest|beta)-mac(?:-x64)?\.yml$/.test(path.basename(filePath)), + ) + const notarizationEvidence = + reportArtifacts.notarizationEvidence?.length + ? reportArtifacts.notarizationEvidence + : releaseFiles.filter((filePath) => + /notary|notar|codesign|staple|spctl/i.test(path.basename(filePath)), + ) + const validNotarizationReports = + report?.signing?.validNotarizationReports ?? [] + const credentialPreflight = report?.signing?.credentialPreflight ?? null + const evidenceAudit = report?.signing?.evidenceAudit ?? null + const uploadPlan = report?.distribution?.uploadPlan ?? null + const migrationEvidenceCandidates = [ + ".1code/program/migration-data-loss/latest.json", + ".1code/program/migration/latest.json", + ".1code/program/data-loss/latest.json", + ] + const migrationEvidence = ( + await Promise.all( + migrationEvidenceCandidates.map(async (candidate) => ({ + candidate, + present: await exists(path.join(rootPath, candidate)), + })), + ) + ).find((candidate) => candidate.present)?.candidate + + const preflightStatus: GateStatus = report + ? report.status === "passed" + ? "passed" + : "failed" + : "missing" + const artifactsStatus: GateStatus = + macArtifacts.length > 0 && updateManifests.length > 0 ? "passed" : "pending" + const signingStatus: GateStatus = + report?.signing?.appleIdentityEnv === "set" ? "passed" : "pending" + const credentialPreflightStatus: GateStatus = + credentialPreflight?.status === "passed" + ? "passed" + : credentialPreflight?.status === "blocked" + ? "blocked" + : credentialPreflight?.status === "failed" + ? "failed" + : report + ? "pending" + : "missing" + const releaseWorkflowStatus: GateStatus = + report?.signing?.releaseWorkflow?.present && report.signing.releaseWorkflow.uploadsEvidence + ? "passed" + : report + ? "pending" + : "missing" + const uploadPlanStatus: GateStatus = + uploadPlan?.status === "passed" && uploadPlan.dryRun && (uploadPlan.artifactCount ?? 0) >= 6 + ? "passed" + : report + ? "pending" + : "missing" + const notarizationStatus: GateStatus = + validNotarizationReports.length > 0 ? "passed" : "pending" + const evidenceAuditStatus: GateStatus = + evidenceAudit?.status === "passed" && evidenceAudit.distributable + ? "passed" + : evidenceAudit?.status === "blocked" + ? "blocked" + : evidenceAudit?.status === "failed" + ? "failed" + : report + ? "pending" + : "missing" + const migrationStatus: GateStatus = migrationEvidence ? "passed" : "pending" + + const gates = [ + gate( + "preflight", + "Preflight", + preflightStatus, + report + ? "Packaging configuration report is available." + : "No release packaging preflight report found.", + "bun run verify:packaging", + latest?.report, + ), + gate( + "artifacts", + "Artifacts", + artifactsStatus, + `${macArtifacts.length} macOS artifact(s), ${updateManifests.length} update manifest(s).`, + "bun run package:mac", + macArtifacts[0], + ), + gate( + "manifest", + "Update manifest", + updateManifests.length > 0 ? "passed" : "pending", + updateManifests.length > 0 + ? "macOS update manifest evidence is present." + : "Run manifest generation after packaging.", + "bun run dist:manifest", + updateManifests[0], + ), + gate( + "upload-plan", + "Upload plan", + uploadPlanStatus, + uploadPlan?.status === "passed" + ? `${uploadPlan.artifactCount ?? 0} CDN upload artifact(s) planned for ${uploadPlan.target?.baseUrl ?? "release CDN"}.` + : "No release CDN upload plan has been generated yet.", + "bun run dist:upload:dry-run", + uploadPlan?.manifest, + ), + gate( + "signing", + "Signing", + signingStatus, + report?.signing?.appleIdentityEnv === "set" + ? "APPLE_IDENTITY is available to electron-builder." + : "APPLE_IDENTITY is not set in this local environment.", + "APPLE_IDENTITY=... bun run package:mac", + report?.signing?.electronBuilderIdentity, + ), + gate( + "credential-preflight", + "Credential preflight", + credentialPreflightStatus, + credentialPreflight?.status === "passed" + ? "Apple signing/notarization credentials and local release tools are ready." + : credentialPreflight?.status === "blocked" + ? `Waiting for Apple credentials: ${(credentialPreflight.missingCredentials ?? []).join(", ") || "missing credentials"}.` + : credentialPreflight?.status === "failed" + ? "Apple credential preflight failed." + : "No Apple signing/notarization credential preflight report found.", + "bun run release:credentials", + credentialPreflight?.report, + ), + gate( + "ci-workflow", + "CI workflow", + releaseWorkflowStatus, + report?.signing?.releaseWorkflow?.present + ? "Release workflow declares signing credentials, notarization, stapling, verification, and evidence upload." + : "No Moss desktop release CI workflow evidence found.", + "workflow_dispatch: Moss Desktop Release", + report?.signing?.releaseWorkflow?.workflowPath, + ), + gate( + "notarization", + "Notarization", + notarizationStatus, + validNotarizationReports.length > 0 + ? "Passing notarytool, stapler, codesign, and spctl evidence was found." + : "No passing CI notarization report found yet.", + "bun run release:notarize", + validNotarizationReports[0] ?? notarizationEvidence[0], + ), + gate( + "evidence-audit", + "Evidence audit", + evidenceAuditStatus, + evidenceAudit?.status === "passed" && evidenceAudit.distributable + ? `${evidenceAudit.acceptedSubmissions ?? 0} accepted notarytool submission(s) and signed distribution evidence are audit-ready.` + : evidenceAudit?.status === "blocked" + ? `${evidenceAudit.blockerCount ?? 0} signed release evidence blocker(s) remain.` + : "No signed release evidence audit has been recorded yet.", + "bun run release:evidence:audit", + evidenceAudit?.report, + ), + gate( + "migration", + "Migration", + migrationStatus, + migrationEvidence + ? "Migration and data-loss evidence is present." + : "Migration and data-loss fixture evidence is still pending.", + "bun run verify:program --release", + migrationEvidence, + ), + ] + + return { + rootPath, + latestPath: latest ? ".1code/program/release-packaging/latest.json" : null, + reportPath: latest?.report ?? null, + generatedAt: report?.generatedAt ?? latest?.generatedAt ?? null, + status: report?.status ?? latest?.status ?? "missing", + scripts: { + build: report?.scripts?.build ?? "electron-vite build", + packageMac: report?.scripts?.packageMac ?? "electron-builder --mac", + distManifest: + report?.scripts?.distManifest ?? "node scripts/generate-update-manifest.mjs", + distUpload: + report?.scripts?.distUpload ?? "node scripts/upload-release.mjs", + distUploadDryRun: + report?.scripts?.distUploadDryRun ?? "node scripts/upload-release.mjs --dry-run", + releaseNotarize: + report?.scripts?.releaseNotarize ?? "node scripts/notarize-release-artifacts.mjs", + releaseEvidenceAudit: + report?.scripts?.releaseEvidenceAudit ?? "node scripts/audit-release-evidence.mjs", + verifyProgram: "node scripts/verify-program-ledger.mjs --release", + }, + mac: { + targets: report?.mac?.targets ?? [], + hardenedRuntime: report?.mac?.hardenedRuntime ?? false, + entitlements: report?.mac?.entitlements ?? null, + entitlementsInherit: report?.mac?.entitlementsInherit ?? null, + publish: report?.mac?.publish ?? null, + }, + signing: report?.signing ?? { + appleIdentityEnv: "missing", + electronBuilderIdentity: "missing", + notarizationMode: "external-ci", + electronBuilderNotarize: false, + validNotarizationReports: [], + evidenceAudit: null, + credentialPreflight: null, + }, + artifacts: { + releaseDir: reportArtifacts.releaseDir ?? "release", + files: releaseFiles, + macArtifacts, + updateManifests, + notarizationEvidence, + }, + distribution: report?.distribution ?? { + uploadScript: "scripts/upload-release.mjs", + uploadPlan: null, + }, + warnings: report?.warnings ?? [], + failures: report?.failures ?? [], + gates, + } + }), +}) diff --git a/src/main/lib/trpc/routers/shared-resources.ts b/src/main/lib/trpc/routers/shared-resources.ts new file mode 100644 index 000000000..925270bf8 --- /dev/null +++ b/src/main/lib/trpc/routers/shared-resources.ts @@ -0,0 +1,809 @@ +import { z } from "zod" +import * as fs from "fs/promises" +import * as path from "path" +import { buildSharedResourceSnapshot } from "../../shared-resources" +import { + ensureMossSource, + getMossSourceLayout, + materializeMossWorkspaceProjections, + removeMossProjectionResource, +} from "../../moss-source" +import { + parseMossFrontmatter, + stringifyMossFrontmatter, +} from "../../moss-source/frontmatter" +import { generateAgentMd, VALID_AGENT_MODELS } from "./agent-utils" +import { publicProcedure, router } from "../index" + +const mossMcpServerInput = z.object({ + projectPath: z.string().min(1), + name: z + .string() + .min(1) + .regex( + /^[a-zA-Z0-9_-]+$/, + "Name must contain only letters, numbers, underscores, and hyphens", + ), + transport: z.enum(["stdio", "http"]), + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + url: z.string().url().optional(), +}) + +const mossSubagentInput = z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1), + prompt: z.string().min(1), + tools: z.array(z.string()).optional(), + disallowedTools: z.array(z.string()).optional(), + model: z.enum(VALID_AGENT_MODELS).optional(), +}) + +const mossMemoryEntryInput = z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + content: z.string().optional(), +}) + +const mossHookInput = z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + event: z.string().optional(), + command: z.string().optional(), + content: z.string().optional(), + enabled: z.boolean().optional(), +}) + +type MossMcpServerConfig = { + command?: string + args?: string[] + env?: Record + url?: string +} + +type MossMcpConfig = { + mcpServers?: Record + servers?: Record +} + +function safeSubagentName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") +} + +function safeMemoryEntryName(name: string): string { + return name + .replace(/\.md$/i, "") + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") +} + +function safeHookName(name: string): string { + return name + .replace(/\.md$/i, "") + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") +} + +function assertSafeSubagentName(name: string): string { + const safeName = safeSubagentName(name) + if (!safeName || safeName.includes("..")) { + throw new Error("Invalid subagent name") + } + return safeName +} + +function assertSafeMemoryEntryName(name: string): string { + const safeName = safeMemoryEntryName(name) + if (!safeName || safeName.includes("..")) { + throw new Error("Invalid memory entry name") + } + return safeName +} + +function assertSafeHookName(name: string): string { + const safeName = safeHookName(name) + if (!safeName || safeName.includes("..")) { + throw new Error("Invalid hook name") + } + return safeName +} + +function buildMossMemoryEntryMd(input: { + name: string + description?: string + content?: string +}): string { + const content = input.content?.trimEnd() ?? "" + return stringifyMossFrontmatter(`${content}${content ? "\n" : ""}`, { + name: input.name, + ...(input.description?.trim() ? { description: input.description.trim() } : {}), + }) +} + +function buildMossHookMd(input: { + name: string + description?: string + event?: string + command?: string + content?: string + enabled?: boolean +}): string { + const content = input.content?.trimEnd() ?? "" + return stringifyMossFrontmatter(`${content}${content ? "\n" : ""}`, { + name: input.name, + enabled: input.enabled !== false, + event: input.event?.trim() || "Stop", + ...(input.command?.trim() ? { command: input.command.trim() } : {}), + ...(input.description?.trim() ? { description: input.description.trim() } : {}), + }) +} + +async function readMossMemoryEntryFile(sourcePath: string): Promise<{ + name: string + description: string + content: string +}> { + const raw = await fs.readFile(sourcePath, "utf-8") + const parsed = parseMossFrontmatter(raw) + return { + name: + typeof parsed.data.name === "string" && parsed.data.name.trim() + ? parsed.data.name + : path.basename(sourcePath, ".md"), + description: + typeof parsed.data.description === "string" + ? parsed.data.description + : "", + content: parsed.content.trimEnd(), + } +} + +async function readMossHookFile(sourcePath: string): Promise<{ + name: string + description: string + event: string + command: string + enabled: boolean + content: string +}> { + const raw = await fs.readFile(sourcePath, "utf-8") + const parsed = parseMossFrontmatter(raw) + return { + name: + typeof parsed.data.name === "string" && parsed.data.name.trim() + ? parsed.data.name + : path.basename(sourcePath, ".md"), + description: + typeof parsed.data.description === "string" + ? parsed.data.description + : "", + event: + typeof parsed.data.event === "string" && parsed.data.event.trim() + ? parsed.data.event + : "Stop", + command: + typeof parsed.data.command === "string" + ? parsed.data.command + : "", + enabled: parsed.data.enabled !== false, + content: parsed.content.trimEnd(), + } +} + +async function mossMemoryEntryPath(projectPath: string, name: string): Promise<{ + safeName: string + sourcePath: string +}> { + await ensureMossSource({ projectPath }) + const safeName = assertSafeMemoryEntryName(name) + return { + safeName, + sourcePath: path.join( + getMossSourceLayout(projectPath).memoryRoot, + `${safeName}.md`, + ), + } +} + +async function mossHookPath(projectPath: string, name: string): Promise<{ + safeName: string + sourcePath: string +}> { + await ensureMossSource({ projectPath }) + const safeName = assertSafeHookName(name) + return { + safeName, + sourcePath: path.join( + getMossSourceLayout(projectPath).hooksRoot, + `${safeName}.md`, + ), + } +} + +function buildMossMcpServerConfig(input: z.infer): MossMcpServerConfig { + if (input.transport === "stdio") { + const command = input.command?.trim() + if (!command) throw new Error("Command is required for stdio servers") + return { + command, + ...(input.args && input.args.length > 0 ? { args: input.args } : {}), + ...(input.env && Object.keys(input.env).length > 0 ? { env: input.env } : {}), + } + } + + const url = input.url?.trim() + if (!url) throw new Error("URL is required for HTTP servers") + return { url } +} + +async function readMossMcpConfig(projectPath: string): Promise<{ + sourcePath: string + config: MossMcpConfig +}> { + await ensureMossSource({ projectPath }) + const sourcePath = getMossSourceLayout(projectPath).mcpConfig + + try { + const raw = await fs.readFile(sourcePath, "utf-8") + const parsed = JSON.parse(raw) as MossMcpConfig + const mcpServers = parsed.mcpServers ?? parsed.servers ?? {} + return { + sourcePath, + config: { + ...parsed, + mcpServers, + }, + } + } catch (error) { + throw new Error( + `Unable to read Moss MCP source: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } +} + +async function writeMossMcpConfig( + projectPath: string, + config: MossMcpConfig, +): Promise { + const sourcePath = getMossSourceLayout(projectPath).mcpConfig + const nextConfig: MossMcpConfig = { + ...config, + mcpServers: config.mcpServers ?? {}, + } + delete nextConfig.servers + await fs.mkdir(path.dirname(sourcePath), { recursive: true }) + await fs.writeFile(sourcePath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf-8") + return sourcePath +} + +async function materializeProject( + projectPath: string, + options?: { expectedResourceIds?: readonly string[] }, +) { + return materializeMossWorkspaceProjections({ + projectPath, + createIfMissing: true, + expectedResourceIds: options?.expectedResourceIds, + }) +} + +async function unlinkProjectedSubagentIfManaged(params: { + projectPath: string + sourcePath: string + targetPath: string +}) { + try { + const stat = await fs.lstat(params.targetPath) + if (!stat.isSymbolicLink()) return + const linkTarget = await fs.readlink(params.targetPath) + const resolvedTarget = path.resolve(path.dirname(params.targetPath), linkTarget) + if (resolvedTarget !== path.resolve(params.sourcePath)) return + await fs.unlink(params.targetPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } +} + +async function removeProjectedSubagentResource(params: { + projectPath: string + safeName: string + sourcePath: string +}) { + await removeMossProjectionResource({ + projectPath: params.projectPath, + resourceId: `moss:subagent:${params.safeName}`, + sourcePath: path.relative(params.projectPath, params.sourcePath), + targetPaths: [ + path.join(".claude", "agents", `${params.safeName}.md`), + path.join(".codex", "agents", `${params.safeName}.md`), + ], + removeTargets: true, + }) +} + +async function removeProjectedHookAdapters(params: { + projectPath: string + safeName: string + sourcePath: string +}) { + await removeMossProjectionResource({ + projectPath: params.projectPath, + resourceId: `moss:hook:${params.safeName}`, + sourcePath: path.relative(params.projectPath, params.sourcePath), + targetPaths: [ + path.join(".claude", "hooks", ".moss-adapter.json"), + path.join(".codex", "hooks", ".moss-adapter.json"), + ], + removeTargets: true, + }) +} + +export const sharedResourcesRouter = router({ + snapshot: publicProcedure + .input( + z + .object({ + projectPath: z.string().optional(), + }) + .optional(), + ) + .query(async ({ input }) => { + return buildSharedResourceSnapshot({ + projectPath: input?.projectPath, + }) + }), + + readMemoryEntry: publicProcedure + .input( + z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + }), + ) + .query(async ({ input }) => { + const { safeName, sourcePath } = await mossMemoryEntryPath( + input.projectPath, + input.name, + ) + const entry = await readMossMemoryEntryFile(sourcePath) + return { + ...entry, + safeName, + sourcePath, + } + }), + + createMemoryEntry: publicProcedure + .input(mossMemoryEntryInput) + .mutation(async ({ input }) => { + const { safeName, sourcePath } = await mossMemoryEntryPath( + input.projectPath, + input.name, + ) + + try { + await fs.access(sourcePath) + throw new Error(`Memory entry "${safeName}" already exists in Moss Unified Source`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + + await fs.mkdir(path.dirname(sourcePath), { recursive: true }) + await fs.writeFile( + sourcePath, + buildMossMemoryEntryMd({ + name: safeName, + description: input.description, + content: input.content, + }), + "utf-8", + ) + + return { + success: true as const, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath, { + expectedResourceIds: [`moss:memory:${safeName}`], + }), + } + }), + + updateMemoryEntry: publicProcedure + .input(mossMemoryEntryInput) + .mutation(async ({ input }) => { + const { safeName, sourcePath } = await mossMemoryEntryPath( + input.projectPath, + input.name, + ) + + await fs.writeFile( + sourcePath, + buildMossMemoryEntryMd({ + name: safeName, + description: input.description, + content: input.content, + }), + "utf-8", + ) + + return { + success: true as const, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath, { + expectedResourceIds: [`moss:memory:${safeName}`], + }), + } + }), + + removeMemoryEntry: publicProcedure + .input( + z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const { safeName, sourcePath } = await mossMemoryEntryPath( + input.projectPath, + input.name, + ) + + let deleted = false + try { + await fs.unlink(sourcePath) + deleted = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + + return { + success: true as const, + deleted, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath), + } + }), + + readHook: publicProcedure + .input( + z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + }), + ) + .query(async ({ input }) => { + const { safeName, sourcePath } = await mossHookPath( + input.projectPath, + input.name, + ) + const hook = await readMossHookFile(sourcePath) + return { + ...hook, + safeName, + sourcePath, + } + }), + + createHook: publicProcedure + .input(mossHookInput) + .mutation(async ({ input }) => { + const { safeName, sourcePath } = await mossHookPath( + input.projectPath, + input.name, + ) + + try { + await fs.access(sourcePath) + throw new Error(`Hook "${safeName}" already exists in Moss Unified Source`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + + await fs.mkdir(path.dirname(sourcePath), { recursive: true }) + await fs.writeFile( + sourcePath, + buildMossHookMd({ + name: safeName, + description: input.description, + event: input.event, + command: input.command, + content: input.content, + enabled: input.enabled, + }), + "utf-8", + ) + + return { + success: true as const, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath, { + expectedResourceIds: [`moss:hook:${safeName}`], + }), + } + }), + + updateHook: publicProcedure + .input(mossHookInput) + .mutation(async ({ input }) => { + const { safeName, sourcePath } = await mossHookPath( + input.projectPath, + input.name, + ) + + await fs.writeFile( + sourcePath, + buildMossHookMd({ + name: safeName, + description: input.description, + event: input.event, + command: input.command, + content: input.content, + enabled: input.enabled, + }), + "utf-8", + ) + + return { + success: true as const, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath, { + expectedResourceIds: [`moss:hook:${safeName}`], + }), + } + }), + + removeHook: publicProcedure + .input( + z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const { safeName, sourcePath } = await mossHookPath( + input.projectPath, + input.name, + ) + + let deleted = false + try { + await fs.unlink(sourcePath) + deleted = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + + await removeProjectedHookAdapters({ + projectPath: input.projectPath, + safeName, + sourcePath, + }) + + return { + success: true as const, + deleted, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath), + } + }), + + createMcpServer: publicProcedure + .input(mossMcpServerInput) + .mutation(async ({ input }) => { + const { sourcePath, config } = await readMossMcpConfig(input.projectPath) + const serverName = input.name.trim() + const mcpServers = config.mcpServers ?? {} + + if (mcpServers[serverName]) { + throw new Error(`MCP server "${serverName}" already exists in Moss Unified Source`) + } + + mcpServers[serverName] = buildMossMcpServerConfig(input) + await writeMossMcpConfig(input.projectPath, { + ...config, + mcpServers, + }) + + return { + success: true as const, + name: serverName, + sourcePath, + projections: await materializeProject(input.projectPath, { + expectedResourceIds: [`moss:mcp:${serverName}`], + }), + } + }), + + updateMcpServer: publicProcedure + .input(mossMcpServerInput) + .mutation(async ({ input }) => { + const { sourcePath, config } = await readMossMcpConfig(input.projectPath) + const serverName = input.name.trim() + const mcpServers = config.mcpServers ?? {} + + if (!mcpServers[serverName]) { + throw new Error(`MCP server "${serverName}" was not found in Moss Unified Source`) + } + + mcpServers[serverName] = buildMossMcpServerConfig(input) + await writeMossMcpConfig(input.projectPath, { + ...config, + mcpServers, + }) + + return { + success: true as const, + name: serverName, + sourcePath, + projections: await materializeProject(input.projectPath), + } + }), + + removeMcpServer: publicProcedure + .input( + z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const { sourcePath, config } = await readMossMcpConfig(input.projectPath) + const serverName = input.name.trim() + const mcpServers = config.mcpServers ?? {} + + if (!mcpServers[serverName]) { + return { + success: true as const, + deleted: false, + name: serverName, + sourcePath, + projections: await materializeProject(input.projectPath), + } + } + + delete mcpServers[serverName] + await writeMossMcpConfig(input.projectPath, { + ...config, + mcpServers, + }) + + return { + success: true as const, + deleted: true, + name: serverName, + sourcePath, + projections: await materializeProject(input.projectPath), + } + }), + + createSubagent: publicProcedure + .input(mossSubagentInput) + .mutation(async ({ input }) => { + await ensureMossSource({ projectPath: input.projectPath }) + const safeName = assertSafeSubagentName(input.name) + const sourcePath = path.join( + getMossSourceLayout(input.projectPath).subagentsRoot, + `${safeName}.md`, + ) + + try { + await fs.access(sourcePath) + throw new Error(`Subagent "${safeName}" already exists in Moss Unified Source`) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + + await fs.mkdir(path.dirname(sourcePath), { recursive: true }) + await fs.writeFile( + sourcePath, + generateAgentMd({ + name: safeName, + description: input.description, + prompt: input.prompt, + tools: input.tools, + disallowedTools: input.disallowedTools, + model: input.model, + }), + "utf-8", + ) + + return { + success: true as const, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath, { + expectedResourceIds: [`moss:subagent:${safeName}`], + }), + } + }), + + updateSubagent: publicProcedure + .input(mossSubagentInput) + .mutation(async ({ input }) => { + await ensureMossSource({ projectPath: input.projectPath }) + const safeName = assertSafeSubagentName(input.name) + const sourcePath = path.join( + getMossSourceLayout(input.projectPath).subagentsRoot, + `${safeName}.md`, + ) + + await fs.writeFile( + sourcePath, + generateAgentMd({ + name: safeName, + description: input.description, + prompt: input.prompt, + tools: input.tools, + disallowedTools: input.disallowedTools, + model: input.model, + }), + "utf-8", + ) + + return { + success: true as const, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath), + } + }), + + removeSubagent: publicProcedure + .input( + z.object({ + projectPath: z.string().min(1), + name: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + await ensureMossSource({ projectPath: input.projectPath }) + const safeName = assertSafeSubagentName(input.name) + const sourcePath = path.join( + getMossSourceLayout(input.projectPath).subagentsRoot, + `${safeName}.md`, + ) + + let deleted = false + try { + await fs.unlink(sourcePath) + deleted = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error + } + + await unlinkProjectedSubagentIfManaged({ + projectPath: input.projectPath, + sourcePath, + targetPath: path.join(input.projectPath, ".claude", "agents", `${safeName}.md`), + }) + await removeProjectedSubagentResource({ + projectPath: input.projectPath, + safeName, + sourcePath, + }) + + return { + success: true as const, + deleted, + name: safeName, + sourcePath, + projections: await materializeProject(input.projectPath), + } + }), +}) diff --git a/src/main/lib/trpc/routers/skill-md.ts b/src/main/lib/trpc/routers/skill-md.ts new file mode 100644 index 000000000..587abc5da --- /dev/null +++ b/src/main/lib/trpc/routers/skill-md.ts @@ -0,0 +1,37 @@ +import { + parseMossFrontmatter, + stringifyMossFrontmatter, +} from "../../moss-source/frontmatter" + +export function parseSkillMd(rawContent: string): { + name?: string + description?: string + content: string +} { + try { + const { data, content } = parseMossFrontmatter(rawContent) + return { + name: typeof data.name === "string" ? data.name : undefined, + description: typeof data.description === "string" ? data.description : undefined, + content: content.trim(), + } + } catch (err) { + console.error("[skills] Failed to parse frontmatter:", err) + return { content: rawContent.trim() } + } +} + +export function generateSkillMd(skill: { + name: string + description: string + content: string +}): string { + const frontmatter: Record = { + name: skill.name, + } + if (skill.description) { + frontmatter.description = skill.description + } + const body = skill.content ? `\n${skill.content}` : "" + return stringifyMossFrontmatter(body, frontmatter) +} diff --git a/src/main/lib/trpc/routers/skills.ts b/src/main/lib/trpc/routers/skills.ts index 87adebd9d..e9106274c 100644 --- a/src/main/lib/trpc/routers/skills.ts +++ b/src/main/lib/trpc/routers/skills.ts @@ -3,46 +3,43 @@ import { router, publicProcedure } from "../index" import * as fs from "fs/promises" import * as path from "path" import * as os from "os" -import matter from "gray-matter" import { discoverInstalledPlugins, getPluginComponentPaths } from "../../plugins" import { isDirentDirectory } from "../../fs/dirent" import { getEnabledPlugins } from "./claude-settings" +import { + ensureMossSource, + getMossSourceLayout, + materializeMossWorkspaceProjections, + removeMossProjectionResource, +} from "../../moss-source" +import { generateSkillMd, parseSkillMd } from "./skill-md" + +type SkillSource = "moss" | "user" | "project" | "plugin" export interface FileSkill { name: string description: string - source: "user" | "project" | "plugin" + source: SkillSource pluginName?: string path: string content: string } -/** - * Parse SKILL.md frontmatter to extract name and description - */ -function parseSkillMd(rawContent: string): { name?: string; description?: string; content: string } { - try { - const { data, content } = matter(rawContent) - return { - name: typeof data.name === "string" ? data.name : undefined, - description: typeof data.description === "string" ? data.description : undefined, - content: content.trim(), - } - } catch (err) { - console.error("[skills] Failed to parse frontmatter:", err) - return { content: rawContent.trim() } - } -} - /** * Scan a directory for SKILL.md files */ async function scanSkillsDirectory( dir: string, - source: "user" | "project" | "plugin", + source: SkillSource, basePath?: string, // For project skills, the cwd to make paths relative to + options?: { + skipResolvedUnder?: string + }, ): Promise { const skills: FileSkill[] = [] + const skipResolvedRoot = options?.skipResolvedUnder + ? path.resolve(options.skipResolvedUnder) + : undefined try { // Check if directory exists @@ -69,12 +66,22 @@ async function scanSkillsDirectory( try { await fs.access(skillMdPath) + if (skipResolvedRoot) { + const realSkillDir = await fs.realpath(path.dirname(skillMdPath)) + if ( + realSkillDir === skipResolvedRoot || + realSkillDir.startsWith(`${skipResolvedRoot}${path.sep}`) + ) { + continue + } + } + const content = await fs.readFile(skillMdPath, "utf-8") const parsed = parseSkillMd(content) - // For project skills, show relative path; for user skills, show ~/.claude/... path + // For project/Moss skills, show relative path; for user skills, show ~/.claude/... path let displayPath: string - if (source === "project" && basePath) { + if ((source === "project" || source === "moss") && basePath) { displayPath = path.relative(basePath, skillMdPath) } else { // For user skills, show ~/.claude/skills/... format @@ -115,10 +122,15 @@ const listSkillsProcedure = publicProcedure const userSkillsDir = path.join(os.homedir(), ".claude", "skills") const userSkillsPromise = scanSkillsDirectory(userSkillsDir, "user") + let mossSkillsPromise = Promise.resolve([]) let projectSkillsPromise = Promise.resolve([]) if (input?.cwd) { + const mossSkillsDir = path.join(input.cwd, ".moss", "skills") + mossSkillsPromise = scanSkillsDirectory(mossSkillsDir, "moss", input.cwd) const projectSkillsDir = path.join(input.cwd, ".claude", "skills") - projectSkillsPromise = scanSkillsDirectory(projectSkillsDir, "project", input.cwd) + projectSkillsPromise = scanSkillsDirectory(projectSkillsDir, "project", input.cwd, { + skipResolvedUnder: mossSkillsDir, + }) } // Discover plugin skills @@ -140,39 +152,89 @@ const listSkillsProcedure = publicProcedure }) // Scan all directories in parallel - const [userSkills, projectSkills, ...pluginSkillsArrays] = + const [userSkills, mossSkills, projectSkills, ...pluginSkillsArrays] = await Promise.all([ userSkillsPromise, + mossSkillsPromise, projectSkillsPromise, ...pluginSkillsPromises, ]) const pluginSkills = pluginSkillsArrays.flat() - return [...projectSkills, ...userSkills, ...pluginSkills] + return [...mossSkills, ...projectSkills, ...userSkills, ...pluginSkills] }) -/** - * Generate SKILL.md content from name, description, and body - */ -function generateSkillMd(skill: { name: string; description: string; content: string }): string { - const frontmatter: string[] = [] - frontmatter.push(`name: ${skill.name}`) - if (skill.description) { - frontmatter.push(`description: ${skill.description}`) - } - return `---\n${frontmatter.join("\n")}\n---\n\n${skill.content}` -} - /** * Resolve the absolute filesystem path of a skill given its display path */ -function resolveSkillPath(displayPath: string): string { +function resolveSkillPath(displayPath: string, cwd?: string): string { if (displayPath.startsWith("~")) { return path.join(os.homedir(), displayPath.slice(1)) } + if (!path.isAbsolute(displayPath)) { + if (!cwd) throw new Error("Project path (cwd) required for project-relative skills") + return path.join(cwd, displayPath) + } return displayPath } +function safeSkillName(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") +} + +function assertSafeSkillName(name: string): string { + const safeName = safeSkillName(name) + if (!safeName || safeName.includes("..")) { + throw new Error("Skill name must contain at least one alphanumeric character") + } + return safeName +} + +function isUnderPath(filePath: string, rootPath: string): boolean { + const resolvedFile = path.resolve(filePath) + const resolvedRoot = path.resolve(rootPath) + return resolvedFile === resolvedRoot || resolvedFile.startsWith(`${resolvedRoot}${path.sep}`) +} + +async function resolveWritableSkill( + displayPath: string, + cwd?: string, +): Promise<{ + absolutePath: string + skillDir: string + skillName: string + isMoss: boolean +}> { + const absolutePath = resolveSkillPath(displayPath, cwd) + let writablePath = absolutePath + let isMoss = false + + if (cwd) { + const mossSkillsRoot = getMossSourceLayout(cwd).skillsRoot + if (isUnderPath(absolutePath, mossSkillsRoot)) { + isMoss = true + } else { + try { + const realPath = await fs.realpath(absolutePath) + if (isUnderPath(realPath, mossSkillsRoot)) { + writablePath = realPath + isMoss = true + } + } catch { + // The caller will handle missing files through fs.access below. + } + } + } + + const skillDir = path.dirname(writablePath) + return { + absolutePath: writablePath, + skillDir, + skillName: path.basename(skillDir), + isMoss, + } +} + export const skillsRouter = router({ /** * List all skills from filesystem @@ -195,18 +257,21 @@ export const skillsRouter = router({ name: z.string(), description: z.string(), content: z.string(), - source: z.enum(["user", "project"]), + source: z.enum(["moss", "user", "project"]), cwd: z.string().optional(), }) ) .mutation(async ({ input }) => { - const safeName = input.name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") - if (!safeName) { - throw new Error("Skill name must contain at least one alphanumeric character") - } + const safeName = assertSafeSkillName(input.name) let targetDir: string - if (input.source === "project") { + if (input.source === "moss") { + if (!input.cwd) { + throw new Error("Project path (cwd) required for Moss skills") + } + await ensureMossSource({ projectPath: input.cwd }) + targetDir = getMossSourceLayout(input.cwd).skillsRoot + } else if (input.source === "project") { if (!input.cwd) { throw new Error("Project path (cwd) required for project skills") } @@ -239,9 +304,18 @@ export const skillsRouter = router({ await fs.writeFile(skillMdPath, fileContent, "utf-8") + if (input.source === "moss" && input.cwd) { + await materializeMossWorkspaceProjections({ + projectPath: input.cwd, + expectedResourceIds: [`moss:skill:${safeName}`], + }) + } + return { name: safeName, - path: skillMdPath, + path: input.source === "moss" && input.cwd + ? path.relative(input.cwd, skillMdPath) + : skillMdPath, source: input.source, } }), @@ -260,12 +334,10 @@ export const skillsRouter = router({ }) ) .mutation(async ({ input }) => { - const absolutePath = input.cwd && !input.path.startsWith("~") && !input.path.startsWith("/") - ? path.join(input.cwd, input.path) - : resolveSkillPath(input.path) + const skill = await resolveWritableSkill(input.path, input.cwd) // Verify file exists before writing - await fs.access(absolutePath) + await fs.access(skill.absolutePath) const fileContent = generateSkillMd({ name: input.name, @@ -273,7 +345,14 @@ export const skillsRouter = router({ content: input.content, }) - await fs.writeFile(absolutePath, fileContent, "utf-8") + await fs.writeFile(skill.absolutePath, fileContent, "utf-8") + + if (skill.isMoss && input.cwd) { + await materializeMossWorkspaceProjections({ + projectPath: input.cwd, + expectedResourceIds: [`moss:skill:${skill.skillName}`], + }) + } return { success: true } }), @@ -293,14 +372,25 @@ export const skillsRouter = router({ throw new Error("Invalid path") } - const absolutePath = input.cwd && !input.path.startsWith("~") && !input.path.startsWith("/") - ? path.join(input.cwd, input.path) - : resolveSkillPath(input.path) - - // Skills are directories containing SKILL.md — delete the parent directory - const skillDir = path.dirname(absolutePath) - await fs.access(skillDir) - await fs.rm(skillDir, { recursive: true }) + const skill = await resolveWritableSkill(input.path, input.cwd) + + // Skills are directories containing SKILL.md - delete the parent directory. + await fs.access(skill.skillDir) + await fs.rm(skill.skillDir, { recursive: true, force: true }) + + if (skill.isMoss && input.cwd) { + await removeMossProjectionResource({ + projectPath: input.cwd, + resourceId: `moss:skill:${skill.skillName}`, + sourcePath: path.join(".moss", "skills", skill.skillName), + targetPaths: [ + path.join(".claude", "skills", skill.skillName), + path.join(".codex", "skills", skill.skillName), + ], + removeTargets: true, + }) + await materializeMossWorkspaceProjections({ projectPath: input.cwd }) + } return { success: true } }), diff --git a/src/main/lib/vscode-theme-scanner.ts b/src/main/lib/vscode-theme-scanner.ts index f2468e8f8..0f6d6f714 100644 --- a/src/main/lib/vscode-theme-scanner.ts +++ b/src/main/lib/vscode-theme-scanner.ts @@ -127,7 +127,7 @@ async function scanExtensionsDir(extensionsDir: string, source: EditorSource): P // Create Dirent-like objects from ls output const entries_final = await Promise.all( - lsEntries.map(async (name) => { + lsEntries.map(async (name: string) => { const fullPath = path.join(extensionsDir, name) try { const stat = await fs.stat(fullPath) diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index 15dcdd137..bf0a7c348 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -10,16 +10,26 @@ import { nativeImage, dialog, } from "electron" -import { join } from "path" +import { join, isAbsolute } from "path" import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs" +import { homedir } from "os" +import { fileURLToPath } from "url" import { createIPCHandler } from "trpc-electron/main" import { createAppRouter } from "../lib/trpc/routers" import { getAuthManager, handleAuthCode, getBaseUrl } from "../index" import { registerGitWatcherIPC } from "../lib/git/watcher" import { hasActiveClaudeSessions, abortAllClaudeSessions } from "../lib/trpc/routers/claude" import { hasActiveCodexStreams, abortAllCodexStreams } from "../lib/trpc/routers/codex" +import { hasActiveHermesStreams, abortAllHermesStreams } from "../lib/trpc/routers/hermes" import { registerThemeScannerIPC } from "../lib/vscode-theme-scanner" import { windowManager } from "./window-manager" +import { + createLocalCodexAutomation, + deleteLocalCodexAutomation, + listLocalCodexAutomations, + runLocalCodexAutomationNow, + updateLocalCodexAutomation, +} from "../lib/codex-automations" // Flag to bypass close confirmation when app.quit() has already been confirmed let isQuitting = false @@ -37,6 +47,27 @@ function getWindowFromEvent( return win && !win.isDestroyed() ? win : null } +function normalizeShellPath(input: string): string | null { + if (typeof input !== "string") return null + const trimmedPath = input.trim() + if (!trimmedPath) return null + + if (trimmedPath.startsWith("file://")) { + try { + return fileURLToPath(trimmedPath) + } catch { + return null + } + } + + if (trimmedPath === "~") return homedir() + if (trimmedPath.startsWith("~/")) { + return join(homedir(), trimmedPath.slice(2)) + } + + return isAbsolute(trimmedPath) ? trimmedPath : null +} + // Register IPC handlers for window operations (only once) let ipcHandlersRegistered = false @@ -80,7 +111,7 @@ function registerIpcHandlers(): void { // Note: Update checking is now handled by auto-updater module (lib/auto-updater.ts) ipcMain.handle("app:set-badge", (event, count: number | null) => { const win = getWindowFromEvent(event) - if (process.platform === "darwin") { + if (process.platform === "darwin" && app.dock) { app.dock.setBadge(count ? String(count) : "") } else if (process.platform === "win32" && win) { // Windows: Update title with count as fallback @@ -285,9 +316,40 @@ function registerIpcHandlers(): void { }) // Shell - ipcMain.handle("shell:open-external", (_event, url: string) => - shell.openExternal(url), - ) + ipcMain.handle("shell:open-external", async (_event, url: string) => { + if (typeof url !== "string") return { ok: false, reason: "invalid-url" } + const trimmedUrl = url.trim() + if (!trimmedUrl) return { ok: false, reason: "invalid-url" } + + try { + const parsedUrl = new URL(trimmedUrl) + if (!["http:", "https:", "mailto:"].includes(parsedUrl.protocol)) { + return { ok: false, reason: "unsupported-protocol" } + } + await shell.openExternal(parsedUrl.toString()) + return { ok: true } + } catch { + return { ok: false, reason: "invalid-url" } + } + }) + + ipcMain.handle("shell:open-path", async (_event, targetPath: string) => { + const localPath = normalizeShellPath(targetPath) + if (!localPath) return { ok: false, reason: "invalid-path" } + + const error = await shell.openPath(localPath) + if (error) return { ok: false, reason: error } + return { ok: true } + }) + + ipcMain.handle("shell:reveal-path", async (_event, targetPath: string) => { + const localPath = normalizeShellPath(targetPath) + if (!localPath) return { ok: false, reason: "invalid-path" } + if (!existsSync(localPath)) return { ok: false, reason: "path-not-found" } + + shell.showItemInFolder(localPath) + return { ok: true } + }) // Clipboard ipcMain.handle("clipboard:write", (_event, text: string) => @@ -346,6 +408,45 @@ function registerIpcHandlers(): void { } } + // Local Codex automations. These mirror the Codex Desktop + // ~/.codex/automations//automation.toml contract for offline parity. + ipcMain.handle("codex-automations:list", async (event) => { + if (!validateSender(event)) return [] + return listLocalCodexAutomations() + }) + + ipcMain.handle( + "codex-automations:create", + async (event, input: Record) => { + if (!validateSender(event)) return null + return createLocalCodexAutomation(input ?? {}) + }, + ) + + ipcMain.handle( + "codex-automations:update", + async (event, input: Record) => { + if (!validateSender(event)) return null + return updateLocalCodexAutomation(input ?? {}) + }, + ) + + ipcMain.handle( + "codex-automations:delete", + async (event, input: Record) => { + if (!validateSender(event)) return null + return deleteLocalCodexAutomation(input ?? {}) + }, + ) + + ipcMain.handle( + "codex-automations:run-now", + async (event, input: Record) => { + if (!validateSender(event)) return null + return runLocalCodexAutomationNow(input ?? {}) + }, + ) + ipcMain.handle("auth:get-user", (event) => { if (!validateSender(event)) return null return getAuthManager().getUser() @@ -642,10 +743,20 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): contextIsolation: true, sandbox: false, // Required for electron-trpc webSecurity: true, + webviewTag: true, partition: "persist:main", // Use persistent session for cookies }, }) + window.webContents.on("will-attach-webview", (event, webPreferences) => { + delete webPreferences.preload + webPreferences.nodeIntegration = false + webPreferences.contextIsolation = true + webPreferences.sandbox = true + webPreferences.webSecurity = true + webPreferences.allowRunningInsecureContent = false + }) + // Register window with manager and get stable ID for localStorage namespacing const stableWindowId = windowManager.register(window) console.log( @@ -710,7 +821,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): if (!input.shift) { // Block Cmd+R entirely event.preventDefault() - } else if (hasActiveClaudeSessions() || hasActiveCodexStreams()) { + } else if (hasActiveClaudeSessions() || hasActiveCodexStreams() || hasActiveHermesStreams()) { // Cmd+Shift+R with active streams — intercept and confirm event.preventDefault() dialog @@ -728,6 +839,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): if (response === 1) { abortAllClaudeSessions() abortAllCodexStreams() + abortAllHermesStreams() window.webContents.reloadIgnoringCache() } }) @@ -737,7 +849,14 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): // Handle external links window.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url) + try { + const parsedUrl = new URL(url) + if (["http:", "https:", "mailto:"].includes(parsedUrl.protocol)) { + shell.openExternal(parsedUrl.toString()).catch(() => undefined) + } + } catch { + // Ignore malformed window-open requests instead of surfacing a main-process error. + } return { action: "deny" } }) @@ -748,10 +867,11 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): // Still abort sessions gracefully so partial state is saved abortAllClaudeSessions() abortAllCodexStreams() + abortAllHermesStreams() return } - if (hasActiveClaudeSessions() || hasActiveCodexStreams()) { + if (hasActiveClaudeSessions() || hasActiveCodexStreams() || hasActiveHermesStreams()) { event.preventDefault() dialog .showMessageBox(window, { @@ -768,6 +888,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): if (response === 1) { abortAllClaudeSessions() abortAllCodexStreams() + abortAllHermesStreams() window.destroy() } }) diff --git a/src/renderer/components/ui/icons.tsx b/src/renderer/components/ui/icons.tsx index 0b1a39da1..1c5edbec4 100644 --- a/src/renderer/components/ui/icons.tsx +++ b/src/renderer/components/ui/icons.tsx @@ -2573,7 +2573,10 @@ export function VolumeIcon({ className }: { className?: string }) { fill="none" className={className} > - + ( export const lastSelectedAgentIdAtom = atomWithStorage( "agents:lastSelectedAgentId", - "claude-code", + "hermes", undefined, { getOnInit: true }, ) @@ -220,16 +274,16 @@ export const lastSelectedModelIdAtom = atomWithStorage( export const lastSelectedCodexModelIdAtom = atomWithStorage( "agents:lastSelectedCodexModelId", - "gpt-5.3-codex", + "gpt-5.5", undefined, { getOnInit: true }, ) -export type CodexThinkingPreference = "low" | "medium" | "high" | "xhigh" +export type CodexThinkingPreference = CodexThinkingLevel export const lastSelectedCodexThinkingAtom = atomWithStorage( "agents:lastSelectedCodexThinking", - "high", + CODEX_DEFAULT_REASONING_EFFORT, undefined, { getOnInit: true }, ) @@ -342,6 +396,30 @@ export const subChatModeAtomFamily = atomFamily((subChatId: string) => ), ) +const subChatPermissionProfilesStorageAtom = atomWithStorage< + Record +>("agents:subChatPermissionProfiles", {}, undefined, { getOnInit: true }) + +export const subChatPermissionProfileAtomFamily = atomFamily((subChatId: string) => + atom( + (get) => { + const stored = get(subChatPermissionProfilesStorageAtom)[subChatId] + if (isAgentPermissionProfile(stored)) return stored + return workModeToDefaultPermissionProfile( + get(subChatModesStorageAtom)[subChatId] ?? "agent", + ) + }, + (get, set, newProfile: AgentPermissionProfile) => { + const current = get(subChatPermissionProfilesStorageAtom) + if (current[subChatId] === newProfile) return + set(subChatPermissionProfilesStorageAtom, { + ...current, + [subChatId]: newProfile, + }) + }, + ), +) + // Model ID to full Claude model string mapping export const MODEL_ID_MAP: Record = { opus: "opus", @@ -409,7 +487,7 @@ export type DiffViewDisplayMode = "side-peek" | "center-peek" | "full-page" export const diffViewDisplayModeAtom = atomWithStorage( "agents:diffViewDisplayMode", - "center-peek", // default to dialog for new users + "side-peek", // Codex desktop defaults review/diff to a docked right pane undefined, { getOnInit: true }, ) @@ -661,6 +739,11 @@ export const subChatToChatMapAtom = atom>(new Map()) // When set, AgentDiffView will only show files matching these paths export const filteredDiffFilesAtom = atom(null) +// Filter files for the Changes/Commit panel (null = show all files) +// Keep this separate from filteredDiffFilesAtom because the diff viewer rewrites +// its own filter whenever the focused diff file changes. +export const filteredChangesFilePathsAtom = atom(null) + // Selected file path in diff sidebar (for highlighting in file list and showing in diff view) // Using atom instead of useState to prevent re-renders of unrelated components export const selectedDiffFilePathAtom = atom(null) @@ -705,7 +788,7 @@ export const pendingConflictResolutionMessageAtom = atom<{ message: string; subC // After successful OAuth flow, this triggers automatic retry of the message export type PendingAuthRetryMessage = { subChatId: string // Required: only retry in the correct chat - provider: "claude-code" | "codex" + provider: RunnableAgentEngineId prompt: string images?: Array<{ base64Data: string @@ -1019,8 +1102,31 @@ export const showMessageJsonAtom = atomWithStorage( // Desktop view mode - takes priority over chat-based rendering // null = default behavior (chat/new-chat/kanban) -export type DesktopView = "automations" | "automations-detail" | "inbox" | "settings" | null +export type DesktopView = + | "automations" + | "automations-detail" + | "inbox" + | "settings" + | "global-search" + | "plugins" + | "skills" + | "mcp-settings" + | "projects" + | "library" + | "pull-requests" + | null export const desktopViewAtom = atom(null) +export type PendingPluginAction = PluginDeepLinkTarget +export const pendingPluginActionAtom = atom(null) +export const pendingPluginDetailIdAtom = atom( + (get) => get(pendingPluginActionAtom)?.pluginId ?? null, + (_get, set, pluginId: string | null) => { + set( + pendingPluginActionAtom, + pluginId ? { pluginId, action: "detail", source: "protocol" } : null, + ) + }, +) // Which automation is being viewed/edited (ID or "new" for creation) export const automationDetailIdAtom = atom(null) @@ -1055,6 +1161,7 @@ export const settingsMcpSidebarWidthAtom = atom(240) export const settingsSkillsSidebarWidthAtom = atom(240) export const settingsAgentsSidebarWidthAtom = atom(240) export const settingsPluginsSidebarWidthAtom = atom(240) +export const settingsMemorySidebarWidthAtom = atom(240) export const settingsKeyboardSidebarWidthAtom = atom(240) export const settingsProjectsSidebarWidthAtom = atom(240) @@ -1062,8 +1169,8 @@ export const settingsProjectsSidebarWidthAtom = atom(240) export type FileViewerDisplayMode = "side-peek" | "center-peek" | "full-page" export const fileViewerDisplayModeAtom = atomWithStorage( - "agents:fileViewerDisplayMode", - "side-peek", + "agents:fileViewerDisplayMode:v2", + "center-peek", undefined, { getOnInit: true }, ) diff --git a/src/renderer/features/agents/lib/agent-runtime.test.ts b/src/renderer/features/agents/lib/agent-runtime.test.ts new file mode 100644 index 000000000..fef035bcb --- /dev/null +++ b/src/renderer/features/agents/lib/agent-runtime.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "bun:test" +import { + AGENT_ENGINE_UI_DEFINITIONS, + CUSTOM_ACP_DEFAULT_MODEL_ID, + DEFAULT_RUNNABLE_AGENT_ENGINE_ID, + buildRuntimeEngineListState, + formatRuntimeModelLabel, + isRunnableAgentEngineId, + mapRuntimeEnginesToUiDefinitions, +} from "./agent-runtime" +import { CODEX_DEFAULT_MODEL_ID } from "./models" + +describe("mapRuntimeEnginesToUiDefinitions", () => { + test("defaults new agent sessions to the Moss-native Hermes runtime", () => { + expect(DEFAULT_RUNNABLE_AGENT_ENGINE_ID).toBe("hermes") + }) + + test("returns static fallback definitions before runtime data loads", () => { + expect(mapRuntimeEnginesToUiDefinitions(undefined)).toEqual( + AGENT_ENGINE_UI_DEFINITIONS, + ) + }) + + test("marks unavailable engines as disabled with status labels", () => { + const engines = mapRuntimeEnginesToUiDefinitions([ + { + id: "claude-code", + label: "Claude Code", + availability: "needs-auth", + statusReason: "No token found.", + defaultModelId: "opus", + }, + { + id: "codex", + label: "OpenAI Codex", + availability: "available", + authMethod: "oauth", + defaultModelId: CODEX_DEFAULT_MODEL_ID, + }, + { + id: "hermes", + label: "Hermes", + availability: "unsupported", + statusReason: "No transport.", + }, + { + id: "custom-acp", + label: "Custom ACP", + availability: "unsupported", + statusReason: "No adapter.", + }, + ]) + + expect(engines).toMatchObject([ + { + id: "claude-code", + disabled: true, + statusLabel: "Needs auth", + statusReason: "No token found.", + }, + { + id: "codex", + disabled: false, + statusLabel: undefined, + authMethod: "oauth", + }, + { + id: "hermes", + disabled: true, + statusLabel: "Unsupported", + statusReason: "No transport.", + }, + { + id: "custom-acp", + disabled: true, + statusLabel: "Unsupported", + statusReason: "No adapter.", + }, + ]) + }) + + test("keeps Custom ACP visible but disabled until an adapter is configured", () => { + const customAcp = AGENT_ENGINE_UI_DEFINITIONS.find( + (engine) => engine.id === "custom-acp", + ) + + expect(customAcp).toMatchObject({ + id: "custom-acp", + name: "Custom ACP", + disabled: true, + availability: "unsupported", + statusLabel: "Unsupported", + defaultModelLabel: "custom-acp", + }) + }) + + test("treats Custom ACP as a valid renderer engine while runtime availability controls selection", () => { + expect(isRunnableAgentEngineId("custom-acp")).toBe(true) + expect(isRunnableAgentEngineId("unknown-engine")).toBe(false) + + const [customAcp] = mapRuntimeEnginesToUiDefinitions([ + { + id: "custom-acp", + label: "Custom ACP", + availability: "available", + defaultModelId: CUSTOM_ACP_DEFAULT_MODEL_ID, + }, + ]) + + expect(customAcp).toMatchObject({ + id: "custom-acp", + disabled: false, + availability: "available", + defaultModelLabel: CUSTOM_ACP_DEFAULT_MODEL_ID, + statusLabel: undefined, + }) + expect(formatRuntimeModelLabel(customAcp?.defaultModelLabel)).toBe( + "Custom ACP Default", + ) + }) + + test("falls back to static state for unknown availability values", () => { + const [hermes] = mapRuntimeEnginesToUiDefinitions([ + { + id: "hermes", + label: "Hermes", + availability: "experimental", + }, + ]) + + expect(hermes).toMatchObject({ + id: "hermes", + disabled: false, + availability: "available", + statusLabel: "Fallback", + fallback: true, + }) + }) + + test("maps Hermes default runtime model to Moss display label", () => { + const [, , hermes] = mapRuntimeEnginesToUiDefinitions([ + { + id: "claude-code", + availability: "available", + }, + { + id: "codex", + availability: "available", + }, + { + id: "hermes", + label: "Hermes", + availability: "available", + defaultModelId: "moss-default", + }, + ]) + + expect(hermes?.defaultModelLabel).toBe("moss-default") + expect(formatRuntimeModelLabel(hermes?.defaultModelLabel)).toBe("Moss Default") + }) + + test("builds a loading state for every engine before health resolves", () => { + const state = buildRuntimeEngineListState({ isLoading: true }) + + expect(state.kind).toBe("loading") + expect(state.engines).toEqual(AGENT_ENGINE_UI_DEFINITIONS) + expect( + state.engines + .filter((engine) => engine.id !== "custom-acp") + .every((engine) => engine.disabled !== true), + ).toBe(true) + }) + + test("builds an empty state instead of hiding all engines", () => { + const state = buildRuntimeEngineListState({ engines: [] }) + + expect(state.kind).toBe("empty") + expect(state.engines).toHaveLength(AGENT_ENGINE_UI_DEFINITIONS.length) + expect(state.engines.every((engine) => engine.disabled)).toBe(true) + expect(state.engines.map((engine) => engine.statusLabel)).toEqual([ + "No engines", + "No engines", + "No engines", + "No engines", + ]) + }) + + test("builds an error state for every engine when health query fails", () => { + const state = buildRuntimeEngineListState({ + isError: true, + errorMessage: "health endpoint failed", + }) + + expect(state.kind).toBe("error") + expect(state.isError).toBe(true) + expect(state.message).toBe("health endpoint failed") + expect(state.engines.every((engine) => engine.disabled)).toBe(true) + expect(state.engines.map((engine) => engine.statusLabel)).toEqual([ + "Runtime error", + "Runtime error", + "Runtime error", + "Runtime error", + ]) + }) + + test("surfaces fallback list state when any engine has unknown availability", () => { + const state = buildRuntimeEngineListState({ + engines: [ + { + id: "hermes", + label: "Hermes", + availability: "experimental", + }, + ], + }) + + expect(state.kind).toBe("fallback") + expect(state.isFallback).toBe(true) + expect(state.engines[0]).toMatchObject({ + id: "hermes", + disabled: false, + statusLabel: "Fallback", + fallback: true, + }) + }) +}) diff --git a/src/renderer/features/agents/lib/agent-runtime.ts b/src/renderer/features/agents/lib/agent-runtime.ts new file mode 100644 index 000000000..e980035b6 --- /dev/null +++ b/src/renderer/features/agents/lib/agent-runtime.ts @@ -0,0 +1,279 @@ +import { CODEX_DEFAULT_MODEL_ID } from "./models" + +export const AGENT_ENGINE_IDS = [ + "claude-code", + "codex", + "hermes", + "custom-acp", +] as const +export const HERMES_DEFAULT_MODEL_ID = "moss-default" +export const CUSTOM_ACP_DEFAULT_MODEL_ID = "custom-acp" + +export type AgentEngineId = (typeof AGENT_ENGINE_IDS)[number] +export type RunnableAgentEngineId = AgentEngineId +export const DEFAULT_RUNNABLE_AGENT_ENGINE_ID: RunnableAgentEngineId = "hermes" + +export type AgentEngineUiDefinition = { + id: AgentEngineId + name: string + disabled?: boolean + availability?: string + statusLabel?: string + statusReason?: string + authMethod?: string + defaultModelLabel?: string + fallback?: boolean +} + +export type RuntimeAvailability = + | "available" + | "needs-auth" + | "not-installed" + | "unsupported" + | "error" + +export type AgentRuntimeEngineListKind = + | "ready" + | "loading" + | "empty" + | "error" + | "fallback" + +export type AgentRuntimeEngineListState = { + kind: AgentRuntimeEngineListKind + engines: AgentEngineUiDefinition[] + message?: string + isLoading: boolean + isError: boolean + isFallback: boolean +} + +export type RuntimeEngineManifestLike = { + id: string + label?: string + availability?: unknown + statusReason?: unknown + authMethod?: unknown + defaultModelId?: string | null +} + +const AVAILABILITY_LABELS: Record = { + available: "Available", + "needs-auth": "Needs auth", + "not-installed": "Not installed", + unsupported: "Unsupported", + error: "Error", +} + +export const AGENT_ENGINE_UI_DEFINITIONS: AgentEngineUiDefinition[] = [ + { + id: "claude-code", + name: "Claude Code", + defaultModelLabel: "opus", + }, + { + id: "codex", + name: "OpenAI Codex", + defaultModelLabel: CODEX_DEFAULT_MODEL_ID, + }, + { + id: "hermes", + name: "Hermes", + defaultModelLabel: HERMES_DEFAULT_MODEL_ID, + }, + { + id: "custom-acp", + name: "Custom ACP", + disabled: true, + availability: "unsupported", + statusLabel: "Unsupported", + statusReason: + "Configure a custom ACP adapter before using this engine.", + defaultModelLabel: CUSTOM_ACP_DEFAULT_MODEL_ID, + }, +] + +const RUNTIME_MODEL_LABELS: Record = { + [HERMES_DEFAULT_MODEL_ID]: "Moss Default", + [CUSTOM_ACP_DEFAULT_MODEL_ID]: "Custom ACP Default", +} + +function withRuntimeStatus( + engine: AgentEngineUiDefinition, + status: { + availability: string + statusLabel: string + statusReason: string + disabled?: boolean + fallback?: boolean + }, +): AgentEngineUiDefinition { + return { + ...engine, + availability: status.availability, + statusLabel: status.statusLabel, + statusReason: status.statusReason, + disabled: status.disabled ?? true, + fallback: status.fallback, + } +} + +export function buildStaticRuntimeStatusEngines( + status: { + availability: string + statusLabel: string + statusReason: string + disabled?: boolean + }, +): AgentEngineUiDefinition[] { + return AGENT_ENGINE_UI_DEFINITIONS.map((engine) => + withRuntimeStatus(engine, status), + ) +} + +export function isRunnableAgentEngineId( + engineId: unknown, +): engineId is RunnableAgentEngineId { + return AGENT_ENGINE_IDS.includes(engineId as AgentEngineId) +} + +export function isRuntimeAvailability( + value: unknown, +): value is RuntimeAvailability { + return ( + value === "available" || + value === "needs-auth" || + value === "not-installed" || + value === "unsupported" || + value === "error" + ) +} + +export function isEngineDisabled(availability: RuntimeAvailability): boolean { + return availability !== "available" +} + +export function mapRuntimeEnginesToUiDefinitions( + engines: RuntimeEngineManifestLike[] | undefined, +): AgentEngineUiDefinition[] { + if (!engines) return AGENT_ENGINE_UI_DEFINITIONS + + return engines.map((engine) => { + const fallback = AGENT_ENGINE_UI_DEFINITIONS.find( + (item) => item.id === engine.id, + ) + const runtimeAvailability: RuntimeAvailability | undefined = + isRuntimeAvailability(engine.availability) ? engine.availability : undefined + const fallbackAvailability: RuntimeAvailability = fallback?.disabled + ? "unsupported" + : "available" + const availability: RuntimeAvailability = + runtimeAvailability ?? fallbackAvailability + const isFallback = + runtimeAvailability === undefined && engine.availability !== undefined + + return { + id: engine.id as AgentEngineId, + name: engine.label || fallback?.name || engine.id, + disabled: isEngineDisabled(availability), + availability, + statusLabel: + isFallback + ? "Fallback" + : availability === "available" + ? undefined + : AVAILABILITY_LABELS[availability], + statusReason: + typeof engine.statusReason === "string" + ? engine.statusReason + : isFallback + ? "Moss runtime returned an unknown availability value; using the static engine fallback." + : undefined, + authMethod: + typeof engine.authMethod === "string" + ? engine.authMethod + : undefined, + defaultModelLabel: + engine.defaultModelId || fallback?.defaultModelLabel, + fallback: isFallback || undefined, + } + }) +} + +export function buildRuntimeEngineListState(params: { + engines?: RuntimeEngineManifestLike[] + isLoading?: boolean + isError?: boolean + errorMessage?: string +}): AgentRuntimeEngineListState { + if (params.isLoading && !params.engines) { + return { + kind: "loading", + engines: mapRuntimeEnginesToUiDefinitions(undefined), + message: "Checking agent runtimes...", + isLoading: true, + isError: false, + isFallback: false, + } + } + + if (params.isError) { + return { + kind: "error", + engines: buildStaticRuntimeStatusEngines({ + availability: "error", + statusLabel: "Runtime error", + statusReason: + params.errorMessage || + "Moss could not read agent runtime health.", + }), + message: + params.errorMessage || + "Moss could not read agent runtime health.", + isLoading: false, + isError: true, + isFallback: false, + } + } + + if (params.engines && params.engines.length === 0) { + return { + kind: "empty", + engines: buildStaticRuntimeStatusEngines({ + availability: "empty", + statusLabel: "No engines", + statusReason: "Moss runtime returned no engine manifests.", + }), + message: "Moss runtime returned no engine manifests.", + isLoading: false, + isError: false, + isFallback: false, + } + } + + const engines = mapRuntimeEnginesToUiDefinitions(params.engines) + const hasFallback = engines.some((engine) => engine.fallback) + + return { + kind: hasFallback ? "fallback" : "ready", + engines, + message: hasFallback + ? "One or more engines are using static fallback metadata." + : undefined, + isLoading: false, + isError: false, + isFallback: hasFallback, + } +} + +export function getAgentEngineLabel(engineId: AgentEngineId): string { + return ( + AGENT_ENGINE_UI_DEFINITIONS.find((engine) => engine.id === engineId) + ?.name ?? engineId + ) +} + +export function formatRuntimeModelLabel(modelId?: string | null): string { + if (!modelId) return "Runtime Default" + return RUNTIME_MODEL_LABELS[modelId] ?? modelId +} diff --git a/src/renderer/features/agents/lib/models.test.ts b/src/renderer/features/agents/lib/models.test.ts new file mode 100644 index 000000000..f86815148 --- /dev/null +++ b/src/renderer/features/agents/lib/models.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" + +import { + CODEX_MODELS, + CODEX_DEFAULT_REASONING_EFFORT, + CODEX_DEFAULT_MODEL_ID, + CODEX_REASONING_EFFORTS, + formatCodexThinkingLabel, + getCodexDefaultThinkingLevel, + isCodexThinkingLevel, +} from "./models" + +describe("Codex model reasoning contract", () => { + test("matches the Codex Desktop reasoning effort order exposed in the model picker", () => { + expect(CODEX_DEFAULT_REASONING_EFFORT).toBe("medium") + expect(CODEX_DEFAULT_MODEL_ID).toBe("gpt-5.5/medium") + expect([...CODEX_REASONING_EFFORTS]).toEqual([ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]) + + for (const model of CODEX_MODELS) { + expect(model.thinkings).toEqual([...CODEX_REASONING_EFFORTS]) + expect(getCodexDefaultThinkingLevel(model.thinkings)).toBe("medium") + } + }) + + test("formats reasoning efforts with Codex Desktop picker labels", () => { + expect(formatCodexThinkingLabel("minimal")).toBe("Minimal") + expect(formatCodexThinkingLabel("low")).toBe("Low") + expect(formatCodexThinkingLabel("medium")).toBe("Medium") + expect(formatCodexThinkingLabel("high")).toBe("High") + expect(formatCodexThinkingLabel("xhigh")).toBe("Extra High") + expect(formatCodexThinkingLabel("max")).toBe("Max") + }) + + test("guards persisted reasoning preferences before transport model resolution", () => { + for (const effort of CODEX_REASONING_EFFORTS) { + expect(isCodexThinkingLevel(effort)).toBe(true) + } + + expect(isCodexThinkingLevel("none")).toBe(false) + expect(isCodexThinkingLevel("extra-high")).toBe(false) + expect(isCodexThinkingLevel(null)).toBe(false) + }) +}) diff --git a/src/renderer/features/agents/lib/models.ts b/src/renderer/features/agents/lib/models.ts index 26fd580e4..783a2a0bc 100644 --- a/src/renderer/features/agents/lib/models.ts +++ b/src/renderer/features/agents/lib/models.ts @@ -4,32 +4,71 @@ export const CLAUDE_MODELS = [ { id: "haiku", name: "Haiku", version: "4.5" }, ] -export type CodexThinkingLevel = "low" | "medium" | "high" | "xhigh" +export const CODEX_REASONING_EFFORTS = [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const + +export type CodexThinkingLevel = (typeof CODEX_REASONING_EFFORTS)[number] + +export const CODEX_DEFAULT_REASONING_EFFORT = "medium" satisfies CodexThinkingLevel +export const CODEX_DEFAULT_MODEL_ID = `gpt-5.5/${CODEX_DEFAULT_REASONING_EFFORT}` + +function createCodexThinkingLevels(): CodexThinkingLevel[] { + return [...CODEX_REASONING_EFFORTS] +} + +export function getCodexDefaultThinkingLevel( + thinkings: readonly CodexThinkingLevel[], +): CodexThinkingLevel { + return thinkings.includes(CODEX_DEFAULT_REASONING_EFFORT) + ? CODEX_DEFAULT_REASONING_EFFORT + : thinkings[0]! +} export const CODEX_MODELS = [ { - id: "gpt-5.3-codex", - name: "Codex 5.3", - thinkings: ["low", "medium", "high", "xhigh"] as CodexThinkingLevel[], + id: "gpt-5.5", + name: "GPT 5.5", + thinkings: createCodexThinkingLevels(), }, { - id: "gpt-5.2-codex", - name: "Codex 5.2", - thinkings: ["low", "medium", "high", "xhigh"] as CodexThinkingLevel[], + id: "gpt-5.4", + name: "GPT 5.4", + thinkings: createCodexThinkingLevels(), }, { - id: "gpt-5.1-codex-max", - name: "Codex 5.1 Max", - thinkings: ["low", "medium", "high", "xhigh"] as CodexThinkingLevel[], + id: "gpt-5.4-mini", + name: "GPT 5.4 Mini", + thinkings: createCodexThinkingLevels(), }, { - id: "gpt-5.1-codex-mini", - name: "Codex 5.1 Mini", - thinkings: ["medium", "high"] as CodexThinkingLevel[], + id: "gpt-5.2", + name: "GPT 5.2", + thinkings: createCodexThinkingLevels(), }, ] +export function isCodexThinkingLevel(value: unknown): value is CodexThinkingLevel { + return ( + typeof value === "string" && + CODEX_REASONING_EFFORTS.includes(value as CodexThinkingLevel) + ) +} + export function formatCodexThinkingLabel(thinking: CodexThinkingLevel): string { - if (thinking === "xhigh") return "Extra High" - return thinking.charAt(0).toUpperCase() + thinking.slice(1) + switch (thinking) { + case "minimal": + return "Minimal" + case "xhigh": + return "Extra High" + case "max": + return "Max" + default: + return thinking.charAt(0).toUpperCase() + thinking.slice(1) + } } diff --git a/src/renderer/features/agents/mentions/agents-mentions-editor.tsx b/src/renderer/features/agents/mentions/agents-mentions-editor.tsx index 548334e42..4dc55e0d9 100644 --- a/src/renderer/features/agents/mentions/agents-mentions-editor.tsx +++ b/src/renderer/features/agents/mentions/agents-mentions-editor.tsx @@ -29,7 +29,7 @@ export interface FileMentionOption { description?: string // skill/agent/tool description tools?: string[] // agent allowed tools model?: string // agent model - source?: "user" | "project" // skill/agent source + source?: "moss" | "user" | "project" | "plugin" // skill/agent source mcpServer?: string // MCP server name for tools } @@ -1365,7 +1365,7 @@ export const AgentsMentionsEditor = memo( return (
{!hasContent && placeholder && ( -
+
{placeholder}
)} @@ -1384,7 +1384,7 @@ export const AgentsMentionsEditor = memo( onFocus={onFocus} onBlur={onBlur} className={cn( - "min-h-[24px] outline-none whitespace-pre-wrap break-words text-sm relative", + "codex-composer-editor relative min-h-[24px] whitespace-pre-wrap break-words outline-none", disabled && "opacity-50 cursor-not-allowed", className, )} diff --git a/src/renderer/features/plugins/plugin-entry-surfaces.test.ts b/src/renderer/features/plugins/plugin-entry-surfaces.test.ts new file mode 100644 index 000000000..2d4310a64 --- /dev/null +++ b/src/renderer/features/plugins/plugin-entry-surfaces.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" +import { + buildPluginEntrySurfaceRoute, + coercePluginManageTabForSurface, + getDesktopViewForSettingsIntegration, + getPluginEntrySurfaceDefaults, + getPluginEntrySurfaceForDesktopView, + getPluginEntrySurfaceManageTabIds, +} from "./plugin-entry-surfaces" + +describe("Codex plugin entry surfaces", () => { + test("routes Settings integrations to independent desktop surfaces", () => { + expect(getDesktopViewForSettingsIntegration("plugins")).toBe("plugins") + expect(getDesktopViewForSettingsIntegration("skills")).toBe("skills") + expect(getDesktopViewForSettingsIntegration("mcp")).toBe("mcp-settings") + expect(getDesktopViewForSettingsIntegration("preferences")).toBeNull() + }) + + test("keeps Plugins, Skills, and MCP as separate page contracts", () => { + expect(getPluginEntrySurfaceForDesktopView("plugins")).toBe("plugins") + expect(getPluginEntrySurfaceForDesktopView("skills")).toBe("skills") + expect(getPluginEntrySurfaceForDesktopView("mcp-settings")).toBe( + "mcp-settings", + ) + + expect(getPluginEntrySurfaceDefaults("plugins")).toEqual({ + pageTab: "plugins", + entryMode: "browse", + manageTab: "plugins", + }) + expect(getPluginEntrySurfaceDefaults("skills")).toEqual({ + pageTab: "skills", + entryMode: "browse", + manageTab: "skills", + }) + expect(getPluginEntrySurfaceDefaults("mcp-settings")).toEqual({ + pageTab: "plugins", + entryMode: "manage", + manageTab: "mcps", + }) + }) + + test("prevents the Plugins page from owning Skills and MCP tabs", () => { + expect(getPluginEntrySurfaceManageTabIds("plugins")).toEqual([ + "plugins", + "apps", + "marketplace", + ]) + expect(getPluginEntrySurfaceManageTabIds("skills")).toEqual(["skills"]) + expect(getPluginEntrySurfaceManageTabIds("mcp-settings")).toEqual(["mcps"]) + + expect(coercePluginManageTabForSurface("plugins", "mcps")).toBe("plugins") + expect(coercePluginManageTabForSurface("skills", "plugins")).toBe("skills") + expect(coercePluginManageTabForSurface("mcp-settings", "skills")).toBe( + "mcps", + ) + }) + + test("builds MCP as a manage route without reusing the Plugins surface", () => { + expect(buildPluginEntrySurfaceRoute("mcp-settings")).toMatchObject({ + surface: "manage", + topTab: "plugins", + entryMode: "manage", + manageTab: "mcps", + }) + expect(buildPluginEntrySurfaceRoute("skills")).toMatchObject({ + surface: "skills", + topTab: "skills", + entryMode: "browse", + }) + }) +}) diff --git a/src/renderer/features/plugins/plugin-entry-surfaces.ts b/src/renderer/features/plugins/plugin-entry-surfaces.ts new file mode 100644 index 000000000..8b53751c6 --- /dev/null +++ b/src/renderer/features/plugins/plugin-entry-surfaces.ts @@ -0,0 +1,91 @@ +import type { SettingsTab } from "../../lib/atoms" +import type { DesktopView } from "../agents/atoms" +import { + buildPluginBrowseRoute, + type PluginEntryMode, + type PluginManageTabId, + type PluginRouteState, + type PluginTopTabId, +} from "./plugin-route-state" + +export type PluginEntrySurface = "plugins" | "skills" | "mcp-settings" + +export type PluginEntrySurfaceDefaults = { + pageTab: PluginTopTabId + entryMode: PluginEntryMode + manageTab: PluginManageTabId +} + +const PLUGIN_ENTRY_SURFACE_DEFAULTS: Record< + PluginEntrySurface, + PluginEntrySurfaceDefaults +> = { + plugins: { + pageTab: "plugins", + entryMode: "browse", + manageTab: "plugins", + }, + skills: { + pageTab: "skills", + entryMode: "browse", + manageTab: "skills", + }, + "mcp-settings": { + pageTab: "plugins", + entryMode: "manage", + manageTab: "mcps", + }, +} + +const PLUGIN_ENTRY_SURFACE_MANAGE_TABS: Record< + PluginEntrySurface, + PluginManageTabId[] +> = { + plugins: ["plugins", "apps", "marketplace"], + skills: ["skills"], + "mcp-settings": ["mcps"], +} + +export function getPluginEntrySurfaceDefaults( + surface: PluginEntrySurface, +): PluginEntrySurfaceDefaults { + return PLUGIN_ENTRY_SURFACE_DEFAULTS[surface] +} + +export function buildPluginEntrySurfaceRoute( + surface: PluginEntrySurface, +): PluginRouteState { + return buildPluginBrowseRoute(getPluginEntrySurfaceDefaults(surface)) +} + +export function getPluginEntrySurfaceManageTabIds( + surface: PluginEntrySurface, +): PluginManageTabId[] { + return PLUGIN_ENTRY_SURFACE_MANAGE_TABS[surface] +} + +export function coercePluginManageTabForSurface( + surface: PluginEntrySurface, + tab: PluginManageTabId, +): PluginManageTabId { + const allowedTabs = getPluginEntrySurfaceManageTabIds(surface) + return allowedTabs.includes(tab) ? tab : allowedTabs[0] +} + +export function getPluginEntrySurfaceForDesktopView( + view: DesktopView, +): PluginEntrySurface | null { + if (view === "plugins") return "plugins" + if (view === "skills") return "skills" + if (view === "mcp-settings") return "mcp-settings" + return null +} + +export function getDesktopViewForSettingsIntegration( + tab: SettingsTab, +): DesktopView { + if (tab === "plugins") return "plugins" + if (tab === "skills") return "skills" + if (tab === "mcp") return "mcp-settings" + return null +} diff --git a/src/renderer/features/plugins/plugin-route-state.ts b/src/renderer/features/plugins/plugin-route-state.ts new file mode 100644 index 000000000..d67ee77e7 --- /dev/null +++ b/src/renderer/features/plugins/plugin-route-state.ts @@ -0,0 +1,389 @@ +export type PluginEntryMode = "browse" | "manage" +export type PluginTopTabId = "plugins" | "skills" +export type PluginManageTabId = + | "plugins" + | "apps" + | "mcps" + | "skills" + | "marketplace" +export type PluginSourceTabId = "openai" | "mine" +export type PluginMutationStatus = "pending" | "success" | "error" +export type PluginResourceRouteKind = + | "app" + | "mcp" + | "managed-skill" + | "recommended-skill" +export type PluginResourceRouteActionId = + | "manage" + | "connect-oauth" + | "open-mcp-capability" + | "try-in-chat" + | "install-skill" + | "unavailable" + +export type PluginRouteOrigin = { + topTab: PluginTopTabId + entryMode: PluginEntryMode + manageTab: PluginManageTabId + sourceTab: PluginSourceTabId + searchQuery: string +} + +export type PluginBrowseRouteState = { + surface: "browse" + topTab: "plugins" + entryMode: "browse" + sourceTab: PluginSourceTabId + searchQuery: string +} + +export type PluginManageRouteState = { + surface: "manage" + topTab: "plugins" + entryMode: "manage" + manageTab: PluginManageTabId + sourceTab: PluginSourceTabId + searchQuery: string +} + +export type PluginSkillsRouteState = { + surface: "skills" + topTab: "skills" + entryMode: "browse" + sourceTab: PluginSourceTabId + searchQuery: string +} + +export type PluginDetailRouteState = { + surface: "detail" + pluginId: string + origin: PluginRouteOrigin + lastAction?: { + type: "install" | "uninstall" + status: "success" | "error" + } +} + +export type PluginInstallRouteState = { + surface: "install" + pluginId: string + origin: PluginRouteOrigin + status: Extract +} + +export type PluginUninstallRouteState = { + surface: "uninstall" + pluginId: string + origin: PluginRouteOrigin + status: "confirming" | Extract +} + +export type PluginTryInChatRouteState = { + surface: "try-in-chat" + pluginId: string + origin: PluginRouteOrigin + promptKey?: string +} + +export type PluginResourceRouteState = { + surface: "resource" + resourceId: string + resourceKind: PluginResourceRouteKind + actionId: PluginResourceRouteActionId + origin: PluginRouteOrigin + promptKey?: string + targetId?: string +} + +export type PluginRouteState = + | PluginBrowseRouteState + | PluginManageRouteState + | PluginSkillsRouteState + | PluginDetailRouteState + | PluginInstallRouteState + | PluginUninstallRouteState + | PluginTryInChatRouteState + | PluginResourceRouteState + +export type BuildPluginRouteOptions = { + pageTab: PluginTopTabId + entryMode: PluginEntryMode + manageTab?: PluginManageTabId + sourceTab?: PluginSourceTabId + searchQuery?: string +} + +const DEFAULT_MANAGE_TAB: PluginManageTabId = "plugins" +const DEFAULT_SOURCE_TAB: PluginSourceTabId = "openai" + +export function getPluginRouteOrigin( + options: BuildPluginRouteOptions, +): PluginRouteOrigin { + return { + topTab: options.pageTab, + entryMode: options.pageTab === "skills" ? "browse" : options.entryMode, + manageTab: options.manageTab ?? DEFAULT_MANAGE_TAB, + sourceTab: options.sourceTab ?? DEFAULT_SOURCE_TAB, + searchQuery: options.searchQuery ?? "", + } +} + +export function buildPluginBrowseRoute( + options: BuildPluginRouteOptions, +): PluginBrowseRouteState | PluginManageRouteState | PluginSkillsRouteState { + const origin = getPluginRouteOrigin(options) + + if (origin.topTab === "skills") { + return { + surface: "skills", + topTab: "skills", + entryMode: "browse", + sourceTab: origin.sourceTab, + searchQuery: origin.searchQuery, + } + } + + if (origin.entryMode === "manage") { + return { + surface: "manage", + topTab: "plugins", + entryMode: "manage", + manageTab: origin.manageTab, + sourceTab: origin.sourceTab, + searchQuery: origin.searchQuery, + } + } + + return { + surface: "browse", + topTab: "plugins", + entryMode: "browse", + sourceTab: origin.sourceTab, + searchQuery: origin.searchQuery, + } +} + +export function getPluginRouteOriginFromState( + route: PluginRouteState, +): PluginRouteOrigin { + if ("origin" in route) return route.origin + + return getPluginRouteOrigin({ + pageTab: route.topTab, + entryMode: route.entryMode, + manageTab: route.surface === "manage" ? route.manageTab : DEFAULT_MANAGE_TAB, + sourceTab: route.sourceTab, + searchQuery: route.searchQuery, + }) +} + +export function openPluginDetailRoute( + pluginId: string, + origin: PluginRouteOrigin, +): PluginDetailRouteState { + return { + surface: "detail", + pluginId, + origin, + } +} + +export function closePluginOverlayRoute( + route: PluginRouteState, +): PluginBrowseRouteState | PluginManageRouteState | PluginSkillsRouteState { + return buildPluginBrowseRoute(pluginRouteToLegacyTabs(route)) +} + +export function startPluginInstallRoute( + pluginId: string, + origin: PluginRouteOrigin, +): PluginInstallRouteState { + return { + surface: "install", + pluginId, + origin, + status: "pending", + } +} + +export function finishPluginInstallRoute( + pluginId: string, + origin: PluginRouteOrigin, + status: Extract, +): PluginDetailRouteState { + return { + surface: "detail", + pluginId, + origin, + lastAction: { + type: "install", + status, + }, + } +} + +export function requestPluginUninstallRoute( + pluginId: string, + origin: PluginRouteOrigin, +): PluginUninstallRouteState { + return { + surface: "uninstall", + pluginId, + origin, + status: "confirming", + } +} + +export function startPluginUninstallRoute( + pluginId: string, + origin: PluginRouteOrigin, +): PluginUninstallRouteState { + return { + surface: "uninstall", + pluginId, + origin, + status: "pending", + } +} + +export function finishPluginUninstallRoute( + pluginId: string, + origin: PluginRouteOrigin, + status: Extract, +): PluginDetailRouteState | PluginUninstallRouteState { + if (status === "error") { + return { + surface: "uninstall", + pluginId, + origin, + status: "error", + } + } + + return { + surface: "detail", + pluginId, + origin, + lastAction: { + type: "uninstall", + status, + }, + } +} + +export function cancelPluginUninstallRoute( + pluginId: string, + origin: PluginRouteOrigin, +): PluginDetailRouteState { + return openPluginDetailRoute(pluginId, origin) +} + +export function startPluginTryInChatRoute( + pluginId: string, + origin: PluginRouteOrigin, + promptKey?: string, +): PluginTryInChatRouteState { + return { + surface: "try-in-chat", + pluginId, + origin, + promptKey, + } +} + +export function openPluginResourceRoute({ + actionId, + origin, + promptKey, + resourceId, + resourceKind, + targetId, +}: { + actionId: PluginResourceRouteActionId + origin: PluginRouteOrigin + promptKey?: string + resourceId: string + resourceKind: PluginResourceRouteKind + targetId?: string +}): PluginResourceRouteState { + return { + surface: "resource", + resourceId, + resourceKind, + actionId, + origin, + promptKey, + targetId, + } +} + +export function pluginRouteToLegacyTabs( + route: PluginRouteState, +): Required { + const origin = getPluginRouteOriginFromState(route) + + return { + pageTab: origin.topTab, + entryMode: origin.entryMode, + manageTab: origin.manageTab, + sourceTab: origin.sourceTab, + searchQuery: origin.searchQuery, + } +} + +export function getPluginRouteDialogKind( + route: PluginRouteState, +): "detail" | "uninstall" | null { + if (route.surface === "detail" || route.surface === "install") { + return "detail" + } + + if (route.surface === "uninstall") return "uninstall" + + return null +} + +export function serializePluginRouteOrigin(origin: PluginRouteOrigin): string { + if (origin.topTab === "skills") { + return `skills:${origin.sourceTab}` + } + + if (origin.entryMode === "manage") { + return `plugins:manage:${origin.manageTab}:${origin.sourceTab}` + } + + return `plugins:browse:${origin.sourceTab}` +} + +export function serializePluginRouteState(route: PluginRouteState): string { + if (route.surface === "resource") { + const origin = serializePluginRouteOrigin(route.origin) + const target = route.targetId ? `:${route.targetId}` : "" + return `resource:${route.resourceKind}:${route.resourceId}:${route.actionId}:${origin}${target}` + } + + if (!("pluginId" in route)) { + return serializePluginRouteOrigin(getPluginRouteOriginFromState(route)) + } + + const origin = serializePluginRouteOrigin(route.origin) + + if (route.surface === "detail") { + const action = route.lastAction + ? `:${route.lastAction.type}-${route.lastAction.status}` + : "" + return `detail:${route.pluginId}:${origin}${action}` + } + + if (route.surface === "install") { + return `install:${route.pluginId}:${route.status}:${origin}` + } + + if (route.surface === "uninstall") { + return `uninstall:${route.pluginId}:${route.status}:${origin}` + } + + return route.promptKey + ? `try-in-chat:${route.pluginId}:${route.promptKey}:${origin}` + : `try-in-chat:${route.pluginId}:${origin}` +} diff --git a/src/renderer/lib/atoms/index.ts b/src/renderer/lib/atoms/index.ts index e28999953..12eb50d00 100644 --- a/src/renderer/lib/atoms/index.ts +++ b/src/renderer/lib/atoms/index.ts @@ -185,17 +185,37 @@ export type SettingsTab = | "profile" | "appearance" | "preferences" + | "capabilities" + | "billing" + | "code-review" | "models" + | "runtime" + | "desktop" + | "release" + | "configuration" + | "personalization" + | "environment" + | "hooks" + | "memory" | "skills" | "agents" | "mcp" | "plugins" + | "archived-conversations" | "worktrees" | "projects" | "debug" | "beta" | "keyboard" export const agentsSettingsDialogActiveTabAtom = atom("preferences") + +export type AppLanguage = "zh-CN" | "en-US" + +export const appLanguageAtom = atomWithStorage( + "app:language", + "zh-CN", +) + // Derived atom: maps settings open/close to desktopView navigation export const agentsSettingsDialogOpenAtom = atom( (get) => get(_desktopViewAtom) === "settings", @@ -210,6 +230,126 @@ export type CustomClaudeConfig = { baseUrl: string } +export type AgentEnvironmentSettings = { + inheritShellEnvironment: boolean + redactSensitiveValues: boolean + requireHookConfirmation: boolean + hookRunnerEnabled: boolean + allowedVariables: string[] +} + +export const DEFAULT_AGENT_ENVIRONMENT_SETTINGS: AgentEnvironmentSettings = { + inheritShellEnvironment: true, + redactSensitiveValues: true, + requireHookConfirmation: true, + hookRunnerEnabled: false, + allowedVariables: [ + "PATH", + "HOME", + "SHELL", + "LANG", + "NODE_ENV", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + ], +} + +export const agentEnvironmentSettingsAtom = + atomWithStorage( + "agents:environment-settings", + DEFAULT_AGENT_ENVIRONMENT_SETTINGS, + ) + +export type AgentCapabilitiesSettings = { + unifiedSourceEnabled: boolean + memoryEnabled: boolean + skillsEnabled: boolean + mcpEnabled: boolean + pluginsEnabled: boolean + hooksEnabled: boolean + requireMcpApproval: boolean +} + +export const DEFAULT_AGENT_CAPABILITIES_SETTINGS: AgentCapabilitiesSettings = { + unifiedSourceEnabled: true, + memoryEnabled: true, + skillsEnabled: true, + mcpEnabled: true, + pluginsEnabled: true, + hooksEnabled: false, + requireMcpApproval: true, +} + +export const agentCapabilitiesSettingsAtom = + atomWithStorage( + "agents:capabilities-settings", + DEFAULT_AGENT_CAPABILITIES_SETTINGS, + ) + +export type AgentCodeReviewTriggerPolicy = + | "manual" + | "pull-request" + | "push-and-pull-request" + +export type AgentCodeReviewRateLimitPolicy = + | "warn-before-expensive" + | "respect-provider-limit" + | "disable-when-low" + +export type AgentCodeReviewSettings = { + automaticReviewEnabled: boolean + exhaustiveReviewEnabled: boolean + triggerPolicy: AgentCodeReviewTriggerPolicy + rateLimitCreditPolicy: AgentCodeReviewRateLimitPolicy + requireProviderReadiness: boolean +} + +export const DEFAULT_AGENT_CODE_REVIEW_SETTINGS: AgentCodeReviewSettings = { + automaticReviewEnabled: false, + exhaustiveReviewEnabled: false, + triggerPolicy: "manual", + rateLimitCreditPolicy: "warn-before-expensive", + requireProviderReadiness: true, +} + +export const agentCodeReviewSettingsAtom = + atomWithStorage( + "agents:code-review-settings", + DEFAULT_AGENT_CODE_REVIEW_SETTINGS, + ) + +export type AgentDesktopParitySettings = { + globalSearchEnabled: boolean + automationMonitorEnabled: boolean + browserToolsEnabled: boolean + requireComputerControlApproval: boolean + shareConnectionsAcrossEngines: boolean + includeArchivedConversations: boolean + appSnapshotsEnabled: boolean + gitBranchPrefix: string + worktreeRoot: string + openTargetLabel: string +} + +export const DEFAULT_AGENT_DESKTOP_PARITY_SETTINGS: AgentDesktopParitySettings = { + globalSearchEnabled: true, + automationMonitorEnabled: true, + browserToolsEnabled: true, + requireComputerControlApproval: true, + shareConnectionsAcrossEngines: true, + includeArchivedConversations: true, + appSnapshotsEnabled: true, + gitBranchPrefix: "codex/", + worktreeRoot: ".1code/worktrees", + openTargetLabel: "Ghostty", +} + +export const agentDesktopParitySettingsAtom = + atomWithStorage( + "agents:desktop-parity-settings", + DEFAULT_AGENT_DESKTOP_PARITY_SETTINGS, + ) + // Model profile system - support multiple configs export type ModelProfile = { id: string @@ -665,6 +805,14 @@ export const recordingHotkeyForActionAtom = atom(null) export const agentsLoginModalOpenAtom = atom(false) export const codexLoginModalOpenAtom = atom(false) +export type CodexLoginModalConfig = { + autoStart: boolean +} + +export const codexLoginModalConfigAtom = atom({ + autoStart: true, +}) + export type ClaudeLoginModalConfig = { hideCustomModelSettingsLink: boolean autoStartAuth: boolean diff --git a/src/renderer/lib/mock-api.ts b/src/renderer/lib/mock-api.ts index c96bf1023..ca5d04357 100644 --- a/src/renderer/lib/mock-api.ts +++ b/src/renderer/lib/mock-api.ts @@ -379,7 +379,7 @@ export const api = { }), }, }, - useUtils: () => { + useUtils: (): AnyObj => { const utils = trpc.useUtils() return { agents: { @@ -455,8 +455,8 @@ export const api = { }, // Stubs for features not needed in desktop teams: { - getUserTeams: { useQuery: () => ({ data: [], isLoading: false }) }, - getTeam: { useQuery: () => ({ data: null, isLoading: false }) }, + getUserTeams: { useQuery: (_args?: AnyObj, _opts?: AnyObj) => ({ data: [], isLoading: false }) }, + getTeam: { useQuery: (_args?: AnyObj, _opts?: AnyObj) => ({ data: null, isLoading: false }) }, updateTeam: { useMutation: () => ({ mutate: () => {}, @@ -467,7 +467,7 @@ export const api = { }, repositorySandboxes: { getRepositoriesWithStatus: { - useQuery: () => ({ + useQuery: (_args?: AnyObj, _opts?: AnyObj) => ({ data: { repositories: [] }, isLoading: false, refetch: async () => ({ data: { repositories: [] } }), diff --git a/src/renderer/lib/trpc.ts b/src/renderer/lib/trpc.ts index 684d64c03..83090088a 100644 --- a/src/renderer/lib/trpc.ts +++ b/src/renderer/lib/trpc.ts @@ -1,4 +1,4 @@ -import { createTRPCReact } from "@trpc/react-query" +import { createTRPCReact, type CreateTRPCReact } from "@trpc/react-query" import { createTRPCProxyClient } from "@trpc/client" import { ipcLink } from "trpc-electron/renderer" import type { AppRouter } from "../../main/lib/trpc/routers" @@ -7,11 +7,45 @@ import superjson from "superjson" /** * React hooks for tRPC */ -export const trpc = createTRPCReact() +export const trpc: CreateTRPCReact = + createTRPCReact() + +type TrpcProxyClient = ReturnType> + +const MISSING_ELECTRON_BRIDGE_MESSAGE = + "1Code desktop IPC bridge is unavailable. Open this screen from the 1Code desktop app instead of a plain browser tab." + +export function hasElectronTrpcBridge(): boolean { + return ( + typeof window !== "undefined" && + Boolean((window as Window & { electronTRPC?: unknown }).electronTRPC) + ) +} + +export function createElectronIpcLink() { + if (!hasElectronTrpcBridge()) { + throw new Error(MISSING_ELECTRON_BRIDGE_MESSAGE) + } + + return ipcLink({ transformer: superjson }) +} + +function createUnavailableTrpcClient(): TrpcProxyClient { + return new Proxy( + {}, + { + get() { + throw new Error(MISSING_ELECTRON_BRIDGE_MESSAGE) + }, + }, + ) as TrpcProxyClient +} /** * Vanilla client for use outside React components (stores, utilities) */ -export const trpcClient = createTRPCProxyClient({ - links: [ipcLink({ transformer: superjson })], -}) +export const trpcClient = hasElectronTrpcBridge() + ? createTRPCProxyClient({ + links: [createElectronIpcLink()], + }) + : createUnavailableTrpcClient() diff --git a/src/shared/codex-runtime-notices.test.ts b/src/shared/codex-runtime-notices.test.ts new file mode 100644 index 000000000..e51070fa0 --- /dev/null +++ b/src/shared/codex-runtime-notices.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" + +import { + isCodexRuntimeNoticeText, + stripCodexRuntimeNoticeText, +} from "./codex-runtime-notices" + +const reconnectNotice = + "Reconnecting... 2/5 (stream disconnected before completion: failed to send websocket request: IO error: Broken pipe (os error 32))" + +describe("codex runtime notice hygiene", () => { + test("recognizes runtime notices", () => { + expect(isCodexRuntimeNoticeText(reconnectNotice)).toBe(true) + expect( + isCodexRuntimeNoticeText( + "Under-development features enabled: chronicle. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in /Users/moss/.codex/config.toml.", + ), + ).toBe(true) + expect( + isCodexRuntimeNoticeText( + "Exceeded skills context budget of 2%. All skill descriptions were removed and 107 additional skills were not included in the model-visible skills list.", + ), + ).toBe(true) + expect(isCodexRuntimeNoticeText("正常回答")).toBe(false) + }) + + test("strips a whole runtime notice", () => { + expect(stripCodexRuntimeNoticeText(reconnectNotice)).toEqual({ + text: "", + changed: true, + }) + }) + + test("strips runtime notice lines from mixed assistant text", () => { + expect( + stripCodexRuntimeNoticeText( + `${reconnectNotice}\n\n页面状态正在稳定流转。\n输入框会恢复。`, + ), + ).toEqual({ + text: "页面状态正在稳定流转。\n输入框会恢复。", + changed: true, + }) + }) +}) diff --git a/src/shared/codex-runtime-notices.ts b/src/shared/codex-runtime-notices.ts new file mode 100644 index 000000000..ae8c07354 --- /dev/null +++ b/src/shared/codex-runtime-notices.ts @@ -0,0 +1,58 @@ +export function normalizeCodexRuntimeComparableText(value: unknown): string { + return String(value ?? "") + .replace(/\s+/g, " ") + .trim() +} + +export function isCodexRuntimeNoticeText(value: unknown): boolean { + const text = normalizeCodexRuntimeComparableText(value) + return ( + (text.startsWith("Under-development features enabled: ") && + text.includes( + "Under-development features are incomplete and may behave unpredictably.", + ) && + text.includes("suppress_unstable_features_warning = true") && + text.includes("/.codex/config.toml")) || + (text.startsWith("Exceeded skills context budget of ") && + text.includes("All skill descriptions were removed") && + text.includes("model-visible skills list")) || + (text.startsWith("Reconnecting...") && + text.includes("stream disconnected before completion")) + ) +} + +export function stripCodexRuntimeNoticeText(value: unknown): { + text: string + changed: boolean +} { + const original = typeof value === "string" ? value : String(value ?? "") + if (!original) return { text: original, changed: false } + + const lines = original.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n") + let changed = false + const keptLines: string[] = [] + + for (const line of lines) { + if (isCodexRuntimeNoticeText(line)) { + changed = true + continue + } + keptLines.push(line) + } + + if (!changed && isCodexRuntimeNoticeText(original)) { + return { text: "", changed: true } + } + + if (!changed) return { text: original, changed: false } + + const text = keptLines + .join("\n") + .replace(/^(?:[ \t]*\n)+/, "") + .replace(/\n{3,}/g, "\n\n") + + return { + text, + changed: true, + } +} diff --git a/src/shared/codex-tool-normalizer.ts b/src/shared/codex-tool-normalizer.ts index ff6df8df6..2ccf5a8b6 100644 --- a/src/shared/codex-tool-normalizer.ts +++ b/src/shared/codex-tool-normalizer.ts @@ -11,6 +11,27 @@ const CODEX_VERB_TO_TOOL_TYPE: Record = { Write: "Write", Thought: "Thinking", Fetch: "WebFetch", + TodoWrite: "TodoWrite", + PlanWrite: "PlanWrite", + ExitPlanMode: "ExitPlanMode", + AskUserQuestion: "AskUserQuestion", + PermissionRequest: "PermissionRequest", + ApprovalRequest: "ApprovalRequest", +} + +const ACP_VERB_ALIAS_TO_TOOL_TYPE: Record = { + terminal: "Bash", + run: "Bash", + write: "Write", + patch: "Edit", + todo: "TodoWrite", +} + +const BUILTIN_MCP_TOOL_NAMES: Record = { + ListMcpResources: { server: "mcp", tool: "list_resources" }, + ListMcpResourcesTool: { server: "mcp", tool: "list_resources" }, + ReadMcpResource: { server: "mcp", tool: "read_resource" }, + ReadMcpResourceTool: { server: "mcp", tool: "read_resource" }, } type CodexToolDescriptor = { @@ -19,10 +40,254 @@ type CodexToolDescriptor = { isMcp: boolean } -type NormalizeCodexToolPartOptions = { +export type NormalizeCodexToolPartOptions = { normalizeState?: boolean } +export type CodexBlockStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "interrupted" + +export type CodexParsedCommandType = + | "read" + | "search" + | "list_files" + | "format" + | "test" + | "lint" + | "noop" + | "unknown" + | (string & {}) + +export type CodexParsedCommand = { + type: CodexParsedCommandType + isFinished: boolean + fileName?: string + skillName?: string + query?: string + path?: string +} + +export type CodexBaseConversationBlock = { + id: string + type: string + turnId?: string + status?: CodexBlockStatus + sourcePart?: unknown +} + +export type CodexExecBlock = CodexBaseConversationBlock & { + type: "exec" + command: string + cwd?: string + processId?: string | number | null + executionStatus: "running" | "completed" | "interrupted" | "failed" + parsedCmd: CodexParsedCommand + output?: { + stdout?: string + stderr?: string + combined?: string + exitCode?: number | null + } + status: CodexBlockStatus +} + +export type CodexMcpToolBlock = CodexBaseConversationBlock & { + type: "mcp-tool-call" + server: string + tool: string + callId: string + input?: unknown + result?: unknown + rawOutput?: unknown + appResourceUri?: string + status: CodexBlockStatus +} + +export type CodexPatchBlock = CodexBaseConversationBlock & { + type: "patch" + toolName: string + input?: unknown + output?: unknown + status: CodexBlockStatus +} + +export type CodexPatchSummaryStatus = + | "applied" + | "pending" + | "streaming" + | "rejected" + | "stopped" + +export type CodexPatchSummaryFile = { + path: string + added?: number + removed?: number + status?: CodexPatchSummaryStatus +} + +export type CodexPatchSummaryOptions = { + chatStatus?: string + displayPath?: (path: string) => string + excludePath?: (path: string) => boolean +} + +export type CodexGeneratedImageBlock = CodexBaseConversationBlock & { + type: "generated-image" + data?: unknown + url?: string + mimeType?: string + prompt?: string + status: CodexBlockStatus +} + +export type CodexTextOutputBlock = CodexBaseConversationBlock & { + type: "text-output" + title?: string + content: string + mimeType?: string + status: CodexBlockStatus +} + +export type CodexTodoListBlock = CodexBaseConversationBlock & { + type: "todo-list" + todos: unknown[] + previousTodos?: unknown[] + input?: unknown + output?: unknown + status: CodexBlockStatus +} + +export type CodexProposedPlanBlock = CodexBaseConversationBlock & { + type: "proposed-plan" + action?: string + plan?: unknown + input?: unknown + output?: unknown + status: CodexBlockStatus +} + +export type CodexActiveGoalBlock = CodexBaseConversationBlock & { + type: "active-goal" + title: string + prompt?: string + elapsed?: string + agentLabel?: string + changedFiles?: number + addedLines?: number + removedLines?: number + status: CodexBlockStatus +} + +export type CodexPermissionRequestBlock = CodexBaseConversationBlock & { + type: "permission-request" + input?: unknown + result?: unknown + status: CodexBlockStatus +} + +export type CodexUserInputAutoResolutionStatus = + | "scheduled" + | "snoozed" + | "resolved" + | "removed" + | "expired" + | (string & {}) + +export type CodexUserInputAutoResolutionState = { + requestId?: string + status?: CodexUserInputAutoResolutionStatus + deadlineMs?: number + durationMs?: number + remainingMs?: number + defaultResponseLabel?: string + reason?: string +} + +export type CodexUserInputBlock = CodexBaseConversationBlock & { + type: "user-input" + prompt?: string + input?: unknown + result?: unknown + autoResolution?: CodexUserInputAutoResolutionState + status: CodexBlockStatus +} + +export type CodexStatusBlock = CodexBaseConversationBlock & { + type: "status" + level: "info" | "warning" | "error" + title?: string + message?: string + data?: unknown + status: CodexBlockStatus +} + +export type CodexDynamicToolBlock = CodexBaseConversationBlock & { + type: "dynamic-tool-call" + toolName: string + input?: unknown + output?: unknown + status: CodexBlockStatus +} + +export type CodexConversationBlock = + | CodexExecBlock + | CodexMcpToolBlock + | CodexPatchBlock + | CodexGeneratedImageBlock + | CodexTextOutputBlock + | CodexTodoListBlock + | CodexProposedPlanBlock + | CodexActiveGoalBlock + | CodexPermissionRequestBlock + | CodexUserInputBlock + | CodexStatusBlock + | CodexDynamicToolBlock + +export type CodexOutputArtifactKind = + | "image" + | "file" + | "text" + | "resource" + | "website" + +export type CodexOutputArtifact = { + id: string + kind: CodexOutputArtifactKind + label: string + sourceBlockId: string + turnId?: string + status: CodexBlockStatus + path?: string + url?: string + mimeType?: string + prompt?: string + content?: string +} + +export function hasPrimaryCodexOutputArtifact( + artifacts: readonly CodexOutputArtifact[], +): boolean { + return artifacts.some((artifact) => + artifact.kind === "image" || + artifact.kind === "text" || + artifact.kind === "resource" || + artifact.kind === "website" + ) +} + +export type NormalizeCodexConversationBlockOptions = + NormalizeCodexToolPartOptions & { + chatStatus?: string + fallbackId?: string + messageRole?: string + partIndex?: number + turnId?: string + } + function isRecord(value: unknown): value is AnyRecord { return typeof value === "object" && value !== null } @@ -138,10 +403,23 @@ function parseCodexToolDescriptor(rawToolName: string): CodexToolDescriptor | nu } } - const spaceIndex = normalizedName.indexOf(" ") - const verb = spaceIndex === -1 ? normalizedName : normalizedName.slice(0, spaceIndex) - const detail = spaceIndex === -1 ? "" : normalizedName.slice(spaceIndex + 1).trim() - const canonicalToolName = CODEX_VERB_TO_TOOL_TYPE[verb] + const colonIndex = normalizedName.indexOf(":") + const hasToolDetailSeparator = + colonIndex > 0 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(normalizedName) + const label = hasToolDetailSeparator + ? normalizedName.slice(0, colonIndex).trim() + : normalizedName + const detail = hasToolDetailSeparator + ? normalizedName.slice(colonIndex + 1).trim() + : (() => { + const spaceIndex = normalizedName.indexOf(" ") + return spaceIndex === -1 ? "" : normalizedName.slice(spaceIndex + 1).trim() + })() + const spaceIndex = label.indexOf(" ") + const verb = spaceIndex === -1 ? label : label.slice(0, spaceIndex) + const canonicalToolName = + CODEX_VERB_TO_TOOL_TYPE[verb] ?? + ACP_VERB_ALIAS_TO_TOOL_TYPE[verb.toLowerCase()] if (!canonicalToolName) return null return { @@ -244,6 +522,12 @@ function normalizeCodexToolInput( } } + if (descriptor.canonicalToolName === "Write" || descriptor.canonicalToolName === "Edit") { + if (!normalizedInput.file_path && descriptor.detail) { + normalizedInput.file_path = descriptor.detail + } + } + if (descriptor.canonicalToolName === "Bash") { if (Array.isArray(normalizedInput.command)) { normalizedInput.command = @@ -297,76 +581,2140 @@ function getPartToolName(part: AnyRecord): string | null { return null } -export function normalizeCodexToolPart( - part: unknown, - options?: NormalizeCodexToolPartOptions, -): unknown { - if (!isRecord(part)) return part - if (typeof part.type !== "string" || !part.type.startsWith("tool-")) return part +function getNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} - const rawToolName = getPartToolName(part) - const descriptor = rawToolName ? parseCodexToolDescriptor(rawToolName) : null - const shouldNormalizeState = - options?.normalizeState === true && - (part.state === "input-available" || part.state === "output-available") +const RUNTIME_STATUS_BLOCK_TITLES: Record = { + "realtime-state": "Realtime voice", + "dictation-state": "Global dictation", + "queued-follow-up": "Queued follow-up", + "rate-limit-status": "Rate limit", + "usage-status": "Usage", + "project-event": "Project", + "library-artifact": "Library artifact", + "pull-request-status": "Pull request", + "diagnostic-snapshot": "Diagnostics", +} - const hasCodexArgsWrapper = - isRecord(part.input) && - (isRecord(part.input.args) || typeof part.input.toolName === "string") +function normalizeExitCode(value: unknown): number | null | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) return undefined + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : undefined + } + if (value === null) return null + return undefined +} - if (!descriptor && !hasCodexArgsWrapper && !shouldNormalizeState) { - return part +function getNestedRecord(source: AnyRecord, key: string): AnyRecord | undefined { + return isRecord(source[key]) ? source[key] : undefined +} + +function getFiniteNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const parsed = Number(value.trim()) + return Number.isFinite(parsed) ? parsed : undefined } + return undefined +} - const normalizedType = descriptor ? `tool-${descriptor.canonicalToolName}` : part.type - const fallbackDescriptor: CodexToolDescriptor = { - canonicalToolName: normalizedType.startsWith("tool-") - ? normalizedType.slice("tool-".length) - : normalizedType, - detail: "", - isMcp: normalizedType.startsWith("tool-mcp__"), +function getFirstFiniteNumber( + source: AnyRecord | undefined, + keys: string[], +): number | undefined { + if (!source) return undefined + for (const key of keys) { + const value = getFiniteNumber(source[key]) + if (value !== undefined) return value } - const normalizedInput = - descriptor - ? normalizeCodexToolInput(part.input, descriptor) - : hasCodexArgsWrapper - ? normalizeCodexToolInput(part.input, fallbackDescriptor) - : part.input - const normalizedOutput = part.output !== undefined ? part.output : part.result - const normalizedResult = part.result !== undefined ? part.result : part.output - const outputPayload = - normalizedOutput !== undefined ? normalizedOutput : normalizedResult - const outputEnrichedInput = - fallbackDescriptor.canonicalToolName === "Read" - ? normalizeReadInputFromPayload(normalizedInput, outputPayload) - : normalizedInput - const finalInput = - outputEnrichedInput !== part.input && isShallowEqual(outputEnrichedInput, part.input) - ? part.input - : outputEnrichedInput + return undefined +} - const normalizedState = shouldNormalizeState - ? toCanonicalToolState(part.state) - : part.state +function getFirstNonEmptyStringFromRecord( + source: AnyRecord | undefined, + keys: string[], +): string | undefined { + if (!source) return undefined + for (const key of keys) { + const value = getNonEmptyString(source[key]) + if (value) return value + } + return undefined +} - const typeChanged = normalizedType !== part.type - const inputChanged = finalInput !== part.input - const stateChanged = normalizedState !== part.state - const outputChanged = normalizedOutput !== part.output - const resultChanged = normalizedResult !== part.result +function getUserInputAutoResolutionCandidate( + source: AnyRecord | undefined, +): AnyRecord | undefined { + if (!source) return undefined + return ( + getNestedRecord(source, "autoResolution") ?? + getNestedRecord(source, "auto_resolution") ?? + getNestedRecord(source, "resolutionState") ?? + getNestedRecord(source, "resolution_state") + ) +} - if (!typeChanged && !inputChanged && !stateChanged && !outputChanged && !resultChanged) { - return part +function normalizeUserInputAutoResolutionState( + part: AnyRecord, + payloadRecord: AnyRecord, +): CodexUserInputAutoResolutionState | undefined { + const inputRecord = isRecord(part.input) ? part.input : undefined + const payloadInputRecord = isRecord(payloadRecord.input) + ? payloadRecord.input + : undefined + const stateRecord = + getUserInputAutoResolutionCandidate(payloadRecord) ?? + getUserInputAutoResolutionCandidate(payloadInputRecord) ?? + getUserInputAutoResolutionCandidate(part) ?? + getUserInputAutoResolutionCandidate(inputRecord) + + if (!stateRecord) return undefined + + const status = + getFirstNonEmptyStringFromRecord(stateRecord, ["status", "state"]) ?? + getFirstNonEmptyStringFromRecord(payloadRecord, [ + "autoResolutionStatus", + "auto_resolution_status", + ]) + const deadlineMs = getFirstFiniteNumber(stateRecord, [ + "deadlineMs", + "deadline_ms", + "expiresAtMs", + "expires_at_ms", + "resolveAtMs", + "resolve_at_ms", + ]) + const durationMs = getFirstFiniteNumber(stateRecord, [ + "durationMs", + "duration_ms", + "timeoutMs", + "timeout_ms", + ]) + const remainingMs = getFirstFiniteNumber(stateRecord, [ + "remainingMs", + "remaining_ms", + ]) + + if (!status && deadlineMs === undefined && durationMs === undefined && remainingMs === undefined) { + return undefined } - const normalizedPart: AnyRecord = { ...part } - if (typeChanged) normalizedPart.type = normalizedType - if (inputChanged) normalizedPart.input = finalInput - if (stateChanged) normalizedPart.state = normalizedState - if (normalizedOutput !== undefined) normalizedPart.output = normalizedOutput - if (normalizedResult !== undefined) normalizedPart.result = normalizedResult + const normalized: CodexUserInputAutoResolutionState = {} + const requestId = + getFirstNonEmptyStringFromRecord(stateRecord, ["requestId", "request_id"]) ?? + getFirstNonEmptyStringFromRecord(payloadRecord, ["requestId", "request_id", "id"]) ?? + getFirstNonEmptyStringFromRecord(part, ["requestId", "request_id", "id"]) + if (requestId) normalized.requestId = requestId + if (status) normalized.status = status as CodexUserInputAutoResolutionStatus + if (deadlineMs !== undefined) normalized.deadlineMs = deadlineMs + if (durationMs !== undefined) normalized.durationMs = durationMs + if (remainingMs !== undefined) normalized.remainingMs = remainingMs - return normalizedPart + const defaultResponseLabel = getFirstNonEmptyStringFromRecord(stateRecord, [ + "defaultResponseLabel", + "default_response_label", + "defaultLabel", + "default_label", + "label", + ]) + if (defaultResponseLabel) normalized.defaultResponseLabel = defaultResponseLabel + + const reason = getFirstNonEmptyStringFromRecord(stateRecord, [ + "reason", + "message", + ]) + if (reason) normalized.reason = reason + + return normalized +} + +function parseJsonLikeOutput(value: unknown): unknown | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + if (!trimmed) return undefined + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined + try { + return JSON.parse(trimmed) + } catch { + return undefined + } +} + +function getOutputPayload(part: AnyRecord): unknown { + if (part.output !== undefined) return part.output + if (part.result !== undefined) return part.result + const parsedErrorText = parseJsonLikeOutput(part.errorText) + if (parsedErrorText !== undefined) return parsedErrorText + const errorText = getNonEmptyString(part.errorText) + if (errorText) { + return { + stderr: errorText, + combined: errorText, + success: false, + status: "failed", + } + } + return undefined +} + +function getExitCodeFromPayload(payload: unknown): number | null | undefined { + if (!isRecord(payload)) return undefined + return ( + normalizeExitCode(payload.exitCode) ?? + normalizeExitCode(payload.exit_code) ?? + normalizeExitCode(payload.code) + ) +} + +function getTextFromContentPayload(payload: unknown): string | undefined { + if (typeof payload === "string") { + const trimmed = payload.trim() + return trimmed.length > 0 ? payload : undefined + } + + if (Array.isArray(payload)) { + const parts = payload + .map((entry) => getTextFromContentPayload(entry)) + .filter((entry): entry is string => Boolean(entry?.trim())) + return parts.length > 0 ? parts.join("\n") : undefined + } + + if (!isRecord(payload)) return undefined + + const direct = + getNonEmptyString(payload.text) ?? + getNonEmptyString(payload.output) ?? + getNonEmptyString(payload.stdout) ?? + getNonEmptyString(payload.stderr) ?? + getNonEmptyString(payload.result) ?? + getNonEmptyString(payload.value) + if (direct) return direct + + if (payload.content !== undefined) { + const nestedContent = getTextFromContentPayload(payload.content) + if (nestedContent) return nestedContent + } + if (payload.data !== undefined) { + const nestedData = getTextFromContentPayload(payload.data) + if (nestedData) return nestedData + } + if (payload.result !== undefined) { + const nestedResult = getTextFromContentPayload(payload.result) + if (nestedResult) return nestedResult + } + + return undefined +} + +function getExitCodeFromText(text: string | undefined): number | undefined { + if (!text) return undefined + const match = + text.match(/(?:exit[_\s-]?code|code)\s*\*{0,2}\s*[:=]\s*\*{0,2}\s*(-?\d+)/i) ?? + text.match(/退出码\s*(-?\d+)/) + if (!match) return undefined + const parsed = Number(match[1]) + return Number.isFinite(parsed) ? parsed : undefined +} + +function normalizeParsedTodoStatus(marker: string | undefined, text: string): string { + if (marker === "✅" || /^\s*(?:done|completed|finished)\b/i.test(text)) { + return "completed" + } + if (marker === "🔄" || /^\s*(?:active|started|in[_\s-]?progress)\b/i.test(text)) { + return "in_progress" + } + return "pending" +} + +function stripTodoStatusPrefix(text: string): string { + return text + .replace(/^\s*(?:done|completed|finished|active|started|in[_\s-]?progress|pending)\s*[::-]\s*/i, "") + .replace(/\*\*/g, "") + .trim() +} + +function getTodosFromText(text: string | undefined): AnyRecord[] { + if (!text) return [] + const todos: AnyRecord[] = [] + for (const line of text.split("\n")) { + const match = line.match(/^\s*(?:-\s*|\*(?!\*)\s*)(?:(✅|🔄|⏳)\s*)?(.*?)\s*$/u) + if (!match) continue + const rawContent = stripTodoStatusPrefix(match[2] ?? "") + if (!rawContent || /^progress\s*:/i.test(rawContent)) continue + todos.push({ + content: rawContent, + status: normalizeParsedTodoStatus(match[1], rawContent), + }) + } + return todos +} + +function normalizeExecutionStatus( + value: unknown, +): "running" | "completed" | "interrupted" | "failed" | undefined { + const status = getNonEmptyString(value)?.toLowerCase() + if (!status) return undefined + if (status === "running" || status === "in_progress" || status === "started") { + return "running" + } + if (status === "completed" || status === "complete" || status === "success") { + return "completed" + } + if ( + status === "interrupted" || + status === "stopped" || + status === "cancelled" || + status === "canceled" + ) { + return "interrupted" + } + if (status === "failed" || status === "error" || status === "errored") { + return "failed" + } + return undefined +} + +function getExplicitExecutionStatus( + part: AnyRecord, +): "running" | "completed" | "interrupted" | "failed" | undefined { + const output = getOutputPayload(part) + const input = getNestedRecord(part, "input") + return ( + normalizeExecutionStatus(part.executionStatus) ?? + normalizeExecutionStatus(part.status) ?? + (isRecord(output) ? normalizeExecutionStatus(output.executionStatus) : undefined) ?? + (isRecord(output) ? normalizeExecutionStatus(output.status) : undefined) ?? + (input ? normalizeExecutionStatus(input.executionStatus) : undefined) ?? + (input ? normalizeExecutionStatus(input.execution_status) : undefined) + ) +} + +function isActiveChatStatus(chatStatus: string | undefined): boolean { + return chatStatus === "streaming" || chatStatus === "submitted" +} + +function getCodexBlockStatus( + part: AnyRecord, + options: NormalizeCodexConversationBlockOptions | undefined, +): CodexBlockStatus { + const explicitExecutionStatus = getExplicitExecutionStatus(part) + if (explicitExecutionStatus === "interrupted") return "interrupted" + + const output = getOutputPayload(part) + const state = getNonEmptyString(part.state) + const exitCode = getExitCodeFromPayload(output) + const hasOutput = output !== undefined + const outputRecord = isRecord(output) ? output : undefined + + if ( + explicitExecutionStatus === "failed" || + state === "output-error" || + outputRecord?.success === false || + outputRecord?.isError === true || + outputRecord?.error !== undefined + ) { + return "failed" + } + + if (typeof exitCode === "number" && exitCode !== 0) { + return "failed" + } + + if ( + explicitExecutionStatus === "completed" || + state === "result" || + state === "output-available" || + hasOutput + ) { + return "completed" + } + + if (explicitExecutionStatus === "running") return "running" + + if (state === "input-streaming" || state === "input-available" || state === "call") { + if (isActiveChatStatus(options?.chatStatus) || !options?.chatStatus) { + return "running" + } + return "interrupted" + } + + return "queued" +} + +function getBlockId( + part: AnyRecord, + options: NormalizeCodexConversationBlockOptions | undefined, +): string { + const input = getNestedRecord(part, "input") + const rawId = + getNonEmptyString(part.id) ?? + getNonEmptyString(part.toolCallId) ?? + getNonEmptyString(part.tool_call_id) ?? + (input ? getNonEmptyString(input.call_id) : undefined) ?? + (input ? getNonEmptyString(input.callId) : undefined) ?? + options?.fallbackId + + if (rawId) return rawId + + const index = options?.partIndex ?? 0 + return options?.turnId ? `${options.turnId}:tool:${index}` : `tool:${index}` +} + +function getPreservedToolCallId(part: AnyRecord): string | undefined { + const input = getNestedRecord(part, "input") + return ( + getNonEmptyString(part.toolCallId) ?? + getNonEmptyString(part.tool_call_id) ?? + (input ? getNonEmptyString(input.toolCallId) : undefined) ?? + (input ? getNonEmptyString(input.tool_call_id) : undefined) + ) +} + +function hasPatchTarget(input: unknown, output: unknown): boolean { + const inputRecord = isRecord(input) ? input : undefined + const outputRecord = isRecord(output) ? output : undefined + return Boolean( + (inputRecord && + (getNonEmptyString(inputRecord.file_path) ?? + getNonEmptyString(inputRecord.filePath) ?? + getNonEmptyString(inputRecord.path))) || + (outputRecord && + (getNonEmptyString(outputRecord.file_path) ?? + getNonEmptyString(outputRecord.filePath) ?? + getNonEmptyString(outputRecord.path))), + ) +} + +const CODEX_PATCH_SUMMARY_TOOL_TYPES = new Set([ + "tool-Edit", + "tool-Write", + "tool-MultiEdit", +]) + +function getNormalizedPatchSummaryPart(part: unknown): AnyRecord | undefined { + const normalized = normalizeCodexToolPart(part, { normalizeState: true }) + return isRecord(normalized) ? normalized : undefined +} + +export function isCodexPatchSummaryToolPart(part: unknown): part is AnyRecord { + const normalized = getNormalizedPatchSummaryPart(part) + return Boolean( + normalized?.type && CODEX_PATCH_SUMMARY_TOOL_TYPES.has(normalized.type), + ) +} + +export function getCodexPatchSummaryPath(part: unknown): string | undefined { + const normalized = getNormalizedPatchSummaryPart(part) + if (!normalized) return undefined + + const input = isRecord(normalized.input) ? normalized.input : undefined + const outputPayload = getOutputPayload(normalized) + const output = isRecord(outputPayload) ? outputPayload : undefined + + return ( + (input ? getNonEmptyString(input.file_path) : undefined) ?? + (input ? getNonEmptyString(input.filePath) : undefined) ?? + (input ? getNonEmptyString(input.path) : undefined) ?? + (output ? getNonEmptyString(output.file_path) : undefined) ?? + (output ? getNonEmptyString(output.filePath) : undefined) ?? + (output ? getNonEmptyString(output.path) : undefined) + ) +} + +function getPatchSummaryNumber( + source: AnyRecord | undefined, + keys: string[], +): number | undefined { + if (!source) return undefined + for (const key of keys) { + const value = source[key] + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, value) + } + } + return undefined +} + +function countPatchSummaryTextLines(value: unknown): number { + if (typeof value !== "string" || value.length === 0) return 0 + return value.split("\n").length +} + +function getUnifiedPatchStats(value: unknown): { + added: number + removed: number +} { + if (typeof value !== "string") return { added: 0, removed: 0 } + let added = 0 + let removed = 0 + for (const line of value.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) added += 1 + if (line.startsWith("-") && !line.startsWith("---")) removed += 1 + } + return { added, removed } +} + +function getStructuredPatchStats(value: unknown): { + added: number + removed: number +} { + if (typeof value === "string") return getUnifiedPatchStats(value) + if (Array.isArray(value)) { + return value.reduce( + (total, entry) => { + const stats = getStructuredPatchStats(entry) + return { + added: total.added + stats.added, + removed: total.removed + stats.removed, + } + }, + { added: 0, removed: 0 }, + ) + } + if (!isRecord(value)) return { added: 0, removed: 0 } + + const directLines = Array.isArray(value.lines) + ? getStructuredPatchStats(value.lines) + : { added: 0, removed: 0 } + const hunks = Array.isArray(value.hunks) + ? getStructuredPatchStats(value.hunks) + : { added: 0, removed: 0 } + const patchText = + getNonEmptyString(value.patch) ?? + getNonEmptyString(value.diff) ?? + getNonEmptyString(value.udiff) ?? + getNonEmptyString(value.text) + const patchTextStats = getUnifiedPatchStats(patchText) + + return { + added: directLines.added + hunks.added + patchTextStats.added, + removed: directLines.removed + hunks.removed + patchTextStats.removed, + } +} + +export function getCodexPatchSummaryStats(part: unknown): { + added: number + removed: number +} { + const normalized = getNormalizedPatchSummaryPart(part) + if (!normalized) return { added: 0, removed: 0 } + + const input = isRecord(normalized.input) ? normalized.input : undefined + const outputPayload = getOutputPayload(normalized) + const output = isRecord(outputPayload) ? outputPayload : undefined + + const explicitAdded = getPatchSummaryNumber(output, [ + "addedLines", + "added_lines", + "added", + "insertions", + ]) + const explicitRemoved = getPatchSummaryNumber(output, [ + "removedLines", + "removed_lines", + "removed", + "deletions", + ]) + if (explicitAdded !== undefined || explicitRemoved !== undefined) { + return { + added: explicitAdded ?? 0, + removed: explicitRemoved ?? 0, + } + } + + const structuredStats = getStructuredPatchStats( + output?.structuredPatch ?? + output?.structured_patch ?? + output?.patch ?? + output?.diff ?? + input?.structuredPatch ?? + input?.structured_patch ?? + input?.patch ?? + input?.diff, + ) + if (structuredStats.added > 0 || structuredStats.removed > 0) { + return structuredStats + } + + if (normalized.type === "tool-Write") { + return { + added: countPatchSummaryTextLines(input?.content ?? output?.content), + removed: 0, + } + } + + if (Array.isArray(input?.edits)) { + return input.edits.reduce( + (total: { added: number; removed: number }, edit: unknown) => { + if (!isRecord(edit)) return total + return { + added: total.added + countPatchSummaryTextLines(edit.new_string), + removed: total.removed + countPatchSummaryTextLines(edit.old_string), + } + }, + { added: 0, removed: 0 }, + ) + } + + return { + added: countPatchSummaryTextLines(input?.new_string), + removed: countPatchSummaryTextLines(input?.old_string), + } +} + +function getExplicitPatchSummaryStatus(part: AnyRecord): string | undefined { + const input = isRecord(part.input) ? part.input : undefined + const outputPayload = getOutputPayload(part) + const output = isRecord(outputPayload) ? outputPayload : undefined + return ( + getNonEmptyString(part.status) ?? + (input ? getNonEmptyString(input.status) : undefined) ?? + (output ? getNonEmptyString(output.status) : undefined) + )?.toLowerCase() +} + +function hasPatchSummaryApplicationEvidence(part: AnyRecord): boolean { + if (part.state === "output-available" || part.state === "result") { + return true + } + + const outputPayload = getOutputPayload(part) + if (outputPayload !== undefined && outputPayload !== null) return true + + const explicitStatus = getExplicitPatchSummaryStatus(part) + return ( + explicitStatus === "applied" || + explicitStatus === "completed" || + explicitStatus === "done" || + explicitStatus === "success" || + explicitStatus === "succeeded" + ) +} + +function isTargetOnlyPatchSummaryPlaceholder(part: AnyRecord): boolean { + const input = isRecord(part.input) ? part.input : undefined + const outputPayload = getOutputPayload(part) + if (!hasPatchTarget(input, outputPayload)) return false + + const state = getNonEmptyString(part.state) + if (state !== undefined && state !== "input-available" && state !== "call") { + return false + } + + if (outputPayload !== undefined && outputPayload !== null) return false + if (getExplicitPatchSummaryStatus(part)) return false + + return true +} + +export function getCodexPatchPartSummaryStatus( + part: unknown, + chatStatus?: string, +): CodexPatchSummaryStatus { + const normalized = getNormalizedPatchSummaryPart(part) + if (!normalized) return "applied" + + const explicitStatus = getExplicitPatchSummaryStatus(normalized) + if ( + explicitStatus === "rejected" || + explicitStatus === "denied" || + explicitStatus === "declined" + ) { + return "rejected" + } + if ( + explicitStatus === "stopped" || + explicitStatus === "interrupted" || + explicitStatus === "cancelled" || + explicitStatus === "canceled" + ) { + return "stopped" + } + if (hasPatchSummaryApplicationEvidence(normalized)) return "applied" + + const blockStatus = getCodexBlockStatus(normalized, { chatStatus }) + if (blockStatus === "running" || blockStatus === "queued") { + return normalized.state === "input-streaming" ? "streaming" : "pending" + } + if (blockStatus === "failed") return "rejected" + if (getCodexPatchSummaryPath(normalized)) return "applied" + if (blockStatus === "interrupted") return "stopped" + return "applied" +} + +export function shouldShowCodexPatchSummaryPart( + part: unknown, + chatStatus: string | undefined, + stats: { added: number; removed: number }, +): boolean { + const normalized = getNormalizedPatchSummaryPart(part) + if (!normalized) return false + + const status = getCodexPatchPartSummaryStatus(normalized, chatStatus) + + if ( + status === "pending" || + status === "streaming" || + status === "rejected" || + status === "stopped" + ) { + return true + } + + if (stats.added > 0 || stats.removed > 0) return true + + return isTargetOnlyPatchSummaryPlaceholder(normalized) +} + +export function getCodexPatchSummaryStatusFromParts( + parts: readonly unknown[], + options?: Pick, +): CodexPatchSummaryStatus | undefined { + return getCodexPatchSummaryStatusFromStatuses(parts + .filter(isCodexPatchSummaryToolPart) + .map((part) => getCodexPatchPartSummaryStatus(part, options?.chatStatus))) +} + +function getCodexPatchSummaryStatusFromStatuses( + statuses: readonly (CodexPatchSummaryStatus | undefined)[], +): CodexPatchSummaryStatus | undefined { + if (statuses.length === 0) return undefined + if (statuses.includes("streaming")) return "streaming" + if (statuses.includes("pending")) return "pending" + if (statuses.includes("rejected")) return "rejected" + if (statuses.includes("stopped")) return "stopped" + return "applied" +} + +export function getCodexPatchSummaryFilesFromParts( + parts: readonly unknown[], + options?: CodexPatchSummaryOptions, +): CodexPatchSummaryFile[] { + const files = new Map() + + for (const part of parts) { + if (!isCodexPatchSummaryToolPart(part)) continue + + const filePath = getCodexPatchSummaryPath(part) + if (!filePath || options?.excludePath?.(filePath)) continue + + const stats = getCodexPatchSummaryStats(part) + if (!shouldShowCodexPatchSummaryPart(part, options?.chatStatus, stats)) { + continue + } + + const displayPath = options?.displayPath?.(filePath) ?? filePath + const partStatus = getCodexPatchPartSummaryStatus(part, options?.chatStatus) + const current = files.get(displayPath) + + if (current) { + current.added = (current.added ?? 0) + stats.added + current.removed = (current.removed ?? 0) + stats.removed + current.status = getCodexPatchSummaryStatusFromStatuses([ + current.status ?? "applied", + partStatus, + ]) + if (current.status === "applied") current.status = undefined + continue + } + + files.set(displayPath, { + path: displayPath, + added: stats.added, + removed: stats.removed, + status: partStatus === "applied" ? undefined : partStatus, + }) + } + + return [...files.values()] +} + +function getResolvedToolBlockStatus( + toolName: string, + part: AnyRecord, + input: unknown, + output: unknown, + status: CodexBlockStatus, + options: NormalizeCodexConversationBlockOptions | undefined, +): CodexBlockStatus { + if (status !== "interrupted") return status + if (isActiveChatStatus(options?.chatStatus)) return status + + const isPatchLikeTool = + toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit" + if ( + isPatchLikeTool && + output === undefined && + hasPatchTarget(input, output) && + (part.state === "call" || part.state === "input-available") + ) { + return "completed" + } + + return status +} + +function getTurnId( + part: AnyRecord, + options: NormalizeCodexConversationBlockOptions | undefined, +): string | undefined { + const input = getNestedRecord(part, "input") + return ( + options?.turnId ?? + getNonEmptyString(part.turnId) ?? + getNonEmptyString(part.turn_id) ?? + (input ? getNonEmptyString(input.turn_id) : undefined) ?? + (input ? getNonEmptyString(input.turnId) : undefined) + ) +} + +function getToolNameFromNormalizedPart(part: AnyRecord): string { + const toolName = getPartToolName(part) + if (toolName) return toolName + return "unknown" +} + +function getCommandFromInput(input: unknown, fallback?: string): string { + if (typeof input === "string") { + return input.trim() || fallback || "" + } + if (!isRecord(input)) return fallback || "" + + const command = input.command + if (Array.isArray(command)) { + const lastCommand = [...command].reverse().find((entry) => typeof entry === "string") + if (typeof lastCommand === "string" && lastCommand.trim().length > 0) { + return lastCommand.trim() + } + } + + return ( + getNonEmptyString(input.command) ?? + getNonEmptyString(input.cmd) ?? + getNonEmptyString(input.shellCommand) ?? + (isRecord(input.args) ? getCommandFromInput(input.args, fallback) : fallback || "") + ) +} + +function getStringFromRecord( + source: AnyRecord | undefined, + keys: string[], +): string | undefined { + if (!source) return undefined + for (const key of keys) { + const value = source[key] + if (typeof value === "string") return value + } + return undefined +} + +function getExecOutput(part: AnyRecord): + | { + stdout?: string + stderr?: string + combined?: string + exitCode?: number | null + } + | undefined { + const payload = getOutputPayload(part) + if (payload === undefined) return undefined + + if (typeof payload === "string") { + return { + stdout: payload, + combined: payload, + exitCode: getExitCodeFromText(payload), + } + } + + if (!isRecord(payload)) { + const payloadText = getTextFromContentPayload(payload) + return payloadText + ? { + stdout: payloadText, + combined: payloadText, + exitCode: getExitCodeFromText(payloadText), + } + : undefined + } + + const stderr = getStringFromRecord(payload, ["stderr", "errorOutput"]) + const payloadText = getTextFromContentPayload(payload) + const stdout = + getStringFromRecord(payload, ["stdout", "output", "text"]) ?? + (stderr ? undefined : payloadText) + const combined = getStringFromRecord(payload, ["combined", "combinedOutput"]) + const exitCode = getExitCodeFromPayload(payload) ?? getExitCodeFromText(payloadText) + const output: { + stdout?: string + stderr?: string + combined?: string + exitCode?: number | null + } = {} + + if (stdout !== undefined) output.stdout = stdout + if (stderr !== undefined) output.stderr = stderr + if (combined !== undefined) output.combined = combined + if (exitCode !== undefined) output.exitCode = exitCode + + return Object.keys(output).length > 0 ? output : undefined +} + +function inferParsedCommandType(command: string): CodexParsedCommandType { + const firstToken = command.trim().split(/\s+/)[0] || "" + if (!firstToken) return "unknown" + if (firstToken === "rg" || firstToken === "grep") return "search" + if (firstToken === "ls" || firstToken === "find") return "list_files" + if (firstToken === "cat" || firstToken === "sed" || firstToken === "nl") return "read" + if (firstToken === "prettier" || firstToken === "eslint" || firstToken === "biome") { + return "format" + } + if (firstToken === "bun" || firstToken === "npm" || firstToken === "pnpm") { + return command.includes(" test") ? "test" : "unknown" + } + return "unknown" +} + +function getParsedCommand( + input: unknown, + command: string, + status: CodexBlockStatus, +): CodexParsedCommand { + const entries = getParsedCmdEntriesFromPayload(input) + const firstEntry = entries[0] + const explicitType = + firstEntry && getNonEmptyString(firstEntry.type) + ? getNonEmptyString(firstEntry.type) + : undefined + + return { + type: explicitType || inferParsedCommandType(command), + isFinished: status !== "queued" && status !== "running", + fileName: + firstEntry && + (getNonEmptyString(firstEntry.fileName) ?? + getNonEmptyString(firstEntry.file_name) ?? + getNonEmptyString(firstEntry.name)), + skillName: + firstEntry && + (getNonEmptyString(firstEntry.skillName) ?? + getNonEmptyString(firstEntry.skill_name)), + query: + firstEntry && + (getNonEmptyString(firstEntry.query) ?? getNonEmptyString(firstEntry.pattern)), + path: + firstEntry && + (getNonEmptyString(firstEntry.path) ?? + getNonEmptyString(firstEntry.file_path) ?? + getNonEmptyString(firstEntry.target_directory)), + } +} + +function getProcessId(input: unknown): string | number | null | undefined { + if (!isRecord(input)) return undefined + const processId = input.process_id ?? input.processId + if (typeof processId === "string" || typeof processId === "number" || processId === null) { + return processId + } + return undefined +} + +function getExecExecutionStatus( + status: CodexBlockStatus, +): "running" | "completed" | "interrupted" | "failed" { + if (status === "completed") return "completed" + if (status === "failed") return "failed" + if (status === "interrupted") return "interrupted" + return "running" +} + +function parseMcpToolName(toolName: string, input: unknown): { server: string; tool: string } { + const rawToolName = toolName.startsWith("tool-") ? toolName.slice("tool-".length) : toolName + const builtinMcpTool = BUILTIN_MCP_TOOL_NAMES[rawToolName] + if (builtinMcpTool) return builtinMcpTool + + if (rawToolName.startsWith("mcp__")) { + const [server = "", ...toolParts] = rawToolName.slice("mcp__".length).split("__") + return { + server, + tool: toolParts.join("__"), + } + } + + if (isRecord(input)) { + return { + server: getNonEmptyString(input.server) || "", + tool: getNonEmptyString(input.tool) || rawToolName, + } + } + + return { server: "", tool: rawToolName } +} + +function getMcpCallId( + part: AnyRecord, + blockId: string, +): string { + const input = getNestedRecord(part, "input") + return ( + getNonEmptyString(part.toolCallId) ?? + getNonEmptyString(part.tool_call_id) ?? + (input ? getNonEmptyString(input.call_id) : undefined) ?? + (input ? getNonEmptyString(input.callId) : undefined) ?? + blockId + ) +} + +function getAppResourceUri(payload: unknown): string | undefined { + if (!isRecord(payload)) return undefined + const meta = isRecord(payload._meta) ? payload._meta : undefined + return ( + getNonEmptyString(payload.appResourceUri) ?? + getNonEmptyString(payload.app_resource_uri) ?? + getNonEmptyString(payload.resourceUri) ?? + getNonEmptyString(payload.resource_uri) ?? + (meta ? getNonEmptyString(meta["openai/outputTemplate"]) : undefined) ?? + (meta ? getNonEmptyString(meta.appResourceUri) : undefined) + ) +} + +function getArrayFromRecord( + source: AnyRecord | undefined, + keys: string[], +): unknown[] | undefined { + if (!source) return undefined + for (const key of keys) { + const value = source[key] + if (Array.isArray(value)) return value + } + return undefined +} + +function getImageData(part: AnyRecord): unknown { + if (part.data !== undefined) return part.data + if (part.image !== undefined) return part.image + if (part.output !== undefined) return part.output + if (part.result !== undefined) return part.result + return undefined +} + +function getImageUrl(data: unknown, part: AnyRecord): string | undefined { + return ( + getNonEmptyString(part.url) ?? + getNonEmptyString(part.src) ?? + (isRecord(data) + ? getNonEmptyString(data.url) ?? + getNonEmptyString(data.src) ?? + getNonEmptyString(data.imageUrl) ?? + getNonEmptyString(data.image_url) + : undefined) + ) +} + +function getImageMimeType(data: unknown, part: AnyRecord): string | undefined { + return ( + getNonEmptyString(part.mimeType) ?? + getNonEmptyString(part.mime_type) ?? + (isRecord(data) + ? getNonEmptyString(data.mimeType) ?? getNonEmptyString(data.mime_type) + : undefined) + ) +} + +function getImagePrompt(data: unknown, part: AnyRecord): string | undefined { + return ( + getNonEmptyString(part.prompt) ?? + (isRecord(data) ? getNonEmptyString(data.prompt) : undefined) + ) +} + +function getStatusMessage(part: AnyRecord): string | undefined { + return ( + getNonEmptyString(part.message) ?? + getNonEmptyString(part.text) ?? + getNonEmptyString(part.errorText) ?? + getNonEmptyString(part.error) ?? + (isRecord(part.data) ? getNonEmptyString(part.data.message) : undefined) ?? + (isRecord(part.data) ? getNonEmptyString(part.data.error) : undefined) + ) +} + +function joinRuntimeStatusSegments( + segments: Array, +): string | undefined { + const filtered = segments.filter((segment): segment is string => + Boolean(segment && segment.trim()), + ) + return filtered.length > 0 ? filtered.join(" ") : undefined +} + +function getRuntimeStatusMessage(part: AnyRecord, payload: AnyRecord): string | undefined { + const direct = + getStatusMessage(part) ?? + getNonEmptyString(payload.summary) ?? + getNonEmptyString(payload.title) ?? + getNonEmptyString(payload.description) + if (direct) return direct + + const mode = + getNonEmptyString(payload.mode) ?? + getNonEmptyString(part.mode) + const queueState = + getNonEmptyString(payload.queueState) ?? + getNonEmptyString(payload.queue_state) + const windowLabel = + getNonEmptyString(payload.window) ?? + getNonEmptyString(payload.period) + const remaining = + typeof payload.remaining === "number" ? `${payload.remaining} remaining` : undefined + const limit = + typeof payload.limit === "number" ? `of ${payload.limit}` : undefined + const projectName = + getNonEmptyString(payload.projectName) ?? + getNonEmptyString(payload.project_name) + const action = getNonEmptyString(payload.action) + const artifactKind = + getNonEmptyString(payload.artifactKind) ?? + getNonEmptyString(payload.artifact_kind) + const artifactTarget = + getNonEmptyString(payload.path) ?? + getNonEmptyString(payload.url) + const prState = + getNonEmptyString(payload.reviewState) ?? + getNonEmptyString(payload.review_state) ?? + getNonEmptyString(payload.checksState) ?? + getNonEmptyString(payload.checks_state) + const snapshotKind = + getNonEmptyString(payload.snapshotKind) ?? + getNonEmptyString(payload.snapshot_kind) + + return joinRuntimeStatusSegments([ + mode, + queueState, + windowLabel, + remaining, + limit, + projectName, + action, + artifactKind, + artifactTarget, + prState, + snapshotKind, + ]) +} + +function getTextOutputContent(part: AnyRecord, payload: AnyRecord): string | undefined { + const direct = + getNonEmptyString(part.content) ?? + getNonEmptyString(part.text) ?? + getNonEmptyString(part.output) ?? + getNonEmptyString(part.result) ?? + getNonEmptyString(payload.content) ?? + getNonEmptyString(payload.text) ?? + getNonEmptyString(payload.output) ?? + getNonEmptyString(payload.result) + if (direct) return direct + + const data = part.data ?? payload.data + if (typeof data === "string" && data.trim()) return data + if (data !== undefined) { + try { + return JSON.stringify(data, null, 2) + } catch { + return String(data) + } + } + + return undefined +} + +function getTextOutputTitle(part: AnyRecord, payload: AnyRecord): string | undefined { + return ( + getNonEmptyString(part.title) ?? + getNonEmptyString(part.label) ?? + getNonEmptyString(part.name) ?? + getNonEmptyString(part.filename) ?? + getNonEmptyString(payload.title) ?? + getNonEmptyString(payload.label) ?? + getNonEmptyString(payload.name) ?? + getNonEmptyString(payload.filename) + ) +} + +function getTextOutputMimeType(part: AnyRecord, payload: AnyRecord): string | undefined { + return ( + getNonEmptyString(part.mimeType) ?? + getNonEmptyString(part.mime_type) ?? + getNonEmptyString(payload.mimeType) ?? + getNonEmptyString(payload.mime_type) + ) +} + +function getPayloadRecord(part: AnyRecord): AnyRecord { + if (isRecord(part.data)) return part.data + if (isRecord(part.output)) return part.output + if (isRecord(part.result)) return part.result + return part +} + +function isPermissionToolName(toolName: string): boolean { + const normalized = toolName.toLowerCase() + return normalized.includes("permission") || normalized.includes("approval") +} + +function isGeneratedImagePart( + part: AnyRecord, + options: NormalizeCodexConversationBlockOptions | undefined, +): boolean { + if (part.type === "generated-image" || part.type === "generated_image") return true + if (part.type !== "data-image") return false + return options?.messageRole === "assistant" +} + +function normalizeCodexNonToolPartToConversationBlock( + part: AnyRecord, + options?: NormalizeCodexConversationBlockOptions, +): CodexConversationBlock | null { + const id = getBlockId(part, options) + const turnId = getTurnId(part, options) + const status = getCodexBlockStatus(part, options) + const normalizedType = part.type.replaceAll("_", "-") + const payloadRecord = getPayloadRecord(part) + const base = { + id, + turnId, + sourcePart: part, + } + + if (normalizedType === "conversation-block" && isRecord(part.block)) { + return normalizeCodexNonToolPartToConversationBlock( + { + ...part.block, + sourcePart: part, + }, + options, + ) + } + + if (isGeneratedImagePart(part, options)) { + const data = getImageData(part) + const resolvedStatus = status === "queued" && data !== undefined ? "completed" : status + const imageBlock: CodexGeneratedImageBlock = { + ...base, + type: "generated-image", + data, + url: getImageUrl(data, part), + mimeType: getImageMimeType(data, part), + prompt: getImagePrompt(data, part), + status: resolvedStatus, + } + return imageBlock + } + + if ( + normalizedType === "data-text" || + normalizedType === "data-output" || + normalizedType === "text-output" || + normalizedType === "output-text" + ) { + const content = getTextOutputContent(part, payloadRecord) + if (!content) return null + const textOutputBlock: CodexTextOutputBlock = { + ...base, + type: "text-output", + title: getTextOutputTitle(part, payloadRecord), + content, + mimeType: getTextOutputMimeType(part, payloadRecord), + status: status === "queued" ? "completed" : status, + } + return textOutputBlock + } + + if (normalizedType === "todo-list" || normalizedType === "data-todo-list") { + const todoBlock: CodexTodoListBlock = { + ...base, + type: "todo-list", + todos: + getArrayFromRecord(payloadRecord, ["newTodos", "todos", "items"]) ?? + [], + previousTodos: getArrayFromRecord(payloadRecord, ["oldTodos", "previousTodos"]), + input: part.input ?? payloadRecord.input ?? payloadRecord, + output: part.output ?? payloadRecord.output ?? part.result, + status, + } + return todoBlock + } + + if ( + normalizedType === "proposed-plan" || + normalizedType === "plan" || + normalizedType === "data-plan" + ) { + const planBlock: CodexProposedPlanBlock = { + ...base, + type: "proposed-plan", + action: getNonEmptyString(payloadRecord.action), + plan: payloadRecord.plan ?? payloadRecord, + input: part.input ?? payloadRecord.input ?? payloadRecord, + output: part.output ?? payloadRecord.output ?? part.result, + status, + } + return planBlock + } + + if ( + normalizedType === "permission-request" || + normalizedType === "approval-request" || + normalizedType === "data-permission-request" + ) { + const permissionBlock: CodexPermissionRequestBlock = { + ...base, + type: "permission-request", + input: part.input ?? payloadRecord.input ?? payloadRecord, + result: part.result ?? payloadRecord.result ?? part.output, + status, + } + return permissionBlock + } + + if ( + normalizedType === "user-input" || + normalizedType === "ask-user-question" || + normalizedType === "data-user-input" + ) { + const userInputBlock: CodexUserInputBlock = { + ...base, + type: "user-input", + prompt: + getNonEmptyString(payloadRecord.question) ?? + getNonEmptyString(payloadRecord.prompt) ?? + getNonEmptyString(payloadRecord.message) ?? + getStatusMessage(part), + input: part.input ?? payloadRecord.input ?? payloadRecord, + result: part.result ?? payloadRecord.result ?? part.output, + autoResolution: normalizeUserInputAutoResolutionState(part, payloadRecord), + status, + } + return userInputBlock + } + + if ( + normalizedType === "dynamic-tool-call" || + normalizedType === "data-dynamic-tool-call" + ) { + const dynamicBlock: CodexDynamicToolBlock = { + ...base, + type: "dynamic-tool-call", + toolName: + getNonEmptyString(payloadRecord.toolName) ?? + getNonEmptyString(payloadRecord.tool_name) ?? + getNonEmptyString(part.toolName) ?? + "dynamic-tool", + input: part.input ?? payloadRecord.input ?? payloadRecord, + output: part.output ?? payloadRecord.output ?? part.result, + status, + } + return dynamicBlock + } + + if (normalizedType === "active-goal" || normalizedType === "data-active-goal") { + const goalBlock: CodexActiveGoalBlock = { + ...base, + type: "active-goal", + title: + getNonEmptyString(payloadRecord.title) ?? + getNonEmptyString(payloadRecord.goal) ?? + "Active goal", + prompt: + getNonEmptyString(payloadRecord.prompt) ?? + getNonEmptyString(payloadRecord.description), + elapsed: getNonEmptyString(payloadRecord.elapsed), + agentLabel: + getNonEmptyString(payloadRecord.agentLabel) ?? + getNonEmptyString(payloadRecord.agent_label), + changedFiles: getPatchSummaryNumber(payloadRecord, [ + "changedFiles", + "changed_files", + ]), + addedLines: getPatchSummaryNumber(payloadRecord, [ + "addedLines", + "added_lines", + ]), + removedLines: getPatchSummaryNumber(payloadRecord, [ + "removedLines", + "removed_lines", + ]), + status, + } + return goalBlock + } + + const runtimeStatusTitle = RUNTIME_STATUS_BLOCK_TITLES[normalizedType] + if (runtimeStatusTitle) { + const statusBlock: CodexStatusBlock = { + ...base, + type: "status", + level: part.status === "failed" ? "error" : "info", + title: + getNonEmptyString(payloadRecord.title) ?? + getNonEmptyString(part.title) ?? + runtimeStatusTitle, + message: getRuntimeStatusMessage(part, payloadRecord), + data: part.data ?? payloadRecord, + status, + } + return statusBlock + } + + if (part.type === "stream-error" || part.type === "error" || part.type === "data-error") { + const statusBlock: CodexStatusBlock = { + ...base, + type: "status", + level: "error", + message: getStatusMessage(part), + data: part.data ?? part.error ?? part, + status: "failed", + } + return statusBlock + } + + if (part.type === "status") { + const level = + part.level === "warning" || part.level === "error" || part.level === "info" + ? part.level + : "info" + const statusBlock: CodexStatusBlock = { + ...base, + type: "status", + level, + message: getStatusMessage(part), + data: part.data, + status, + } + return statusBlock + } + + return null +} + +export function normalizeCodexToolPart( + part: unknown, + options?: NormalizeCodexToolPartOptions, +): unknown { + if (!isRecord(part)) return part + if (typeof part.type !== "string" || !part.type.startsWith("tool-")) return part + + const rawToolName = getPartToolName(part) + const descriptor = rawToolName ? parseCodexToolDescriptor(rawToolName) : null + const shouldNormalizeState = + options?.normalizeState === true && + (part.state === "input-available" || part.state === "output-available") + + const hasCodexArgsWrapper = + isRecord(part.input) && + (isRecord(part.input.args) || typeof part.input.toolName === "string") + + if (!descriptor && !hasCodexArgsWrapper && !shouldNormalizeState) { + return part + } + + const normalizedType = descriptor ? `tool-${descriptor.canonicalToolName}` : part.type + const fallbackDescriptor: CodexToolDescriptor = { + canonicalToolName: normalizedType.startsWith("tool-") + ? normalizedType.slice("tool-".length) + : normalizedType, + detail: "", + isMcp: normalizedType.startsWith("tool-mcp__"), + } + const normalizedInput = + descriptor + ? normalizeCodexToolInput(part.input, descriptor) + : hasCodexArgsWrapper + ? normalizeCodexToolInput(part.input, fallbackDescriptor) + : part.input + let normalizedOutput = part.output !== undefined ? part.output : part.result + const normalizedResult = part.result !== undefined ? part.result : part.output + const outputPayload = + normalizedOutput !== undefined ? normalizedOutput : normalizedResult + let outputEnrichedInput = + fallbackDescriptor.canonicalToolName === "Read" + ? normalizeReadInputFromPayload(normalizedInput, outputPayload) + : normalizedInput + if (fallbackDescriptor.canonicalToolName === "TodoWrite") { + const inputRecord = isRecord(outputEnrichedInput) ? outputEnrichedInput : undefined + const outputRecord = + isRecord(normalizedOutput) && !Array.isArray(normalizedOutput) + ? normalizedOutput + : undefined + const existingTodos = + getArrayFromRecord(inputRecord, ["todos"]) ?? + getArrayFromRecord(outputRecord, ["newTodos", "todos"]) + if (!existingTodos) { + const outputText = getTextFromContentPayload(outputPayload) + const todos = getTodosFromText(outputText) + if (todos.length > 0) { + outputEnrichedInput = { + ...(inputRecord ?? {}), + todos, + } + normalizedOutput = { + ...(outputRecord ?? {}), + oldTodos: getArrayFromRecord(outputRecord, ["oldTodos", "previousTodos"]) ?? [], + newTodos: todos, + text: outputText, + } + } + } + } + const finalInput = + outputEnrichedInput !== part.input && isShallowEqual(outputEnrichedInput, part.input) + ? part.input + : outputEnrichedInput + + const normalizedState = shouldNormalizeState + ? toCanonicalToolState(part.state) + : part.state + + const typeChanged = normalizedType !== part.type + const inputChanged = finalInput !== part.input + const stateChanged = normalizedState !== part.state + const outputChanged = normalizedOutput !== part.output + const resultChanged = normalizedResult !== part.result + + if (!typeChanged && !inputChanged && !stateChanged && !outputChanged && !resultChanged) { + return part + } + + const normalizedPart: AnyRecord = { ...part } + if (typeChanged) normalizedPart.type = normalizedType + if (inputChanged) normalizedPart.input = finalInput + if (stateChanged) normalizedPart.state = normalizedState + if (normalizedOutput !== undefined) normalizedPart.output = normalizedOutput + if (normalizedResult !== undefined) normalizedPart.result = normalizedResult + + const preservedToolCallId = getPreservedToolCallId(part) + if ( + preservedToolCallId && + normalizedPart.toolCallId === undefined && + normalizedPart.tool_call_id === undefined + ) { + normalizedPart.toolCallId = preservedToolCallId + } + + return normalizedPart +} + +export function normalizeCodexToolPartToConversationBlock( + part: unknown, + options?: NormalizeCodexConversationBlockOptions, +): CodexConversationBlock | null { + const normalizedPart = normalizeCodexToolPart(part, { + normalizeState: options?.normalizeState ?? true, + }) + if (!isRecord(normalizedPart)) return null + if ( + typeof normalizedPart.type !== "string" || + !normalizedPart.type.startsWith("tool-") + ) { + return null + } + + const toolName = getToolNameFromNormalizedPart(normalizedPart) + const id = getBlockId(normalizedPart, options) + const turnId = getTurnId(normalizedPart, options) + const input = normalizedPart.input + const output = getOutputPayload(normalizedPart) + const status = getResolvedToolBlockStatus( + toolName, + normalizedPart, + input, + output, + getCodexBlockStatus(normalizedPart, options), + options, + ) + const base = { + id, + turnId, + status, + sourcePart: normalizedPart, + } + + if (toolName === "Bash" || toolName === "Run") { + const command = getCommandFromInput(input) + const execOutput = getExecOutput(normalizedPart) + const execBlock: CodexExecBlock = { + ...base, + type: "exec", + command, + cwd: isRecord(input) ? getNonEmptyString(input.cwd) : undefined, + processId: getProcessId(input), + executionStatus: getExecExecutionStatus(status), + parsedCmd: getParsedCommand(input, command, status), + output: execOutput, + } + return execBlock + } + + if (toolName.startsWith("mcp__") || BUILTIN_MCP_TOOL_NAMES[toolName]) { + const { server, tool } = parseMcpToolName(toolName, input) + const mcpBlock: CodexMcpToolBlock = { + ...base, + type: "mcp-tool-call", + server, + tool, + callId: getMcpCallId(normalizedPart, id), + input, + result: output, + rawOutput: output, + appResourceUri: getAppResourceUri(output), + } + return mcpBlock + } + + if (toolName === "TodoWrite") { + const inputRecord = isRecord(input) ? input : undefined + const outputRecord = isRecord(output) ? output : undefined + const todoBlock: CodexTodoListBlock = { + ...base, + type: "todo-list", + todos: + getArrayFromRecord(outputRecord, ["newTodos", "todos"]) ?? + getArrayFromRecord(inputRecord, ["todos"]) ?? + [], + previousTodos: getArrayFromRecord(outputRecord, ["oldTodos", "previousTodos"]), + input, + output, + } + return todoBlock + } + + if (toolName === "PlanWrite" || toolName === "ExitPlanMode") { + const inputRecord = isRecord(input) ? input : undefined + const outputRecord = isRecord(output) ? output : undefined + const planBlock: CodexProposedPlanBlock = { + ...base, + type: "proposed-plan", + action: inputRecord ? getNonEmptyString(inputRecord.action) : undefined, + plan: inputRecord?.plan ?? outputRecord?.plan, + input, + output, + } + return planBlock + } + + if (toolName === "AskUserQuestion") { + const inputRecord = isRecord(input) ? input : undefined + const userInputBlock: CodexUserInputBlock = { + ...base, + type: "user-input", + prompt: + (inputRecord + ? getNonEmptyString(inputRecord.question) ?? + getNonEmptyString(inputRecord.prompt) ?? + getNonEmptyString(inputRecord.message) + : undefined) ?? getStatusMessage(normalizedPart), + input, + result: output, + autoResolution: normalizeUserInputAutoResolutionState( + normalizedPart, + inputRecord ?? {}, + ), + } + return userInputBlock + } + + if (isPermissionToolName(toolName)) { + const permissionBlock: CodexPermissionRequestBlock = { + ...base, + type: "permission-request", + input, + result: output, + } + return permissionBlock + } + + if (toolName === "Edit" || toolName === "Write" || toolName === "MultiEdit") { + const patchBlock: CodexPatchBlock = { + ...base, + type: "patch", + toolName, + input, + output, + } + return patchBlock + } + + const dynamicBlock: CodexDynamicToolBlock = { + ...base, + type: "dynamic-tool-call", + toolName, + input, + output, + } + return dynamicBlock +} + +export function normalizeCodexPartToConversationBlock( + part: unknown, + options?: NormalizeCodexConversationBlockOptions, +): CodexConversationBlock | null { + if (isRecord(part)) { + const nonToolBlock = normalizeCodexNonToolPartToConversationBlock(part, options) + if (nonToolBlock) return nonToolBlock + } + + return normalizeCodexToolPartToConversationBlock(part, options) +} + +export function normalizeCodexConversationBlocksFromMessage( + message: unknown, + options?: NormalizeCodexConversationBlockOptions, +): CodexConversationBlock[] { + if (!isRecord(message) || !Array.isArray(message.parts)) return [] + const messageRole = getNonEmptyString(message.role) + const turnId = + options?.turnId ?? + getNonEmptyString(message.id) ?? + getNonEmptyString(message.turnId) ?? + getNonEmptyString(message.turn_id) + + return message.parts.flatMap((part, partIndex) => { + const block = normalizeCodexPartToConversationBlock(part, { + ...options, + messageRole, + partIndex, + turnId, + }) + return block ? [block] : [] + }) +} + +function getBaseName(pathLike: string): string { + const cleaned = pathLike.trim().replace(/\/+$/, "") + if (!cleaned) return pathLike + return cleaned.split(/[\\/]/).filter(Boolean).pop() || cleaned +} + +function getDirectoryName(pathLike: string): string { + const cleaned = pathLike.trim().replace(/\/+$/, "") + const parts = cleaned.split(/[\\/]/).filter(Boolean) + if (parts.length <= 1) return "" + const prefix = cleaned.startsWith("/") ? "/" : "" + return `${prefix}${parts.slice(0, -1).join("/")}` +} + +function normalizeFilePathForArtifact(pathLike: string): string { + return pathLike.trim().replace(/\\/g, "/").replace(/\/+$/, "") +} + +function isHtmlFilePath(pathLike: string | undefined): boolean { + if (!pathLike) return false + return /\.x?html?$/i.test(pathLike.split(/[?#]/, 1)[0] ?? pathLike) +} + +function encodeUrlPath(pathLike: string): string { + return pathLike + .split(/[\\/]/) + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join("/") +} + +function appendPathToBaseUrl(baseUrl: string, pathLike: string): string { + const encodedPath = encodeUrlPath(pathLike) + if (!encodedPath) return baseUrl + return `${baseUrl.replace(/\/+$/, "")}/${encodedPath}` +} + +function displayUrlHostAndPath(url: string): string { + try { + const parsed = new URL(url) + return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}` + } catch { + return url + } +} + +type LocalWebsiteCandidate = { + baseUrl: string + root?: string + explicitTarget?: string +} + +function normalizeLocalPreviewUrl(url: string): string { + try { + const parsed = new URL(url) + if (parsed.hostname === "0.0.0.0" || parsed.hostname === "::") { + parsed.hostname = "127.0.0.1" + } + return parsed.toString() + } catch { + return url + } +} + +function getLocalUrlCandidates(text: string): string[] { + const matches = text.matchAll( + /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/[^\s'"<>)\]]*)?/gi, + ) + return Array.from(matches, (match) => normalizeLocalPreviewUrl(match[0])) +} + +function getHttpServerDirectory(command: string): string | undefined { + const directoryMatch = + command.match(/--directory(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/i) ?? + command.match(/-d(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/i) + return directoryMatch?.[1] ?? directoryMatch?.[2] ?? directoryMatch?.[3] +} + +function getLocalWebsiteCandidatesFromExec(block: CodexExecBlock): LocalWebsiteCandidate[] { + const command = block.command || "" + const outputText = [ + block.output?.combined, + block.output?.stdout, + block.output?.stderr, + ].filter(Boolean).join("\n") + const text = [command, outputText].filter(Boolean).join("\n") + const candidates: LocalWebsiteCandidate[] = [] + const seen = new Set() + + for (const url of getLocalUrlCandidates(text)) { + try { + const parsed = new URL(url) + const pathname = decodeURIComponent(parsed.pathname || "/") + const explicitTarget = isHtmlFilePath(pathname) ? url : undefined + if (!explicitTarget) { + parsed.pathname = "/" + parsed.search = "" + parsed.hash = "" + } + const baseUrl = normalizeLocalPreviewUrl(parsed.toString()) + if (seen.has(`${baseUrl}|${explicitTarget ?? ""}`)) continue + seen.add(`${baseUrl}|${explicitTarget ?? ""}`) + candidates.push({ baseUrl, root: block.cwd, explicitTarget }) + } catch { + // Ignore malformed local URLs surfaced in terminal logs. + } + } + + const pythonServerMatch = command.match( + /\bpython(?:3(?:\.\d+)?)?\s+-m\s+http\.server(?:\s+(\d+))?/i, + ) + if (pythonServerMatch) { + const port = pythonServerMatch[1] || "8000" + const root = getHttpServerDirectory(command) || block.cwd + const baseUrl = `http://127.0.0.1:${port}/` + if (!seen.has(`${baseUrl}|`)) { + seen.add(`${baseUrl}|`) + candidates.push({ baseUrl, root }) + } + } + + return candidates +} + +function getRelativeWebsitePath( + filePath: string, + candidate: LocalWebsiteCandidate, +): string | undefined { + if (candidate.explicitTarget) return "" + + const normalizedFilePath = normalizeFilePathForArtifact(filePath) + const normalizedRoot = candidate.root + ? normalizeFilePathForArtifact(candidate.root) + : "" + + if (!normalizedRoot) return getBaseName(filePath) + if (normalizedFilePath === normalizedRoot) return getBaseName(filePath) + if (normalizedFilePath.startsWith(`${normalizedRoot}/`)) { + return normalizedFilePath.slice(normalizedRoot.length + 1) + } + + const fileDirectory = getDirectoryName(filePath) + if (fileDirectory && normalizeFilePathForArtifact(fileDirectory) === normalizedRoot) { + return getBaseName(filePath) + } + + return undefined +} + +function getWebsitePreviewUrlForFile( + filePath: string, + candidates: LocalWebsiteCandidate[], +): string | undefined { + for (const candidate of candidates) { + if (candidate.explicitTarget) return candidate.explicitTarget + const relativePath = getRelativeWebsitePath(filePath, candidate) + if (relativePath === undefined) continue + return appendPathToBaseUrl(candidate.baseUrl, relativePath) + } + return undefined +} + +function promoteHtmlFileArtifactsToWebsites( + artifacts: CodexOutputArtifact[], + blocks: CodexConversationBlock[], +): CodexOutputArtifact[] { + const candidates = blocks.flatMap((block) => + block.type === "exec" ? getLocalWebsiteCandidatesFromExec(block) : [], + ) + if (!candidates.length) return artifacts + + return artifacts.map((artifact) => { + const filePath = artifact.path || artifact.url + if (!filePath || artifact.kind !== "file" || !isHtmlFilePath(filePath)) { + return artifact + } + const previewUrl = getWebsitePreviewUrlForFile(filePath, candidates) + if (!previewUrl) return artifact + + return { + ...artifact, + id: `${artifact.id}:website`, + kind: "website", + label: displayUrlHostAndPath(previewUrl), + url: previewUrl, + mimeType: "text/html", + } + }) +} + +function getMcpResultContentBlocks(result: unknown): AnyRecord[] { + const content = isRecord(result) && Array.isArray(result.content) + ? result.content + : Array.isArray(result) + ? result + : [] + return content.filter(isRecord) +} + +function getMcpResourceObject(block: AnyRecord): AnyRecord { + return isRecord(block.resource) ? block.resource : block +} + +function getMcpResourceUri(block: AnyRecord): string | undefined { + const resource = getMcpResourceObject(block) + return ( + getNonEmptyString(resource.uri) ?? + getNonEmptyString(resource.url) ?? + getNonEmptyString(block.uri) ?? + getNonEmptyString(block.url) + ) +} + +function getMcpResourceMimeType(block: AnyRecord): string | undefined { + const resource = getMcpResourceObject(block) + return ( + getNonEmptyString(resource.mimeType) ?? + getNonEmptyString(resource.mime_type) ?? + getNonEmptyString(block.mimeType) ?? + getNonEmptyString(block.mime_type) + ) +} + +function getMcpResourceText(block: AnyRecord): string | undefined { + const resource = getMcpResourceObject(block) + return ( + getNonEmptyString(resource.text) ?? + getNonEmptyString(block.text) ?? + getNonEmptyString(resource.content) ?? + getNonEmptyString(block.content) + ) +} + +function getMcpResourceLabel(block: AnyRecord, uri: string, fallback: string): string { + const resource = getMcpResourceObject(block) + return ( + getNonEmptyString(block.name) ?? + getNonEmptyString(block.title) ?? + getNonEmptyString(resource.name) ?? + getNonEmptyString(resource.title) ?? + getBaseName(uri) ?? + fallback + ) +} + +function getMcpImageDataUrl(block: AnyRecord): string | undefined { + const uri = getNonEmptyString(block.uri) ?? getNonEmptyString(block.url) + if (uri) return uri + const data = getNonEmptyString(block.data) ?? getNonEmptyString(block.blob) + if (!data) return undefined + if (data.startsWith("data:")) return data + const mimeType = getMcpResourceMimeType(block) ?? "image/png" + return `data:${mimeType};base64,${data}` +} + +function getMcpStructuredContentText(result: unknown): string | undefined { + if (!isRecord(result)) return undefined + const structuredContent = result.structuredContent ?? result.structured_content + if (structuredContent === undefined) return undefined + if (typeof structuredContent === "string") return structuredContent + try { + return JSON.stringify(structuredContent, null, 2) + } catch { + return undefined + } +} + +function getPatchFilePath(block: CodexPatchBlock): string | undefined { + const input = isRecord(block.input) ? block.input : undefined + const output = isRecord(block.output) ? block.output : undefined + + return ( + (input ? getNonEmptyString(input.file_path) : undefined) ?? + (input ? getNonEmptyString(input.filePath) : undefined) ?? + (input ? getNonEmptyString(input.path) : undefined) ?? + (output ? getNonEmptyString(output.file_path) : undefined) ?? + (output ? getNonEmptyString(output.filePath) : undefined) ?? + (output ? getNonEmptyString(output.path) : undefined) + ) +} + +function codexOutputArtifactsFromBlock( + block: CodexConversationBlock, + index: number, +): CodexOutputArtifact[] { + if (block.type === "generated-image") { + const imageIndex = index + 1 + const imageFileName = block.url && !block.url.startsWith("data:") + ? getBaseName(block.url.split("?")[0] || "") + : "" + const label = imageFileName || `Generated image ${imageIndex}` + + return [{ + id: `${block.id}:artifact:image`, + kind: "image", + label, + sourceBlockId: block.id, + turnId: block.turnId, + status: block.status, + url: block.url, + mimeType: block.mimeType, + prompt: block.prompt, + }] + } + + if (block.type === "patch") { + const path = getPatchFilePath(block) + if (!path) return [] + return [{ + id: `${block.id}:artifact:file`, + kind: "file", + label: getBaseName(path), + sourceBlockId: block.id, + turnId: block.turnId, + status: block.status, + path, + }] + } + + if (block.type === "text-output") { + return [{ + id: `${block.id}:artifact:text`, + kind: "text", + label: block.title || `Output ${index + 1}`, + sourceBlockId: block.id, + turnId: block.turnId, + status: block.status, + mimeType: block.mimeType, + content: block.content, + }] + } + + if (block.type === "mcp-tool-call") { + const artifacts: CodexOutputArtifact[] = [] + const seenResources = new Set() + const contentBlocks = getMcpResultContentBlocks(block.result) + + contentBlocks.forEach((contentBlock, contentIndex) => { + const contentType = getNonEmptyString(contentBlock.type) + if (contentType === "image") { + const url = getMcpImageDataUrl(contentBlock) + if (!url) return + artifacts.push({ + id: `${block.id}:artifact:image:${contentIndex}`, + kind: "image", + label: + getNonEmptyString(contentBlock.name) ?? + getNonEmptyString(contentBlock.title) ?? + `Image output ${index + artifacts.length + 1}`, + sourceBlockId: block.id, + turnId: block.turnId, + status: block.status, + url, + mimeType: getMcpResourceMimeType(contentBlock), + prompt: getNonEmptyString(contentBlock.alt), + }) + return + } + + if ( + contentType !== "resource_link" && + contentType !== "embedded_resource" && + contentType !== "resource" && + !isRecord(contentBlock.resource) + ) { + return + } + + const uri = getMcpResourceUri(contentBlock) + if (!uri) return + seenResources.add(uri) + artifacts.push({ + id: `${block.id}:artifact:resource:${contentIndex}`, + kind: "resource", + label: getMcpResourceLabel( + contentBlock, + uri, + `Resource ${index + artifacts.length + 1}`, + ), + sourceBlockId: block.id, + turnId: block.turnId, + status: block.status, + url: uri, + mimeType: getMcpResourceMimeType(contentBlock), + content: getMcpResourceText(contentBlock), + }) + }) + + if (block.appResourceUri && !seenResources.has(block.appResourceUri)) { + artifacts.push({ + id: `${block.id}:artifact:resource:app`, + kind: "resource", + label: getBaseName(block.appResourceUri) || `${block.server}.${block.tool}`, + sourceBlockId: block.id, + turnId: block.turnId, + status: block.status, + url: block.appResourceUri, + mimeType: "application/vnd.openai.app+html", + content: getMcpStructuredContentText(block.result), + }) + } + + return artifacts + } + + return [] +} + +export function getCodexOutputArtifactsFromBlocks( + blocks: CodexConversationBlock[], +): CodexOutputArtifact[] { + const artifacts: CodexOutputArtifact[] = [] + + for (const block of blocks) { + artifacts.push(...codexOutputArtifactsFromBlock(block, artifacts.length)) + } + + return promoteHtmlFileArtifactsToWebsites(artifacts, blocks) } export function normalizeCodexAssistantMessage( diff --git a/src/shared/plugin-deep-link.ts b/src/shared/plugin-deep-link.ts new file mode 100644 index 000000000..22beabdde --- /dev/null +++ b/src/shared/plugin-deep-link.ts @@ -0,0 +1,79 @@ +export type PluginDeepLinkAction = "detail" | "try-in-chat" + +export type PluginDeepLinkSource = "protocol" | "mention" | "catalog" + +export type PluginDeepLinkTarget = { + pluginId: string + action: PluginDeepLinkAction + source?: PluginDeepLinkSource +} + +const TRY_IN_CHAT_ACTIONS = new Set(["try", "try-in-chat", "tryInChat"]) +const PLUGIN_DEEP_LINK_PROTOCOLS = new Set([ + "twentyfirst-agents:", + "twentyfirst-agents-dev:", +]) + +function normalizePluginDeepLinkAction(value: string | null): PluginDeepLinkAction { + if (!value) return "detail" + return TRY_IN_CHAT_ACTIONS.has(value) ? "try-in-chat" : "detail" +} + +export function buildPluginDeepLinkUrl({ + action = "detail", + pluginId, + protocol, +}: { + action?: PluginDeepLinkAction + pluginId: string + protocol: string +}): string { + const encodedPluginId = encodeURIComponent(pluginId) + + if (action === "try-in-chat") { + return `${protocol}://plugins/${encodedPluginId}/try-in-chat` + } + + return `${protocol}://plugins/${encodedPluginId}` +} + +export function parsePluginDeepLink(url: string): PluginDeepLinkTarget | null { + const trimmed = url.trim() + if (!trimmed) return null + + let parsed: URL + const hasProtocol = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) + + try { + parsed = hasProtocol + ? new URL(trimmed) + : new URL(trimmed, "twentyfirst-agents://local") + } catch { + return null + } + + if (hasProtocol && !PLUGIN_DEEP_LINK_PROTOCOLS.has(parsed.protocol)) { + return null + } + + if (parsed.host !== "plugins" && !parsed.pathname.startsWith("/plugins/")) { + return null + } + + const pathParts = parsed.pathname.split("/").filter(Boolean) + const rawPluginId = parsed.host === "plugins" ? pathParts[0] : pathParts[1] + + if (!rawPluginId) return null + + const pathAction = parsed.host === "plugins" ? pathParts[1] : pathParts[2] + const queryAction = + parsed.searchParams.get("action") ?? + parsed.searchParams.get("intent") ?? + (parsed.searchParams.get("tryInChat") === "true" ? "try-in-chat" : null) + + return { + pluginId: decodeURIComponent(rawPluginId), + action: normalizePluginDeepLinkAction(pathAction ?? queryAction), + source: "protocol", + } +}