diff --git a/.github/workflows/moss-desktop-release.yml b/.github/workflows/moss-desktop-release.yml new file mode 100644 index 000000000..e078ad76e --- /dev/null +++ b/.github/workflows/moss-desktop-release.yml @@ -0,0 +1,95 @@ +name: Moss Desktop Release + +on: + workflow_dispatch: + inputs: + channel: + description: electron-updater channel + required: true + default: latest + type: choice + options: + - latest + - beta + +jobs: + macos-release: + name: Build, sign, notarize, and verify macOS artifacts + runs-on: macos-latest + timeout-minutes: 120 + env: + APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Download bundled runtimes + run: | + bun run claude:download:all + bun run codex:download:all + + - name: Verify runtime and source gates + run: | + bun run test:runtime + bun run ts:check --pretty false + + - name: Build app + run: bun run build + + - name: Verify Apple release credentials + run: bun run release:credentials:strict + + - name: Package signed macOS artifacts + run: | + rm -rf release + bun run package:mac + + - name: Smoke packaged app bundle + run: bun run test:packaged-app-smoke + + - name: Notarize and staple release artifacts + run: bun run release:notarize + + - name: Generate update manifests + run: node scripts/generate-update-manifest.mjs --channel "${{ inputs.channel }}" + + - name: Prepare release upload plan + run: node scripts/upload-release.mjs --dry-run --channel "${{ inputs.channel }}" + + - name: Audit signed release evidence + run: bun run release:evidence:audit --require-notarization + + - name: Verify commercial release evidence + run: node scripts/verify-release-packaging.mjs --require-artifacts --require-bundled-binaries --require-notarization --require-upload-plan + + - name: Upload verified release artifacts + uses: actions/upload-artifact@v4 + with: + name: moss-desktop-macos-${{ inputs.channel }} + path: | + release/*.dmg + release/*.zip + release/*mac*.yml + 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 + if-no-files-found: error 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..67b63fea9 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,25 @@ 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 { + startDesktopMobileGateway, + type RunningDesktopMobileGateway, +} from "./lib/mobile-gateway/desktop" +import { setDesktopMobileGateway } from "./lib/mobile-gateway/desktop-state" import { createMainWindow, createWindow, @@ -39,7 +68,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 @@ -93,6 +131,7 @@ export function getAppUrl(): string { // Auth manager singleton (use the one from auth-manager module) let authManager: AuthManager +let mobileGatewayServer: RunningDesktopMobileGateway | null = null export function getAuthManager(): AuthManager { // First try to get from module, fallback to local variable for backwards compat @@ -185,6 +224,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 +284,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 +374,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 +460,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 +544,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 +607,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 +632,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 +678,6 @@ if (gotTheLock) { // app.name = "Agents Dev" // } - // Register protocol handler (must be after app is ready) initialRegistration = registerProtocol() @@ -613,11 +732,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 +797,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 +818,10 @@ if (gotTheLock) { }) buildMenu() } else { - dialog.showErrorBox("Installation Failed", result.error || "Unknown error") + dialog.showErrorBox( + "Installation Failed", + result.error || "Unknown error", + ) } } }, @@ -708,7 +837,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 +850,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 +928,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 +948,7 @@ if (gotTheLock) { if (response === 1) { abortAllClaudeSessions() abortAllCodexStreams() + abortAllHermesStreams() win.webContents.reloadIgnoringCache() } }) @@ -853,7 +993,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 +1079,33 @@ if (gotTheLock) { console.error("[App] Failed to initialize database:", error) } + try { + mobileGatewayServer = await startDesktopMobileGateway({ + enabled: IS_DEV || process.env.ONECODE_MOBILE_GATEWAY === "1", + }) + setDesktopMobileGateway(mobileGatewayServer) + if (mobileGatewayServer) { + console.log(`[Mobile Gateway] Listening on ${mobileGatewayServer.url}`) + if (IS_DEV) { + console.log( + `[Mobile Gateway] Dev pairing token: ${mobileGatewayServer.pairingToken}`, + ) + const pairingUrl = mobileGatewayServer.getPairingUrl() + if (pairingUrl) { + console.log(`[Mobile Gateway] Dev pairing URL: ${pairingUrl}`) + } + } + } + } catch (error) { + setDesktopMobileGateway(null) + console.warn("[Mobile Gateway] Failed to start:", error) + } + + // Dev builds still render update preferences, but should not initialize updater. + if (!app.isPackaged) { + registerAutoUpdaterIpcHandlers() + } + // Create main window createMainWindow() @@ -1003,6 +1170,12 @@ if (gotTheLock) { app.on("before-quit", async () => { console.log("[App] Shutting down...") cancelAllPendingOAuth() + if (mobileGatewayServer) { + await mobileGatewayServer.close() + mobileGatewayServer = null + setDesktopMobileGateway(null) + console.log("[Mobile Gateway] Closed") + } await cleanupGitWatchers() await shutdownAnalytics() await closeDatabase() 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..6d2a1b6b6 --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/claude-code.ts @@ -0,0 +1,161 @@ +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 { + createUnsupportedAgentRuntimeControlResult, + createUnsupportedAgentRuntimeReceipt, + streamUnsupportedAgentRuntimeRun, +} from "../runtime-run-ledger" +import type { + AgentRuntimeAdapter, + AgentRuntimeAvailability, + AgentRuntimeHealth, + AgentRuntimeSessionRef, + AgentRuntimeStartRequest, +} 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", + })), + } +} + +function unsupportedClaudeRun( + action: "start" | "resume", + request: AgentRuntimeStartRequest, +) { + return createUnsupportedAgentRuntimeReceipt({ + action, + request, + reason: + "Claude Code lifecycle is still owned by the existing SDK transport; the unified Moss adapter bridge is not connected yet.", + }) +} + +export const claudeCodeAdapter: AgentRuntimeAdapter = { + manifest: getAgentRuntimeManifest("claude-code"), + async inspect( + _session: AgentRuntimeSessionRef, + ): Promise { + return inspectClaudeRuntime() + }, + async canStart( + _session: AgentRuntimeSessionRef, + ): Promise { + return (await inspectClaudeRuntime()).availability + }, + async start(request) { + return unsupportedClaudeRun("start", request) + }, + async resume(request) { + return unsupportedClaudeRun("resume", request) + }, + stream(request) { + return streamUnsupportedAgentRuntimeRun(unsupportedClaudeRun("start", request)) + }, + async stop(request) { + return createUnsupportedAgentRuntimeControlResult( + request, + "Claude Code unified stop requires the SDK transport bridge slice.", + ) + }, + async submitToolResult(request) { + return createUnsupportedAgentRuntimeControlResult( + request, + "Claude Code unified tool result submission requires the SDK transport bridge slice.", + ) + }, +} 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..359633aac --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/codex.ts @@ -0,0 +1,582 @@ +import { + batchWriteCodexAppServerConfig, + cancelCodexAppServerAccountLogin, + clearCodexAppServerThreadGoal, + controlCodexAppServerThread, + detectCodexAppServerExternalAgentConfig, + forkCodexAppServerThread, + getCodexAppServerThreadFullDiff, + getCodexAppServerThreadGoal, + getCodexAppServerThreadTurnDiff, + importCodexAppServerExternalAgentConfig, + installCodexAppServerPlugin, + listCodexAppServerApps, + listCodexAppServerHooks, + listCodexAppServerMcpServerStatuses, + listCodexAppServerModels, + listCodexAppServerPermissionProfiles, + listCodexAppServerPlugins, + listCodexAppServerSkills, + listInstalledCodexAppServerPlugins, + listLoadedCodexAppServerThreads, + listCodexAppServerThreads, + logoutCodexAppServerAccount, + readCodexAppServerAccount, + readCodexAppServerAccountRateLimits, + readCodexAppServerAccountUsage, + readCodexAppServerConfig, + readCodexAppServerConfigRequirements, + readCodexAppServerPlugin, + readCodexAppServerThread, + reloadCodexAppServerMcpServerConfig, + runCodexAppServerRuntimeRun, + rollbackCodexAppServerThread, + setCodexAppServerThreadGoal, + setCodexAppServerThreadName, + startCodexAppServerAccountLogin, + startCodexAppServerMcpServerOauthLogin, + streamCodexAppServerRuntimeRun, + updateCodexAppServerThreadMetadata, + writeCodexAppServerConfigValue, +} from "../codex-app-server-runtime"; +import { getAgentRuntimeManifest } from "../manifests"; +import { + stopAgentRuntimeProcess, + submitAgentRuntimeToolResult, +} from "../process-registry"; +import type { + AgentRuntimeAdapter, + AgentRuntimeAccountLoginCancelRequest, + AgentRuntimeAccountLoginCancelResult, + AgentRuntimeAccountLoginStartRequest, + AgentRuntimeAccountLoginStartResult, + AgentRuntimeAccountLogoutRequest, + AgentRuntimeAccountLogoutResult, + AgentRuntimeAccountRateLimitsReadRequest, + AgentRuntimeAccountRateLimitsReadResult, + AgentRuntimeAccountReadRequest, + AgentRuntimeAccountReadResult, + AgentRuntimeAccountUsageReadRequest, + AgentRuntimeAccountUsageReadResult, + AgentRuntimeAppListRequest, + AgentRuntimeAppListResult, + AgentRuntimeConfigBatchWriteRequest, + AgentRuntimeAvailability, + AgentRuntimeConfigReadRequest, + AgentRuntimeConfigReadResult, + AgentRuntimeConfigRequirementsReadRequest, + AgentRuntimeConfigRequirementsReadResult, + AgentRuntimeConfigValueWriteRequest, + AgentRuntimeConfigWriteResult, + AgentRuntimeExternalAgentConfigDetectRequest, + AgentRuntimeExternalAgentConfigDetectResult, + AgentRuntimeExternalAgentConfigImportRequest, + AgentRuntimeExternalAgentConfigImportResult, + AgentRuntimeHookListRequest, + AgentRuntimeHookListResult, + AgentRuntimeMcpServerConfigReloadRequest, + AgentRuntimeMcpServerConfigReloadResult, + AgentRuntimeMcpServerStatusListRequest, + AgentRuntimeMcpServerStatusListResult, + AgentRuntimeMcpServerOauthLoginRequest, + AgentRuntimeMcpServerOauthLoginResult, + AgentRuntimeModelListRequest, + AgentRuntimeModelListResult, + AgentRuntimePermissionProfileListRequest, + AgentRuntimePermissionProfileListResult, + AgentRuntimePluginInstalledRequest, + AgentRuntimePluginInstalledResult, + AgentRuntimePluginInstallRequest, + AgentRuntimePluginInstallResult, + AgentRuntimePluginListRequest, + AgentRuntimePluginListResult, + AgentRuntimePluginReadRequest, + AgentRuntimePluginReadResult, + AgentRuntimeRunReceipt, + AgentRuntimeHealth, + AgentRuntimeSessionRef, + AgentRuntimeSkillListRequest, + AgentRuntimeSkillListResult, + AgentRuntimeStartRequest, + AgentRuntimeStreamEvent, + AgentRuntimeThreadControlRequest, + AgentRuntimeThreadControlResult, + AgentRuntimeThreadDiffResult, + AgentRuntimeThreadFullDiffRequest, + AgentRuntimeThreadForkRequest, + AgentRuntimeThreadForkResult, + AgentRuntimeThreadGoalClearRequest, + AgentRuntimeThreadGoalClearResult, + AgentRuntimeThreadGoalGetRequest, + AgentRuntimeThreadGoalGetResult, + AgentRuntimeThreadGoalSetRequest, + AgentRuntimeThreadGoalSetResult, + AgentRuntimeThreadLoadedListRequest, + AgentRuntimeThreadLoadedListResult, + AgentRuntimeThreadListRequest, + AgentRuntimeThreadListResult, + AgentRuntimeThreadMetadataUpdateRequest, + AgentRuntimeThreadMetadataUpdateResult, + AgentRuntimeThreadNameSetRequest, + AgentRuntimeThreadNameSetResult, + AgentRuntimeThreadReadRequest, + AgentRuntimeThreadReadResult, + AgentRuntimeThreadRollbackRequest, + AgentRuntimeThreadRollbackResult, + AgentRuntimeThreadTurnDiffRequest, +} from "../types"; + +function createCodexRuntimeVersionAdvisory( + version: string | null, +): AgentRuntimeHealth["versionAdvisory"] { + return { + status: "unknown", + currentVersion: version, + latestVersion: null, + updateCommand: null, + canUpdate: false, + checkedAt: new Date().toISOString(), + message: version + ? "Codex CLI version detected; latest-version checks are not connected yet." + : "Codex CLI version could not be detected.", + }; +} + +async function inspectCodexRuntime(): Promise { + const manifest = getAgentRuntimeManifest("codex"); + + try { + const { getCodexIntegrationStatus } = + await import("../../trpc/routers/codex"); + 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, + version: integration.version, + versionAdvisory: createCodexRuntimeVersionAdvisory( + integration.version, + ), + 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, + version: integration.version, + versionAdvisory: createCodexRuntimeVersionAdvisory(integration.version), + 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", + version: null, + versionAdvisory: createCodexRuntimeVersionAdvisory(null), + models: manifest.models?.map((model) => ({ + ...model, + availability: missingBinary ? "not-installed" : "error", + reason: message, + })), + }; + } +} + +async function runCodexLifecycle( + action: "start" | "resume", + request: AgentRuntimeStartRequest, +): Promise { + return runCodexAppServerRuntimeRun(action, request); +} + +async function* streamCodexLifecycle( + request: AgentRuntimeStartRequest, +): AsyncIterable { + const action = + request.session.nativeSessionId && !request.forceNewSession + ? "resume" + : "start"; + yield* streamCodexAppServerRuntimeRun(action, request); +} + +async function readCodexThread( + request: AgentRuntimeThreadReadRequest, +): Promise { + return readCodexAppServerThread(request); +} + +async function readCodexConfig( + request: AgentRuntimeConfigReadRequest, +): Promise { + return readCodexAppServerConfig(request); +} + +async function writeCodexConfigValue( + request: AgentRuntimeConfigValueWriteRequest, +): Promise { + return writeCodexAppServerConfigValue(request); +} + +async function batchWriteCodexConfig( + request: AgentRuntimeConfigBatchWriteRequest, +): Promise { + return batchWriteCodexAppServerConfig(request); +} + +async function readCodexConfigRequirements( + request: AgentRuntimeConfigRequirementsReadRequest, +): Promise { + return readCodexAppServerConfigRequirements(request); +} + +async function listCodexPermissionProfiles( + request: AgentRuntimePermissionProfileListRequest, +): Promise { + return listCodexAppServerPermissionProfiles(request); +} + +async function listCodexMcpServerStatuses( + request: AgentRuntimeMcpServerStatusListRequest, +): Promise { + return listCodexAppServerMcpServerStatuses(request); +} + +async function reloadCodexMcpServerConfig( + request: AgentRuntimeMcpServerConfigReloadRequest, +): Promise { + return reloadCodexAppServerMcpServerConfig(request); +} + +async function listCodexSkills( + request: AgentRuntimeSkillListRequest, +): Promise { + return listCodexAppServerSkills(request); +} + +async function listCodexHooks( + request: AgentRuntimeHookListRequest, +): Promise { + return listCodexAppServerHooks(request); +} + +async function listCodexApps( + request: AgentRuntimeAppListRequest, +): Promise { + return listCodexAppServerApps(request); +} + +async function listCodexPlugins( + request: AgentRuntimePluginListRequest, +): Promise { + return listCodexAppServerPlugins(request); +} + +async function listInstalledCodexPlugins( + request: AgentRuntimePluginInstalledRequest, +): Promise { + return listInstalledCodexAppServerPlugins(request); +} + +async function readCodexPlugin( + request: AgentRuntimePluginReadRequest, +): Promise { + return readCodexAppServerPlugin(request); +} + +async function installCodexPlugin( + request: AgentRuntimePluginInstallRequest, +): Promise { + return installCodexAppServerPlugin(request); +} + +async function detectCodexExternalAgentConfig( + request: AgentRuntimeExternalAgentConfigDetectRequest, +): Promise { + return detectCodexAppServerExternalAgentConfig(request); +} + +async function importCodexExternalAgentConfig( + request: AgentRuntimeExternalAgentConfigImportRequest, +): Promise { + return importCodexAppServerExternalAgentConfig(request); +} + +async function startCodexMcpServerOauthLogin( + request: AgentRuntimeMcpServerOauthLoginRequest, +): Promise { + return startCodexAppServerMcpServerOauthLogin(request); +} + +async function listCodexModels( + request: AgentRuntimeModelListRequest, +): Promise { + return listCodexAppServerModels(request); +} + +async function readCodexAccount( + request: AgentRuntimeAccountReadRequest, +): Promise { + return readCodexAppServerAccount(request); +} + +async function startCodexAccountLogin( + request: AgentRuntimeAccountLoginStartRequest, +): Promise { + return startCodexAppServerAccountLogin(request); +} + +async function cancelCodexAccountLogin( + request: AgentRuntimeAccountLoginCancelRequest, +): Promise { + return cancelCodexAppServerAccountLogin(request); +} + +async function logoutCodexAccount( + request: AgentRuntimeAccountLogoutRequest, +): Promise { + return logoutCodexAppServerAccount(request); +} + +async function readCodexAccountRateLimits( + request: AgentRuntimeAccountRateLimitsReadRequest, +): Promise { + return readCodexAppServerAccountRateLimits(request); +} + +async function readCodexAccountUsage( + request: AgentRuntimeAccountUsageReadRequest, +): Promise { + return readCodexAppServerAccountUsage(request); +} + +async function forkCodexThread( + request: AgentRuntimeThreadForkRequest, +): Promise { + return forkCodexAppServerThread(request); +} + +async function getCodexThreadTurnDiff( + request: AgentRuntimeThreadTurnDiffRequest, +): Promise { + return getCodexAppServerThreadTurnDiff(request); +} + +async function getCodexThreadFullDiff( + request: AgentRuntimeThreadFullDiffRequest, +): Promise { + return getCodexAppServerThreadFullDiff(request); +} + +async function listCodexThreads( + request: AgentRuntimeThreadListRequest, +): Promise { + return listCodexAppServerThreads(request); +} + +async function listLoadedCodexThreads( + request: AgentRuntimeThreadLoadedListRequest, +): Promise { + return listLoadedCodexAppServerThreads(request); +} + +async function controlCodexThread( + request: AgentRuntimeThreadControlRequest, +): Promise { + return controlCodexAppServerThread(request); +} + +async function setCodexThreadName( + request: AgentRuntimeThreadNameSetRequest, +): Promise { + return setCodexAppServerThreadName(request); +} + +async function updateCodexThreadMetadata( + request: AgentRuntimeThreadMetadataUpdateRequest, +): Promise { + return updateCodexAppServerThreadMetadata(request); +} + +async function getCodexThreadGoal( + request: AgentRuntimeThreadGoalGetRequest, +): Promise { + return getCodexAppServerThreadGoal(request); +} + +async function setCodexThreadGoal( + request: AgentRuntimeThreadGoalSetRequest, +): Promise { + return setCodexAppServerThreadGoal(request); +} + +async function clearCodexThreadGoal( + request: AgentRuntimeThreadGoalClearRequest, +): Promise { + return clearCodexAppServerThreadGoal(request); +} + +async function rollbackCodexThread( + request: AgentRuntimeThreadRollbackRequest, +): Promise { + return rollbackCodexAppServerThread(request); +} + +export const codexAdapter: AgentRuntimeAdapter = { + manifest: getAgentRuntimeManifest("codex"), + async inspect(_session: AgentRuntimeSessionRef): Promise { + return inspectCodexRuntime(); + }, + async canStart( + _session: AgentRuntimeSessionRef, + ): Promise { + return (await inspectCodexRuntime()).availability; + }, + async start(request) { + return runCodexLifecycle("start", request); + }, + async resume(request) { + return runCodexLifecycle("resume", request); + }, + stream(request) { + return streamCodexLifecycle(request); + }, + async stop(request) { + return stopAgentRuntimeProcess(request); + }, + async submitToolResult(request) { + return submitAgentRuntimeToolResult(request); + }, + async readConfig(request) { + return readCodexConfig(request); + }, + async writeConfigValue(request) { + return writeCodexConfigValue(request); + }, + async batchWriteConfig(request) { + return batchWriteCodexConfig(request); + }, + async readConfigRequirements(request) { + return readCodexConfigRequirements(request); + }, + async listPermissionProfiles(request) { + return listCodexPermissionProfiles(request); + }, + async listMcpServerStatuses(request) { + return listCodexMcpServerStatuses(request); + }, + async reloadMcpServerConfig(request) { + return reloadCodexMcpServerConfig(request); + }, + async listSkills(request) { + return listCodexSkills(request); + }, + async listHooks(request) { + return listCodexHooks(request); + }, + async listApps(request) { + return listCodexApps(request); + }, + async listPlugins(request) { + return listCodexPlugins(request); + }, + async listInstalledPlugins(request) { + return listInstalledCodexPlugins(request); + }, + async readPlugin(request) { + return readCodexPlugin(request); + }, + async installPlugin(request) { + return installCodexPlugin(request); + }, + async detectExternalAgentConfig(request) { + return detectCodexExternalAgentConfig(request); + }, + async importExternalAgentConfig(request) { + return importCodexExternalAgentConfig(request); + }, + async startMcpServerOauthLogin(request) { + return startCodexMcpServerOauthLogin(request); + }, + async listModels(request) { + return listCodexModels(request); + }, + async startAccountLogin(request) { + return startCodexAccountLogin(request); + }, + async cancelAccountLogin(request) { + return cancelCodexAccountLogin(request); + }, + async logoutAccount(request) { + return logoutCodexAccount(request); + }, + async readAccount(request) { + return readCodexAccount(request); + }, + async readAccountRateLimits(request) { + return readCodexAccountRateLimits(request); + }, + async readAccountUsage(request) { + return readCodexAccountUsage(request); + }, + async readThread(request) { + return readCodexThread(request); + }, + async forkThread(request) { + return forkCodexThread(request); + }, + async getThreadTurnDiff(request) { + return getCodexThreadTurnDiff(request); + }, + async getThreadFullDiff(request) { + return getCodexThreadFullDiff(request); + }, + async listThreads(request) { + return listCodexThreads(request); + }, + async listLoadedThreads(request) { + return listLoadedCodexThreads(request); + }, + async controlThread(request) { + return controlCodexThread(request); + }, + async setThreadName(request) { + return setCodexThreadName(request); + }, + async updateThreadMetadata(request) { + return updateCodexThreadMetadata(request); + }, + async getThreadGoal(request) { + return getCodexThreadGoal(request); + }, + async setThreadGoal(request) { + return setCodexThreadGoal(request); + }, + async clearThreadGoal(request) { + return clearCodexThreadGoal(request); + }, + async rollbackThread(request) { + return rollbackCodexThread(request); + }, +}; 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..cac970086 --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/custom-acp.ts @@ -0,0 +1,76 @@ +import { getAgentRuntimeManifest } from "../manifests" +import { + createUnsupportedAgentRuntimeControlResult, + createUnsupportedAgentRuntimeReceipt, + streamUnsupportedAgentRuntimeRun, +} from "../runtime-run-ledger" +import type { + AgentRuntimeAdapter, + AgentRuntimeAvailability, + AgentRuntimeHealth, + AgentRuntimeSessionRef, + AgentRuntimeStartRequest, +} 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.", + })), + } +} + +function unsupportedCustomAcpRun( + action: "start" | "resume", + request: AgentRuntimeStartRequest, +) { + return createUnsupportedAgentRuntimeReceipt({ + action, + request, + reason: + "Configure a Moss Custom ACP endpoint or command adapter before starting sessions.", + }) +} + +export const customAcpAdapter: AgentRuntimeAdapter = { + manifest: getAgentRuntimeManifest("custom-acp"), + async inspect( + _session: AgentRuntimeSessionRef, + ): Promise { + return inspectCustomAcpRuntime() + }, + async canStart( + _session: AgentRuntimeSessionRef, + ): Promise { + return (await inspectCustomAcpRuntime()).availability + }, + async start(request) { + return unsupportedCustomAcpRun("start", request) + }, + async resume(request) { + return unsupportedCustomAcpRun("resume", request) + }, + stream(request) { + return streamUnsupportedAgentRuntimeRun(unsupportedCustomAcpRun("start", request)) + }, + async stop(request) { + return createUnsupportedAgentRuntimeControlResult( + request, + "Custom ACP stop requires a configured endpoint or command adapter.", + ) + }, + async submitToolResult(request) { + return createUnsupportedAgentRuntimeControlResult( + request, + "Custom ACP tool result submission requires a configured endpoint or command adapter.", + ) + }, +} 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..824bfc375 --- /dev/null +++ b/src/main/lib/agent-runtime/adapters/hermes.ts @@ -0,0 +1,394 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import { createRequire } from "node:module" +import { getAgentRuntimeManifest } from "../manifests" +import { runHermesCliResumeBridge } from "../hermes-native-session" +import { streamHermesAcpRuntimeRun } from "../hermes-acp-runtime" +import { + createAgentRuntimeProcessHandle, + registerAgentRuntimeProcessHandle, + stopAgentRuntimeProcess, + unregisterAgentRuntimeProcessHandle, +} from "../process-registry" +import { + createAgentRuntimeRunReceipt, + createUnsupportedAgentRuntimeControlResult, + createUnsupportedAgentRuntimeReceipt, + streamUnsupportedAgentRuntimeRun, +} from "../runtime-run-ledger" +import type { + AgentRuntimeAdapter, + AgentRuntimeAvailability, + AgentRuntimeHealth, + AgentRuntimeSessionRef, + AgentRuntimeStartRequest, + AgentRuntimeStreamEvent, +} from "../types" +import { + resolveHermesRuntime, + type HermesRuntimeResolution, +} from "../../hermes/runtime" + +const require = createRequire(import.meta.url) +const yaml = require("js-yaml") as { + load(source: string): unknown +} + +export interface HermesRuntimeModelConfig { + provider?: string + defaultModel?: string + baseUrl?: string + apiKey?: string + apiMode?: string +} + +export type HermesModelCatalogResult = + | { status: "ok"; modelIds: string[] } + | { status: "auth-error"; message: string } + | { status: "error"; message: string } + +export interface HermesRuntimeHealthDeps { + resolveRuntime?: () => HermesRuntimeResolution + readModelConfig?: () => Promise + fetchModelCatalog?: (config: HermesRuntimeModelConfig) => Promise +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + return value as Record +} + +function asCleanString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +function modelHealth( + availability: AgentRuntimeAvailability, + reason?: string, +): AgentRuntimeHealth["models"] { + const manifest = getAgentRuntimeManifest("hermes") + return manifest.models?.map((model) => ({ + ...model, + availability, + ...(reason ? { reason } : {}), + })) +} + +function sanitizeBaseUrl(baseUrl: string): string { + try { + const url = new URL(baseUrl) + url.username = "" + url.password = "" + return url.toString().replace(/\/$/, "") + } catch { + return baseUrl + } +} + +function isAuthLikeMessage(message: string): boolean { + return /auth_unavailable|no auth available|not authenticated|unauthorized|forbidden|authentication/i + .test(message) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export async function readHermesModelConfig(): Promise { + try { + const configPath = path.join(os.homedir(), ".hermes", "config.yaml") + const raw = await fs.readFile(configPath, "utf-8") + const parsed = asRecord(yaml.load(raw)) + const model = asRecord(parsed?.model) + if (!model) return null + + return { + provider: asCleanString(model.provider), + defaultModel: asCleanString(model.default) ?? asCleanString(model.defaultModel), + baseUrl: asCleanString(model.base_url) ?? asCleanString(model.baseUrl), + apiKey: asCleanString(model.api_key) ?? asCleanString(model.apiKey), + apiMode: asCleanString(model.api_mode) ?? asCleanString(model.apiMode), + } + } catch { + return null + } +} + +export async function fetchHermesModelCatalog( + config: HermesRuntimeModelConfig, +): Promise { + if (!config.baseUrl) { + return { status: "error", message: "Hermes custom endpoint base URL is missing." } + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2000) + + try { + const response = await fetch(`${config.baseUrl.replace(/\/+$/, "")}/models`, { + method: "GET", + headers: { + ...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}), + }, + signal: controller.signal, + }) + const body = await response.text() + const authLike = isAuthLikeMessage(body) + + if (!response.ok) { + const message = body.trim() + ? `HTTP ${response.status}: ${body.trim()}` + : `HTTP ${response.status}` + if (response.status === 401 || response.status === 403 || authLike) { + return { status: "auth-error", message } + } + return { status: "error", message } + } + + const parsed = asRecord(body.trim() ? JSON.parse(body) : {}) + const data = Array.isArray(parsed?.data) ? parsed.data : [] + const modelIds = data + .map((item) => asCleanString(asRecord(item)?.id)) + .filter((id): id is string => Boolean(id)) + + return { status: "ok", modelIds } + } catch (error) { + const message = errorMessage(error) + return { + status: isAuthLikeMessage(message) ? "auth-error" : "error", + message, + } + } finally { + clearTimeout(timeout) + } +} + +export async function inspectHermesRuntime( + _session?: AgentRuntimeSessionRef, + deps: HermesRuntimeHealthDeps = {}, +): Promise { + const runtime = (deps.resolveRuntime ?? resolveHermesRuntime)() + + if (!runtime.executable && !runtime.sourceRoot) { + return { + availability: "not-installed", + statusReason: "Hermes CLI and source root were not found.", + authMethod: "unknown", + models: modelHealth("not-installed", "Hermes is not installed."), + } + } + + if ((runtime.acpExecutable || runtime.executable) && runtime.acpAdapterPath) { + const launchPath = runtime.acpExecutable || `${runtime.executable} acp` + const availableHealth: AgentRuntimeHealth = { + availability: "available", + statusReason: + `Hermes ACP transport is available via ${launchPath}.`, + authMethod: "shell-config", + models: modelHealth( + "available", + "Hermes uses the current ACP runtime model unless a concrete ACP model is selected.", + ), + } + + const config = await (deps.readModelConfig ?? readHermesModelConfig)() + if ( + config?.provider !== "custom" || + !config.baseUrl || + !config.defaultModel + ) { + return availableHealth + } + + const safeBaseUrl = sanitizeBaseUrl(config.baseUrl) + const catalog = await (deps.fetchModelCatalog ?? fetchHermesModelCatalog)(config) + if (catalog.status === "auth-error") { + return { + availability: "needs-auth", + statusReason: `Hermes custom endpoint auth failed: ${catalog.message}`, + authMethod: "not-authenticated", + models: modelHealth("needs-auth", catalog.message), + } + } + + if (catalog.status === "error") { + return { + availability: "error", + statusReason: `Hermes custom endpoint probe failed: ${catalog.message}`, + authMethod: "unknown", + models: modelHealth("error", catalog.message), + } + } + + if (!catalog.modelIds.includes(config.defaultModel)) { + const reason = + `Hermes custom endpoint at ${safeBaseUrl} does not expose configured model ${config.defaultModel}.` + return { + availability: "needs-auth", + statusReason: reason, + authMethod: "not-authenticated", + models: modelHealth("needs-auth", reason), + } + } + + return { + ...availableHealth, + statusReason: + `Hermes custom endpoint exposes ${config.defaultModel} via ${safeBaseUrl}.`, + } + } + + return { + availability: "unsupported", + statusReason: + `Hermes source detected at ${runtime.sourceRoot || "unknown"}, but executable or ACP adapter is missing.`, + authMethod: "unknown", + models: modelHealth("unsupported", "Hermes executable or ACP adapter is missing."), + } +} + +function unsupportedHermesStart(request: AgentRuntimeStartRequest) { + return createUnsupportedAgentRuntimeReceipt({ + action: "start", + request, + reason: + "Hermes start requires the ACP session registry slice before Moss can own start/stream/stop.", + }) +} + +async function resumeHermesRuntime(request: AgentRuntimeStartRequest) { + const handle = registerAgentRuntimeProcessHandle( + createAgentRuntimeProcessHandle({ + action: "resume", + session: request.session, + runId: request.runId, + }), + ) + const nativeSessionId = request.session.nativeSessionId + if (!nativeSessionId) { + unregisterAgentRuntimeProcessHandle(handle.runId) + return createAgentRuntimeRunReceipt({ + runId: handle.runId, + action: "resume", + session: request.session, + status: "error", + resultSubtype: "error", + now: handle.createdAt, + completedAt: new Date(), + error: "Hermes resume requires a native session id.", + }) + } + + try { + const [{ getMossProviderSecret }, { resolveMossProviderForEngine }] = + await Promise.all([ + import("../../moss-source/provider-secrets"), + import("../../moss-source/provider-config"), + ]) + const mossProvider = await resolveMossProviderForEngine({ + projectPath: request.session.projectPath ?? request.session.cwd, + engineId: "hermes", + requestedModelId: request.session.modelId ?? undefined, + createIfMissing: true, + secretResolver: { getSecret: getMossProviderSecret }, + }) + if (mossProvider.warnings.length > 0) { + console.warn("[hermes-runtime] Moss provider warnings:", mossProvider.warnings) + } + const runtime = resolveHermesRuntime() + const result = await runHermesCliResumeBridge({ + sessionId: nativeSessionId, + cwd: request.session.cwd, + prompt: request.prompt, + modelId: mossProvider.status === "resolved" + ? mossProvider.model ?? request.session.modelId + : request.session.modelId, + permissionMode: request.session.permissionMode, + command: runtime.executable, + env: mossProvider.status === "resolved" ? mossProvider.env : {}, + abortSignal: handle.abortController.signal, + }) + const wasCancelled = handle.abortController.signal.aborted + + return createAgentRuntimeRunReceipt({ + runId: handle.runId, + action: "resume", + session: request.session, + status: wasCancelled ? "cancelled" : result.success ? "success" : "error", + nativeSessionId, + resultSubtype: wasCancelled ? "cancelled" : result.success ? "success" : "error", + now: handle.createdAt, + completedAt: new Date(), + error: wasCancelled ? "Hermes runtime run was cancelled." : result.error, + metadata: { + bridge: result.plan.bridge, + command: result.plan.command, + args: result.plan.args, + promptSource: result.plan.promptSource, + canRunHeadless: result.plan.canRunHeadless, + lastText: result.lastText, + exitCode: result.exitCode, + stderr: result.stderr, + stopReason: handle.stopReason, + }, + }) + } catch (error) { + const wasCancelled = handle.abortController.signal.aborted + return createAgentRuntimeRunReceipt({ + runId: handle.runId, + action: "resume", + session: request.session, + status: wasCancelled ? "cancelled" : "error", + resultSubtype: wasCancelled ? "cancelled" : "error", + now: handle.createdAt, + completedAt: new Date(), + error: wasCancelled ? "Hermes runtime run was cancelled." : error instanceof Error ? error.message : String(error), + }) + } finally { + unregisterAgentRuntimeProcessHandle(handle.runId) + } +} + +async function* streamHermesRuntime( + request: AgentRuntimeStartRequest, +): AsyncIterable { + if (!request.session.nativeSessionId || request.forceNewSession) { + yield* streamUnsupportedAgentRuntimeRun(unsupportedHermesStart(request)) + return + } + + yield* streamHermesAcpRuntimeRun(request) +} + +export const hermesAdapter: AgentRuntimeAdapter = { + manifest: getAgentRuntimeManifest("hermes"), + async inspect( + session: AgentRuntimeSessionRef, + ): Promise { + return inspectHermesRuntime(session) + }, + async canStart( + session: AgentRuntimeSessionRef, + ): Promise { + return (await inspectHermesRuntime(session)).availability + }, + async start(request) { + return unsupportedHermesStart(request) + }, + async resume(request) { + return resumeHermesRuntime(request) + }, + stream(request) { + return streamHermesRuntime(request) + }, + async stop(request) { + return stopAgentRuntimeProcess(request) + }, + async submitToolResult(request) { + return createUnsupportedAgentRuntimeControlResult( + request, + "Hermes tool result submission requires the ACP session registry slice.", + ) + }, +} 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-app-server-client.ts b/src/main/lib/agent-runtime/codex-app-server-client.ts new file mode 100644 index 000000000..1945c9e50 --- /dev/null +++ b/src/main/lib/agent-runtime/codex-app-server-client.ts @@ -0,0 +1,285 @@ +import type { Readable, Writable } from "node:stream" + +export type CodexAppServerJsonRpcId = string | number + +export interface CodexAppServerRpcNotification { + method: string + params?: unknown +} + +export interface CodexAppServerRpcRequest + extends CodexAppServerRpcNotification { + id: CodexAppServerJsonRpcId +} + +export interface CodexAppServerRpcErrorShape { + code: number + message: string + data?: unknown +} + +export interface CodexAppServerRpcClientOptions { + stdin: Writable + stdout: Readable + stderr?: Readable | null + signal?: AbortSignal | null + onNotification?: (notification: CodexAppServerRpcNotification) => void + onRequest?: (request: CodexAppServerRpcRequest) => void + onStderrLine?: (line: string) => void + onProtocolError?: (error: Error) => void +} + +interface PendingRpcRequest { + method: string + resolve(value: unknown): void + reject(error: Error): void +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isJsonRpcId(value: unknown): value is CodexAppServerJsonRpcId { + return typeof value === "string" || typeof value === "number" +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function errorFromRpcResponse( + method: string, + requestId: string, + error: unknown, +): Error { + if (isRecord(error)) { + const message = + typeof error.message === "string" + ? error.message + : `Codex app-server request ${method} failed.` + const code = typeof error.code === "number" ? error.code : undefined + const details = code === undefined ? message : `${message} (${code})` + return new Error(`Codex app-server request ${method} failed for id ${requestId}: ${details}`) + } + + return new Error(`Codex app-server request ${method} failed for id ${requestId}.`) +} + +export function codexAppServerMethodNotFoundError( + method: string, +): CodexAppServerRpcErrorShape { + return { + code: -32601, + message: `Method not found: ${method}`, + } +} + +export function codexAppServerInvalidParamsError( + message: string, + data?: unknown, +): CodexAppServerRpcErrorShape { + return { + code: -32602, + message, + ...(data !== undefined ? { data } : {}), + } +} + +export class CodexAppServerRpcClient { + private nextRequestId = 1 + private stdoutRemainder = "" + private stderrRemainder = "" + private closed = false + private readonly pending = new Map() + private readonly abortListener: (() => void) | undefined + + constructor(private readonly options: CodexAppServerRpcClientOptions) { + this.options.stdout.on("data", this.handleStdoutData) + this.options.stdout.on("error", this.handleStreamError) + this.options.stdout.on("end", this.handleStreamEnd) + this.options.stdout.on("close", this.handleStreamEnd) + + this.options.stderr?.on("data", this.handleStderrData) + this.options.stderr?.on("error", this.handleProtocolError) + + if (this.options.signal) { + this.abortListener = () => { + this.close(new Error("Codex app-server request was aborted.")) + } + if (this.options.signal.aborted) { + this.abortListener() + } else { + this.options.signal.addEventListener("abort", this.abortListener, { once: true }) + } + } + } + + request(method: string, params?: unknown): Promise { + if (this.closed) { + return Promise.reject(new Error("Codex app-server RPC client is closed.")) + } + + const id = this.nextRequestId++ + const requestId = String(id) + const message: Record = { id, method } + if (params !== undefined) message.params = params + + return new Promise((resolve, reject) => { + this.pending.set(requestId, { method, resolve, reject }) + try { + this.writeMessage(message) + } catch (error) { + this.pending.delete(requestId) + reject(toError(error)) + } + }) + } + + notify(method: string, params?: unknown): void { + const message: Record = { method } + if (params !== undefined) message.params = params + this.writeMessage(message) + } + + respond(id: CodexAppServerJsonRpcId, result: unknown): void { + this.writeMessage({ + id, + ...(result !== undefined ? { result } : {}), + }) + } + + respondError( + id: CodexAppServerJsonRpcId, + error: CodexAppServerRpcErrorShape, + ): void { + this.writeMessage({ id, error }) + } + + close(error?: Error): void { + if (this.closed) return + this.closed = true + this.options.stdout.off("data", this.handleStdoutData) + this.options.stdout.off("error", this.handleStreamError) + this.options.stdout.off("end", this.handleStreamEnd) + this.options.stdout.off("close", this.handleStreamEnd) + this.options.stderr?.off("data", this.handleStderrData) + this.options.stderr?.off("error", this.handleProtocolError) + if (this.options.signal && this.abortListener) { + this.options.signal.removeEventListener("abort", this.abortListener) + } + + const closeError = error ?? new Error("Codex app-server RPC client closed.") + for (const pending of this.pending.values()) { + pending.reject(closeError) + } + this.pending.clear() + } + + private writeMessage(message: Record): void { + if (this.closed) { + throw new Error("Codex app-server RPC client is closed.") + } + + this.options.stdin.write(`${JSON.stringify(message)}\n`) + } + + private readonly handleStdoutData = (chunk: Buffer | string): void => { + this.stdoutRemainder = this.consumeJsonLines( + this.stdoutRemainder + chunk.toString(), + this.handleWireLine, + ) + } + + private readonly handleStderrData = (chunk: Buffer | string): void => { + this.stderrRemainder = this.consumeJsonLines( + this.stderrRemainder + chunk.toString(), + (line) => this.options.onStderrLine?.(line), + ) + } + + private consumeJsonLines( + value: string, + onLine: (line: string) => void, + ): string { + const lines = value.split("\n") + const remainder = lines.pop() ?? "" + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, "") + if (line.trim()) onLine(line) + } + return remainder + } + + private readonly handleWireLine = (line: string): void => { + let decoded: unknown + try { + decoded = JSON.parse(line) + } catch (error) { + this.handleProtocolError( + new Error(`Failed to parse Codex app-server JSON line: ${toError(error).message}`), + ) + return + } + + if (!isRecord(decoded)) { + this.handleProtocolError(new Error("Codex app-server message must be an object.")) + return + } + + if (isJsonRpcId(decoded.id) && ("result" in decoded || "error" in decoded)) { + this.handleResponse(decoded) + return + } + + if (typeof decoded.method === "string" && isJsonRpcId(decoded.id)) { + this.options.onRequest?.({ + id: decoded.id, + method: decoded.method, + ...(decoded.params !== undefined ? { params: decoded.params } : {}), + }) + return + } + + if (typeof decoded.method === "string" && !("id" in decoded)) { + this.options.onNotification?.({ + method: decoded.method, + ...(decoded.params !== undefined ? { params: decoded.params } : {}), + }) + return + } + + this.handleProtocolError( + new Error("Codex app-server message was not a response, request, or notification."), + ) + } + + private handleResponse(message: Record): void { + const id = String(message.id) + const pending = this.pending.get(id) + if (!pending) { + this.handleProtocolError( + new Error(`Codex app-server response had no pending request for id ${id}.`), + ) + return + } + + this.pending.delete(id) + if ("error" in message && message.error !== undefined) { + pending.reject(errorFromRpcResponse(pending.method, id, message.error)) + return + } + pending.resolve(message.result) + } + + private readonly handleStreamError = (error: Error): void => { + this.close(error) + } + + private readonly handleStreamEnd = (): void => { + this.close(new Error("Codex app-server stdout closed.")) + } + + private readonly handleProtocolError = (error: Error): void => { + this.options.onProtocolError?.(error) + } +} diff --git a/src/main/lib/agent-runtime/codex-app-server-events.ts b/src/main/lib/agent-runtime/codex-app-server-events.ts new file mode 100644 index 000000000..d9dc49995 --- /dev/null +++ b/src/main/lib/agent-runtime/codex-app-server-events.ts @@ -0,0 +1,788 @@ +import type { + AgentRuntimeConversationBlock, + AgentRuntimeStreamEvent, +} from "./types" +import { + providerRuntimeEventToStreamEvent, + type CanonicalRuntimeItemType, + type CanonicalRuntimeRequestType, + type ProviderRuntimeEvent, + type ProviderRuntimeEventType, +} from "./provider-runtime-contract" + +export interface CodexAppServerNotification { + method: string + params?: unknown +} + +export interface CodexAppServerServerRequest { + id: string | number + method: string + params?: unknown +} + +export interface CodexAppServerNotificationContext { + providerInstanceId?: string | null + nativeSessionId?: string | null + eventId?: string | null + createdAt?: string | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function readRecord(value: unknown): Record | null { + return isRecord(value) ? value : null +} + +function readString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + +function readArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +export function codexAppServerServerRequestId( + value: CodexAppServerServerRequest, +): string { + const params = readRecord(value.params) ?? {} + return ( + readString(params.approvalId) ?? + readString(params.requestId) ?? + readString(params.itemId) ?? + readString(params.callId) ?? + String(value.id) + ) +} + +function readThreadId(method: string, params: Record): string | undefined { + if (method === "thread/started") { + return readString(readRecord(params.thread)?.id) + } + return readString(params.threadId) +} + +function readTurnRecord(params: Record): Record | null { + return readRecord(params.turn) +} + +function readTurnId(params: Record): string | undefined { + return readString(params.turnId) ?? readString(readTurnRecord(params)?.id) +} + +function readItemRecord(params: Record): Record | null { + return readRecord(params.item) +} + +function readItemId(params: Record): string | undefined { + return readString(params.itemId) ?? readString(readItemRecord(params)?.id) +} + +function mapCodexAppServerItemType(value: unknown): CanonicalRuntimeItemType { + switch (value) { + case "userMessage": + return "user_message" + case "agentMessage": + return "assistant_message" + case "reasoning": + return "reasoning" + case "plan": + return "plan" + case "commandExecution": + return "command_execution" + case "fileChange": + return "file_change" + case "mcpToolCall": + return "mcp_tool_call" + case "dynamicToolCall": + return "dynamic_tool_call" + case "collabAgentToolCall": + return "collab_agent_tool_call" + case "webSearch": + return "web_search" + case "imageView": + return "image_view" + case "enteredReviewMode": + return "review_entered" + case "exitedReviewMode": + return "review_exited" + case "contextCompaction": + return "context_compaction" + default: + return "unknown" + } +} + +export function codexAppServerRequestType( + method: string, +): CanonicalRuntimeRequestType { + switch (method) { + case "item/commandExecution/requestApproval": + return "command_execution_approval" + case "item/fileRead/requestApproval": + return "file_read_approval" + case "item/fileChange/requestApproval": + return "file_change_approval" + case "applyPatchApproval": + return "apply_patch_approval" + case "execCommandApproval": + return "exec_command_approval" + case "item/tool/requestUserInput": + return "tool_user_input" + case "item/tool/call": + return "dynamic_tool_call" + case "account/chatgptAuthTokens/refresh": + return "auth_tokens_refresh" + default: + return "unknown" + } +} + +function titleForItem(item: Record): string | undefined { + switch (item.type) { + case "commandExecution": + return readString(item.command) + case "mcpToolCall": { + const server = readString(item.server) + const tool = readString(item.tool) + return server && tool ? `${server}.${tool}` : tool ?? server + } + case "dynamicToolCall": + return readString(item.tool) + case "webSearch": + return readString(item.query) + case "imageView": + return readString(item.path) + case "imageGeneration": + return readString(item.revisedPrompt) ?? readString(item.result) + case "enteredReviewMode": + case "exitedReviewMode": + return readString(item.review) + default: + return undefined + } +} + +function detailForItem(item: Record): string | undefined { + return ( + readString(item.command) ?? + readString(item.title) ?? + readString(item.summary) ?? + readString(item.text) ?? + readString(item.aggregatedOutput) ?? + readString(item.path) ?? + readString(item.prompt) + ) +} + +function detailForRequest( + method: string, + params: Record, +): string | undefined { + switch (method) { + case "item/commandExecution/requestApproval": + return readString(params.command) ?? readString(params.reason) + case "item/fileChange/requestApproval": + case "applyPatchApproval": + return readString(params.reason) + case "execCommandApproval": { + const command = readArray(params.command) + .filter((entry): entry is string => typeof entry === "string") + .join(" ") + return readString(params.reason) ?? readString(command) + } + case "item/tool/call": + return readString(params.tool) + default: + return undefined + } +} + +function tokenUsagePayload(params: Record): Record { + const tokenUsage = readRecord(params.tokenUsage) + const total = readRecord(tokenUsage?.total) ?? tokenUsage ?? {} + const last = readRecord(tokenUsage?.last) + const totalTokens = readNumber(total.totalTokens) + const usedTokens = readNumber(total.usedTokens) ?? totalTokens + const maxTokens = + readNumber(tokenUsage?.modelContextWindow) ?? + readNumber(tokenUsage?.maxTokens) ?? + readNumber(total.modelContextWindow) ?? + readNumber(total.maxTokens) + return { + usage: { + inputTokens: readNumber(total.inputTokens), + outputTokens: readNumber(total.outputTokens), + totalTokens, + usedTokens, + totalProcessedTokens: + readNumber(tokenUsage?.totalProcessedTokens) ?? + readNumber(total.totalProcessedTokens), + reasoningOutputTokens: readNumber(total.reasoningOutputTokens), + cachedInputTokens: readNumber(total.cachedInputTokens), + maxTokens, + modelContextWindow: maxTokens, + lastUsedTokens: readNumber(last?.usedTokens) ?? readNumber(last?.totalTokens), + lastInputTokens: readNumber(last?.inputTokens), + lastCachedInputTokens: readNumber(last?.cachedInputTokens), + lastOutputTokens: readNumber(last?.outputTokens), + lastReasoningOutputTokens: readNumber(last?.reasoningOutputTokens), + toolUses: readNumber(tokenUsage?.toolUses) ?? readNumber(total.toolUses), + durationMs: readNumber(tokenUsage?.durationMs) ?? readNumber(total.durationMs), + compactsAutomatically: + readBoolean(tokenUsage?.compactsAutomatically) ?? + readBoolean(total.compactsAutomatically), + last, + }, + } +} + +function compactObject(value: Record): Record { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) +} + +function makeProviderRuntimeEvent( + type: ProviderRuntimeEventType, + notification: CodexAppServerNotification, + context: CodexAppServerNotificationContext, + payload: Record, + fallbackEventId?: string, +): ProviderRuntimeEvent | null { + const params = readRecord(notification.params) ?? {} + const threadId = readThreadId(notification.method, params) + const turnId = readTurnId(params) + const itemId = readItemId(params) + const requestId = readString(params.requestId) + + return compactObject({ + type, + engineId: "codex", + providerInstanceId: readString(context.providerInstanceId), + nativeSessionId: readString(context.nativeSessionId) ?? null, + eventId: readString(context.eventId) ?? readString(fallbackEventId), + createdAt: readString(context.createdAt), + threadId, + turnId, + itemId, + requestId, + payload, + raw: notification, + }) as unknown as ProviderRuntimeEvent +} + +function itemPayload(params: Record): Record { + const item = readItemRecord(params) ?? {} + return { + itemType: mapCodexAppServerItemType(item.type), + title: titleForItem(item), + status: item.status, + detail: detailForItem(item), + data: item, + } +} + +function warningPayload(params: Record): Record { + return { + summary: + readString(params.summary) ?? + readString(params.title) ?? + readString(params.message) ?? + "Codex app-server warning", + message: readString(params.message) ?? readString(params.details), + details: readString(params.details), + data: params, + } +} + +function hookRunOutput(run: Record): string | undefined { + const entries = readArray(run.entries) + .map((entry) => readString(readRecord(entry)?.text)) + .filter((entry): entry is string => Boolean(entry)) + return readString(entries.join("\n")) ?? readString(run.statusMessage) +} + +function hookRunOutcome(run: Record): "success" | "error" | "cancelled" { + const status = readString(run.status) + if (status === "completed") return "success" + if (status === "stopped") return "cancelled" + return "error" +} + +function hookRunPayload( + params: Record, + phase: "started" | "completed", +): Record { + const run = readRecord(params.run) ?? {} + const hookId = readString(run.id) + const hookName = + readString(run.sourcePath) ?? + readString(run.handlerType) ?? + "hook" + const output = hookRunOutput(run) + + return compactObject({ + hookId, + hookName, + hookEvent: readString(run.eventName), + status: readString(run.status), + ...(phase === "completed" ? { outcome: hookRunOutcome(run) } : {}), + output, + durationMs: readNumber(run.durationMs), + run, + }) +} + +function directBlockPatch( + notification: CodexAppServerNotification, + context: CodexAppServerNotificationContext, + patch: Partial, +): AgentRuntimeStreamEvent | null { + const params = readRecord(notification.params) ?? {} + const id = readItemId(params) ?? readString(params.requestId) + if (!id) return null + const patchRecord = patch as Record + const metadata = isRecord(patchRecord.metadata) ? patchRecord.metadata : {} + + return { + type: "conversation-block-update", + id, + patch: { + ...patch, + metadata: { + ...metadata, + codexAppServerMethod: notification.method, + ...(readString(context.providerInstanceId) + ? { providerInstanceId: readString(context.providerInstanceId) } + : {}), + }, + } as Partial, + } +} + +export function codexAppServerServerRequestToProviderRuntimeEvent( + request: CodexAppServerServerRequest, + context: CodexAppServerNotificationContext = {}, +): ProviderRuntimeEvent | null { + const params = readRecord(request.params) ?? {} + const requestId = codexAppServerServerRequestId(request) + const base = compactObject({ + engineId: "codex", + providerInstanceId: readString(context.providerInstanceId), + nativeSessionId: readString(context.nativeSessionId) ?? null, + eventId: readString(context.eventId), + createdAt: readString(context.createdAt), + threadId: readString(params.threadId) ?? readString(context.nativeSessionId), + turnId: readString(params.turnId), + requestId, + raw: request, + }) + + if (request.method === "item/tool/requestUserInput") { + return { + ...base, + type: "user-input.requested", + payload: { + requestType: codexAppServerRequestType(request.method), + questions: params.questions, + args: params, + }, + } as ProviderRuntimeEvent + } + + return { + ...base, + type: "request.opened", + payload: compactObject({ + requestType: codexAppServerRequestType(request.method), + detail: detailForRequest(request.method, params), + args: params, + }), + } as ProviderRuntimeEvent +} + +export function codexAppServerServerRequestToStreamEvent( + request: CodexAppServerServerRequest, + context: CodexAppServerNotificationContext = {}, +): AgentRuntimeStreamEvent | null { + const event = codexAppServerServerRequestToProviderRuntimeEvent(request, context) + return event ? providerRuntimeEventToStreamEvent(event) : null +} + +export function codexAppServerNotificationToProviderRuntimeEvent( + notification: CodexAppServerNotification, + context: CodexAppServerNotificationContext = {}, +): ProviderRuntimeEvent | null { + const params = readRecord(notification.params) ?? {} + + switch (notification.method) { + case "session/connecting": + return makeProviderRuntimeEvent("session.state.changed", notification, context, { + state: "starting", + reason: + readString(params.message) ?? + "Starting Codex app-server session.", + }) + case "session/ready": + return makeProviderRuntimeEvent("session.state.changed", notification, context, { + state: "ready", + reason: + readString(params.message) ?? + "Codex app-server session ready.", + }) + case "session/error": + return makeProviderRuntimeEvent("session.state.changed", notification, context, { + state: "error", + reason: + readString(params.message) ?? + readString(readRecord(params.error)?.message) ?? + "Codex app-server session failed.", + error: params.error, + }) + case "thread/started": + return makeProviderRuntimeEvent("thread.started", notification, context, { + thread: params.thread, + }) + case "thread/status/changed": + return makeProviderRuntimeEvent("thread.state.changed", notification, context, { + status: params.status, + }) + case "thread/tokenUsage/updated": + return makeProviderRuntimeEvent( + "thread.token-usage.updated", + notification, + context, + tokenUsagePayload(params), + ) + case "turn/started": + return makeProviderRuntimeEvent("turn.started", notification, context, { + state: readString(readTurnRecord(params)?.status) ?? "inProgress", + turn: params.turn, + }) + case "turn/completed": { + const turn = readTurnRecord(params) + return makeProviderRuntimeEvent("turn.completed", notification, context, { + state: readString(turn?.status) ?? "failed", + error: turn?.error, + turn, + }) + } + case "turn/plan/updated": + return makeProviderRuntimeEvent("turn.plan.updated", notification, context, { + plan: params.plan, + explanation: params.explanation, + }) + case "item/plan/delta": + return makeProviderRuntimeEvent("turn.proposed.delta", notification, context, { + delta: readString(params.delta) ?? "", + }) + case "hook/started": { + const run = readRecord(params.run) ?? {} + return makeProviderRuntimeEvent( + "hook.started", + notification, + context, + hookRunPayload(params, "started"), + `codex-app-server:hook:${readString(run.id) ?? "unknown"}`, + ) + } + case "hook/completed": { + const run = readRecord(params.run) ?? {} + return makeProviderRuntimeEvent( + "hook.completed", + notification, + context, + hookRunPayload(params, "completed"), + `codex-app-server:hook:${readString(run.id) ?? "unknown"}`, + ) + } + case "item/started": + return makeProviderRuntimeEvent( + "item.started", + notification, + context, + itemPayload(params), + ) + case "item/completed": { + const item = readItemRecord(params) ?? {} + const planMarkdown = item.type === "plan" ? readString(item.text) : undefined + if (planMarkdown) { + return makeProviderRuntimeEvent( + "turn.proposed.completed", + notification, + context, + { + planMarkdown, + data: item, + }, + ) + } + return makeProviderRuntimeEvent( + "item.completed", + notification, + context, + itemPayload(params), + ) + } + case "serverRequest/resolved": + return makeProviderRuntimeEvent("request.resolved", notification, context, { + requestId: params.requestId, + requestType: readString(params.requestType), + rawRequestId: params.rawRequestId, + state: "resolved", + }) + case "item/mcpToolCall/progress": + return makeProviderRuntimeEvent("tool.progress", notification, context, { + toolUseId: readString(params.itemId), + summary: readString(params.message), + toolName: readString(params.toolName), + data: params, + }) + case "model/rerouted": + return makeProviderRuntimeEvent("model.rerouted", notification, context, { + fromModel: params.fromModel, + toModel: params.toModel, + reason: params.reason, + }) + case "thread/realtime/started": + return makeProviderRuntimeEvent( + "thread.realtime.started", + notification, + context, + { + threadId: readString(params.threadId), + realtimeSessionId: readString(params.realtimeSessionId), + version: readString(params.version), + }, + `codex-app-server:realtime:${readString(params.threadId) ?? "unknown"}`, + ) + case "thread/realtime/itemAdded": + return makeProviderRuntimeEvent( + "thread.realtime.item-added", + notification, + context, + { + threadId: readString(params.threadId), + item: params.item, + }, + `codex-app-server:realtime:${readString(params.threadId) ?? "unknown"}`, + ) + case "thread/realtime/outputAudio/delta": + return makeProviderRuntimeEvent( + "thread.realtime.audio.delta", + notification, + context, + { + threadId: readString(params.threadId), + audio: params.audio, + }, + `codex-app-server:realtime:${readString(params.threadId) ?? "unknown"}`, + ) + case "thread/realtime/error": + return makeProviderRuntimeEvent( + "thread.realtime.error", + notification, + context, + { + threadId: readString(params.threadId), + message: readString(params.message) ?? "Realtime error", + }, + `codex-app-server:realtime:${readString(params.threadId) ?? "unknown"}`, + ) + case "thread/realtime/closed": + return makeProviderRuntimeEvent( + "thread.realtime.closed", + notification, + context, + { + threadId: readString(params.threadId), + reason: readString(params.reason), + }, + `codex-app-server:realtime:${readString(params.threadId) ?? "unknown"}`, + ) + case "configWarning": + return makeProviderRuntimeEvent( + "config.warning", + notification, + context, + warningPayload(params), + ) + case "account/updated": + return makeProviderRuntimeEvent( + "account.updated", + notification, + context, + { + account: params, + }, + "codex-app-server:account/updated", + ) + case "account/rateLimits/updated": + return makeProviderRuntimeEvent( + "account.rate-limits.updated", + notification, + context, + { + rateLimits: readRecord(params.rateLimits) ?? params.rateLimits ?? params, + }, + "codex-app-server:account/rateLimits/updated", + ) + case "mcpServer/startupStatus/updated": + return makeProviderRuntimeEvent( + "mcp.status.updated", + notification, + context, + { + name: readString(params.name), + status: readString(params.status), + error: readString(params.error), + data: params, + }, + `codex-app-server:mcp:${readString(params.name) ?? "unknown"}`, + ) + case "mcpServer/oauthLogin/completed": + return makeProviderRuntimeEvent( + "mcp.oauth.completed", + notification, + context, + { + name: readString(params.name), + success: readBoolean(params.success) ?? false, + error: readString(params.error), + }, + `codex-app-server:mcp-oauth:${readString(params.name) ?? "unknown"}`, + ) + case "app/list/updated": + return makeProviderRuntimeEvent( + "app.list.updated", + notification, + context, + { + apps: readArray(params.data), + }, + "codex-app-server:app/list/updated", + ) + case "externalAgentConfig/import/completed": + return makeProviderRuntimeEvent( + "config.updated", + notification, + context, + { + source: "external-agent-config", + title: "External config imported", + message: "External agent configuration import completed.", + }, + "codex-app-server:externalAgentConfig/import/completed", + ) + case "deprecationNotice": + return makeProviderRuntimeEvent( + "deprecation.notice", + notification, + context, + { + summary: + readString(params.summary) ?? + "Codex app-server deprecation notice", + details: readString(params.details), + }, + "codex-app-server:deprecationNotice", + ) + case "warning": + case "guardianWarning": + return makeProviderRuntimeEvent( + "runtime.warning", + notification, + context, + warningPayload(params), + ) + case "error": { + const error = readRecord(params.error) + return makeProviderRuntimeEvent("runtime.error", notification, context, { + message: + readString(error?.message) ?? + readString(params.message) ?? + "Codex app-server error.", + error: params.error, + willRetry: params.willRetry, + }) + } + default: + return null + } +} + +export function codexAppServerNotificationToStreamEvent( + notification: CodexAppServerNotification, + context: CodexAppServerNotificationContext = {}, +): AgentRuntimeStreamEvent | null { + const params = readRecord(notification.params) ?? {} + + switch (notification.method) { + case "item/agentMessage/delta": { + const delta = readString(params.delta) + return delta ? { type: "text", text: delta } : null + } + case "item/plan/delta": { + const event = codexAppServerNotificationToProviderRuntimeEvent( + notification, + context, + ) + return event ? providerRuntimeEventToStreamEvent(event) : null + } + case "item/commandExecution/outputDelta": + case "item/fileChange/outputDelta": { + const delta = readString(params.delta) + return delta + ? directBlockPatch(notification, context, { + output: { delta }, + }) + : null + } + case "item/reasoning/summaryTextDelta": + case "item/reasoning/textDelta": { + const delta = readString(params.delta) + return delta + ? directBlockPatch(notification, context, { + summary: delta, + }) + : null + } + case "turn/diff/updated": + return directBlockPatch(notification, context, { + input: { + diff: params.diff, + }, + }) + default: { + const event = codexAppServerNotificationToProviderRuntimeEvent( + notification, + context, + ) + return event ? providerRuntimeEventToStreamEvent(event) : null + } + } +} + +export function codexAppServerNotificationsToStreamEvents( + notifications: readonly CodexAppServerNotification[], + context: CodexAppServerNotificationContext = {}, +): AgentRuntimeStreamEvent[] { + return notifications + .map((notification) => + codexAppServerNotificationToStreamEvent(notification, context), + ) + .filter((event): event is AgentRuntimeStreamEvent => Boolean(event)) +} diff --git a/src/main/lib/agent-runtime/codex-app-server-plan.ts b/src/main/lib/agent-runtime/codex-app-server-plan.ts new file mode 100644 index 000000000..f42175dae --- /dev/null +++ b/src/main/lib/agent-runtime/codex-app-server-plan.ts @@ -0,0 +1,339 @@ +import { homedir } from "node:os" +import path from "node:path" +import { resolveCodexNativeCommand } from "./codex-native-session" +import { + resolveCodexAppServerPolicies, + type CodexAppServerApprovalPolicy, + type CodexAppServerRuntimeMode, + type CodexAppServerSandboxMode, +} from "./codex-app-server-policy" +import { + resolveAgentRuntimeProviderInstanceId, + type AgentRuntimeProviderInstanceId, +} from "./provider-instances" +import type { AgentPermissionMode, AgentRuntimeSessionRef } from "./types" + +export type CodexAppServerSandboxPolicy = + | { type: "readOnly" } + | { type: "workspaceWrite"; writableRoots?: string[] } + | { type: "dangerFullAccess" } + +export type CodexAppServerInteractionMode = "default" | "plan" + +export interface CodexAppServerImageInput { + type: "image" + url: string +} + +export type CodexAppServerUserInput = + | { + type: "text" + text: string + } + | CodexAppServerImageInput + +export interface CodexAppServerCollaborationMode { + mode: CodexAppServerInteractionMode + settings: { + model: string + reasoning_effort: string + developer_instructions?: string + } +} + +export interface CodexAppServerThreadStartParams { + cwd: string + approvalPolicy?: CodexAppServerApprovalPolicy + sandbox?: CodexAppServerSandboxMode + model?: string + serviceTier?: string +} + +export interface CodexAppServerThreadResumeParams + extends CodexAppServerThreadStartParams { + threadId: string +} + +export interface CodexAppServerThreadForkParams + extends CodexAppServerThreadStartParams { + threadId: string +} + +export interface CodexAppServerTurnStartParams { + threadId: string + input: CodexAppServerUserInput[] + approvalPolicy?: CodexAppServerApprovalPolicy + sandboxPolicy?: CodexAppServerSandboxPolicy + model?: string + serviceTier?: string + effort?: string + collaborationMode?: CodexAppServerCollaborationMode +} + +export interface CodexAppServerLaunchPlan { + engine: "codex" + bridge: "codex-app-server" + command: string + args: string[] + cwd: string + env: Record + extendEnv: boolean + providerInstanceId: AgentRuntimeProviderInstanceId + modelId?: string + permissionMode: AgentPermissionMode + runtimeMode: CodexAppServerRuntimeMode + approvalPolicy?: CodexAppServerApprovalPolicy + sandboxMode?: CodexAppServerSandboxMode + usesCodexConfigDefaults: boolean + bypassApprovalsAndSandbox: boolean + notes: string[] +} + +export interface BuildCodexAppServerLaunchPlanInput { + session: AgentRuntimeSessionRef + command?: string | null + appServerArgs?: readonly string[] | null + env?: NodeJS.ProcessEnv | Record | null + codeHome?: string | null + homeDir?: string | null + providerInstanceId?: string | null +} + +export interface BuildCodexAppServerThreadParamsInput { + plan: Pick< + CodexAppServerLaunchPlan, + "cwd" | "modelId" | "approvalPolicy" | "sandboxMode" | "usesCodexConfigDefaults" + > + serviceTier?: string | null +} + +export interface BuildCodexAppServerTurnParamsInput { + plan: Pick< + CodexAppServerLaunchPlan, + "modelId" | "approvalPolicy" | "runtimeMode" | "usesCodexConfigDefaults" + > + threadId: string + prompt?: string | null + images?: readonly CodexAppServerImageInput[] | null + serviceTier?: string | null + effort?: string | null + interactionMode?: CodexAppServerInteractionMode | null + developerInstructions?: string | null +} + +function cleanString(value: string | null | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function readMetadataModelSelection( + metadata: Record | undefined, +): Record | null { + const modelSelection = metadata?.modelSelection + return isRecord(modelSelection) ? modelSelection : null +} + +function requireCleanString( + value: string | null | undefined, + label: string, +): string { + const cleaned = cleanString(value) + if (!cleaned) { + throw new Error(`Codex app-server ${label} is required.`) + } + return cleaned +} + +function cleanAppServerArgs(args: readonly string[] | null | undefined): string[] { + return (args ?? []) + .map((argument) => cleanString(argument)) + .filter((argument): argument is string => Boolean(argument)) +} + +function cleanEnv( + env: NodeJS.ProcessEnv | Record | null | undefined, +): Record { + const cleaned: Record = {} + for (const [key, value] of Object.entries(env ?? {})) { + if (value !== undefined) cleaned[key] = value + } + return cleaned +} + +function expandHomePath(value: string, homeDir: string): string { + if (value === "~") return homeDir + if (value.startsWith("~/")) return path.join(homeDir, value.slice(2)) + return value +} + +function resolveCodeHome(input: BuildCodexAppServerLaunchPlanInput): string | undefined { + const configured = cleanString(input.codeHome) ?? cleanString(input.env?.CODEX_HOME) + if (!configured) return undefined + return expandHomePath(configured, cleanString(input.homeDir) ?? homedir()) +} + +export function codexAppServerRuntimeModeToSandboxPolicy( + runtimeMode: CodexAppServerRuntimeMode, +): CodexAppServerSandboxPolicy { + if (runtimeMode === "approval-required") return { type: "readOnly" } + if (runtimeMode === "auto-accept-edits") return { type: "workspaceWrite" } + return { type: "dangerFullAccess" } +} + +export function hasConfiguredCodexAppServerMcpServer( + appServerArgs: readonly string[] | null | undefined, +): boolean { + return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true +} + +export function isRecoverableCodexAppServerThreadResumeError( + error: unknown, +): boolean { + const message = (error instanceof Error ? error.message : String(error)).toLowerCase() + if (!message.includes("thread")) return false + return [ + "not found", + "missing thread", + "no such thread", + "unknown thread", + "does not exist", + ].some((snippet) => message.includes(snippet)) +} + +export function buildCodexAppServerLaunchPlan( + input: BuildCodexAppServerLaunchPlanInput, +): CodexAppServerLaunchPlan { + if (input.session.engineId !== "codex") { + throw new Error("Codex app-server launch plans require a codex session.") + } + + const cwd = requireCleanString(input.session.cwd, "working directory") + const permissionMode = input.session.permissionMode ?? "agent" + const policies = resolveCodexAppServerPolicies(permissionMode) + const env = cleanEnv(input.env) + const codeHome = resolveCodeHome(input) + if (codeHome) env.CODEX_HOME = codeHome + + const explicitProviderInstanceId = cleanString(input.providerInstanceId) + const providerInstanceId = explicitProviderInstanceId + ? (explicitProviderInstanceId as AgentRuntimeProviderInstanceId) + : resolveAgentRuntimeProviderInstanceId(input.session) + const metadataModelSelection = readMetadataModelSelection(input.session.metadata) + const modelId = + cleanString(input.session.modelSelection?.modelId) ?? + cleanString(metadataModelSelection?.modelId as string | undefined) ?? + cleanString(metadataModelSelection?.model as string | undefined) ?? + cleanString(input.session.modelId) + + return { + engine: "codex", + bridge: "codex-app-server", + command: resolveCodexNativeCommand({ + command: input.command, + env: input.env, + homeDir: input.homeDir, + }), + args: ["app-server", ...cleanAppServerArgs(input.appServerArgs)], + cwd, + env, + extendEnv: input.env === undefined || input.env === null, + providerInstanceId, + ...(modelId ? { modelId } : {}), + permissionMode, + runtimeMode: policies.runtimeMode, + ...(policies.approvalPolicy ? { approvalPolicy: policies.approvalPolicy } : {}), + ...(policies.sandboxMode ? { sandboxMode: policies.sandboxMode } : {}), + usesCodexConfigDefaults: policies.usesCodexConfigDefaults, + bypassApprovalsAndSandbox: policies.bypassApprovalsAndSandbox, + notes: [ + "Codex app-server is launched as a persistent stdio RPC process.", + ...policies.notes, + ...(hasConfiguredCodexAppServerMcpServer(input.appServerArgs) + ? ["Codex app-server args include inline MCP configuration."] + : []), + ], + } +} + +export function buildCodexAppServerThreadStartParams( + input: BuildCodexAppServerThreadParamsInput, +): CodexAppServerThreadStartParams { + return { + cwd: input.plan.cwd, + ...(!input.plan.usesCodexConfigDefaults && input.plan.approvalPolicy + ? { approvalPolicy: input.plan.approvalPolicy } + : {}), + ...(!input.plan.usesCodexConfigDefaults && input.plan.sandboxMode + ? { sandbox: input.plan.sandboxMode } + : {}), + ...(input.plan.modelId ? { model: input.plan.modelId } : {}), + ...(cleanString(input.serviceTier) ? { serviceTier: cleanString(input.serviceTier) } : {}), + } +} + +export function buildCodexAppServerThreadResumeParams( + input: BuildCodexAppServerThreadParamsInput & { threadId: string }, +): CodexAppServerThreadResumeParams { + return { + threadId: requireCleanString(input.threadId, "thread id"), + ...buildCodexAppServerThreadStartParams(input), + } +} + +export function buildCodexAppServerThreadForkParams( + input: BuildCodexAppServerThreadParamsInput & { threadId: string }, +): CodexAppServerThreadForkParams { + return { + threadId: requireCleanString(input.threadId, "thread id"), + ...buildCodexAppServerThreadStartParams(input), + } +} + +export function buildCodexAppServerTurnStartParams( + input: BuildCodexAppServerTurnParamsInput, +): CodexAppServerTurnStartParams { + const prompt = cleanString(input.prompt) + const serviceTier = cleanString(input.serviceTier) + const effort = cleanString(input.effort) + const developerInstructions = cleanString(input.developerInstructions) + const inputItems: CodexAppServerUserInput[] = [] + if (prompt) inputItems.push({ type: "text", text: prompt }) + for (const image of input.images ?? []) { + if (image.type === "image" && cleanString(image.url)) { + inputItems.push({ type: "image", url: image.url }) + } + } + + const collaborationMode = + input.interactionMode && input.plan.modelId + ? { + mode: input.interactionMode, + settings: { + model: input.plan.modelId, + reasoning_effort: effort ?? "medium", + ...(developerInstructions + ? { developer_instructions: developerInstructions } + : {}), + }, + } + : undefined + + return { + threadId: requireCleanString(input.threadId, "thread id"), + input: inputItems, + ...(!input.plan.usesCodexConfigDefaults && input.plan.approvalPolicy + ? { approvalPolicy: input.plan.approvalPolicy } + : {}), + ...(!input.plan.usesCodexConfigDefaults + ? { sandboxPolicy: codexAppServerRuntimeModeToSandboxPolicy(input.plan.runtimeMode) } + : {}), + ...(input.plan.modelId ? { model: input.plan.modelId } : {}), + ...(serviceTier ? { serviceTier } : {}), + ...(effort ? { effort } : {}), + ...(collaborationMode ? { collaborationMode } : {}), + } +} diff --git a/src/main/lib/agent-runtime/codex-app-server-policy.ts b/src/main/lib/agent-runtime/codex-app-server-policy.ts new file mode 100644 index 000000000..6872a9b82 --- /dev/null +++ b/src/main/lib/agent-runtime/codex-app-server-policy.ts @@ -0,0 +1,93 @@ +import type { AgentPermissionMode } from "./types" + +export type CodexAppServerRuntimeMode = + | "approval-required" + | "auto-accept-edits" + | "full-access" + +export type CodexAppServerApprovalPolicy = + | "untrusted" + | "on-failure" + | "on-request" + | "never" + +export type CodexAppServerSandboxMode = + | "read-only" + | "workspace-write" + | "danger-full-access" + +export interface CodexAppServerPolicyResolution { + runtimeMode: CodexAppServerRuntimeMode + approvalPolicy?: CodexAppServerApprovalPolicy + sandboxMode?: CodexAppServerSandboxMode + bypassApprovalsAndSandbox: boolean + usesCodexConfigDefaults: boolean + notes: string[] +} + +export function resolveCodexAppServerPolicies( + permissionMode: AgentPermissionMode, +): CodexAppServerPolicyResolution { + if (permissionMode === "bypass" || permissionMode === "full-access") { + return { + runtimeMode: "full-access", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + bypassApprovalsAndSandbox: true, + usesCodexConfigDefaults: false, + notes: [ + permissionMode === "full-access" + ? "Moss full-access maps to Codex app-server full-access runtime." + : "Moss bypass maps to Codex app-server full-access runtime.", + ], + } + } + + if (permissionMode === "custom") { + return { + runtimeMode: "approval-required", + bypassApprovalsAndSandbox: false, + usesCodexConfigDefaults: true, + notes: [ + "Moss custom permissions defer app-server approval and sandbox overrides to Codex config.", + ], + } + } + + if (permissionMode === "plan" || permissionMode === "read-only") { + return { + runtimeMode: "approval-required", + approvalPolicy: "untrusted", + sandboxMode: "read-only", + bypassApprovalsAndSandbox: false, + usesCodexConfigDefaults: false, + notes: [ + "Moss read-only permissions map to Codex app-server approval-required runtime.", + ], + } + } + + if (permissionMode === "ask-approval") { + return { + runtimeMode: "auto-accept-edits", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + bypassApprovalsAndSandbox: false, + usesCodexConfigDefaults: false, + notes: [ + "Moss ask-approval maps to workspace-write with on-request approvals.", + ], + } + } + + return { + runtimeMode: "auto-accept-edits", + approvalPolicy: "never", + sandboxMode: "workspace-write", + bypassApprovalsAndSandbox: false, + usesCodexConfigDefaults: false, + notes: [ + "Legacy Moss agent permissions keep Codex app-server workspace writes with approvals disabled for compatibility.", + ], + } +} diff --git a/src/main/lib/agent-runtime/codex-app-server-runtime.ts b/src/main/lib/agent-runtime/codex-app-server-runtime.ts new file mode 100644 index 000000000..7246f9588 --- /dev/null +++ b/src/main/lib/agent-runtime/codex-app-server-runtime.ts @@ -0,0 +1,3824 @@ +import { spawn } from "node:child_process"; +import type { Readable, Writable } from "node:stream"; +import { + CodexAppServerRpcClient, + codexAppServerInvalidParamsError, + type CodexAppServerJsonRpcId, + type CodexAppServerRpcNotification, + type CodexAppServerRpcRequest, +} from "./codex-app-server-client"; +import { + codexAppServerNotificationToStreamEvent, + codexAppServerRequestType, + codexAppServerServerRequestId, + codexAppServerServerRequestToStreamEvent, +} from "./codex-app-server-events"; +import { + buildCodexAppServerLaunchPlan, + buildCodexAppServerThreadForkParams, + buildCodexAppServerThreadResumeParams, + buildCodexAppServerThreadStartParams, + buildCodexAppServerTurnStartParams, + hasConfiguredCodexAppServerMcpServer, + isRecoverableCodexAppServerThreadResumeError, + type CodexAppServerLaunchPlan, +} from "./codex-app-server-plan"; +import { + createAgentRuntimeProcessHandle, + registerAgentRuntimeProcessHandle, + unregisterAgentRuntimeProcessHandle, +} from "./process-registry"; +import { + createAgentRuntimeRunId, + createAgentRuntimeRunReceipt, + createInMemoryAgentRuntimeRunLedger, + projectAgentRuntimeRunLedgerSnapshot, + replayAgentRuntimeRunLedgerEvents, + type AgentRuntimeRunLedger, +} from "./runtime-run-ledger"; +import type { AgentRuntimeReceiptBus } from "./runtime-receipt-bus"; +import type { + AgentRuntimeAccountRateLimitsReadRequest, + AgentRuntimeAccountRateLimitsReadResult, + AgentRuntimeAccountLoginCancelRequest, + AgentRuntimeAccountLoginCancelResult, + AgentRuntimeAccountLoginStartRequest, + AgentRuntimeAccountLoginStartResult, + AgentRuntimeAccountLoginType, + AgentRuntimeAccountLogoutRequest, + AgentRuntimeAccountLogoutResult, + AgentRuntimeAccountReadRequest, + AgentRuntimeAccountReadResult, + AgentRuntimeAccountUsageReadRequest, + AgentRuntimeAccountUsageReadResult, + AgentRuntimeAppListRequest, + AgentRuntimeAppListResult, + AgentRuntimeConfigBatchWriteRequest, + AgentRuntimeConfigReadRequest, + AgentRuntimeConfigReadResult, + AgentRuntimeConfigRequirementsReadRequest, + AgentRuntimeConfigRequirementsReadResult, + AgentRuntimeConfigValueWriteRequest, + AgentRuntimeConfigWriteEdit, + AgentRuntimeConfigWriteMergeStrategy, + AgentRuntimeConfigWriteResult, + AgentRuntimeControlResult, + AgentRuntimeExternalAgentConfigDetectRequest, + AgentRuntimeExternalAgentConfigDetectResult, + AgentRuntimeExternalAgentConfigImportRequest, + AgentRuntimeExternalAgentConfigImportResult, + AgentRuntimeExternalAgentConfigMigrationDetails, + AgentRuntimeExternalAgentConfigMigrationItem, + AgentRuntimeExternalAgentConfigMigrationItemType, + AgentRuntimeHookListRequest, + AgentRuntimeHookListResult, + AgentRuntimeMcpServerConfigReloadRequest, + AgentRuntimeMcpServerConfigReloadResult, + AgentRuntimeMcpServerOauthLoginRequest, + AgentRuntimeMcpServerOauthLoginResult, + AgentRuntimeMcpServerStatusDetail, + AgentRuntimeMcpServerStatusListRequest, + AgentRuntimeMcpServerStatusListResult, + AgentRuntimeModelListRequest, + AgentRuntimeModelListResult, + AgentRuntimePermissionProfileListRequest, + AgentRuntimePermissionProfileListResult, + AgentRuntimePluginInstalledRequest, + AgentRuntimePluginInstalledResult, + AgentRuntimePluginInstallRequest, + AgentRuntimePluginInstallResult, + AgentRuntimePluginListRequest, + AgentRuntimePluginListResult, + AgentRuntimePluginMarketplaceKind, + AgentRuntimePluginReadRequest, + AgentRuntimePluginReadResult, + AgentRuntimeRunAction, + AgentRuntimeRunReceipt, + AgentRuntimeSessionRef, + AgentRuntimeSkillListRequest, + AgentRuntimeSkillListResult, + AgentRuntimeStartRequest, + AgentRuntimeStreamEvent, + AgentRuntimeThreadControlRequest, + AgentRuntimeThreadControlResult, + AgentRuntimeThreadDiffResult, + AgentRuntimeThreadFullDiffRequest, + AgentRuntimeThreadForkRequest, + AgentRuntimeThreadForkResult, + AgentRuntimeThreadGoal, + AgentRuntimeThreadGoalClearRequest, + AgentRuntimeThreadGoalClearResult, + AgentRuntimeThreadGoalGetRequest, + AgentRuntimeThreadGoalGetResult, + AgentRuntimeThreadGoalSetRequest, + AgentRuntimeThreadGoalSetResult, + AgentRuntimeThreadGoalStatus, + AgentRuntimeThreadLoadedListRequest, + AgentRuntimeThreadLoadedListResult, + AgentRuntimeThreadListRequest, + AgentRuntimeThreadListResult, + AgentRuntimeThreadMetadataUpdateRequest, + AgentRuntimeThreadMetadataUpdateResult, + AgentRuntimeThreadNameSetRequest, + AgentRuntimeThreadNameSetResult, + AgentRuntimeThreadReadRequest, + AgentRuntimeThreadReadResult, + AgentRuntimeThreadRollbackRequest, + AgentRuntimeThreadRollbackResult, + AgentRuntimeThreadTurnDiffRequest, + AgentRuntimeToolResultRequest, +} from "./types"; +import { + providerRuntimeEventToStreamEvent, + type CanonicalRuntimeRequestType, +} from "./provider-runtime-contract"; + +interface CodexAppServerProcess { + stdin: Writable; + stdout: Readable; + stderr?: Readable | null; + kill(signal?: NodeJS.Signals | number): boolean; + once( + event: "exit" | "close", + listener: (...args: unknown[]) => void, + ): unknown; +} + +interface PendingServerRequest { + jsonRpcId: CodexAppServerJsonRpcId; + method: string; + requestId: string; + requestType: CanonicalRuntimeRequestType; + turnId?: string; + itemId?: string; +} + +export interface CodexAppServerRuntimeOptions { + spawnProcess?: (plan: CodexAppServerLaunchPlan) => CodexAppServerProcess; + runLedger?: AgentRuntimeRunLedger | null; + recordRunLedger?: boolean; + runtimeReceipts?: AgentRuntimeReceiptBus; + appServerArgs?: readonly string[] | null; + command?: string | null; + env?: NodeJS.ProcessEnv | Record | null; + codeHome?: string | null; + homeDir?: string | null; +} + +let defaultCodexAppServerRuntimeRunLedger = + createInMemoryAgentRuntimeRunLedger(); + +export function getCodexAppServerRuntimeRunLedger(): AgentRuntimeRunLedger { + return defaultCodexAppServerRuntimeRunLedger; +} + +export function resetCodexAppServerRuntimeRunLedgerForTests(): void { + defaultCodexAppServerRuntimeRunLedger = + createInMemoryAgentRuntimeRunLedger(); +} + +const THREAD_GOAL_STATUSES = new Set([ + "active", + "paused", + "blocked", + "usageLimited", + "budgetLimited", + "complete", +]); + +const MCP_SERVER_STATUS_DETAILS = new Set([ + "full", + "toolsAndAuthOnly", +]); + +const CONFIG_WRITE_MERGE_STRATEGIES = + new Set(["replace", "upsert"]); + +const ACCOUNT_LOGIN_TYPES = new Set([ + "apiKey", + "chatgpt", + "chatgptDeviceCode", + "chatgptAuthTokens", +]); + +const PLUGIN_MARKETPLACE_KINDS = new Set([ + "local", + "vertical", + "workspace-directory", + "shared-with-me", +]); + +const EXTERNAL_AGENT_CONFIG_ITEM_TYPES = + new Set([ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS", + ]); + +class AsyncEventQueue implements AsyncIterable { + private readonly items: T[] = []; + private readonly waiters: Array<{ + resolve(value: IteratorResult): void; + reject(error: unknown): void; + }> = []; + private closed = false; + private error: unknown; + + push(item: T): void { + if (this.closed) return; + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve({ value: item, done: false }); + return; + } + this.items.push(item); + } + + close(): void { + if (this.closed) return; + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter.resolve({ value: undefined as T, done: true }); + } + } + + fail(error: unknown): void { + if (this.closed) return; + this.closed = true; + this.error = error; + for (const waiter of this.waiters.splice(0)) { + waiter.reject(error); + } + } + + async next(): Promise> { + if (this.items.length > 0) { + return { value: this.items.shift() as T, done: false }; + } + if (this.error) throw this.error; + if (this.closed) return { value: undefined as T, done: true }; + + return new Promise>((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } + + [Symbol.asyncIterator](): AsyncIterator { + return this; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cleanString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function splitModelAndReasoning(modelId: string | null | undefined): { + modelId?: string; + reasoningEffort?: string; +} { + const cleaned = cleanString(modelId); + if (!cleaned) return {}; + const separatorIndex = cleaned.indexOf("/"); + if (separatorIndex === -1) return { modelId: cleaned }; + + const model = cleanString(cleaned.slice(0, separatorIndex)); + const reasoningEffort = cleanString(cleaned.slice(separatorIndex + 1)); + return { + ...(model ? { modelId: model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + }; +} + +function readOptionsRecord(value: unknown): Record | null { + return isRecord(value) ? value : null; +} + +function resolveSessionModelAndEffort(session: AgentRuntimeSessionRef): { + modelId?: string; + reasoningEffort?: string; + serviceTier?: string; + developerInstructions?: string; +} { + const metadataSelection = readOptionsRecord(session.metadata?.modelSelection); + const options = + readOptionsRecord(session.modelSelection?.options) ?? + readOptionsRecord(metadataSelection?.options); + const selectedModel = + cleanString(session.modelSelection?.modelId) ?? + cleanString(metadataSelection?.modelId) ?? + cleanString(metadataSelection?.model); + const legacy = splitModelAndReasoning(selectedModel ? null : session.modelId); + + return { + modelId: selectedModel ?? legacy.modelId, + reasoningEffort: + cleanString(options?.reasoningEffort) ?? + cleanString(options?.reasoning_effort) ?? + legacy.reasoningEffort, + serviceTier: + cleanString(options?.serviceTier) ?? cleanString(options?.service_tier), + developerInstructions: + cleanString(options?.developerInstructions) ?? + cleanString(options?.developer_instructions), + }; +} + +function resolveModelAndEffort(request: AgentRuntimeStartRequest): { + modelId?: string; + reasoningEffort?: string; + serviceTier?: string; + developerInstructions?: string; +} { + return resolveSessionModelAndEffort(request.session); +} + +function patchPlanModel( + plan: CodexAppServerLaunchPlan, + modelId: string | undefined, +): CodexAppServerLaunchPlan { + if (!modelId || plan.modelId === modelId) return plan; + return { ...plan, modelId }; +} + +function cleanStringArray( + value: string[] | null | undefined, +): string[] | undefined { + const cleaned = (value ?? []) + .map((entry) => cleanString(entry)) + .filter((entry): entry is string => Boolean(entry)); + return cleaned.length > 0 ? cleaned : undefined; +} + +function cleanCwdFilter( + value: string | string[] | null | undefined, +): string | string[] | undefined { + if (Array.isArray(value)) return cleanStringArray(value); + return cleanString(value); +} + +function buildThreadListParams( + request: AgentRuntimeThreadListRequest, +): Record { + const params: Record = {}; + if (typeof request.archived === "boolean") params.archived = request.archived; + const cursor = cleanString(request.cursor); + if (cursor) params.cursor = cursor; + const cwd = cleanCwdFilter(request.cwd); + if (cwd) params.cwd = cwd; + if (request.limit !== null && request.limit !== undefined) { + if (!Number.isInteger(request.limit) || request.limit < 0) { + throw new Error( + "Codex app-server thread/list limit must be an integer >= 0.", + ); + } + params.limit = request.limit; + } + const modelProviders = cleanStringArray(request.modelProviders); + if (modelProviders) params.modelProviders = modelProviders; + const searchTerm = cleanString(request.searchTerm); + if (searchTerm) params.searchTerm = searchTerm; + if (request.sortDirection) params.sortDirection = request.sortDirection; + if (request.sortKey) params.sortKey = request.sortKey; + const sourceKinds = cleanStringArray(request.sourceKinds); + if (sourceKinds) params.sourceKinds = sourceKinds; + if (typeof request.useStateDbOnly === "boolean") { + params.useStateDbOnly = request.useStateDbOnly; + } + return params; +} + +function buildThreadLoadedListParams( + request: AgentRuntimeThreadLoadedListRequest, +): Record { + const params: Record = {}; + const cursor = cleanString(request.cursor); + if (cursor) params.cursor = cursor; + if (request.limit !== null && request.limit !== undefined) { + if (!Number.isInteger(request.limit) || request.limit < 0) { + throw new Error( + "Codex app-server thread/loaded/list limit must be an integer >= 0.", + ); + } + params.limit = request.limit; + } + return params; +} + +function writeOptionalLimitParam( + params: Record, + value: number | null | undefined, + method: string, +): void { + if (value === null || value === undefined) return; + if (!Number.isInteger(value) || value < 0) { + throw new Error( + `Codex app-server ${method} limit must be an integer >= 0.`, + ); + } + params.limit = value; +} + +function buildConfigReadParams( + request: AgentRuntimeConfigReadRequest, +): Record { + const params: Record = {}; + const cwd = cleanString(request.cwd); + if (cwd) params.cwd = cwd; + if (typeof request.includeLayers === "boolean") { + params.includeLayers = request.includeLayers; + } + return params; +} + +function normalizeConfigWriteMergeStrategy( + value: AgentRuntimeConfigWriteMergeStrategy | undefined, +): AgentRuntimeConfigWriteMergeStrategy { + if (value === undefined) return "replace"; + if (!CONFIG_WRITE_MERGE_STRATEGIES.has(value)) { + throw new Error( + `Unsupported Codex app-server config merge strategy: ${value}`, + ); + } + return value; +} + +function buildConfigWriteEdit( + edit: AgentRuntimeConfigWriteEdit, + index: number, +): Record { + const keyPath = cleanString(edit.keyPath); + if (!keyPath) { + throw new Error( + `Codex app-server config/batchWrite edit ${index + 1} requires a keyPath.`, + ); + } + if (edit.value === undefined) { + throw new Error( + `Codex app-server config/batchWrite edit ${index + 1} requires a JSON value.`, + ); + } + return { + keyPath, + mergeStrategy: normalizeConfigWriteMergeStrategy(edit.mergeStrategy), + value: edit.value, + }; +} + +function writeConfigFileOptions( + params: Record, + request: { + filePath?: string | null; + expectedVersion?: string | null; + }, +): void { + const filePath = cleanString(request.filePath); + if (filePath) params.filePath = filePath; + const expectedVersion = cleanString(request.expectedVersion); + if (expectedVersion) params.expectedVersion = expectedVersion; +} + +function buildConfigValueWriteParams( + request: AgentRuntimeConfigValueWriteRequest, +): Record { + const keyPath = cleanString(request.keyPath); + if (!keyPath) { + throw new Error("Codex app-server config/value/write requires a keyPath."); + } + if (request.value === undefined) { + throw new Error( + "Codex app-server config/value/write requires a JSON value.", + ); + } + const params: Record = { + keyPath, + mergeStrategy: normalizeConfigWriteMergeStrategy(request.mergeStrategy), + value: request.value, + }; + writeConfigFileOptions(params, request); + return params; +} + +function buildConfigBatchWriteParams( + request: AgentRuntimeConfigBatchWriteRequest, +): Record { + if (!Array.isArray(request.edits) || request.edits.length === 0) { + throw new Error( + "Codex app-server config/batchWrite requires at least one edit.", + ); + } + const params: Record = { + edits: request.edits.map((edit, index) => + buildConfigWriteEdit(edit, index), + ), + }; + writeConfigFileOptions(params, request); + if (typeof request.reloadUserConfig === "boolean") { + params.reloadUserConfig = request.reloadUserConfig; + } + return params; +} + +function buildPermissionProfileListParams( + request: AgentRuntimePermissionProfileListRequest, +): Record { + const params: Record = {}; + const cursor = cleanString(request.cursor); + if (cursor) params.cursor = cursor; + const cwd = cleanString(request.cwd); + if (cwd) params.cwd = cwd; + writeOptionalLimitParam(params, request.limit, "permissionProfile/list"); + return params; +} + +function buildMcpServerStatusListParams( + request: AgentRuntimeMcpServerStatusListRequest, +): Record { + const params: Record = {}; + const cursor = cleanString(request.cursor); + if (cursor) params.cursor = cursor; + if (request.detail !== null && request.detail !== undefined) { + if (!MCP_SERVER_STATUS_DETAILS.has(request.detail)) { + throw new Error( + `Unsupported Codex app-server mcpServerStatus/list detail: ${request.detail}`, + ); + } + params.detail = request.detail; + } + writeOptionalLimitParam(params, request.limit, "mcpServerStatus/list"); + const threadId = cleanString(request.threadId); + if (threadId) params.threadId = threadId; + return params; +} + +function writeCwdsParam( + params: Record, + cwds: string[] | null | undefined, +): void { + const cleaned = cleanStringArray(cwds); + if (cleaned) params.cwds = cleaned; +} + +function buildSkillListParams( + request: AgentRuntimeSkillListRequest, +): Record { + const params: Record = {}; + writeCwdsParam(params, request.cwds); + if (typeof request.forceReload === "boolean") { + params.forceReload = request.forceReload; + } + return params; +} + +function buildHookListParams( + request: AgentRuntimeHookListRequest, +): Record { + const params: Record = {}; + writeCwdsParam(params, request.cwds); + return params; +} + +function buildAppListParams( + request: AgentRuntimeAppListRequest, +): Record { + const params: Record = {}; + const cursor = cleanString(request.cursor); + if (cursor) params.cursor = cursor; + if (typeof request.forceRefetch === "boolean") { + params.forceRefetch = request.forceRefetch; + } + writeOptionalLimitParam(params, request.limit, "app/list"); + const threadId = cleanString(request.threadId); + if (threadId) params.threadId = threadId; + return params; +} + +function cleanPluginMarketplaceKinds( + value: AgentRuntimePluginMarketplaceKind[] | null | undefined, +): AgentRuntimePluginMarketplaceKind[] | undefined { + const cleaned = (value ?? []) + .map((entry) => cleanString(entry)) + .filter((entry): entry is AgentRuntimePluginMarketplaceKind => + Boolean(entry), + ); + for (const kind of cleaned) { + if (!PLUGIN_MARKETPLACE_KINDS.has(kind)) { + throw new Error( + `Unsupported Codex app-server plugin marketplace kind: ${kind}`, + ); + } + } + return cleaned.length > 0 ? cleaned : undefined; +} + +function buildPluginListParams( + request: AgentRuntimePluginListRequest, +): Record { + const params: Record = {}; + writeCwdsParam(params, request.cwds); + const marketplaceKinds = cleanPluginMarketplaceKinds( + request.marketplaceKinds, + ); + if (marketplaceKinds) params.marketplaceKinds = marketplaceKinds; + return params; +} + +function buildPluginInstalledParams( + request: AgentRuntimePluginInstalledRequest, +): Record { + const params: Record = {}; + writeCwdsParam(params, request.cwds); + const installSuggestionPluginNames = cleanStringArray( + request.installSuggestionPluginNames, + ); + if (installSuggestionPluginNames) { + params.installSuggestionPluginNames = installSuggestionPluginNames; + } + return params; +} + +function writePluginTargetParams( + params: Record, + request: AgentRuntimePluginReadRequest | AgentRuntimePluginInstallRequest, + method: string, +): void { + const pluginName = cleanString(request.pluginName); + if (!pluginName) { + throw new Error(`Codex app-server ${method} requires a pluginName.`); + } + params.pluginName = pluginName; + const marketplacePath = cleanString(request.marketplacePath); + if (marketplacePath) params.marketplacePath = marketplacePath; + const remoteMarketplaceName = cleanString(request.remoteMarketplaceName); + if (remoteMarketplaceName) + params.remoteMarketplaceName = remoteMarketplaceName; +} + +function buildPluginReadParams( + request: AgentRuntimePluginReadRequest, +): Record { + const params: Record = {}; + writePluginTargetParams(params, request, "plugin/read"); + return params; +} + +function buildPluginInstallParams( + request: AgentRuntimePluginInstallRequest, +): Record { + const params: Record = {}; + writePluginTargetParams(params, request, "plugin/install"); + return params; +} + +function buildExternalAgentConfigDetectParams( + request: AgentRuntimeExternalAgentConfigDetectRequest, +): Record { + const params: Record = {}; + writeCwdsParam(params, request.cwds); + if (typeof request.includeHome === "boolean") { + params.includeHome = request.includeHome; + } + return params; +} + +function normalizeNamedMigrationDetails( + value: unknown, +): Array<{ name: string }> | undefined { + if (!Array.isArray(value)) return undefined; + const entries = value.flatMap((entry) => { + const name = cleanString(readOptionsRecord(entry)?.name); + return name ? [{ name }] : []; + }); + return entries.length > 0 ? entries : undefined; +} + +function normalizePluginMigrationDetails( + value: unknown, +): Array<{ marketplaceName: string; pluginNames: string[] }> | undefined { + if (!Array.isArray(value)) return undefined; + const entries = value.flatMap((entry) => { + const record = readOptionsRecord(entry); + const marketplaceName = cleanString(record?.marketplaceName); + const pluginNames = cleanStringArray( + Array.isArray(record?.pluginNames) ? record.pluginNames : undefined, + ); + if (!marketplaceName || !pluginNames) return []; + return [{ marketplaceName, pluginNames }]; + }); + return entries.length > 0 ? entries : undefined; +} + +function normalizeSessionMigrationDetails( + value: unknown, +): Array<{ cwd: string; path: string; title?: string | null }> | undefined { + if (!Array.isArray(value)) return undefined; + const entries = value.flatMap((entry) => { + const record = readOptionsRecord(entry); + const cwd = cleanString(record?.cwd); + const path = cleanString(record?.path); + if (!cwd || !path) return []; + const title = cleanString(record?.title); + return [ + { + cwd, + path, + ...(title ? { title } : record?.title === null ? { title: null } : {}), + }, + ]; + }); + return entries.length > 0 ? entries : undefined; +} + +function normalizeExternalAgentConfigMigrationDetails( + value: unknown, +): AgentRuntimeExternalAgentConfigMigrationDetails | null | undefined { + if (value === null) return null; + const record = readOptionsRecord(value); + if (!record) return undefined; + + const details: AgentRuntimeExternalAgentConfigMigrationDetails = {}; + const commands = normalizeNamedMigrationDetails(record.commands); + if (commands) details.commands = commands; + const hooks = normalizeNamedMigrationDetails(record.hooks); + if (hooks) details.hooks = hooks; + const mcpServers = normalizeNamedMigrationDetails(record.mcpServers); + if (mcpServers) details.mcpServers = mcpServers; + const plugins = normalizePluginMigrationDetails(record.plugins); + if (plugins) details.plugins = plugins; + const sessions = normalizeSessionMigrationDetails(record.sessions); + if (sessions) details.sessions = sessions; + const subagents = normalizeNamedMigrationDetails(record.subagents); + if (subagents) details.subagents = subagents; + + return Object.keys(details).length > 0 ? details : undefined; +} + +function normalizeExternalAgentConfigMigrationItem( + value: unknown, + index: number, +): AgentRuntimeExternalAgentConfigMigrationItem { + const record = readOptionsRecord(value); + if (!record) { + throw new Error( + `Codex app-server externalAgentConfig/import item ${index + 1} must be an object.`, + ); + } + const itemType = cleanString(record.itemType); + if ( + !itemType || + !EXTERNAL_AGENT_CONFIG_ITEM_TYPES.has( + itemType as AgentRuntimeExternalAgentConfigMigrationItemType, + ) + ) { + throw new Error( + `Codex app-server externalAgentConfig/import item ${index + 1} has an unsupported itemType.`, + ); + } + const description = cleanString(record.description); + if (!description) { + throw new Error( + `Codex app-server externalAgentConfig/import item ${index + 1} requires a description.`, + ); + } + const cwd = cleanString(record.cwd); + const details = normalizeExternalAgentConfigMigrationDetails(record.details); + + return { + cwd: cwd ?? null, + description, + ...(details !== undefined ? { details } : {}), + itemType: itemType as AgentRuntimeExternalAgentConfigMigrationItemType, + }; +} + +function readExternalAgentConfigMigrationItems( + response: unknown, +): AgentRuntimeExternalAgentConfigMigrationItem[] { + return readArrayProperty(response, "items").flatMap((item, index) => { + try { + return [normalizeExternalAgentConfigMigrationItem(item, index)]; + } catch { + return []; + } + }); +} + +function buildExternalAgentConfigImportParams( + request: AgentRuntimeExternalAgentConfigImportRequest, +): Record { + if ( + !Array.isArray(request.migrationItems) || + request.migrationItems.length === 0 + ) { + throw new Error( + "Codex app-server externalAgentConfig/import requires at least one migration item.", + ); + } + + return { + migrationItems: request.migrationItems.map((item, index) => + normalizeExternalAgentConfigMigrationItem(item, index), + ), + }; +} + +function buildMcpServerOauthLoginParams( + request: AgentRuntimeMcpServerOauthLoginRequest, +): Record { + const name = cleanString(request.name); + if (!name) { + throw new Error("Codex app-server mcpServer/oauth/login requires a name."); + } + const params: Record = { name }; + if (request.scopes !== null && request.scopes !== undefined) { + const scopes = request.scopes + .map((scope) => cleanString(scope)) + .filter((scope): scope is string => Boolean(scope)); + if (scopes.length > 0) params.scopes = scopes; + } + if (request.timeoutSecs !== null && request.timeoutSecs !== undefined) { + if (!Number.isInteger(request.timeoutSecs) || request.timeoutSecs < 0) { + throw new Error( + "Codex app-server mcpServer/oauth/login timeoutSecs must be an integer >= 0.", + ); + } + params.timeoutSecs = request.timeoutSecs; + } + return params; +} + +function buildModelListParams( + request: AgentRuntimeModelListRequest, +): Record { + const params: Record = {}; + const cursor = cleanString(request.cursor); + if (cursor) params.cursor = cursor; + if (request.includeHidden !== null && request.includeHidden !== undefined) { + params.includeHidden = request.includeHidden; + } + writeOptionalLimitParam(params, request.limit, "model/list"); + return params; +} + +function buildAccountReadParams( + request: AgentRuntimeAccountReadRequest, +): Record { + const params: Record = {}; + if (typeof request.refreshToken === "boolean") { + params.refreshToken = request.refreshToken; + } + return params; +} + +function buildAccountLoginStartParams( + request: AgentRuntimeAccountLoginStartRequest, +): Record { + if (!ACCOUNT_LOGIN_TYPES.has(request.type)) { + throw new Error( + `Unsupported Codex app-server account login type: ${request.type}`, + ); + } + + switch (request.type) { + case "apiKey": { + const apiKey = cleanString(request.apiKey); + if (!apiKey) { + throw new Error( + "Codex app-server account/login/start apiKey requires an API key.", + ); + } + return { + apiKey, + type: "apiKey", + }; + } + case "chatgpt": { + return { + ...(typeof request.codexStreamlinedLogin === "boolean" + ? { codexStreamlinedLogin: request.codexStreamlinedLogin } + : {}), + type: "chatgpt", + }; + } + case "chatgptDeviceCode": + return { + type: "chatgptDeviceCode", + }; + case "chatgptAuthTokens": { + const accessToken = cleanString(request.accessToken); + const chatgptAccountId = cleanString(request.chatgptAccountId); + if (!accessToken || !chatgptAccountId) { + throw new Error( + "Codex app-server account/login/start chatgptAuthTokens requires accessToken and chatgptAccountId.", + ); + } + return { + accessToken, + chatgptAccountId, + ...(request.chatgptPlanType !== undefined + ? { + chatgptPlanType: + request.chatgptPlanType === null + ? null + : (cleanString(request.chatgptPlanType) ?? null), + } + : {}), + type: "chatgptAuthTokens", + }; + } + } +} + +function buildAccountLoginCancelParams( + request: AgentRuntimeAccountLoginCancelRequest, +): Record { + const loginId = cleanString(request.loginId); + if (!loginId) { + throw new Error( + "Codex app-server account/login/cancel requires a loginId.", + ); + } + return { loginId }; +} + +function buildThreadNameSetParams( + request: AgentRuntimeThreadNameSetRequest, +): Record { + const name = cleanString(request.name); + if (!name) { + throw new Error( + "Codex app-server thread/name/set requires a non-empty name.", + ); + } + return { + threadId: requireThreadId(request.threadId), + name, + }; +} + +function buildThreadMetadataUpdateParams( + request: AgentRuntimeThreadMetadataUpdateRequest, +): Record { + const params: Record = { + threadId: requireThreadId(request.threadId), + }; + if (request.gitInfo !== undefined) { + const gitInfo = request.gitInfo; + params.gitInfo = + gitInfo === null + ? null + : { + ...(gitInfo.branch !== undefined + ? { + branch: + gitInfo.branch === null + ? null + : (cleanString(gitInfo.branch) ?? null), + } + : {}), + ...(gitInfo.originUrl !== undefined + ? { + originUrl: + gitInfo.originUrl === null + ? null + : (cleanString(gitInfo.originUrl) ?? null), + } + : {}), + ...(gitInfo.sha !== undefined + ? { + sha: + gitInfo.sha === null + ? null + : (cleanString(gitInfo.sha) ?? null), + } + : {}), + }; + } + return params; +} + +function cleanThreadGoalStatus( + status: AgentRuntimeThreadGoalStatus | null | undefined, +): AgentRuntimeThreadGoalStatus | null | undefined { + if (status === null || status === undefined) return status; + if (!THREAD_GOAL_STATUSES.has(status)) { + throw new Error( + `Unsupported Codex app-server thread goal status: ${status}`, + ); + } + return status; +} + +function buildThreadGoalSetParams( + request: AgentRuntimeThreadGoalSetRequest, +): Record { + const params: Record = { + threadId: requireThreadId(request.threadId), + }; + + if (request.objective !== undefined) { + params.objective = + request.objective === null + ? null + : (cleanString(request.objective) ?? null); + } + if (request.status !== undefined) { + params.status = cleanThreadGoalStatus(request.status); + } + if (request.tokenBudget !== undefined) { + if ( + request.tokenBudget !== null && + (!Number.isInteger(request.tokenBudget) || request.tokenBudget < 0) + ) { + throw new Error( + "Codex app-server thread/goal/set tokenBudget must be an integer >= 0.", + ); + } + params.tokenBudget = request.tokenBudget; + } + + return params; +} + +function imageToAppServerInput( + image: NonNullable[number], +): { type: "image"; url: string } | null { + const mediaType = cleanString(image.mediaType); + const base64Data = cleanString(image.base64Data); + if (!mediaType || !base64Data) return null; + return { + type: "image", + url: `data:${mediaType};base64,${base64Data}`, + }; +} + +function readNestedString(value: unknown, key: string): string | undefined { + return isRecord(value) ? cleanString(value[key]) : undefined; +} + +function readThreadId(response: unknown): string | undefined { + return ( + readNestedString(readOptionsRecord(response)?.thread, "id") ?? + readNestedString(response, "threadId") + ); +} + +function readThreadList(response: unknown): unknown[] { + return readDataList(response); +} + +function readDataList(response: unknown): unknown[] { + const data = readOptionsRecord(response)?.data; + return Array.isArray(data) ? data : []; +} + +function readArrayProperty(response: unknown, key: string): unknown[] { + const value = readOptionsRecord(response)?.[key]; + return Array.isArray(value) ? value : []; +} + +function readStringArrayProperty(response: unknown, key: string): string[] { + return readArrayProperty(response, key).filter( + (value): value is string => typeof value === "string", + ); +} + +function readThreadIdList(response: unknown): string[] { + const data = readOptionsRecord(response)?.data; + return Array.isArray(data) + ? data.filter((value): value is string => typeof value === "string") + : []; +} + +function readOptionalString(response: unknown, key: string): string | null { + const value = readOptionsRecord(response)?.[key]; + return typeof value === "string" && value.trim() ? value : null; +} + +function readBoolean(response: unknown, key: string): boolean | undefined { + const value = readOptionsRecord(response)?.[key]; + return typeof value === "boolean" ? value : undefined; +} + +function readNumber(response: unknown, key: string): number | undefined { + const value = readOptionsRecord(response)?.[key]; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function readThreadDiffString(response: unknown): string | undefined { + const record = readOptionsRecord(response); + if (typeof record?.diff === "string") return record.diff; + + const nested = readOptionsRecord(record?.threadDiff); + return typeof nested?.diff === "string" ? nested.diff : undefined; +} + +function readThreadDiffNumber( + response: unknown, + key: "fromTurnCount" | "toTurnCount", +): number | undefined { + return ( + readNumber(response, key) ?? + ((): number | undefined => { + const nested = readOptionsRecord(readOptionsRecord(response)?.threadDiff); + const value = nested?.[key]; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; + })() + ); +} + +function readThreadGoal( + response: unknown, +): AgentRuntimeThreadGoal | null | undefined { + const goal = readOptionsRecord(response)?.goal; + if (goal === null) return null; + const goalRecord = readOptionsRecord(goal); + if (!goalRecord) return undefined; + + return { + createdAt: + typeof goalRecord.createdAt === "number" + ? goalRecord.createdAt + : undefined, + objective: + typeof goalRecord.objective === "string" + ? goalRecord.objective + : undefined, + status: + typeof goalRecord.status === "string" ? goalRecord.status : undefined, + threadId: + typeof goalRecord.threadId === "string" ? goalRecord.threadId : undefined, + timeUsedSeconds: + typeof goalRecord.timeUsedSeconds === "number" + ? goalRecord.timeUsedSeconds + : undefined, + tokenBudget: + goalRecord.tokenBudget === null || + typeof goalRecord.tokenBudget === "number" + ? goalRecord.tokenBudget + : undefined, + tokensUsed: + typeof goalRecord.tokensUsed === "number" + ? goalRecord.tokensUsed + : undefined, + updatedAt: + typeof goalRecord.updatedAt === "number" + ? goalRecord.updatedAt + : undefined, + }; +} + +function readTurnId(response: unknown): string | undefined { + return ( + readNestedString(readOptionsRecord(response)?.turn, "id") ?? + readNestedString(response, "turnId") + ); +} + +function makeInitializeParams(): Record { + return { + clientInfo: { + name: "1code_desktop", + title: "1code Desktop", + version: "0.0.0", + }, + capabilities: { + experimentalApi: true, + }, + }; +} + +function spawnCodexAppServer( + plan: CodexAppServerLaunchPlan, +): CodexAppServerProcess { + const child = spawn(plan.command, plan.args, { + cwd: plan.cwd, + env: plan.extendEnv ? { ...process.env, ...plan.env } : plan.env, + stdio: ["pipe", "pipe", "pipe"], + }); + return child; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function requireNativeThreadId(session: AgentRuntimeSessionRef): string { + const threadId = cleanString(session.nativeSessionId); + if (!threadId) { + throw new Error( + "Codex app-server thread control requires a native thread id.", + ); + } + return threadId; +} + +function requireThreadId(threadId: string | null | undefined): string { + const cleaned = cleanString(threadId); + if (!cleaned) { + throw new Error("Codex app-server thread control requires a thread id."); + } + return cleaned; +} + +async function withCodexAppServerControl( + session: AgentRuntimeSessionRef, + options: CodexAppServerRuntimeOptions, + runControl: ( + client: CodexAppServerRpcClient, + plan: CodexAppServerLaunchPlan, + ) => Promise, +): Promise { + const modelOptions = resolveModelAndEffort({ + session, + prompt: "", + }); + const launchPlan = patchPlanModel( + buildCodexAppServerLaunchPlan({ + session, + appServerArgs: options.appServerArgs, + command: options.command, + env: options.env, + codeHome: options.codeHome, + homeDir: options.homeDir, + }), + modelOptions.modelId, + ); + const child = (options.spawnProcess ?? spawnCodexAppServer)(launchPlan); + const client = new CodexAppServerRpcClient({ + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + }); + + try { + await client.request("initialize", makeInitializeParams()); + client.notify("initialized"); + + if (hasConfiguredCodexAppServerMcpServer(launchPlan.args)) { + await client.request("config/mcpServer/reload"); + } + + return await runControl(client, launchPlan); + } finally { + client.close(); + child.kill("SIGTERM"); + } +} + +export async function readCodexAppServerThread( + request: AgentRuntimeThreadReadRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + const includeTurns = request.includeTurns ?? true; + let threadId: string | undefined; + + try { + const nativeThreadId = requireNativeThreadId(request.session); + threadId = nativeThreadId; + const response = await withCodexAppServerControl( + request.session, + options, + (client) => + client.request("thread/read", { + threadId: nativeThreadId, + includeTurns, + }), + ); + + return { + status: "success", + threadId: readThreadId(response) ?? nativeThreadId, + thread: response, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/read", + includeTurns, + }, + }; + } catch (error) { + return { + status: "error", + threadId, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/read", + includeTurns, + }, + }; + } +} + +export async function forkCodexAppServerThread( + request: AgentRuntimeThreadForkRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + let sourceThreadId: string | undefined; + + try { + const nativeThreadId = requireNativeThreadId(request.session); + sourceThreadId = nativeThreadId; + const modelOptions = resolveSessionModelAndEffort(request.session); + const response = await withCodexAppServerControl( + request.session, + options, + (client, plan) => + client.request( + "thread/fork", + buildCodexAppServerThreadForkParams({ + plan: patchPlanModel(plan, modelOptions.modelId), + threadId: nativeThreadId, + serviceTier: modelOptions.serviceTier, + }), + ), + ); + const forkedThreadId = readThreadId(response); + if (!forkedThreadId) { + throw new Error( + "Codex app-server thread/fork response did not include a forked thread id.", + ); + } + + return { + status: "success", + sourceThreadId: nativeThreadId, + threadId: forkedThreadId, + thread: response, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/fork", + }, + }; + } catch (error) { + return { + status: "error", + sourceThreadId, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/fork", + }, + }; + } +} + +function resolveThreadDiffThreadId( + request: + | AgentRuntimeThreadTurnDiffRequest + | AgentRuntimeThreadFullDiffRequest, +): string { + return requireThreadId(request.threadId ?? request.session.nativeSessionId); +} + +function makeThreadDiffMetadata( + method: "thread/diff/turn" | "thread/diff/full", + ignoreWhitespace: boolean | undefined, +): Record { + return { + bridge: "codex-app-server", + method, + ignoreWhitespace: ignoreWhitespace === true, + }; +} + +function validateTurnDiffRange( + fromTurnCount: number, + toTurnCount: number, +): string | null { + if ( + !Number.isInteger(fromTurnCount) || + !Number.isInteger(toTurnCount) || + fromTurnCount < 0 || + toTurnCount < 0 || + fromTurnCount > toTurnCount + ) { + return "Codex app-server thread/diff/turn requires fromTurnCount and toTurnCount to be integers >= 0 with fromTurnCount <= toTurnCount."; + } + + return null; +} + +function validateFullThreadDiffTarget(toTurnCount: number): string | null { + if (!Number.isInteger(toTurnCount) || toTurnCount < 0) { + return "Codex app-server thread/diff/full requires toTurnCount to be an integer >= 0."; + } + + return null; +} + +export async function getCodexAppServerThreadTurnDiff( + request: AgentRuntimeThreadTurnDiffRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + const method = "thread/diff/turn" as const; + const metadata = makeThreadDiffMetadata(method, request.ignoreWhitespace); + const validationError = validateTurnDiffRange( + request.fromTurnCount, + request.toTurnCount, + ); + + if (validationError) { + return { + status: "error", + threadId: request.threadId ?? request.session.nativeSessionId ?? null, + fromTurnCount: request.fromTurnCount, + toTurnCount: request.toTurnCount, + message: validationError, + updatedAt: nowIso(), + metadata, + }; + } + + let threadId: string | undefined; + + try { + threadId = resolveThreadDiffThreadId(request); + const params: Record = { + threadId, + fromTurnCount: request.fromTurnCount, + toTurnCount: request.toTurnCount, + }; + if (request.ignoreWhitespace === true) { + params.ignoreWhitespace = true; + } + + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request(method, params), + ); + + return { + status: "success", + threadId: readThreadId(response) ?? threadId, + fromTurnCount: + readThreadDiffNumber(response, "fromTurnCount") ?? + request.fromTurnCount, + toTurnCount: + readThreadDiffNumber(response, "toTurnCount") ?? request.toTurnCount, + diff: readThreadDiffString(response) ?? "", + updatedAt: nowIso(), + metadata, + }; + } catch (error) { + return { + status: "error", + threadId, + fromTurnCount: request.fromTurnCount, + toTurnCount: request.toTurnCount, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata, + }; + } +} + +export async function getCodexAppServerThreadFullDiff( + request: AgentRuntimeThreadFullDiffRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + const method = "thread/diff/full" as const; + const metadata = makeThreadDiffMetadata(method, request.ignoreWhitespace); + const validationError = validateFullThreadDiffTarget(request.toTurnCount); + + if (validationError) { + return { + status: "error", + threadId: request.threadId ?? request.session.nativeSessionId ?? null, + fromTurnCount: 0, + toTurnCount: request.toTurnCount, + message: validationError, + updatedAt: nowIso(), + metadata, + }; + } + + let threadId: string | undefined; + + try { + threadId = resolveThreadDiffThreadId(request); + const params: Record = { + threadId, + toTurnCount: request.toTurnCount, + }; + if (request.ignoreWhitespace === true) { + params.ignoreWhitespace = true; + } + + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request(method, params), + ); + + return { + status: "success", + threadId: readThreadId(response) ?? threadId, + fromTurnCount: readThreadDiffNumber(response, "fromTurnCount") ?? 0, + toTurnCount: + readThreadDiffNumber(response, "toTurnCount") ?? request.toTurnCount, + diff: readThreadDiffString(response) ?? "", + updatedAt: nowIso(), + metadata, + }; + } catch (error) { + return { + status: "error", + threadId, + fromTurnCount: 0, + toTurnCount: request.toTurnCount, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata, + }; + } +} + +export async function listCodexAppServerThreads( + request: AgentRuntimeThreadListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildThreadListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("thread/list", params), + ); + + return { + status: "success", + threads: readThreadList(response), + nextCursor: readOptionalString(response, "nextCursor"), + backwardsCursor: readOptionalString(response, "backwardsCursor"), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/list", + }, + }; + } +} + +export async function listLoadedCodexAppServerThreads( + request: AgentRuntimeThreadLoadedListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildThreadLoadedListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("thread/loaded/list", params), + ); + + return { + status: "success", + threadIds: readThreadIdList(response), + nextCursor: readOptionalString(response, "nextCursor"), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/loaded/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/loaded/list", + }, + }; + } +} + +export async function readCodexAppServerConfig( + request: AgentRuntimeConfigReadRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildConfigReadParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("config/read", params), + ); + const record = readOptionsRecord(response) ?? {}; + const layers = record.layers; + + return { + status: "success", + config: record.config, + layers: Array.isArray(layers) + ? layers + : layers === null + ? null + : undefined, + origins: readOptionsRecord(record.origins) ?? {}, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "config/read", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "config/read", + }, + }; + } +} + +function buildConfigWriteResult( + response: unknown, + method: "config/value/write" | "config/batchWrite", + params: Record, +): AgentRuntimeConfigWriteResult { + const record = readOptionsRecord(response) ?? {}; + return { + status: "success", + filePath: typeof record.filePath === "string" ? record.filePath : null, + writeStatus: typeof record.status === "string" ? record.status : undefined, + version: typeof record.version === "string" ? record.version : null, + overriddenMetadata: + record.overriddenMetadata === undefined + ? undefined + : record.overriddenMetadata, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method, + params, + }, + }; +} + +export async function writeCodexAppServerConfigValue( + request: AgentRuntimeConfigValueWriteRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildConfigValueWriteParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("config/value/write", params), + ); + return buildConfigWriteResult(response, "config/value/write", params); + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "config/value/write", + }, + }; + } +} + +export async function batchWriteCodexAppServerConfig( + request: AgentRuntimeConfigBatchWriteRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildConfigBatchWriteParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("config/batchWrite", params), + ); + return buildConfigWriteResult(response, "config/batchWrite", params); + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "config/batchWrite", + }, + }; + } +} + +export async function readCodexAppServerConfigRequirements( + request: AgentRuntimeConfigRequirementsReadRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("configRequirements/read"), + ); + const record = readOptionsRecord(response) ?? {}; + + return { + status: "success", + requirements: + record.requirements === undefined ? null : record.requirements, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "configRequirements/read", + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "configRequirements/read", + }, + }; + } +} + +export async function listCodexAppServerPermissionProfiles( + request: AgentRuntimePermissionProfileListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildPermissionProfileListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("permissionProfile/list", params), + ); + + return { + status: "success", + profiles: readDataList(response), + nextCursor: readOptionalString(response, "nextCursor"), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "permissionProfile/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "permissionProfile/list", + }, + }; + } +} + +export async function listCodexAppServerMcpServerStatuses( + request: AgentRuntimeMcpServerStatusListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildMcpServerStatusListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("mcpServerStatus/list", params), + ); + + return { + status: "success", + servers: readDataList(response), + nextCursor: readOptionalString(response, "nextCursor"), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "mcpServerStatus/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "mcpServerStatus/list", + }, + }; + } +} + +export async function reloadCodexAppServerMcpServerConfig( + request: AgentRuntimeMcpServerConfigReloadRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + await withCodexAppServerControl(request.session, options, (client) => + client.request("config/mcpServer/reload"), + ); + + return { + status: "success", + reloaded: true, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "config/mcpServer/reload", + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "config/mcpServer/reload", + }, + }; + } +} + +export async function listCodexAppServerSkills( + request: AgentRuntimeSkillListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildSkillListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("skills/list", params), + ); + + return { + status: "success", + entries: readDataList(response), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "skills/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "skills/list", + }, + }; + } +} + +export async function listCodexAppServerHooks( + request: AgentRuntimeHookListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildHookListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("hooks/list", params), + ); + + return { + status: "success", + entries: readDataList(response), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "hooks/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "hooks/list", + }, + }; + } +} + +export async function listCodexAppServerApps( + request: AgentRuntimeAppListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildAppListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("app/list", params), + ); + + return { + status: "success", + apps: readDataList(response), + nextCursor: readOptionalString(response, "nextCursor"), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "app/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "app/list", + }, + }; + } +} + +export async function listCodexAppServerPlugins( + request: AgentRuntimePluginListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildPluginListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("plugin/list", params), + ); + + return { + status: "success", + marketplaces: readArrayProperty(response, "marketplaces"), + featuredPluginIds: readStringArrayProperty(response, "featuredPluginIds"), + marketplaceLoadErrors: readArrayProperty( + response, + "marketplaceLoadErrors", + ), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "plugin/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "plugin/list", + }, + }; + } +} + +export async function listInstalledCodexAppServerPlugins( + request: AgentRuntimePluginInstalledRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildPluginInstalledParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("plugin/installed", params), + ); + + return { + status: "success", + marketplaces: readArrayProperty(response, "marketplaces"), + marketplaceLoadErrors: readArrayProperty( + response, + "marketplaceLoadErrors", + ), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "plugin/installed", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "plugin/installed", + }, + }; + } +} + +export async function readCodexAppServerPlugin( + request: AgentRuntimePluginReadRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildPluginReadParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("plugin/read", params), + ); + + return { + status: "success", + plugin: readOptionsRecord(response)?.plugin, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "plugin/read", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "plugin/read", + }, + }; + } +} + +export async function installCodexAppServerPlugin( + request: AgentRuntimePluginInstallRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildPluginInstallParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("plugin/install", params), + ); + + return { + status: "success", + appsNeedingAuth: readArrayProperty(response, "appsNeedingAuth"), + authPolicy: readOptionalString(response, "authPolicy"), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "plugin/install", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "plugin/install", + }, + }; + } +} + +export async function detectCodexAppServerExternalAgentConfig( + request: AgentRuntimeExternalAgentConfigDetectRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildExternalAgentConfigDetectParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("externalAgentConfig/detect", params), + ); + + return { + status: "success", + items: readExternalAgentConfigMigrationItems(response), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "externalAgentConfig/detect", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "externalAgentConfig/detect", + }, + }; + } +} + +export async function importCodexAppServerExternalAgentConfig( + request: AgentRuntimeExternalAgentConfigImportRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildExternalAgentConfigImportParams(request); + await withCodexAppServerControl(request.session, options, (client) => + client.request("externalAgentConfig/import", params), + ); + + return { + status: "success", + importedCount: request.migrationItems.length, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "externalAgentConfig/import", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "externalAgentConfig/import", + }, + }; + } +} + +export async function startCodexAppServerMcpServerOauthLogin( + request: AgentRuntimeMcpServerOauthLoginRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildMcpServerOauthLoginParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("mcpServer/oauth/login", params), + ); + + return { + status: "success", + authorizationUrl: + readOptionalString(response, "authorizationUrl") ?? null, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "mcpServer/oauth/login", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "mcpServer/oauth/login", + }, + }; + } +} + +export async function listCodexAppServerModels( + request: AgentRuntimeModelListRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildModelListParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("model/list", params), + ); + + return { + status: "success", + models: readDataList(response), + nextCursor: readOptionalString(response, "nextCursor"), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "model/list", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "model/list", + }, + }; + } +} + +export async function startCodexAppServerAccountLogin( + request: AgentRuntimeAccountLoginStartRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildAccountLoginStartParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("account/login/start", params), + ); + const record = readOptionsRecord(response) ?? {}; + + return { + status: "success", + type: + typeof record.type === "string" + ? (record.type as AgentRuntimeAccountLoginType | (string & {})) + : undefined, + authUrl: readOptionalString(record, "authUrl") ?? null, + loginId: readOptionalString(record, "loginId") ?? null, + verificationUrl: readOptionalString(record, "verificationUrl") ?? null, + userCode: readOptionalString(record, "userCode") ?? null, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/login/start", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/login/start", + }, + }; + } +} + +export async function cancelCodexAppServerAccountLogin( + request: AgentRuntimeAccountLoginCancelRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildAccountLoginCancelParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("account/login/cancel", params), + ); + const record = readOptionsRecord(response) ?? {}; + + return { + status: "success", + cancelStatus: + typeof record.status === "string" + ? (record.status as AgentRuntimeAccountLoginCancelResult["cancelStatus"]) + : undefined, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/login/cancel", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/login/cancel", + }, + }; + } +} + +export async function logoutCodexAppServerAccount( + request: AgentRuntimeAccountLogoutRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + await withCodexAppServerControl(request.session, options, (client) => + client.request("account/logout"), + ); + + return { + status: "success", + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/logout", + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/logout", + }, + }; + } +} + +export async function readCodexAppServerAccount( + request: AgentRuntimeAccountReadRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const params = buildAccountReadParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("account/read", params), + ); + const record = readOptionsRecord(response) ?? {}; + + return { + status: "success", + account: record.account ?? null, + requiresOpenaiAuth: + typeof record.requiresOpenaiAuth === "boolean" + ? record.requiresOpenaiAuth + : undefined, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/read", + params, + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/read", + }, + }; + } +} + +export async function readCodexAppServerAccountRateLimits( + request: AgentRuntimeAccountRateLimitsReadRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("account/rateLimits/read"), + ); + const record = readOptionsRecord(response) ?? {}; + + return { + status: "success", + rateLimits: record.rateLimits, + rateLimitsByLimitId: + readOptionsRecord(record.rateLimitsByLimitId) ?? + (record.rateLimitsByLimitId === null ? null : undefined), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/rateLimits/read", + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/rateLimits/read", + }, + }; + } +} + +export async function readCodexAppServerAccountUsage( + request: AgentRuntimeAccountUsageReadRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + try { + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("account/usage/read"), + ); + const record = readOptionsRecord(response) ?? {}; + const buckets = record.dailyUsageBuckets; + + return { + status: "success", + summary: record.summary, + dailyUsageBuckets: Array.isArray(buckets) + ? buckets + : buckets === null + ? null + : undefined, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/usage/read", + }, + }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "account/usage/read", + }, + }; + } +} + +export async function controlCodexAppServerThread( + request: AgentRuntimeThreadControlRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + const methodByAction = { + archive: "thread/archive", + unarchive: "thread/unarchive", + delete: "thread/delete", + } as const; + let threadId: string | undefined; + + try { + threadId = requireThreadId(request.threadId); + const method = methodByAction[request.action]; + const response = await withCodexAppServerControl( + request.session, + options, + (client) => + client.request(method, { + threadId, + }), + ); + + return { + status: "success", + action: request.action, + threadId: readThreadId(response) ?? threadId, + thread: response, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method, + }, + }; + } catch (error) { + return { + status: "error", + action: request.action, + threadId, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: methodByAction[request.action], + }, + }; + } +} + +export async function setCodexAppServerThreadName( + request: AgentRuntimeThreadNameSetRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + let threadId: string | undefined; + let name: string | undefined; + + try { + const params = buildThreadNameSetParams(request); + threadId = params.threadId as string; + name = params.name as string; + await withCodexAppServerControl(request.session, options, (client) => + client.request("thread/name/set", params), + ); + + return { + status: "success", + threadId, + name, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/name/set", + }, + }; + } catch (error) { + return { + status: "error", + threadId, + name, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/name/set", + }, + }; + } +} + +export async function updateCodexAppServerThreadMetadata( + request: AgentRuntimeThreadMetadataUpdateRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + let threadId: string | undefined; + + try { + threadId = requireThreadId(request.threadId); + const params = buildThreadMetadataUpdateParams(request); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("thread/metadata/update", params), + ); + + return { + status: "success", + threadId: readThreadId(response) ?? threadId, + thread: response, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/metadata/update", + }, + }; + } catch (error) { + return { + status: "error", + threadId, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/metadata/update", + }, + }; + } +} + +export async function getCodexAppServerThreadGoal( + request: AgentRuntimeThreadGoalGetRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + let threadId: string | undefined; + + try { + threadId = requireThreadId(request.threadId); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("thread/goal/get", { threadId }), + ); + const goal = readThreadGoal(response); + + return { + status: "success", + threadId: goal?.threadId ?? threadId, + goal, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/goal/get", + }, + }; + } catch (error) { + return { + status: "error", + threadId, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/goal/get", + }, + }; + } +} + +export async function setCodexAppServerThreadGoal( + request: AgentRuntimeThreadGoalSetRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + let threadId: string | undefined; + + try { + const params = buildThreadGoalSetParams(request); + threadId = params.threadId as string; + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("thread/goal/set", params), + ); + const goal = readThreadGoal(response); + + return { + status: "success", + threadId: goal?.threadId ?? threadId, + goal, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/goal/set", + params, + }, + }; + } catch (error) { + return { + status: "error", + threadId, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/goal/set", + }, + }; + } +} + +export async function clearCodexAppServerThreadGoal( + request: AgentRuntimeThreadGoalClearRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + let threadId: string | undefined; + + try { + threadId = requireThreadId(request.threadId); + const response = await withCodexAppServerControl( + request.session, + options, + (client) => client.request("thread/goal/clear", { threadId }), + ); + + return { + status: "success", + threadId, + cleared: readBoolean(response, "cleared") ?? true, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/goal/clear", + }, + }; + } catch (error) { + return { + status: "error", + threadId, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/goal/clear", + }, + }; + } +} + +export async function rollbackCodexAppServerThread( + request: AgentRuntimeThreadRollbackRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + let threadId: string | undefined; + + if (!Number.isInteger(request.numTurns) || request.numTurns < 1) { + return { + status: "error", + threadId: request.session.nativeSessionId ?? null, + numTurns: request.numTurns, + message: + "Codex app-server rollback requires numTurns to be an integer >= 1.", + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/rollback", + }, + }; + } + + try { + const nativeThreadId = requireNativeThreadId(request.session); + threadId = nativeThreadId; + const response = await withCodexAppServerControl( + request.session, + options, + (client) => + client.request("thread/rollback", { + threadId: nativeThreadId, + numTurns: request.numTurns, + }), + ); + + return { + status: "success", + threadId: readThreadId(response) ?? nativeThreadId, + numTurns: request.numTurns, + thread: response, + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/rollback", + }, + }; + } catch (error) { + return { + status: "error", + threadId, + numTurns: request.numTurns, + message: error instanceof Error ? error.message : String(error), + updatedAt: nowIso(), + metadata: { + bridge: "codex-app-server", + method: "thread/rollback", + }, + }; + } +} + +function isAcceptLike(value: string | undefined): boolean { + return [ + "accept", + "accepted", + "approve", + "approved", + "allow", + "yes", + "true", + ].includes(value ?? ""); +} + +function isSessionAcceptLike(value: string | undefined): boolean { + return [ + "acceptforsession", + "accept_for_session", + "approved_for_session", + "approve_for_session", + ].includes(value ?? ""); +} + +function isCancelLike(value: string | undefined): boolean { + return ["cancel", "abort", "aborted"].includes(value ?? ""); +} + +function readDecisionValue( + result: unknown, + isError: boolean | undefined, +): string { + if (isError) return "decline"; + if (typeof result === "boolean") return result ? "accept" : "decline"; + if (typeof result === "string") return result.trim().toLowerCase(); + if (isRecord(result)) { + return ( + cleanString(result.decision) ?? + cleanString(result.approval) ?? + cleanString(result.status) ?? + (typeof result.approved === "boolean" + ? result.approved + ? "accept" + : "decline" + : undefined) ?? + "accept" + ).toLowerCase(); + } + return "accept"; +} + +function normalizeApprovalDecision( + method: string, + request: AgentRuntimeToolResultRequest, +): string { + const value = readDecisionValue(request.result, request.isError); + if (method === "applyPatchApproval" || method === "execCommandApproval") { + if (isSessionAcceptLike(value)) return "approved_for_session"; + if (isAcceptLike(value)) return "approved"; + if (isCancelLike(value)) return "abort"; + return "denied"; + } + + if (isSessionAcceptLike(value)) return "acceptForSession"; + if (isAcceptLike(value)) return "accept"; + if (isCancelLike(value)) return "cancel"; + return "decline"; +} + +function normalizeUserInputAnswers( + result: unknown, +): Record { + const answers = + isRecord(result) && isRecord(result.answers) ? result.answers : result; + if (!isRecord(answers)) return {}; + + const normalized: Record = {}; + for (const [questionId, value] of Object.entries(answers)) { + if (isRecord(value) && Array.isArray(value.answers)) { + normalized[questionId] = { + answers: value.answers.map((entry) => String(entry)), + }; + } else if (Array.isArray(value)) { + normalized[questionId] = { + answers: value.map((entry) => String(entry)), + }; + } else { + normalized[questionId] = { + answers: [String(value)], + }; + } + } + return normalized; +} + +function responseForToolResult( + pending: PendingServerRequest, + request: AgentRuntimeToolResultRequest, +): unknown { + if ( + isRecord(request.result) && + request.result.codexAppServerResponse !== undefined + ) { + return request.result.codexAppServerResponse; + } + + if (pending.method === "item/tool/requestUserInput") { + return { + answers: normalizeUserInputAnswers(request.result), + }; + } + + if (pending.method === "item/tool/call") { + if (isRecord(request.result) && "contentItems" in request.result) + return request.result; + return { + contentItems: [{ type: "text", text: String(request.result ?? "") }], + success: request.isError !== true, + }; + } + + if (pending.method === "item/permissions/requestApproval") { + if (isRecord(request.result) && "permissions" in request.result) + return request.result; + return { + permissions: {}, + }; + } + + return { + decision: normalizeApprovalDecision(pending.method, request), + }; +} + +function makeResolvedEvent( + pending: PendingServerRequest, + response: unknown, + failed: boolean, +): AgentRuntimeStreamEvent { + if (pending.method === "item/tool/requestUserInput") { + const event = providerRuntimeEventToStreamEvent({ + type: "user-input.resolved", + engineId: "codex", + requestId: pending.requestId, + ...(pending.turnId ? { turnId: pending.turnId } : {}), + ...(pending.itemId ? { itemId: pending.itemId } : {}), + payload: { + ...(isRecord(response) && response.answers !== undefined + ? { answers: response.answers } + : { response }), + ...(failed ? { error: true } : {}), + }, + }); + if (event) return event; + } + + return { + type: "conversation-block-update", + id: pending.requestId, + patch: { + status: failed ? "failed" : "completed", + output: { + method: pending.method, + response, + }, + }, + }; +} + +function createAcceptedControlResult( + request: AgentRuntimeToolResultRequest, + pending: PendingServerRequest, +): AgentRuntimeControlResult { + return { + runId: request.runId, + status: "accepted", + message: "Codex app-server tool result accepted.", + updatedAt: new Date().toISOString(), + metadata: { + engineId: request.session.engineId, + requestId: pending.requestId, + method: pending.method, + }, + }; +} + +function createErrorControlResult( + request: AgentRuntimeToolResultRequest, + message: string, +): AgentRuntimeControlResult { + return { + runId: request.runId, + status: "error", + message, + updatedAt: new Date().toISOString(), + }; +} + +function createNotFoundControlResult( + request: AgentRuntimeToolResultRequest, +): AgentRuntimeControlResult { + return { + runId: request.runId, + status: "not-found", + message: `No pending Codex app-server request matched ${request.toolCallId}.`, + updatedAt: new Date().toISOString(), + }; +} + +function registerPendingAliases( + pendingById: Map, + request: CodexAppServerRpcRequest, + pending: PendingServerRequest, +): void { + pendingById.set(pending.requestId, pending); + pendingById.set(String(request.id), pending); + const params = readOptionsRecord(request.params); + for (const key of ["approvalId", "requestId", "itemId", "callId"]) { + const alias = cleanString(params?.[key]); + if (alias) pendingById.set(alias, pending); + } +} + +function readRequestAlias(value: unknown): string | undefined { + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return cleanString(value); +} + +function removePendingAliases( + pendingById: Map, + pending: PendingServerRequest, +): void { + for (const [key, value] of [...pendingById.entries()]) { + if (value === pending) pendingById.delete(key); + } +} + +function correlateResolvedServerRequestNotification( + notification: CodexAppServerRpcNotification, + correlationsById: Map, +): CodexAppServerRpcNotification { + if (notification.method !== "serverRequest/resolved") return notification; + const params = readOptionsRecord(notification.params) ?? {}; + const rawRequestId = readRequestAlias(params.requestId); + if (!rawRequestId) return notification; + + const correlation = correlationsById.get(rawRequestId); + if (!correlation) return notification; + + removePendingAliases(correlationsById, correlation); + return { + ...notification, + params: { + ...params, + rawRequestId, + requestId: correlation.requestId, + requestType: correlation.requestType, + ...(correlation.turnId ? { turnId: correlation.turnId } : {}), + ...(correlation.itemId ? { itemId: correlation.itemId } : {}), + }, + }; +} + +function shouldCompleteForNotification( + notification: CodexAppServerRpcNotification, + activeTurnId: string | null, +): boolean { + if (notification.method !== "turn/completed") return false; + if (!activeTurnId) return true; + const params = readOptionsRecord(notification.params); + const turn = readOptionsRecord(params?.turn); + return ( + cleanString(params?.turnId) === activeTurnId || + cleanString(turn?.id) === activeTurnId + ); +} + +function isRuntimeStreamEventPayload( + value: unknown, +): value is AgentRuntimeStreamEvent { + return isRecord(value) && typeof value.type === "string"; +} + +export async function* streamCodexAppServerRuntimeRun( + action: AgentRuntimeRunAction, + request: AgentRuntimeStartRequest, + options: CodexAppServerRuntimeOptions = {}, +): AsyncIterable { + const startedAt = new Date(); + const runId = + request.runId ?? + createAgentRuntimeRunId(request.session, action, startedAt.getTime()); + const runLedger = + options.recordRunLedger === false || options.runLedger === null + ? null + : (options.runLedger ?? getCodexAppServerRuntimeRunLedger()); + const commandId = runId; + + if (runLedger) { + const commandReceipt = runLedger.getCommandReceipt(commandId); + if (commandReceipt?.status === "accepted") { + const snapshot = runLedger.snapshot(commandReceipt.runId); + if (snapshot && snapshot.receipt.status !== "running") { + for (const event of replayAgentRuntimeRunLedgerEvents(snapshot)) { + if ( + event.kind === "stream-event" && + isRuntimeStreamEventPayload(event.payload) + ) { + yield event.payload; + } + } + return; + } + } + if (commandReceipt?.status === "rejected") { + yield { + type: "error", + message: + commandReceipt.error ?? + "Codex app-server command was previously rejected.", + }; + yield { + type: "finish", + nativeSessionId: request.session.nativeSessionId ?? null, + resultSubtype: "error", + }; + return; + } + } + + const queue = new AsyncEventQueue(); + const pendingById = new Map(); + const requestCorrelationsById = new Map(); + let client: CodexAppServerRpcClient | undefined; + let child: CodexAppServerProcess | undefined; + let nativeSessionId = request.session.nativeSessionId ?? null; + let ledgerStatus: Exclude = + "success"; + let ledgerResultSubtype: AgentRuntimeRunReceipt["resultSubtype"] = "success"; + let ledgerErrorMessage: string | undefined; + let latestLedgerEventId: string | null = null; + let recordedStreamEventCount = 0; + let ledgerFinished = false; + let activeTurnId: string | null = null; + let providerInstanceId = request.session.providerInstanceId ?? null; + let lifecycleSettled = false; + let unregisterRun = true; + let interruptSent = false; + + const context = () => ({ + providerInstanceId, + nativeSessionId, + }); + + if (runLedger) { + runLedger.upsertReceipt( + createAgentRuntimeRunReceipt({ + runId, + action, + session: request.session, + status: "running", + nativeSessionId, + now: startedAt, + }), + ); + latestLedgerEventId = runLedger.appendEvent({ + runId, + engineId: request.session.engineId, + kind: "run-started", + commandId, + correlationId: commandId, + payload: { + bridge: "codex-app-server", + forceNewSession: request.forceNewSession === true, + imageCount: request.images?.length ?? 0, + promptLength: request.prompt.length, + streaming: true, + }, + }).id; + } + options.runtimeReceipts?.publish({ + type: "runtime.run.started", + runId, + engineId: request.session.engineId, + chatId: request.session.chatId, + subChatId: request.session.subChatId, + createdAt: startedAt.toISOString(), + source: "codex-app-server", + metadata: { + action, + forceNewSession: request.forceNewSession === true, + imageCount: request.images?.length ?? 0, + promptLength: request.prompt.length, + streaming: true, + }, + }); + + const recordStreamEvent = (event: AgentRuntimeStreamEvent): void => { + recordedStreamEventCount += 1; + if (event.type === "error" || event.type === "auth-error") { + ledgerStatus = "error"; + ledgerResultSubtype = "error"; + ledgerErrorMessage = event.message; + } + if (event.type === "finish") { + nativeSessionId = event.nativeSessionId ?? nativeSessionId; + ledgerResultSubtype = event.resultSubtype ?? ledgerResultSubtype; + if (event.resultSubtype === "cancelled") ledgerStatus = "cancelled"; + else if (event.resultSubtype === "error") ledgerStatus = "error"; + } + if (!runLedger) return; + latestLedgerEventId = runLedger.appendEvent({ + runId, + engineId: request.session.engineId, + kind: "stream-event", + commandId, + correlationId: commandId, + causationEventId: latestLedgerEventId, + payload: event as unknown as Record, + }).id; + }; + + const finishStreamLedger = (): void => { + if (ledgerFinished) return; + ledgerFinished = true; + let latestSequence: number | null = null; + if (runLedger) { + runLedger.finishRun({ + runId, + status: ledgerStatus, + resultSubtype: ledgerResultSubtype, + commandId, + correlationId: commandId, + causationEventId: latestLedgerEventId, + error: ledgerErrorMessage, + metadata: { + bridge: "codex-app-server", + eventCount: recordedStreamEventCount, + streaming: true, + }, + }); + const snapshot = runLedger.snapshot(runId); + const projection = snapshot + ? projectAgentRuntimeRunLedgerSnapshot(snapshot) + : null; + latestSequence = projection?.latestEventSequence ?? null; + const commandReceipt = runLedger.getCommandReceipt(commandId); + runLedger.upsertReceipt( + createAgentRuntimeRunReceipt({ + runId, + action, + session: request.session, + status: ledgerStatus, + nativeSessionId, + resultSubtype: ledgerResultSubtype, + now: startedAt, + completedAt: new Date(), + error: ledgerErrorMessage, + metadata: { + bridge: "codex-app-server", + eventCount: recordedStreamEventCount, + streaming: true, + ...(projection + ? { + ledger: { + eventCount: projection.eventCount, + latestEventSequence: projection.latestEventSequence ?? null, + latestEventKind: projection.latestEventKind ?? null, + correlationIds: projection.correlationIds, + causationEventIds: projection.causationEventIds, + }, + } + : {}), + ...(commandReceipt + ? { + commandReceipt: { + status: commandReceipt.status, + resultSequence: commandReceipt.resultSequence, + }, + } + : {}), + }, + }), + ); + } + options.runtimeReceipts?.publish({ + type: "turn.processing.quiesced", + runId, + engineId: request.session.engineId, + chatId: request.session.chatId, + subChatId: request.session.subChatId, + status: ledgerStatus, + resultSubtype: ledgerResultSubtype, + latestSequence, + eventCount: recordedStreamEventCount, + createdAt: new Date().toISOString(), + error: ledgerErrorMessage ?? null, + source: "codex-app-server", + metadata: { + action, + streaming: true, + }, + }); + }; + + const pushSessionNotification = ( + method: "session/connecting" | "session/ready" | "session/error", + message: string, + error?: unknown, + ): void => { + const event = codexAppServerNotificationToStreamEvent( + { + method, + params: { + message, + ...(nativeSessionId ? { threadId: nativeSessionId } : {}), + ...(error !== undefined ? { error } : {}), + }, + }, + { + ...context(), + eventId: `codex-app-server:${method}`, + }, + ); + if (event) queue.push(event); + }; + + const interruptActiveTurn = async (): Promise => { + if (interruptSent || !client || !nativeSessionId || !activeTurnId) return; + interruptSent = true; + await client.request("turn/interrupt", { + threadId: nativeSessionId, + turnId: activeTurnId, + }); + }; + + const handle = registerAgentRuntimeProcessHandle( + createAgentRuntimeProcessHandle({ + action, + session: request.session, + runId, + onStop: interruptActiveTurn, + submitToolResult: async (toolResult) => { + const pending = pendingById.get(toolResult.toolCallId); + if (!pending) return createNotFoundControlResult(toolResult); + if (!client) { + return createErrorControlResult( + toolResult, + "Codex app-server RPC client is not ready.", + ); + } + + try { + const response = responseForToolResult(pending, toolResult); + if (toolResult.isError) { + client.respondError( + pending.jsonRpcId, + codexAppServerInvalidParamsError( + "Codex app-server request rejected.", + response, + ), + ); + } else { + client.respond(pending.jsonRpcId, response); + } + removePendingAliases(pendingById, pending); + queue.push( + makeResolvedEvent(pending, response, toolResult.isError === true), + ); + return createAcceptedControlResult(toolResult, pending); + } catch (error) { + return createErrorControlResult( + toolResult, + error instanceof Error ? error.message : String(error), + ); + } + }, + }), + ); + + const runLifecycle = async (): Promise => { + try { + if (action === "resume" && !nativeSessionId) { + throw new Error( + "Codex app-server resume requires a native session id.", + ); + } + + const modelOptions = resolveModelAndEffort(request); + const launchPlan = patchPlanModel( + buildCodexAppServerLaunchPlan({ + session: request.session, + appServerArgs: options.appServerArgs, + command: options.command, + env: options.env, + codeHome: options.codeHome, + homeDir: options.homeDir, + }), + modelOptions.modelId, + ); + providerInstanceId = launchPlan.providerInstanceId; + child = (options.spawnProcess ?? spawnCodexAppServer)(launchPlan); + const appServerChild = child; + + let completeAbortListener: (() => void) | undefined; + const complete = new Promise((resolve, reject) => { + const abort = () => resolve(); + completeAbortListener = abort; + const fail = (error: Error) => { + if (handle.abortController.signal.aborted) resolve(); + else reject(error); + }; + + if (handle.abortController.signal.aborted) { + resolve(); + } else { + handle.abortController.signal.addEventListener("abort", abort, { + once: true, + }); + } + + const rpcClient = new CodexAppServerRpcClient({ + stdin: appServerChild.stdin, + stdout: appServerChild.stdout, + stderr: appServerChild.stderr, + signal: handle.abortController.signal, + onNotification(notification) { + const routedNotification = + correlateResolvedServerRequestNotification( + notification, + requestCorrelationsById, + ); + const params = readOptionsRecord(notification.params); + if (routedNotification.method === "thread/started") { + nativeSessionId = + readNestedString(params?.thread, "id") ?? nativeSessionId; + } + const event = codexAppServerNotificationToStreamEvent( + routedNotification, + context(), + ); + if (event) queue.push(event); + if ( + shouldCompleteForNotification(routedNotification, activeTurnId) + ) { + resolve(); + } + }, + onRequest(serverRequest) { + const requestId = codexAppServerServerRequestId(serverRequest); + const params = readOptionsRecord(serverRequest.params) ?? {}; + const pending: PendingServerRequest = { + jsonRpcId: serverRequest.id, + method: serverRequest.method, + requestId, + requestType: codexAppServerRequestType(serverRequest.method), + turnId: cleanString(params.turnId), + itemId: cleanString(params.itemId), + }; + registerPendingAliases(pendingById, serverRequest, pending); + registerPendingAliases( + requestCorrelationsById, + serverRequest, + pending, + ); + const event = codexAppServerServerRequestToStreamEvent( + serverRequest, + context(), + ); + if (event) queue.push(event); + }, + onStderrLine(line) { + if (!line.trim()) return; + queue.push({ + type: "conversation-block", + block: { + id: `codex-stderr:${Date.now()}`, + type: "status", + status: "completed", + level: "warning", + title: "Codex app-server", + message: line, + data: { + bridge: "codex-app-server", + stream: "stderr", + }, + }, + }); + }, + onProtocolError(error) { + queue.push({ type: "error", message: error.message }); + }, + }); + client = rpcClient; + + appServerChild.once("exit", () => + fail(new Error("Codex app-server process exited.")), + ); + appServerChild.once("close", () => + fail(new Error("Codex app-server process closed.")), + ); + }).finally(() => { + if (completeAbortListener) { + handle.abortController.signal.removeEventListener( + "abort", + completeAbortListener, + ); + } + }); + + const rpcClient = client; + if (!rpcClient) + throw new Error("Codex app-server RPC client is not ready."); + + pushSessionNotification( + "session/connecting", + "Starting Codex app-server session.", + ); + await rpcClient.request("initialize", makeInitializeParams()); + rpcClient.notify("initialized"); + + if (hasConfiguredCodexAppServerMcpServer(launchPlan.args)) { + await rpcClient.request("config/mcpServer/reload"); + } + pushSessionNotification( + "session/ready", + "Codex app-server session ready.", + ); + + let threadResponse: unknown; + if (action === "resume" && nativeSessionId && !request.forceNewSession) { + try { + threadResponse = await rpcClient.request( + "thread/resume", + buildCodexAppServerThreadResumeParams({ + plan: launchPlan, + threadId: nativeSessionId, + serviceTier: modelOptions.serviceTier, + }), + ); + } catch (error) { + if (!isRecoverableCodexAppServerThreadResumeError(error)) throw error; + threadResponse = await rpcClient.request( + "thread/start", + buildCodexAppServerThreadStartParams({ + plan: launchPlan, + serviceTier: modelOptions.serviceTier, + }), + ); + } + } else { + threadResponse = await rpcClient.request( + "thread/start", + buildCodexAppServerThreadStartParams({ + plan: launchPlan, + serviceTier: modelOptions.serviceTier, + }), + ); + } + + nativeSessionId = readThreadId(threadResponse) ?? nativeSessionId; + if (!nativeSessionId) { + throw new Error("Codex app-server did not return a thread id."); + } + + const turnResponse = await rpcClient.request( + "turn/start", + buildCodexAppServerTurnStartParams({ + plan: launchPlan, + threadId: nativeSessionId, + prompt: request.prompt, + images: request.images + ?.map(imageToAppServerInput) + .filter((image): image is { type: "image"; url: string } => + Boolean(image), + ), + effort: modelOptions.reasoningEffort, + serviceTier: modelOptions.serviceTier, + interactionMode: + request.session.permissionMode === "plan" ? "plan" : "default", + developerInstructions: modelOptions.developerInstructions, + }), + ); + activeTurnId = readTurnId(turnResponse) ?? activeTurnId; + await complete; + if (handle.abortController.signal.aborted) { + queue.push({ + type: "finish", + nativeSessionId, + resultSubtype: "cancelled", + }); + } + } catch (error) { + const wasCancelled = handle.abortController.signal.aborted; + if (!wasCancelled) { + pushSessionNotification( + "session/error", + error instanceof Error ? error.message : String(error), + error, + ); + queue.push({ + type: "error", + message: error instanceof Error ? error.message : String(error), + }); + } + queue.push({ + type: "finish", + nativeSessionId, + resultSubtype: wasCancelled ? "cancelled" : "error", + }); + } finally { + lifecycleSettled = true; + client?.close(); + child?.kill("SIGTERM"); + pendingById.clear(); + requestCorrelationsById.clear(); + if (unregisterRun) unregisterAgentRuntimeProcessHandle(handle.runId); + queue.close(); + } + }; + + const lifecyclePromise = runLifecycle(); + + try { + for await (const event of queue) { + recordStreamEvent(event); + yield event; + } + await lifecyclePromise; + finishStreamLedger(); + } finally { + if (!lifecycleSettled && !handle.abortController.signal.aborted) { + unregisterRun = false; + ledgerStatus = "cancelled"; + ledgerResultSubtype = "cancelled"; + handle.abortController.abort("Codex app-server stream consumer stopped."); + client?.close(); + child?.kill("SIGTERM"); + unregisterAgentRuntimeProcessHandle(handle.runId); + } + finishStreamLedger(); + } +} + +export async function runCodexAppServerRuntimeRun( + action: AgentRuntimeRunAction, + request: AgentRuntimeStartRequest, + options: CodexAppServerRuntimeOptions = {}, +): Promise { + const startedAt = new Date(); + const runId = + request.runId ?? + createAgentRuntimeRunId(request.session, action, startedAt.getTime()); + const runRequest = + request.runId === runId ? request : { ...request, runId }; + const runLedger = options.runLedger ?? getCodexAppServerRuntimeRunLedger(); + const commandId = runId; + const existingCommandReceipt = runLedger.getCommandReceipt(commandId); + if (existingCommandReceipt?.status === "accepted") { + const existingSnapshot = runLedger.snapshot(existingCommandReceipt.runId); + if (existingSnapshot && existingSnapshot.receipt.status !== "running") { + return existingSnapshot.receipt; + } + } + if (existingCommandReceipt?.status === "rejected") { + return createAgentRuntimeRunReceipt({ + runId, + action, + session: request.session, + status: "error", + nativeSessionId: request.session.nativeSessionId ?? null, + resultSubtype: "error", + now: startedAt, + completedAt: new Date(), + error: + existingCommandReceipt.error ?? + "Codex app-server command was previously rejected.", + metadata: { + bridge: "codex-app-server", + commandReceipt: { + status: existingCommandReceipt.status, + resultSequence: existingCommandReceipt.resultSequence, + }, + }, + }); + } + let latestLedgerEventId: string | null = null; + let lastText = ""; + let usage: Record | undefined; + let status: Exclude = "success"; + let resultSubtype: AgentRuntimeRunReceipt["resultSubtype"] = "success"; + let errorMessage: string | undefined; + let nativeSessionId = request.session.nativeSessionId ?? null; + let eventCount = 0; + + runLedger.upsertReceipt( + createAgentRuntimeRunReceipt({ + runId, + action, + session: request.session, + status: "running", + nativeSessionId, + now: startedAt, + }), + ); + latestLedgerEventId = runLedger.appendEvent({ + runId, + engineId: request.session.engineId, + kind: "run-started", + commandId, + correlationId: commandId, + payload: { + bridge: "codex-app-server", + forceNewSession: request.forceNewSession === true, + imageCount: request.images?.length ?? 0, + promptLength: request.prompt.length, + }, + }).id; + + for await (const event of streamCodexAppServerRuntimeRun( + action, + runRequest, + { + ...options, + runLedger, + recordRunLedger: false, + }, + )) { + eventCount += 1; + latestLedgerEventId = runLedger.appendEvent({ + runId, + engineId: request.session.engineId, + kind: "stream-event", + commandId, + correlationId: commandId, + causationEventId: latestLedgerEventId, + payload: event as unknown as Record, + }).id; + if (event.type === "text") lastText += event.text; + if (event.type === "usage") + usage = event as unknown as Record; + if (event.type === "error" || event.type === "auth-error") { + status = "error"; + resultSubtype = "error"; + errorMessage = event.message; + } + if (event.type === "finish") { + nativeSessionId = event.nativeSessionId ?? nativeSessionId; + resultSubtype = event.resultSubtype ?? resultSubtype; + if (event.resultSubtype === "cancelled") status = "cancelled"; + else if (event.resultSubtype === "error") status = "error"; + } + } + + runLedger.finishRun({ + runId, + status, + resultSubtype, + commandId, + correlationId: commandId, + causationEventId: latestLedgerEventId, + error: errorMessage, + metadata: { + bridge: "codex-app-server", + eventCount, + }, + }); + const ledgerSnapshot = runLedger.snapshot(runId); + const ledgerProjection = ledgerSnapshot + ? projectAgentRuntimeRunLedgerSnapshot(ledgerSnapshot) + : null; + const commandReceipt = runLedger.getCommandReceipt(commandId); + + const finalReceipt = createAgentRuntimeRunReceipt({ + runId, + action, + session: request.session, + status, + nativeSessionId, + resultSubtype, + now: startedAt, + completedAt: new Date(), + error: errorMessage, + metadata: { + bridge: "codex-app-server", + eventCount, + lastText, + usage, + ...(ledgerProjection + ? { + ledger: { + eventCount: ledgerProjection.eventCount, + latestEventSequence: ledgerProjection.latestEventSequence ?? null, + latestEventKind: ledgerProjection.latestEventKind ?? null, + correlationIds: ledgerProjection.correlationIds, + causationEventIds: ledgerProjection.causationEventIds, + }, + } + : {}), + ...(commandReceipt + ? { + commandReceipt: { + status: commandReceipt.status, + resultSequence: commandReceipt.resultSequence, + }, + } + : {}), + }, + }); + runLedger.upsertReceipt(finalReceipt); + return finalReceipt; +} 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..8b7d1c753 --- /dev/null +++ b/src/main/lib/agent-runtime/codex-native-session.ts @@ -0,0 +1,1668 @@ +import { spawn } from "node:child_process" +import { constants, statSync } from "node:fs" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { homedir, 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 + env?: NodeJS.ProcessEnv | null +} + +export interface ResolveCodexNativeCommandInput { + command?: string | null + env?: NodeJS.ProcessEnv | Record | null + homeDir?: string | null + platform?: NodeJS.Platform + pathDelimiter?: string + isExecutableFile?: (filePath: string) => boolean +} + +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 +} + +function defaultIsExecutableFile(filePath: string): boolean { + try { + const stats = statSync(filePath) + if (!stats.isFile()) return false + if (process.platform === "win32") return true + return (stats.mode & constants.X_OK) !== 0 + } catch { + return false + } +} + +function uniqueCleanStrings(values: Array): string[] { + return Array.from( + new Set(values.map((value) => cleanString(value)).filter(Boolean) as string[]), + ) +} + +export function resolveCodexNativeCommand( + input: ResolveCodexNativeCommandInput = {}, +): string { + const explicitCommand = cleanString(input.command) + if (explicitCommand) return explicitCommand + + const env = input.env ?? process.env + const platform = input.platform ?? process.platform + const binaryName = platform === "win32" ? "codex.exe" : "codex" + const configuredPath = + cleanString(env.MOSS_CODEX_CLI_PATH) ?? cleanString(env.CODEX_CLI_PATH) + const isExecutableFile = input.isExecutableFile ?? defaultIsExecutableFile + + if (configuredPath) { + if (!isExecutableFile(configuredPath)) { + throw new Error(`[codex] Configured Codex CLI not found at ${configuredPath}.`) + } + + return configuredPath + } + + const home = cleanString(input.homeDir) ?? homedir() + const delimiter = + input.pathDelimiter ?? (platform === "win32" ? ";" : path.delimiter) + const pathValue = platform === "win32" ? env.PATH ?? env.Path : env.PATH + const pathCandidates = + pathValue + ?.split(delimiter) + .map((pathEntry) => cleanString(pathEntry)) + .filter(Boolean) + .map((pathEntry) => path.join(pathEntry!, binaryName)) ?? [] + const candidates = uniqueCleanStrings([ + path.join(home, ".local", "bin", binaryName), + path.join(home, ".bun", "bin", binaryName), + path.join(home, "bin", binaryName), + platform === "darwin" ? path.join("/opt/homebrew/bin", binaryName) : undefined, + platform === "darwin" ? path.join("/usr/local/bin", binaryName) : undefined, + ...pathCandidates, + ]) + + return candidates.find((candidate) => isExecutableFile(candidate)) ?? binaryName +} + +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 = resolveCodexNativeCommand({ + command: input.command, + env: input.env, + }) + 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, + env: input.env, + }) + 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..7c47f459c --- /dev/null +++ b/src/main/lib/agent-runtime/control-plane.ts @@ -0,0 +1,402 @@ +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 { + readNativeThreadReadSummaryFromMetadata, + type NativeThreadReadSummary, +} from "./native-thread-summary" +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 + nativeThreadRead: NativeThreadReadSummary | null + 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, + nativeThreadRead: readNativeThreadReadSummaryFromMetadata( + subChat.runtimeMetadata, + ), + 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..dfce8cfce --- /dev/null +++ b/src/main/lib/agent-runtime/events.ts @@ -0,0 +1,193 @@ +import type { + AgentRuntimeBlockStatus, + AgentRuntimeConversationBlock, + AgentRuntimeStreamEvent, +} from "./types" +import { providerRuntimeEventToStreamEvent } from "./provider-runtime-contract" + +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 { + const providerRuntimeEvent = providerRuntimeEventToStreamEvent(chunk) + if (providerRuntimeEvent) return providerRuntimeEvent + + 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-acp-runtime.ts b/src/main/lib/agent-runtime/hermes-acp-runtime.ts new file mode 100644 index 000000000..0015f4abe --- /dev/null +++ b/src/main/lib/agent-runtime/hermes-acp-runtime.ts @@ -0,0 +1,279 @@ +import { createACPProvider, type ACPProvider } from "@mcpc-tech/acp-ai-provider" +import { streamText } from "ai" +import { homedir } from "node:os" +import { join } from "node:path" +import { resolveHermesAcpLaunch } from "../hermes/runtime" +import { + resolveMossProviderForEngine, + type ResolvedMossProvider, +} from "../moss-source/provider-config" +import { + createAgentRuntimeProcessHandle, + registerAgentRuntimeProcessHandle, + unregisterAgentRuntimeProcessHandle, +} from "./process-registry" +import type { + AgentPermissionMode, + AgentRuntimeStartRequest, + AgentRuntimeStreamEvent, +} from "./types" + +type HermesAcpProvider = Pick< + ACPProvider, + "cleanup" | "getSessionId" | "initSession" | "languageModel" | "tools" +> + +export type HermesAcpTextStreamInput = { + model: unknown + messages: Array<{ role: "user"; content: unknown }> + tools: unknown + abortSignal?: AbortSignal +} + +export type HermesAcpRuntimeDeps = { + resolveProvider?: typeof resolveMossProviderForEngine + getProviderSecret?: MossProviderSecretGetter + resolveLaunch?: typeof resolveHermesAcpLaunch + createProvider?: (input: Parameters[0]) => HermesAcpProvider + textStream?: (input: HermesAcpTextStreamInput) => AsyncIterable + env?: NodeJS.ProcessEnv + shellEnv?: Record +} + +type MossProviderSecretGetter = (providerId: string) => Promise<{ + apiKey?: string +}> + +const DEFAULT_HERMES_MODEL = "moss-default" + +export function resolveHermesAcpModelForCall( + model: string | null | undefined, +): string | undefined { + const normalized = model?.trim() + if (!normalized || normalized === DEFAULT_HERMES_MODEL || normalized === "hermes") { + return undefined + } + return normalized +} + +export function resolveHermesAcpMode( + permissionMode: AgentPermissionMode | undefined, +): string { + return permissionMode === "plan" || permissionMode === "read-only" + ? "default" + : "accept_edits" +} + +export function detectHermesAcpErrorText(text: string): string | undefined { + const trimmed = text.trim() + if (/^API call failed after \d+ retries:/i.test(trimmed)) { + return trimmed + } + return undefined +} + +export function buildHermesAcpProviderEnv(params: { + mossProvider?: ResolvedMossProvider | null + baseEnv?: NodeJS.ProcessEnv + shellEnv?: Record +}): Record { + const env: Record = {} + + for (const [key, value] of Object.entries(params.baseEnv ?? process.env)) { + if (typeof value === "string") env[key] = value + } + + for (const [key, value] of Object.entries(params.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 +} + +export async function* streamHermesAcpRuntimeRun( + request: AgentRuntimeStartRequest, + deps: HermesAcpRuntimeDeps = {}, +): AsyncIterable { + const handle = registerAgentRuntimeProcessHandle( + createAgentRuntimeProcessHandle({ + action: "resume", + session: request.session, + }), + ) + let provider: HermesAcpProvider | null = null + + try { + const nativeSessionId = request.session.nativeSessionId + if (!nativeSessionId) { + yield { + type: "error", + message: "Hermes ACP resume requires a native session id.", + } + yield { type: "finish", resultSubtype: "error", nativeSessionId: null } + return + } + + const resolveProvider = deps.resolveProvider ?? resolveMossProviderForEngine + const mossProvider = await resolveProvider({ + projectPath: request.session.projectPath ?? request.session.cwd, + engineId: "hermes", + requestedModelId: request.session.modelId ?? undefined, + createIfMissing: true, + secretResolver: { + getSecret: deps.getProviderSecret ?? getDefaultMossProviderSecret, + }, + }) + + if (mossProvider.warnings.length > 0) { + console.warn("[hermes-acp-runtime] Moss provider warnings:", mossProvider.warnings) + } + + const launch = (deps.resolveLaunch ?? resolveHermesAcpLaunch)() + const createProvider: (input: Parameters[0]) => HermesAcpProvider = + deps.createProvider ?? createACPProvider + const shellEnv = deps.shellEnv ?? await getDefaultClaudeShellEnvironment() + const activeProvider = createProvider({ + command: launch.command, + args: launch.args, + env: buildHermesAcpProviderEnv({ + mossProvider, + baseEnv: deps.env, + shellEnv, + }), + ...((deps.env ?? process.env).HERMES_ACP_AUTH_METHOD + ? { authMethodId: (deps.env ?? process.env).HERMES_ACP_AUTH_METHOD } + : {}), + session: { + cwd: request.session.cwd, + mcpServers: [], + }, + existingSessionId: nativeSessionId, + persistSession: true, + }) + provider = activeProvider + + const sessionInfo = await activeProvider.initSession() + const activeNativeSessionId = + activeProvider.getSessionId() || sessionInfo?.sessionId || nativeSessionId + const providerModel = + mossProvider.status === "resolved" + ? mossProvider.model ?? request.session.modelId + : request.session.modelId + const languageModel = activeProvider.languageModel( + resolveHermesAcpModelForCall(providerModel), + resolveHermesAcpMode(request.session.permissionMode), + ) + const textStream = deps.textStream ?? defaultHermesAcpTextStream + let accumulatedText = "" + + for await (const text of textStream({ + model: languageModel, + messages: [ + { + role: "user", + content: buildHermesAcpUserContent(request), + }, + ], + tools: activeProvider.tools, + abortSignal: handle.abortController.signal, + })) { + if (text) { + accumulatedText += text + yield { type: "text", text } + } + } + + const wasCancelled = handle.abortController.signal.aborted + const detectedError = wasCancelled + ? undefined + : detectHermesAcpErrorText(accumulatedText) + if (detectedError) { + yield { type: "error", message: detectedError } + yield { + type: "finish", + nativeSessionId: activeNativeSessionId, + resultSubtype: "error", + } + return + } + + yield { + type: "finish", + nativeSessionId: activeNativeSessionId, + resultSubtype: wasCancelled ? "cancelled" : "success", + } + } catch (error) { + const wasCancelled = handle.abortController.signal.aborted + yield { + type: "error", + message: wasCancelled + ? "Hermes ACP runtime run was cancelled." + : error instanceof Error ? error.message : String(error), + } + yield { + type: "finish", + nativeSessionId: request.session.nativeSessionId ?? null, + resultSubtype: wasCancelled ? "cancelled" : "error", + } + } finally { + provider?.cleanup() + unregisterAgentRuntimeProcessHandle(handle.runId) + } +} + +async function* defaultHermesAcpTextStream( + input: HermesAcpTextStreamInput, +): AsyncIterable { + const result = streamText({ + model: input.model as never, + messages: input.messages as never, + tools: input.tools as never, + abortSignal: input.abortSignal, + }) as unknown as { + text?: PromiseLike + textStream?: AsyncIterable + } + + if (result.textStream) { + for await (const text of result.textStream) { + yield text + } + return + } + + const text = await result.text + if (text) yield text +} + +async function getDefaultMossProviderSecret(providerId: string): Promise<{ + apiKey?: string +}> { + const { getMossProviderSecret } = await import("../moss-source/provider-secrets") + return getMossProviderSecret(providerId) +} + +async function getDefaultClaudeShellEnvironment(): Promise> { + const { getClaudeShellEnvironment } = await import("../claude/env") + return getClaudeShellEnvironment() +} + +function buildHermesAcpUserContent(request: AgentRuntimeStartRequest): unknown { + if (!request.images?.length) return request.prompt + + return [ + { type: "text", text: request.prompt }, + ...request.images.map((image) => ({ + type: "image", + image: `data:${image.mediaType};base64,${image.base64Data}`, + ...(image.filename ? { filename: image.filename } : {}), + })), + ] +} 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..3ab6d9b2c --- /dev/null +++ b/src/main/lib/agent-runtime/hermes-native-session.ts @@ -0,0 +1,310 @@ +import { spawn } from "node:child_process" +import { resolveHermesRuntime } from "../hermes/runtime" +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) ?? + resolveHermesRuntime().executable ?? + "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) + if (modelId) { + args.push("-m", modelId) + } + 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 = detectHermesCliError({ + exitCode: result.exitCode, + stdout, + stderr, + }) + + return { + ...result, + plan, + nativeSessionId: plan.sessionId, + ...(stdout ? { lastText: stdout } : {}), + ...(error ? { error } : {}), + success: result.exitCode === 0 && !error, + } +} + +function detectHermesCliError(input: { + exitCode: number | null + stdout?: string + stderr?: string +}): string | undefined { + if (input.exitCode !== 0) { + return input.stderr ?? input.stdout ?? `Hermes exited with ${input.exitCode}.` + } + + const text = input.stderr ?? input.stdout + if (text && /^API call failed after \d+ retries:/i.test(text)) { + return text + } + + return undefined +} diff --git a/src/main/lib/agent-runtime/index.ts b/src/main/lib/agent-runtime/index.ts new file mode 100644 index 000000000..2b0fd1d9b --- /dev/null +++ b/src/main/lib/agent-runtime/index.ts @@ -0,0 +1,22 @@ +export * from "./types" +export * from "./manifests" +export * from "./session-store" +export * from "./launch-plan" +export * from "./session-actions" +export * from "./session-records" +export * from "./codex-native-session" +export * from "./hermes-native-session" +export * from "./control-plane" +export * from "./native-thread-summary" +export * from "./adapters" +export * from "./events" +export * from "./provider-instances" +export * from "./provider-runtime-contract" +export * from "./codex-app-server-policy" +export * from "./codex-app-server-plan" +export * from "./codex-app-server-events" +export * from "./codex-app-server-client" +export * from "./codex-app-server-runtime" +export * from "./process-registry" +export * from "./runtime-run-ledger" +export * from "./runtime-receipt-bus" diff --git a/src/main/lib/agent-runtime/launch-plan.ts b/src/main/lib/agent-runtime/launch-plan.ts new file mode 100644 index 000000000..00f49e7fd --- /dev/null +++ b/src/main/lib/agent-runtime/launch-plan.ts @@ -0,0 +1,183 @@ +import type { + AgentPermissionMode, + AgentRuntimeFeature, + AgentRuntimeManifest, + AgentRuntimeSessionRef, +} from "./types" +import { getAgentRuntimeManifest } from "./manifests" + +export type AgentRuntimeLaunchPlanResultSubtype = + | "running" + | "success" + | "error" + | "cancelled" + +export type AgentRuntimeNativeSessionStrategy = + | "start" + | "resume" + | "continue" + | "not-supported" + | "unknown" + +export type AgentRuntimeProjectionStatus = + | "ready" + | "partial" + | "missing" + | "unsupported" + | "unknown" + +export type AgentRuntimeCapabilityStatus = + | "native" + | "moss-projected" + | "unsupported" + +export interface AgentRuntimeNegotiatedCapability { + feature: AgentRuntimeFeature + status: AgentRuntimeCapabilityStatus +} + +export interface AgentRuntimeLaunchPlan { + version: 1 + runId: string + subChatId: string + chatId: string + engineId: AgentRuntimeSessionRef["engineId"] + modelId: string | null + permissionMode: AgentPermissionMode + cwd: string + projectPath: string | null + runtimeConfigDir: string | null + nativeSessionId: string | null + nativeSessionStrategy: AgentRuntimeNativeSessionStrategy + transport: string | null + providerRoute: string | null + projectionStatus: AgentRuntimeProjectionStatus + runtimeContextFingerprint: string | null + mcpFingerprint: string | null + resultSubtype: AgentRuntimeLaunchPlanResultSubtype + negotiatedCapabilities: AgentRuntimeNegotiatedCapability[] + createdAt: string + metadata?: Record +} + +export interface BuildAgentRuntimeLaunchPlanInput { + runId: string + session: AgentRuntimeSessionRef + manifest?: AgentRuntimeManifest + modelId?: string | null + nativeSessionId?: string | null + nativeSessionStrategy?: AgentRuntimeNativeSessionStrategy + transport?: string | null + providerRoute?: string | null + projectionStatus?: AgentRuntimeProjectionStatus + runtimeContextFingerprint?: string | null + mcpFingerprint?: string | null + resultSubtype?: AgentRuntimeLaunchPlanResultSubtype + now?: Date | string + metadata?: Record +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function resolveCreatedAt(value: Date | string | undefined): string { + if (typeof value === "string") return value + return (value ?? new Date()).toISOString() +} + +function inferNativeSessionStrategy(params: { + manifest: AgentRuntimeManifest + nativeSessionId: string | null + explicit?: AgentRuntimeNativeSessionStrategy +}): AgentRuntimeNativeSessionStrategy { + if (params.explicit) return params.explicit + if (params.manifest.availability === "unsupported") return "not-supported" + if (params.nativeSessionId) return "resume" + if (params.manifest.features.includes("chat")) return "start" + return "unknown" +} + +function buildNegotiatedCapabilities( + manifest: AgentRuntimeManifest, +): AgentRuntimeNegotiatedCapability[] { + return manifest.features.map((feature) => ({ + feature, + status: + manifest.availability === "unsupported" ? "unsupported" : "native", + })) +} + +export function buildAgentRuntimeLaunchPlan( + input: BuildAgentRuntimeLaunchPlanInput, +): AgentRuntimeLaunchPlan { + const manifest = input.manifest ?? getAgentRuntimeManifest(input.session.engineId) + const nativeSessionId = + input.nativeSessionId ?? input.session.nativeSessionId ?? null + const modelId = + input.modelId ?? + input.session.modelId ?? + manifest.defaultModelId ?? + null + + return { + version: 1, + runId: input.runId, + subChatId: input.session.subChatId, + chatId: input.session.chatId, + engineId: input.session.engineId, + modelId, + permissionMode: input.session.permissionMode, + cwd: input.session.cwd, + projectPath: input.session.projectPath ?? null, + runtimeConfigDir: input.session.runtimeConfigDir ?? null, + nativeSessionId, + nativeSessionStrategy: inferNativeSessionStrategy({ + manifest, + nativeSessionId, + explicit: input.nativeSessionStrategy, + }), + transport: input.transport ?? null, + providerRoute: input.providerRoute ?? null, + projectionStatus: input.projectionStatus ?? "unknown", + runtimeContextFingerprint: input.runtimeContextFingerprint ?? null, + mcpFingerprint: input.mcpFingerprint ?? null, + resultSubtype: input.resultSubtype ?? "running", + negotiatedCapabilities: buildNegotiatedCapabilities(manifest), + createdAt: resolveCreatedAt(input.now), + ...(input.metadata ? { metadata: input.metadata } : {}), + } +} + +function parseMetadata( + value: Record | string | null | undefined, +): Record { + if (isRecord(value)) return value + if (typeof value !== "string" || value.trim().length === 0) return {} + + try { + const parsed = JSON.parse(value) + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +export function mergeAgentRuntimeLaunchPlanMetadata( + runtimeMetadata: Record | string | null | undefined, + launchPlan: AgentRuntimeLaunchPlan, +): Record { + const parsed = parseMetadata(runtimeMetadata) + const mossSessionControl = isRecord(parsed.mossSessionControl) + ? parsed.mossSessionControl + : {} + + return { + ...parsed, + mossSessionControl: { + ...mossSessionControl, + launchPlan, + launchPlanUpdatedAt: launchPlan.createdAt, + }, + } +} diff --git a/src/main/lib/agent-runtime/manifests.ts b/src/main/lib/agent-runtime/manifests.ts new file mode 100644 index 000000000..4d1b7137f --- /dev/null +++ b/src/main/lib/agent-runtime/manifests.ts @@ -0,0 +1,176 @@ +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< + AgentEngineId, + AgentRuntimeManifest +> = { + "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", + "fork", + "rollback", + "mcp", + "plugins", + "memory", + "images", + "usage", + "permissions", + "projects", + "library", + "pull-requests", + "follow-ups", + "rate-limits", + "realtime-voice", + "dictation", + "diagnostics", + "thread-management", + ], + 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 fork, resume, rollback, thread management, config reads/writes, config requirements, permission profiles, model inventory, account snapshots, account login/logout, usage, rate limits, skill, hook, app, plugin, and MCP inventory, plus plugin install, external config migration, MCP config reload, and MCP OAuth login, are routed through the Codex app-server 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/native-thread-summary.ts b/src/main/lib/agent-runtime/native-thread-summary.ts new file mode 100644 index 000000000..a824fcf70 --- /dev/null +++ b/src/main/lib/agent-runtime/native-thread-summary.ts @@ -0,0 +1,515 @@ +import type { AgentRuntimeThreadReadResult } from "./types" + +export type NativeThreadConsistencyStatus = + | "consistent" + | "diverged" + | "unknown" + +export interface NativeThreadReadSummary { + status: AgentRuntimeThreadReadResult["status"] + threadId: string | null + includeTurns: boolean + turnCount?: number + firstTurnId?: string + lastTurnId?: string + turnIds?: string[] + turnIdsTruncated?: boolean + itemCount?: number + checkpointCount?: number + readyCheckpointCount?: number + checkpoints?: NativeThreadCheckpointSummary[] + checkpointsTruncated?: boolean + latestReadyCheckpoint?: NativeThreadCheckpointSummary + localUserTurnCount?: number + consistencyStatus?: NativeThreadConsistencyStatus + consistencyReason?: string + message?: string + updatedAt: string + bridge?: string + method?: string +} + +export type NativeThreadCheckpointStatus = "ready" | "missing" | "error" + +export interface NativeThreadCheckpointFileSummary { + path: string + kind?: string + additions: number + deletions: number +} + +export interface NativeThreadCheckpointSummary { + turnId: string + checkpointTurnCount: number + checkpointRef: string + status: NativeThreadCheckpointStatus + fileCount: number + additions: number + deletions: number + assistantMessageId?: string | null + completedAt?: string + files?: NativeThreadCheckpointFileSummary[] + filesTruncated?: boolean +} + +const DEFAULT_MAX_TURN_IDS = 64 +const DEFAULT_MAX_CHECKPOINTS = 64 +const DEFAULT_MAX_CHECKPOINT_FILES = 24 + +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 ? trimmed : undefined +} + +function readRecord(value: unknown, key: string): Record | null { + if (!isRecord(value)) return null + const nested = value[key] + return isRecord(nested) ? nested : null +} + +function readArray(value: unknown, key: string): unknown[] | null { + if (!isRecord(value)) return null + const nested = value[key] + return Array.isArray(nested) ? nested : null +} + +function readStringArray(value: unknown, key: string): string[] | undefined { + if (!isRecord(value)) return undefined + const nested = value[key] + if (!Array.isArray(nested)) return undefined + return nested.filter((entry): entry is string => typeof entry === "string") +} + +function readNativeThreadCheckpointStatus( + value: unknown, +): NativeThreadCheckpointStatus | undefined { + if (value === "ready" || value === "missing" || value === "error") { + return value + } + return undefined +} + +function readNonNegativeNumber(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined + } + return value +} + +function parseMessageArray(value: unknown): unknown[] { + if (Array.isArray(value)) return value + if (typeof value !== "string") return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +export function countLocalTranscriptUserTurns(messages: unknown): number { + return parseMessageArray(messages).filter( + (message) => isRecord(message) && message.role === "user", + ).length +} + +function readTurnId(turn: unknown): string | undefined { + if (!isRecord(turn)) return undefined + return cleanString(turn.id) ?? cleanString(turn.turnId) +} + +function compactSummary>(summary: T): T { + return Object.fromEntries( + Object.entries(summary).filter(([, value]) => value !== undefined), + ) as T +} + +function summarizeNativeCheckpointFile( + file: unknown, +): NativeThreadCheckpointFileSummary | null { + if (!isRecord(file)) return null + const path = cleanString(file.path) + if (!path) return null + const kind = cleanString(file.kind) + return compactSummary({ + path, + ...(kind ? { kind } : {}), + additions: readNonNegativeNumber(file.additions) ?? 0, + deletions: readNonNegativeNumber(file.deletions) ?? 0, + }) +} + +function readCheckpointTurnCount(checkpoint: Record): number | undefined { + return ( + readNonNegativeNumber(checkpoint.checkpointTurnCount) ?? + readNonNegativeNumber(checkpoint.turnCount) + ) +} + +function checkpointOrder( + left: NativeThreadCheckpointSummary, + right: NativeThreadCheckpointSummary, +): number { + return left.checkpointTurnCount - right.checkpointTurnCount +} + +function summarizeNativeCheckpoint( + checkpoint: unknown, + options: { maxCheckpointFiles: number }, +): NativeThreadCheckpointSummary | null { + if (!isRecord(checkpoint)) return null + const turnId = cleanString(checkpoint.turnId) ?? cleanString(checkpoint.id) + const checkpointTurnCount = readCheckpointTurnCount(checkpoint) + const checkpointRef = cleanString(checkpoint.checkpointRef) + const status = readNativeThreadCheckpointStatus(checkpoint.status) + if (!turnId || checkpointTurnCount === undefined || !checkpointRef || !status) { + return null + } + + const rawFiles: unknown[] = Array.isArray(checkpoint.files) + ? checkpoint.files + : [] + const hasRawFiles = Array.isArray(checkpoint.files) + const allFiles = rawFiles + .map(summarizeNativeCheckpointFile) + .filter((file): file is NativeThreadCheckpointFileSummary => Boolean(file)) + const files = allFiles.slice(0, Math.max(0, options.maxCheckpointFiles)) + const fileCount = hasRawFiles + ? allFiles.length + : readNonNegativeNumber(checkpoint.fileCount) ?? allFiles.length + const additions = hasRawFiles + ? allFiles.reduce((sum, file) => sum + file.additions, 0) + : readNonNegativeNumber(checkpoint.additions) ?? 0 + const deletions = hasRawFiles + ? allFiles.reduce((sum, file) => sum + file.deletions, 0) + : readNonNegativeNumber(checkpoint.deletions) ?? 0 + const assistantMessageId = cleanString(checkpoint.assistantMessageId) + const completedAt = cleanString(checkpoint.completedAt) + + return compactSummary({ + turnId, + checkpointTurnCount, + checkpointRef, + status, + fileCount, + additions, + deletions, + ...(assistantMessageId + ? { assistantMessageId } + : checkpoint.assistantMessageId === null + ? { assistantMessageId: null } + : {}), + ...(completedAt ? { completedAt } : {}), + ...(files.length > 0 ? { files } : {}), + ...(allFiles.length > files.length ? { filesTruncated: true } : {}), + }) +} + +function summarizeNativeCheckpoints( + payload: unknown, + options: { maxCheckpoints: number; maxCheckpointFiles: number }, +): Pick< + NativeThreadReadSummary, + | "checkpointCount" + | "readyCheckpointCount" + | "checkpoints" + | "checkpointsTruncated" + | "latestReadyCheckpoint" +> { + const root = isRecord(payload) ? payload : {} + const thread = readRecord(root, "thread") ?? root + const rawCheckpoints = + readArray(thread, "checkpoints") ?? readArray(root, "checkpoints") + if (!rawCheckpoints) return {} + + const allCheckpoints = rawCheckpoints + .map((checkpoint) => + summarizeNativeCheckpoint(checkpoint, { + maxCheckpointFiles: options.maxCheckpointFiles, + }), + ) + .filter((checkpoint): checkpoint is NativeThreadCheckpointSummary => + Boolean(checkpoint), + ) + .sort(checkpointOrder) + const readyCheckpoints = allCheckpoints.filter( + (checkpoint) => checkpoint.status === "ready", + ) + const maxCheckpoints = Math.max(0, options.maxCheckpoints) + const retainedCheckpoints = + maxCheckpoints === 0 + ? [] + : allCheckpoints.slice(Math.max(0, allCheckpoints.length - maxCheckpoints)) + + return compactSummary({ + checkpointCount: allCheckpoints.length, + readyCheckpointCount: readyCheckpoints.length, + ...(retainedCheckpoints.length > 0 ? { checkpoints: retainedCheckpoints } : {}), + ...(allCheckpoints.length > retainedCheckpoints.length + ? { checkpointsTruncated: true } + : {}), + ...(readyCheckpoints.at(-1) + ? { latestReadyCheckpoint: readyCheckpoints.at(-1) } + : {}), + }) +} + +function readNativeCheckpointSummary( + value: unknown, +): NativeThreadCheckpointSummary | null { + return summarizeNativeCheckpoint(value, { + maxCheckpointFiles: DEFAULT_MAX_CHECKPOINT_FILES, + }) +} + +function readNativeCheckpointSummaries(value: unknown): NativeThreadCheckpointSummary[] { + if (!Array.isArray(value)) return [] + return value + .map(readNativeCheckpointSummary) + .filter((checkpoint): checkpoint is NativeThreadCheckpointSummary => + Boolean(checkpoint), + ) +} + +function resolveConsistency(params: { + status: AgentRuntimeThreadReadResult["status"] + turnCount?: number + localUserTurnCount?: number +}): { + consistencyStatus?: NativeThreadConsistencyStatus + consistencyReason?: string +} { + if (params.status !== "success") { + return { + consistencyStatus: "unknown", + consistencyReason: "Native thread read did not succeed.", + } + } + if (params.turnCount === undefined) { + return { + consistencyStatus: "unknown", + consistencyReason: "Native thread read did not include turn history.", + } + } + if (params.localUserTurnCount === undefined) { + return { + consistencyStatus: "unknown", + consistencyReason: "Local transcript turn count was not provided.", + } + } + if (params.turnCount === params.localUserTurnCount) { + return { + consistencyStatus: "consistent", + consistencyReason: "Native turns match local user turns.", + } + } + return { + consistencyStatus: "diverged", + consistencyReason: `Native turns (${params.turnCount}) differ from local user turns (${params.localUserTurnCount}).`, + } +} + +export function summarizeNativeThreadPayload( + payload: unknown, + options: { + maxTurnIds?: number + maxCheckpoints?: number + maxCheckpointFiles?: number + } = {}, +): { + threadId: string | null + turnCount?: number + firstTurnId?: string + lastTurnId?: string + turnIds?: string[] + turnIdsTruncated?: boolean + itemCount?: number + checkpointCount?: number + readyCheckpointCount?: number + checkpoints?: NativeThreadCheckpointSummary[] + checkpointsTruncated?: boolean + latestReadyCheckpoint?: NativeThreadCheckpointSummary +} { + const root = isRecord(payload) ? payload : {} + const thread = readRecord(root, "thread") ?? root + const turns = readArray(thread, "turns") ?? readArray(root, "turns") + const rootItems = readArray(thread, "items") ?? readArray(root, "items") + const turnItemCount = turns?.reduce((count, turn) => { + const items = readArray(turn, "items") + return count + (items?.length ?? 0) + }, 0) + const itemCount = + rootItems?.length ?? (typeof turnItemCount === "number" ? turnItemCount : undefined) + const maxTurnIds = Math.max(0, options.maxTurnIds ?? DEFAULT_MAX_TURN_IDS) + const allTurnIds = + turns?.map(readTurnId).filter((turnId): turnId is string => Boolean(turnId)) ?? + [] + const turnIds = allTurnIds.slice(0, maxTurnIds) + const checkpointSummary = summarizeNativeCheckpoints(payload, { + maxCheckpoints: options.maxCheckpoints ?? DEFAULT_MAX_CHECKPOINTS, + maxCheckpointFiles: + options.maxCheckpointFiles ?? DEFAULT_MAX_CHECKPOINT_FILES, + }) + + return { + threadId: + cleanString(thread.id) ?? + cleanString(root.threadId) ?? + cleanString(root.id) ?? + null, + ...(turns ? { turnCount: turns.length } : {}), + ...(allTurnIds[0] ? { firstTurnId: allTurnIds[0] } : {}), + ...(allTurnIds.at(-1) ? { lastTurnId: allTurnIds.at(-1) } : {}), + ...(turns && turnIds.length > 0 ? { turnIds } : {}), + ...(turns && allTurnIds.length > maxTurnIds + ? { turnIdsTruncated: true } + : {}), + ...(typeof itemCount === "number" ? { itemCount } : {}), + ...checkpointSummary, + } +} + +export function summarizeNativeThreadReadResult( + result: AgentRuntimeThreadReadResult, + options: { + includeTurns?: boolean + localMessages?: unknown + localUserTurnCount?: number + } = {}, +): NativeThreadReadSummary { + const payload = summarizeNativeThreadPayload(result.thread) + const metadata = isRecord(result.metadata) ? result.metadata : {} + const localUserTurnCount = + options.localUserTurnCount ?? + (options.localMessages !== undefined + ? countLocalTranscriptUserTurns(options.localMessages) + : undefined) + const consistency = resolveConsistency({ + status: result.status, + turnCount: payload.turnCount, + localUserTurnCount, + }) + + return compactSummary({ + status: result.status, + threadId: result.threadId ?? payload.threadId, + includeTurns: options.includeTurns ?? true, + ...(payload.turnCount !== undefined ? { turnCount: payload.turnCount } : {}), + ...(payload.firstTurnId !== undefined ? { firstTurnId: payload.firstTurnId } : {}), + ...(payload.lastTurnId !== undefined ? { lastTurnId: payload.lastTurnId } : {}), + ...(payload.turnIds !== undefined ? { turnIds: payload.turnIds } : {}), + ...(payload.turnIdsTruncated !== undefined + ? { turnIdsTruncated: payload.turnIdsTruncated } + : {}), + ...(payload.itemCount !== undefined ? { itemCount: payload.itemCount } : {}), + ...(payload.checkpointCount !== undefined + ? { checkpointCount: payload.checkpointCount } + : {}), + ...(payload.readyCheckpointCount !== undefined + ? { readyCheckpointCount: payload.readyCheckpointCount } + : {}), + ...(payload.checkpoints !== undefined ? { checkpoints: payload.checkpoints } : {}), + ...(payload.checkpointsTruncated !== undefined + ? { checkpointsTruncated: payload.checkpointsTruncated } + : {}), + ...(payload.latestReadyCheckpoint !== undefined + ? { latestReadyCheckpoint: payload.latestReadyCheckpoint } + : {}), + ...(localUserTurnCount !== undefined ? { localUserTurnCount } : {}), + ...consistency, + ...(result.message ? { message: result.message } : {}), + updatedAt: result.updatedAt, + bridge: cleanString(metadata.bridge), + method: cleanString(metadata.method), + }) +} + +export function readNativeThreadReadSummaryFromMetadata( + runtimeMetadata: string | null | undefined, +): NativeThreadReadSummary | null { + if (!runtimeMetadata) return null + + let parsed: unknown + try { + parsed = JSON.parse(runtimeMetadata) + } catch { + return null + } + + const control = readRecord(parsed, "mossSessionControl") + const summary = readRecord(control, "nativeThreadRead") + if (!summary) return null + + const rawStatus = cleanString(summary.status) + const updatedAt = cleanString(summary.updatedAt) + if ( + rawStatus !== "success" && + rawStatus !== "unsupported" && + rawStatus !== "error" + ) { + return null + } + if (!updatedAt) return null + const status: NativeThreadReadSummary["status"] = rawStatus + const consistencyStatus: NativeThreadConsistencyStatus | undefined = + summary.consistencyStatus === "consistent" || + summary.consistencyStatus === "diverged" || + summary.consistencyStatus === "unknown" + ? summary.consistencyStatus + : undefined + const consistencyReason = cleanString(summary.consistencyReason) + const checkpoints = readNativeCheckpointSummaries(summary.checkpoints) + const latestReadyCheckpoint = readNativeCheckpointSummary( + summary.latestReadyCheckpoint, + ) + + return compactSummary({ + status, + threadId: cleanString(summary.threadId) ?? null, + includeTurns: + typeof summary.includeTurns === "boolean" ? summary.includeTurns : true, + ...(typeof summary.turnCount === "number" + ? { turnCount: summary.turnCount } + : {}), + ...(cleanString(summary.firstTurnId) + ? { firstTurnId: cleanString(summary.firstTurnId) } + : {}), + ...(cleanString(summary.lastTurnId) + ? { lastTurnId: cleanString(summary.lastTurnId) } + : {}), + ...(readStringArray(summary, "turnIds") + ? { turnIds: readStringArray(summary, "turnIds") } + : {}), + ...(typeof summary.turnIdsTruncated === "boolean" + ? { turnIdsTruncated: summary.turnIdsTruncated } + : {}), + ...(typeof summary.itemCount === "number" + ? { itemCount: summary.itemCount } + : {}), + ...(typeof summary.checkpointCount === "number" + ? { checkpointCount: summary.checkpointCount } + : {}), + ...(typeof summary.readyCheckpointCount === "number" + ? { readyCheckpointCount: summary.readyCheckpointCount } + : {}), + ...(checkpoints.length > 0 ? { checkpoints } : {}), + ...(typeof summary.checkpointsTruncated === "boolean" + ? { checkpointsTruncated: summary.checkpointsTruncated } + : {}), + ...(latestReadyCheckpoint ? { latestReadyCheckpoint } : {}), + ...(typeof summary.localUserTurnCount === "number" + ? { localUserTurnCount: summary.localUserTurnCount } + : {}), + ...(consistencyStatus ? { consistencyStatus } : {}), + ...(consistencyReason ? { consistencyReason } : {}), + ...(cleanString(summary.message) ? { message: cleanString(summary.message) } : {}), + updatedAt, + bridge: cleanString(summary.bridge), + method: cleanString(summary.method), + }) as NativeThreadReadSummary +} diff --git a/src/main/lib/agent-runtime/process-registry.ts b/src/main/lib/agent-runtime/process-registry.ts new file mode 100644 index 000000000..bc67f1d3d --- /dev/null +++ b/src/main/lib/agent-runtime/process-registry.ts @@ -0,0 +1,196 @@ +import { + createAgentRuntimeRunId, + createUnsupportedAgentRuntimeControlResult, +} from "./runtime-run-ledger" +import type { + AgentRuntimeControlResult, + AgentRuntimeRunAction, + AgentRuntimeSessionRef, + AgentRuntimeStopRequest, + AgentRuntimeToolResultRequest, +} from "./types" + +export type AgentRuntimeProcessState = "running" | "stopping" + +export type AgentRuntimeToolResultSink = ( + request: AgentRuntimeToolResultRequest, +) => AgentRuntimeControlResult | Promise + +export type AgentRuntimeStopSink = ( + request: AgentRuntimeStopRequest, +) => void | Promise + +export interface AgentRuntimeProcessHandle { + runId: string + action: AgentRuntimeRunAction + session: AgentRuntimeSessionRef + abortController: AbortController + state: AgentRuntimeProcessState + createdAt: string + updatedAt: string + stopReason?: string + submitToolResult?: AgentRuntimeToolResultSink + onStop?: AgentRuntimeStopSink +} + +const processHandles = new Map() + +export function createAgentRuntimeProcessHandle(params: { + action: AgentRuntimeRunAction + session: AgentRuntimeSessionRef + runId?: string + now?: Date | string + submitToolResult?: AgentRuntimeToolResultSink + onStop?: AgentRuntimeStopSink +}): AgentRuntimeProcessHandle { + const createdAt = isoString(params.now) + return { + runId: + params.runId ?? + createAgentRuntimeRunId(params.session, params.action, Date.parse(createdAt)), + action: params.action, + session: params.session, + abortController: new AbortController(), + state: "running", + createdAt, + updatedAt: createdAt, + submitToolResult: params.submitToolResult, + onStop: params.onStop, + } +} + +export function registerAgentRuntimeProcessHandle( + handle: AgentRuntimeProcessHandle, +): AgentRuntimeProcessHandle { + processHandles.set(handle.runId, handle) + return handle +} + +export function getAgentRuntimeProcessHandle( + runId: string, +): AgentRuntimeProcessHandle | null { + return processHandles.get(runId) ?? null +} + +export function unregisterAgentRuntimeProcessHandle( + runId: string, +): AgentRuntimeProcessHandle | null { + const handle = getAgentRuntimeProcessHandle(runId) + if (!handle) return null + processHandles.delete(runId) + return handle +} + +export function stopAgentRuntimeProcess( + request: AgentRuntimeStopRequest, +): AgentRuntimeControlResult { + const handle = getAgentRuntimeProcessHandle(request.runId) + if (!handle) { + return { + ...createUnsupportedAgentRuntimeControlResult( + request, + "No active runtime process is registered for this run id.", + ), + status: "not-found", + } + } + + if (handle.session.engineId !== request.session.engineId) { + return { + runId: request.runId, + status: "error", + message: `Run ${request.runId} belongs to ${handle.session.engineId}, not ${request.session.engineId}.`, + updatedAt: new Date().toISOString(), + } + } + + const updatedAt = new Date().toISOString() + handle.state = "stopping" + handle.updatedAt = updatedAt + handle.stopReason = request.reason + try { + const stopResult = handle.onStop?.(request) + if (stopResult) { + void Promise.resolve(stopResult).catch(() => undefined) + } + } catch { + // Stop is best-effort. The abort signal below remains the authoritative local stop. + } + if (!handle.abortController.signal.aborted) { + handle.abortController.abort(request.reason ?? "Runtime stop requested.") + } + + return { + runId: request.runId, + status: "accepted", + message: "Runtime stop signal accepted.", + updatedAt, + metadata: { + engineId: handle.session.engineId, + subChatId: handle.session.subChatId, + state: handle.state, + reason: request.reason ?? null, + }, + } +} + +export async function submitAgentRuntimeToolResult( + request: AgentRuntimeToolResultRequest, +): Promise { + const handle = getAgentRuntimeProcessHandle(request.runId) + if (!handle) { + return { + ...createUnsupportedAgentRuntimeControlResult( + request, + "No active runtime process is registered for this run id.", + ), + status: "not-found", + } + } + + if (handle.session.engineId !== request.session.engineId) { + return { + runId: request.runId, + status: "error", + message: `Run ${request.runId} belongs to ${handle.session.engineId}, not ${request.session.engineId}.`, + updatedAt: new Date().toISOString(), + } + } + + if (!handle.submitToolResult) { + return createUnsupportedAgentRuntimeControlResult( + request, + "Active runtime process does not accept out-of-band tool result submission.", + ) + } + + try { + const result = await handle.submitToolResult(request) + handle.updatedAt = result.updatedAt + return result + } catch (error) { + const updatedAt = new Date().toISOString() + handle.updatedAt = updatedAt + return { + runId: request.runId, + status: "error", + message: error instanceof Error ? error.message : String(error), + updatedAt, + } + } +} + +export function listAgentRuntimeProcessHandles(): AgentRuntimeProcessHandle[] { + return [...processHandles.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ) +} + +export function clearAgentRuntimeProcessHandlesForTests(): void { + processHandles.clear() +} + +function isoString(value: Date | string | undefined): string { + if (!value) return new Date().toISOString() + return typeof value === "string" ? value : value.toISOString() +} diff --git a/src/main/lib/agent-runtime/provider-instances.ts b/src/main/lib/agent-runtime/provider-instances.ts new file mode 100644 index 000000000..d57f97ce3 --- /dev/null +++ b/src/main/lib/agent-runtime/provider-instances.ts @@ -0,0 +1,114 @@ +import { z } from "zod" +import { + AGENT_ENGINE_IDS, + type AgentEngineId, + type AgentRuntimeSessionRef, +} from "./types" + +export type AgentRuntimeProviderInstanceId = string & { + readonly __agentRuntimeProviderInstanceId: unique symbol +} + +export interface AgentRuntimeModelSelection { + instanceId: AgentRuntimeProviderInstanceId + modelId: string + options?: Record +} + +export const DEFAULT_AGENT_RUNTIME_PROVIDER_INSTANCE_IDS: Record< + AgentEngineId, + AgentRuntimeProviderInstanceId +> = { + "claude-code": "claude-code" as AgentRuntimeProviderInstanceId, + codex: "codex" as AgentRuntimeProviderInstanceId, + hermes: "hermes" as AgentRuntimeProviderInstanceId, + "custom-acp": "custom-acp" as AgentRuntimeProviderInstanceId, +} + +const agentEngineIdSet = new Set(AGENT_ENGINE_IDS) + +const legacyModelSelectionSchema = z.object({ + provider: z.unknown().optional(), + engineId: z.unknown().optional(), + instanceId: z.unknown().optional(), + model: z.unknown().optional(), + modelId: z.unknown().optional(), + options: z.record(z.string(), z.unknown()).optional(), +}).passthrough() + +function cleanString(value: unknown): string | null { + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +export function isAgentEngineId(value: unknown): value is AgentEngineId { + return typeof value === "string" && agentEngineIdSet.has(value) +} + +export function defaultProviderInstanceIdForEngine( + engineId: AgentEngineId, +): AgentRuntimeProviderInstanceId { + return DEFAULT_AGENT_RUNTIME_PROVIDER_INSTANCE_IDS[engineId] +} + +export function makeAgentRuntimeProviderInstanceId( + value: string, +): AgentRuntimeProviderInstanceId { + const cleaned = cleanString(value) + if (!cleaned) { + throw new Error("Agent runtime provider instance id must be a non-empty string.") + } + return cleaned as AgentRuntimeProviderInstanceId +} + +export function normalizeAgentRuntimeModelSelection( + value: unknown, + fallback?: { + engineId?: AgentEngineId | null + modelId?: string | null + }, +): AgentRuntimeModelSelection | null { + const parsed = legacyModelSelectionSchema.safeParse(value) + const source = parsed.success ? parsed.data : {} + const legacyEngine = + isAgentEngineId(source.engineId) + ? source.engineId + : isAgentEngineId(source.provider) + ? source.provider + : fallback?.engineId ?? null + const instanceId = + cleanString(source.instanceId) ?? + (legacyEngine ? defaultProviderInstanceIdForEngine(legacyEngine) : null) + const modelId = + cleanString(source.modelId) ?? + cleanString(source.model) ?? + cleanString(fallback?.modelId) + + if (!instanceId || !modelId) return null + + return { + instanceId: makeAgentRuntimeProviderInstanceId(instanceId), + modelId, + ...(source.options ? { options: source.options } : {}), + } +} + +export function resolveAgentRuntimeProviderInstanceId( + session: Pick< + AgentRuntimeSessionRef, + "engineId" | "modelId" | "metadata" | "providerInstanceId" | "modelSelection" + >, +): AgentRuntimeProviderInstanceId { + const explicit = cleanString(session.providerInstanceId) + if (explicit) return makeAgentRuntimeProviderInstanceId(explicit) + + const modelSelection = normalizeAgentRuntimeModelSelection( + session.modelSelection ?? session.metadata?.modelSelection, + { + engineId: session.engineId, + modelId: session.modelId, + }, + ) + return modelSelection?.instanceId ?? defaultProviderInstanceIdForEngine(session.engineId) +} diff --git a/src/main/lib/agent-runtime/provider-runtime-contract.ts b/src/main/lib/agent-runtime/provider-runtime-contract.ts new file mode 100644 index 000000000..74b52b251 --- /dev/null +++ b/src/main/lib/agent-runtime/provider-runtime-contract.ts @@ -0,0 +1,820 @@ +import { z } from "zod" +import type { + AgentEngineId, + AgentRuntimeConversationBlock, + AgentRuntimeStreamEvent, +} from "./types" +import { + isAgentEngineId, + makeAgentRuntimeProviderInstanceId, + type AgentRuntimeProviderInstanceId, +} from "./provider-instances" + +export const PROVIDER_RUNTIME_EVENT_TYPES = [ + "session.started", + "session.configured", + "session.state.changed", + "session.exited", + "thread.started", + "thread.state.changed", + "thread.metadata.updated", + "thread.token-usage.updated", + "thread.realtime.started", + "thread.realtime.item-added", + "thread.realtime.audio.delta", + "thread.realtime.error", + "thread.realtime.closed", + "turn.started", + "turn.completed", + "turn.aborted", + "turn.plan.updated", + "turn.proposed.delta", + "turn.proposed.completed", + "turn.diff.updated", + "item.started", + "item.updated", + "item.completed", + "content.delta", + "request.opened", + "request.resolved", + "user-input.requested", + "user-input.resolved", + "task.started", + "task.progress", + "task.completed", + "hook.started", + "hook.progress", + "hook.completed", + "tool.progress", + "tool.summary", + "tool.denied", + "auth.status", + "account.updated", + "account.rate-limits.updated", + "mcp.status.updated", + "mcp.oauth.completed", + "app.list.updated", + "model.rerouted", + "config.updated", + "config.warning", + "deprecation.notice", + "files.persisted", + "runtime.warning", + "runtime.error", +] as const + +export type ProviderRuntimeEventType = + (typeof PROVIDER_RUNTIME_EVENT_TYPES)[number] + +export const CANONICAL_RUNTIME_ITEM_TYPES = [ + "user_message", + "assistant_message", + "reasoning", + "plan", + "command_execution", + "file_change", + "mcp_tool_call", + "dynamic_tool_call", + "collab_agent_tool_call", + "web_search", + "image_view", + "review_entered", + "review_exited", + "context_compaction", + "error", + "unknown", +] as const + +export type CanonicalRuntimeItemType = + (typeof CANONICAL_RUNTIME_ITEM_TYPES)[number] + +export const CANONICAL_RUNTIME_REQUEST_TYPES = [ + "command_execution_approval", + "file_read_approval", + "file_change_approval", + "apply_patch_approval", + "exec_command_approval", + "tool_user_input", + "dynamic_tool_call", + "auth_tokens_refresh", + "unknown", +] as const + +export type CanonicalRuntimeRequestType = + (typeof CANONICAL_RUNTIME_REQUEST_TYPES)[number] + +const providerRuntimeEventSchema = z.object({ + type: z.enum(PROVIDER_RUNTIME_EVENT_TYPES), + eventId: z.string().trim().min(1).optional(), + provider: z.string().trim().min(1).optional(), + engineId: z.string().trim().min(1).optional(), + providerInstanceId: z.string().trim().min(1).optional(), + subChatId: z.string().trim().min(1).optional(), + chatId: z.string().trim().min(1).optional(), + threadId: z.string().trim().min(1).optional(), + nativeSessionId: z.string().trim().min(1).nullable().optional(), + turnId: z.string().trim().min(1).optional(), + itemId: z.string().trim().min(1).optional(), + requestId: z.string().trim().min(1).optional(), + createdAt: z.string().trim().min(1).optional(), + providerRefs: z.record(z.string(), z.unknown()).optional(), + payload: z.record(z.string(), z.unknown()).optional(), + raw: z.unknown().optional(), +}).passthrough() + +export interface ProviderRuntimeEvent { + type: ProviderRuntimeEventType + eventId?: string + provider?: string + engineId?: AgentEngineId + providerInstanceId?: AgentRuntimeProviderInstanceId + subChatId?: string + chatId?: string + threadId?: string + nativeSessionId?: string | null + turnId?: string + itemId?: string + requestId?: string + createdAt?: string + providerRefs?: Record + payload: Record + raw?: unknown +} + +function cleanString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function cleanNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined +} + +function cleanBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + +function engineIdFromProvider(provider: unknown): AgentEngineId | undefined { + if (isAgentEngineId(provider)) return provider + if (provider === "claudeAgent" || provider === "claude-code") return "claude-code" + if (provider === "codex") return "codex" + if (provider === "hermes") return "hermes" + return undefined +} + +function compactRecord>(value: T): T { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as T +} + +function eventBlockId( + event: Pick< + ProviderRuntimeEvent, + "eventId" | "threadId" | "turnId" | "itemId" | "requestId" + >, +): string { + return ( + event.requestId ?? + event.itemId ?? + event.turnId ?? + event.threadId ?? + event.eventId ?? + "runtime-event" + ) +} + +function payloadBlockId( + event: ProviderRuntimeEvent, + keys: readonly string[], +): string { + for (const key of keys) { + const value = cleanString(event.payload[key]) + if (value) return value + } + return eventBlockId(event) +} + +function normalizeItemStatus(value: unknown): "running" | "completed" | "failed" | undefined { + if (value === "inProgress" || value === "running" || value === "queued") return "running" + if (value === "completed" || value === "declined") return "completed" + if (value === "failed" || value === "error") return "failed" + return undefined +} + +function runtimeItemTypeToBlock( + itemType: unknown, +): Pick & Record { + switch (itemType) { + case "command_execution": + return { + type: "exec", + command: "", + executionStatus: "running", + parsedCmd: { type: "unknown", isFinished: false }, + } + case "file_change": + return { type: "patch", toolName: "Edit" } + case "mcp_tool_call": + return { type: "mcp-tool-call", server: "mcp", tool: "tool", callId: "" } + case "dynamic_tool_call": + return { type: "dynamic-tool-call", toolName: "dynamic-tool" } + case "plan": + return { type: "proposed-plan" } + case "context_compaction": + return { type: "context-compaction" } + case "error": + return { type: "status", level: "error" } + default: + return { type: "status", level: "info" } + } +} + +function makeConversationBlock( + event: ProviderRuntimeEvent, + base: Record, +): AgentRuntimeConversationBlock { + return ({ + id: eventBlockId(event), + turnId: event.turnId, + status: normalizeItemStatus(base.status) ?? "running", + ...base, + metadata: { + providerRuntimeEventType: event.type, + ...(event.provider ? { provider: event.provider } : {}), + ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), + ...(event.engineId ? { engineId: event.engineId } : {}), + ...(event.providerRefs ? { providerRefs: event.providerRefs } : {}), + }, + } as unknown) as AgentRuntimeConversationBlock +} + +function turnStateToResultSubtype( + state: unknown, +): "success" | "error" | "cancelled" { + if (state === "completed") return "success" + if (state === "cancelled" || state === "interrupted") return "cancelled" + return "error" +} + +function hookOutcomeToStatus(value: unknown): "completed" | "failed" | "interrupted" { + if (value === "success" || value === "completed") return "completed" + if (value === "cancelled" || value === "canceled" || value === "stopped") { + return "interrupted" + } + return "failed" +} + +function realtimeBlockId(event: ProviderRuntimeEvent): string { + return `realtime:${ + event.threadId ?? + cleanString(event.payload.threadId) ?? + event.nativeSessionId ?? + event.chatId ?? + event.subChatId ?? + cleanString(event.payload.realtimeSessionId) ?? + eventBlockId(event) + }` +} + +export function parseProviderRuntimeEvent(value: unknown): ProviderRuntimeEvent | null { + const parsed = providerRuntimeEventSchema.safeParse(value) + if (!parsed.success) return null + const engineId = + engineIdFromProvider(parsed.data.engineId) ?? + engineIdFromProvider(parsed.data.provider) + const providerInstanceId = parsed.data.providerInstanceId + ? makeAgentRuntimeProviderInstanceId(parsed.data.providerInstanceId) + : undefined + + return { + type: parsed.data.type, + ...(parsed.data.eventId ? { eventId: parsed.data.eventId } : {}), + ...(parsed.data.provider ? { provider: parsed.data.provider } : {}), + ...(engineId ? { engineId } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), + ...(parsed.data.subChatId ? { subChatId: parsed.data.subChatId } : {}), + ...(parsed.data.chatId ? { chatId: parsed.data.chatId } : {}), + ...(parsed.data.threadId ? { threadId: parsed.data.threadId } : {}), + ...(parsed.data.nativeSessionId !== undefined + ? { nativeSessionId: parsed.data.nativeSessionId } + : {}), + ...(parsed.data.turnId ? { turnId: parsed.data.turnId } : {}), + ...(parsed.data.itemId ? { itemId: parsed.data.itemId } : {}), + ...(parsed.data.requestId ? { requestId: parsed.data.requestId } : {}), + ...(parsed.data.createdAt ? { createdAt: parsed.data.createdAt } : {}), + ...(parsed.data.providerRefs ? { providerRefs: parsed.data.providerRefs } : {}), + payload: parsed.data.payload ?? {}, + ...(parsed.data.raw !== undefined ? { raw: parsed.data.raw } : {}), + } +} + +export function providerRuntimeEventToStreamEvent( + value: unknown, +): AgentRuntimeStreamEvent | null { + const event = parseProviderRuntimeEvent(value) + if (!event) return null + + switch (event.type) { + case "content.delta": { + const delta = typeof event.payload.delta === "string" ? event.payload.delta : "" + return delta ? { type: "text", text: delta } : null + } + case "thread.token-usage.updated": { + const usage = event.payload.usage + if (!usage || typeof usage !== "object") return null + const record = usage as Record + const inputTokens = cleanNumber(record.inputTokens) + const outputTokens = cleanNumber(record.outputTokens) + const usedTokens = cleanNumber(record.usedTokens) + const totalTokens = usedTokens ?? cleanNumber(record.totalTokens) + const maxTokens = cleanNumber(record.maxTokens) + return compactRecord({ + type: "usage" as const, + inputTokens, + outputTokens, + totalTokens, + modelContextWindow: maxTokens ?? cleanNumber(record.modelContextWindow), + usedTokens, + totalProcessedTokens: cleanNumber(record.totalProcessedTokens), + maxTokens, + cachedInputTokens: cleanNumber(record.cachedInputTokens), + reasoningOutputTokens: cleanNumber(record.reasoningOutputTokens), + lastUsedTokens: cleanNumber(record.lastUsedTokens), + lastInputTokens: cleanNumber(record.lastInputTokens), + lastCachedInputTokens: cleanNumber(record.lastCachedInputTokens), + lastOutputTokens: cleanNumber(record.lastOutputTokens), + lastReasoningOutputTokens: cleanNumber(record.lastReasoningOutputTokens), + toolUses: cleanNumber(record.toolUses), + durationMs: cleanNumber(record.durationMs), + compactsAutomatically: cleanBoolean(record.compactsAutomatically), + }) + } + case "thread.state.changed": { + const state = cleanString(event.payload.state) ?? cleanString(event.payload.status) + if (state !== "compacted") return null + const detail = + event.payload.detail && typeof event.payload.detail === "object" + ? (event.payload.detail as Record) + : {} + return { + type: "conversation-block", + block: makeConversationBlock(event, { + id: + event.eventId ?? + `context-compaction:${event.threadId ?? event.turnId ?? "runtime-event"}`, + type: "context-compaction", + title: "Context compacted", + summary: cleanString(event.payload.reason) ?? "Context compacted", + input: event.payload, + previousInputTokens: cleanNumber(detail.previousInputTokens), + nextInputTokens: cleanNumber(detail.nextInputTokens), + droppedMessages: cleanNumber(detail.droppedMessages), + status: "completed", + }), + } + } + case "turn.completed": { + return { + type: "finish", + nativeSessionId: event.nativeSessionId ?? null, + resultSubtype: turnStateToResultSubtype(event.payload.state), + } + } + case "turn.aborted": { + return { + type: "finish", + nativeSessionId: event.nativeSessionId ?? null, + resultSubtype: "cancelled", + } + } + case "turn.proposed.delta": { + const delta = typeof event.payload.delta === "string" ? event.payload.delta : "" + return delta + ? { + type: "conversation-block-update", + id: eventBlockId(event), + patch: { + type: "proposed-plan", + status: "running", + input: { delta }, + } as Partial, + } + : null + } + case "turn.proposed.completed": { + const planMarkdown = cleanString(event.payload.planMarkdown) + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "proposed-plan", + plan: planMarkdown ? { markdown: planMarkdown } : event.payload, + output: event.payload, + status: "completed", + }), + } + } + case "session.state.changed": { + const state = cleanString(event.payload.state) ?? "unknown" + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: state === "error" ? "error" : "info", + title: "Codex app-server", + message: + cleanString(event.payload.reason) ?? + cleanString(event.payload.message) ?? + `Session ${state}.`, + data: event.payload, + status: state === "starting" ? "running" : "completed", + }), + } + } + case "thread.realtime.started": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + id: realtimeBlockId(event), + type: "realtime-state", + title: "Realtime voice", + mode: "voice-only", + input: event.payload, + status: "running", + }), + } + } + case "thread.realtime.item-added": + case "thread.realtime.audio.delta": { + return { + type: "conversation-block-update", + id: realtimeBlockId(event), + patch: { + status: "running", + output: event.payload, + } as Partial, + } + } + case "thread.realtime.error": { + return { + type: "conversation-block-update", + id: realtimeBlockId(event), + patch: { + status: "failed", + summary: cleanString(event.payload.message) ?? "Realtime error", + output: event.payload, + } as Partial, + } + } + case "thread.realtime.closed": { + return { + type: "conversation-block-update", + id: realtimeBlockId(event), + patch: { + status: "completed", + summary: cleanString(event.payload.reason), + output: event.payload, + } as Partial, + } + } + case "runtime.error": { + return { + type: "error", + message: + cleanString(event.payload.message) ?? + cleanString(event.payload.error) ?? + "Provider runtime error.", + } + } + case "auth.status": { + const error = cleanString(event.payload.error) + return error ? { type: "auth-error", message: error } : null + } + case "account.updated": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: "info", + title: "Account updated", + message: "Codex account state updated.", + data: event.payload, + status: "completed", + }), + } + } + case "account.rate-limits.updated": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: "info", + title: "Rate limits updated", + message: "Codex rate limit state updated.", + data: event.payload, + status: "completed", + }), + } + } + case "mcp.status.updated": { + const status = cleanString(event.payload.status) ?? "updated" + const serverName = cleanString(event.payload.name) + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: status === "failed" ? "error" : "info", + title: serverName ? `MCP ${serverName}` : "MCP server", + message: + cleanString(event.payload.error) ?? `MCP server status: ${status}.`, + data: event.payload, + status: status === "starting" ? "running" : "completed", + }), + } + } + case "mcp.oauth.completed": { + const serverName = cleanString(event.payload.name) + const success = event.payload.success === true + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: success ? "info" : "error", + title: serverName ? `MCP OAuth ${serverName}` : "MCP OAuth", + message: + cleanString(event.payload.error) ?? + (success ? "MCP OAuth completed." : "MCP OAuth failed."), + data: event.payload, + status: "completed", + }), + } + } + case "app.list.updated": { + const apps = Array.isArray(event.payload.apps) + ? event.payload.apps + : undefined + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: "info", + title: "Apps updated", + message: + typeof apps?.length === "number" + ? `${apps.length} app${apps.length === 1 ? "" : "s"} available.` + : "App list updated.", + data: event.payload, + status: "completed", + }), + } + } + case "config.updated": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: "info", + title: cleanString(event.payload.title) ?? "Config updated", + message: + cleanString(event.payload.message) ?? + "Codex configuration state updated.", + data: event.payload, + status: "completed", + }), + } + } + case "model.rerouted": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "model-reroute", + fromModelId: cleanString(event.payload.fromModel), + toModelId: cleanString(event.payload.toModel), + reason: cleanString(event.payload.reason), + status: "completed", + }), + } + } + case "turn.plan.updated": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "proposed-plan", + plan: event.payload.plan, + input: event.payload, + status: "running", + }), + } + } + case "request.opened": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "permission-request", + input: event.payload, + status: "running", + }), + } + } + case "request.resolved": { + return { + type: "conversation-block-update", + id: eventBlockId(event), + patch: { + status: event.payload.decision === "decline" ? "failed" : "completed", + output: event.payload, + } as Partial, + } + } + case "user-input.requested": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "user-input", + input: event.payload, + status: "running", + }), + } + } + case "user-input.resolved": { + return { + type: "conversation-block-update", + id: eventBlockId(event), + patch: { + status: "completed", + output: event.payload, + } as Partial, + } + } + case "item.started": + case "item.updated": + case "item.completed": { + const status = + event.type === "item.completed" + ? "completed" + : normalizeItemStatus(event.payload.status) ?? "running" + const blockBase = runtimeItemTypeToBlock(event.payload.itemType) + return { + type: "conversation-block", + block: makeConversationBlock(event, { + ...blockBase, + title: cleanString(event.payload.title), + summary: cleanString(event.payload.detail), + input: event.payload.data, + status, + }), + } + } + case "hook.started": { + const hookName = cleanString(event.payload.hookName) ?? "hook" + const hookEvent = cleanString(event.payload.hookEvent) + return { + type: "conversation-block", + block: makeConversationBlock(event, { + id: payloadBlockId(event, ["hookId"]), + type: "status", + level: "info", + title: `Hook ${hookName}`, + message: hookEvent ? `${hookEvent} started.` : "Hook started.", + data: event.payload, + status: "running", + }), + } + } + case "hook.progress": { + const output = + cleanString(event.payload.output) ?? + cleanString(event.payload.stdout) ?? + cleanString(event.payload.stderr) + return { + type: "conversation-block-update", + id: payloadBlockId(event, ["hookId"]), + patch: { + status: "running", + ...(output ? { summary: output } : {}), + output: event.payload, + } as Partial, + } + } + case "hook.completed": { + const output = + cleanString(event.payload.output) ?? + cleanString(event.payload.stdout) ?? + cleanString(event.payload.stderr) + return { + type: "conversation-block-update", + id: payloadBlockId(event, ["hookId"]), + patch: { + status: hookOutcomeToStatus(event.payload.outcome ?? event.payload.status), + ...(output ? { summary: output } : {}), + output: event.payload, + } as Partial, + } + } + case "tool.progress": { + const summary = cleanString(event.payload.summary) + return { + type: "conversation-block-update", + id: payloadBlockId(event, ["toolUseId"]), + patch: { + status: "running", + ...(summary ? { summary } : {}), + output: event.payload, + } as Partial, + } + } + case "tool.summary": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: "info", + title: "Tool summary", + message: cleanString(event.payload.summary), + data: event.payload, + status: "completed", + }), + } + } + case "deprecation.notice": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: "warning", + title: cleanString(event.payload.summary) ?? "Deprecation notice", + message: cleanString(event.payload.details), + data: event.payload, + status: "completed", + }), + } + } + case "tool.denied": { + const toolUseId = cleanString(event.payload.toolUseId) + const toolName = cleanString(event.payload.toolName) ?? "Tool" + const reason = cleanString(event.payload.reason) + if (toolUseId) { + return { + type: "conversation-block-update", + id: toolUseId, + patch: { + status: "failed", + summary: reason ?? `${toolName} denied.`, + output: event.payload, + } as Partial, + } + } + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: "warning", + title: `${toolName} denied`, + message: reason, + data: event.payload, + status: "completed", + }), + } + } + case "files.persisted": { + const files = Array.isArray(event.payload.files) + ? event.payload.files + : [] + const failed = Array.isArray(event.payload.failed) + ? event.payload.failed + : [] + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: failed.length > 0 ? "warning" : "info", + title: "Files persisted", + message: + failed.length > 0 + ? `${files.length} file${files.length === 1 ? "" : "s"} persisted, ${failed.length} failed.` + : `${files.length} file${files.length === 1 ? "" : "s"} persisted.`, + data: event.payload, + status: "completed", + }), + } + } + case "runtime.warning": + case "config.warning": { + return { + type: "conversation-block", + block: makeConversationBlock(event, { + type: "status", + level: "warning", + title: cleanString(event.payload.summary), + message: + cleanString(event.payload.message) ?? cleanString(event.payload.details), + data: event.payload, + status: "completed", + }), + } + } + default: + return null + } +} diff --git a/src/main/lib/agent-runtime/runtime-receipt-bus.ts b/src/main/lib/agent-runtime/runtime-receipt-bus.ts new file mode 100644 index 000000000..e18745333 --- /dev/null +++ b/src/main/lib/agent-runtime/runtime-receipt-bus.ts @@ -0,0 +1,110 @@ +import type { + AgentEngineId, + AgentRuntimeRunReceipt, + AgentRuntimeRunStatus, +} from "./types" + +export type AgentRuntimeReceiptSource = + | "agent-runtime" + | "mobile-gateway" + | "codex-app-server" + | string + +interface AgentRuntimeReceiptBase { + sequence: number + createdAt: string + runId: string + engineId: AgentEngineId + chatId: string + subChatId: string + source?: AgentRuntimeReceiptSource + metadata?: Record +} + +export interface AgentRuntimeRunStartedReceipt + extends AgentRuntimeReceiptBase { + type: "runtime.run.started" +} + +export interface AgentRuntimeTurnProcessingQuiescedReceipt + extends AgentRuntimeReceiptBase { + type: "turn.processing.quiesced" + status: Exclude + resultSubtype: AgentRuntimeRunReceipt["resultSubtype"] + latestSequence?: number | null + eventCount: number + error?: string | null +} + +export type AgentRuntimeReceipt = + | AgentRuntimeRunStartedReceipt + | AgentRuntimeTurnProcessingQuiescedReceipt + +export type AgentRuntimeReceiptInput = + | Omit + | Omit + +export interface AgentRuntimeReceiptBus { + publish(receipt: AgentRuntimeReceiptInput): AgentRuntimeReceipt +} + +export interface AgentRuntimeReceiptWaitOptions { + timeoutMs?: number +} + +export interface AgentRuntimeReceiptBusForTests + extends AgentRuntimeReceiptBus { + listReceipts(): AgentRuntimeReceipt[] + waitForReceipt( + predicate: (receipt: AgentRuntimeReceipt) => boolean, + options?: AgentRuntimeReceiptWaitOptions, + ): Promise +} + +export function createNoopAgentRuntimeReceiptBus(): AgentRuntimeReceiptBus { + return { + publish(receipt) { + return { ...receipt, sequence: 0 } as AgentRuntimeReceipt + }, + } +} + +export function createInMemoryAgentRuntimeReceiptBus(): AgentRuntimeReceiptBusForTests { + const receipts: AgentRuntimeReceipt[] = [] + const subscribers = new Set<(receipt: AgentRuntimeReceipt) => void>() + let nextSequence = 1 + + return { + publish(receipt) { + const stored = { + ...receipt, + sequence: nextSequence++, + } as AgentRuntimeReceipt + receipts.push(stored) + for (const subscriber of subscribers) subscriber(stored) + return stored + }, + listReceipts() { + return [...receipts] + }, + waitForReceipt(predicate, options = {}) { + const existing = receipts.find(predicate) + if (existing) return Promise.resolve(existing) + + const timeoutMs = options.timeoutMs ?? 1000 + return new Promise((resolve) => { + let timeout: ReturnType | null = null + const complete = (receipt: AgentRuntimeReceipt | null) => { + if (timeout) clearTimeout(timeout) + subscribers.delete(onReceipt) + resolve(receipt) + } + const onReceipt = (receipt: AgentRuntimeReceipt) => { + if (predicate(receipt)) complete(receipt) + } + subscribers.add(onReceipt) + timeout = setTimeout(() => complete(null), timeoutMs) + }) + }, + } +} diff --git a/src/main/lib/agent-runtime/runtime-run-ledger.ts b/src/main/lib/agent-runtime/runtime-run-ledger.ts new file mode 100644 index 000000000..c39788a0c --- /dev/null +++ b/src/main/lib/agent-runtime/runtime-run-ledger.ts @@ -0,0 +1,422 @@ +import type { + AgentRuntimeControlResult, + AgentRuntimeRunAction, + AgentRuntimeRunReceipt, + AgentRuntimeRunStatus, + AgentRuntimeSessionRef, + AgentRuntimeStartRequest, + AgentRuntimeStopRequest, + AgentRuntimeStreamEvent, + AgentRuntimeToolResultRequest, +} from "./types" + +export type AgentRuntimeLedgerEventKind = + | "run-started" + | "stream-event" + | "tool-result-submitted" + | "run-stopped" + | "run-finished" + +export interface AgentRuntimeLedgerEvent { + id: string + runId: string + engineId: AgentRuntimeSessionRef["engineId"] + kind: AgentRuntimeLedgerEventKind + sequence: number + createdAt: string + commandId?: string | null + causationEventId?: string | null + correlationId?: string | null + metadata?: Record + payload?: Record +} + +export type AgentRuntimeCommandReceiptStatus = "accepted" | "rejected" + +export interface AgentRuntimeCommandReceipt { + commandId: string + runId: string + engineId: AgentRuntimeSessionRef["engineId"] + acceptedAt: string + resultSequence: number + status: AgentRuntimeCommandReceiptStatus + error?: string | null +} + +export type AgentRuntimeLedgerAppendEvent = Omit< + AgentRuntimeLedgerEvent, + "id" | "createdAt" | "sequence" +> & + Partial> + +export interface AgentRuntimeRunLedgerSnapshot { + runId: string + receipt: AgentRuntimeRunReceipt + events: AgentRuntimeLedgerEvent[] +} + +export interface AgentRuntimeRunReplayOptions { + fromSequenceExclusive?: number + limit?: number +} + +export interface AgentRuntimeRunProjection { + runId: string + engineId: AgentRuntimeSessionRef["engineId"] + status: AgentRuntimeRunStatus + resultSubtype: AgentRuntimeRunReceipt["resultSubtype"] + startedAt: string + updatedAt: string + completedAt?: string | null + nativeSessionId?: string | null + eventCount: number + eventKinds: Partial> + latestEventSequence?: number + latestEventKind?: AgentRuntimeLedgerEventKind + latestEventAt?: string + commandIds: string[] + correlationIds: string[] + causationEventIds: string[] + errors: string[] +} + +export interface AgentRuntimeRunLedger { + upsertReceipt(receipt: AgentRuntimeRunReceipt): AgentRuntimeRunReceipt + upsertCommandReceipt( + receipt: AgentRuntimeCommandReceipt, + ): AgentRuntimeCommandReceipt + getCommandReceipt(commandId: string): AgentRuntimeCommandReceipt | null + listCommandReceipts(): AgentRuntimeCommandReceipt[] + appendEvent(event: AgentRuntimeLedgerAppendEvent): AgentRuntimeLedgerEvent + finishRun(params: { + runId: string + status: Exclude + resultSubtype?: AgentRuntimeRunReceipt["resultSubtype"] + commandId?: string | null + causationEventId?: string | null + correlationId?: string | null + error?: string + metadata?: Record + }): AgentRuntimeRunReceipt | null + snapshot(runId: string): AgentRuntimeRunLedgerSnapshot | null + replayEvents( + runId: string, + options?: AgentRuntimeRunReplayOptions, + ): AgentRuntimeLedgerEvent[] | null + list(): AgentRuntimeRunLedgerSnapshot[] +} + +export function createAgentRuntimeRunId( + session: Pick, + action: AgentRuntimeRunAction, + now = Date.now(), +): string { + return `${session.engineId}:${session.subChatId}:${action}:${now}` +} + +export function createAgentRuntimeRunReceipt(params: { + runId?: string + action: AgentRuntimeRunAction + session: AgentRuntimeSessionRef + status?: AgentRuntimeRunStatus + nativeSessionId?: string | null + resultSubtype?: AgentRuntimeRunReceipt["resultSubtype"] + now?: Date | string + completedAt?: Date | string | null + error?: string + metadata?: Record +}): AgentRuntimeRunReceipt { + const now = isoString(params.now) + return { + version: 1, + runId: params.runId ?? createAgentRuntimeRunId(params.session, params.action), + action: params.action, + engineId: params.session.engineId, + subChatId: params.session.subChatId, + chatId: params.session.chatId, + status: params.status ?? "running", + nativeSessionId: params.nativeSessionId ?? params.session.nativeSessionId ?? null, + resultSubtype: params.resultSubtype ?? null, + startedAt: now, + updatedAt: now, + completedAt: params.completedAt === undefined + ? null + : params.completedAt === null + ? null + : isoString(params.completedAt), + ...(params.error ? { error: params.error } : {}), + ...(params.metadata ? { metadata: params.metadata } : {}), + } +} + +export function createUnsupportedAgentRuntimeReceipt(params: { + action: AgentRuntimeRunAction + request: AgentRuntimeStartRequest + reason: string + metadata?: Record +}): AgentRuntimeRunReceipt { + const now = new Date() + return createAgentRuntimeRunReceipt({ + runId: params.request.runId, + action: params.action, + session: params.request.session, + status: "unsupported", + resultSubtype: "error", + now, + completedAt: now, + error: params.reason, + metadata: params.metadata, + }) +} + +export function createUnsupportedAgentRuntimeControlResult( + request: AgentRuntimeStopRequest | AgentRuntimeToolResultRequest, + message: string, +): AgentRuntimeControlResult { + return { + runId: request.runId, + status: "unsupported", + message, + updatedAt: new Date().toISOString(), + } +} + +export async function* streamUnsupportedAgentRuntimeRun( + receipt: AgentRuntimeRunReceipt, +): AsyncIterable { + yield { + type: "error", + message: receipt.error ?? "This runtime does not support streamed execution.", + } + yield { + type: "finish", + nativeSessionId: receipt.nativeSessionId, + resultSubtype: "error", + } +} + +export function createInMemoryAgentRuntimeRunLedger(): AgentRuntimeRunLedger { + const receipts = new Map() + const events = new Map() + const commandReceipts = new Map() + + return { + upsertReceipt(receipt) { + receipts.set(receipt.runId, receipt) + if (!events.has(receipt.runId)) events.set(receipt.runId, []) + return receipt + }, + upsertCommandReceipt(receipt) { + commandReceipts.set(receipt.commandId, receipt) + return receipt + }, + getCommandReceipt(commandId) { + return commandReceipts.get(commandId) ?? null + }, + listCommandReceipts() { + return [...commandReceipts.values()].sort((left, right) => + left.commandId.localeCompare(right.commandId), + ) + }, + appendEvent(event) { + const sequence = event.sequence ?? ((events.get(event.runId)?.length ?? 0) + 1) + const entry = { + ...event, + sequence, + id: event.id ?? `${event.runId}:${event.kind}:${sequence}`, + createdAt: event.createdAt ?? new Date().toISOString(), + } + const current = events.get(event.runId) ?? [] + current.push(entry) + events.set(event.runId, current) + return entry + }, + finishRun(params) { + const receipt = receipts.get(params.runId) + if (!receipt) return null + const now = new Date().toISOString() + const updated = { + ...receipt, + status: params.status, + resultSubtype: params.resultSubtype ?? receipt.resultSubtype, + updatedAt: now, + completedAt: now, + ...(params.error ? { error: params.error } : {}), + metadata: { + ...(receipt.metadata ?? {}), + ...(params.metadata ?? {}), + }, + } + receipts.set(params.runId, updated) + const finishedEvent = this.appendEvent({ + runId: params.runId, + engineId: receipt.engineId, + kind: "run-finished", + commandId: params.commandId, + causationEventId: params.causationEventId, + correlationId: params.correlationId, + payload: { + status: updated.status, + resultSubtype: updated.resultSubtype, + error: updated.error, + }, + }) + if (params.commandId) { + this.upsertCommandReceipt({ + commandId: params.commandId, + runId: params.runId, + engineId: receipt.engineId, + acceptedAt: finishedEvent.createdAt, + resultSequence: finishedEvent.sequence, + status: "accepted", + error: updated.error ?? null, + }) + } + return updated + }, + snapshot(runId) { + const receipt = receipts.get(runId) + if (!receipt) return null + return { + runId, + receipt, + events: [...(events.get(runId) ?? [])], + } + }, + replayEvents(runId, options) { + const snapshot = this.snapshot(runId) + if (!snapshot) return null + return replayAgentRuntimeRunLedgerEvents(snapshot, options) + }, + list() { + return [...receipts.keys()] + .sort() + .map((runId) => this.snapshot(runId)) + .filter(Boolean) as AgentRuntimeRunLedgerSnapshot[] + }, + } +} + +export function orderAgentRuntimeLedgerEvents( + events: ReadonlyArray, +): AgentRuntimeLedgerEvent[] { + return [...events].sort((left, right) => { + if (left.sequence !== right.sequence) return left.sequence - right.sequence + const createdAt = left.createdAt.localeCompare(right.createdAt) + if (createdAt !== 0) return createdAt + return left.id.localeCompare(right.id) + }) +} + +export function replayAgentRuntimeRunLedgerEvents( + snapshot: AgentRuntimeRunLedgerSnapshot, + options: AgentRuntimeRunReplayOptions = {}, +): AgentRuntimeLedgerEvent[] { + const fromSequenceExclusive = options.fromSequenceExclusive ?? 0 + const events = orderAgentRuntimeLedgerEvents(snapshot.events).filter( + (event) => event.sequence > fromSequenceExclusive, + ) + if (options.limit === undefined) return events + return events.slice(0, Math.max(0, options.limit)) +} + +export function projectAgentRuntimeRunLedgerSnapshot( + snapshot: AgentRuntimeRunLedgerSnapshot, +): AgentRuntimeRunProjection { + const orderedEvents = replayAgentRuntimeRunLedgerEvents(snapshot) + const eventKinds: Partial> = {} + const commandIds = new Set() + const correlationIds = new Set() + const causationEventIds = new Set() + const errors: string[] = [] + let status = snapshot.receipt.status + let resultSubtype = snapshot.receipt.resultSubtype + let updatedAt = snapshot.receipt.updatedAt + let completedAt = snapshot.receipt.completedAt + let nativeSessionId = snapshot.receipt.nativeSessionId + + for (const event of orderedEvents) { + eventKinds[event.kind] = (eventKinds[event.kind] ?? 0) + 1 + if (event.commandId) commandIds.add(event.commandId) + if (event.correlationId) correlationIds.add(event.correlationId) + if (event.causationEventId) causationEventIds.add(event.causationEventId) + updatedAt = event.createdAt + + const eventNativeSessionId = readString(event.payload?.nativeSessionId) + if (eventNativeSessionId) nativeSessionId = eventNativeSessionId + + if (event.kind === "stream-event" && event.payload?.type === "error") { + const message = readString(event.payload.message) + if (message) errors.push(message) + } + + if (event.kind === "run-finished") { + const finishedStatus = readRunStatus(event.payload?.status) + const finishedSubtype = readRunResultSubtype(event.payload?.resultSubtype) + status = finishedStatus ?? status + resultSubtype = finishedSubtype ?? resultSubtype + completedAt = event.createdAt + const error = readString(event.payload?.error) + if (error) errors.push(error) + } + } + + const latestEvent = orderedEvents.at(-1) + + return { + runId: snapshot.runId, + engineId: snapshot.receipt.engineId, + status, + resultSubtype, + startedAt: snapshot.receipt.startedAt, + updatedAt, + completedAt, + nativeSessionId, + eventCount: orderedEvents.length, + eventKinds, + ...(latestEvent + ? { + latestEventSequence: latestEvent.sequence, + latestEventKind: latestEvent.kind, + latestEventAt: latestEvent.createdAt, + } + : {}), + commandIds: [...commandIds], + correlationIds: [...correlationIds], + causationEventIds: [...causationEventIds], + errors, + } +} + +export function projectAgentRuntimeRunLedgerSnapshots( + snapshots: ReadonlyArray, +): AgentRuntimeRunProjection[] { + return snapshots.map(projectAgentRuntimeRunLedgerSnapshot) +} + +function isoString(value: Date | string | undefined): string { + if (!value) return new Date().toISOString() + return typeof value === "string" ? value : value.toISOString() +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null +} + +function readRunStatus(value: unknown): AgentRuntimeRunStatus | null { + return value === "running" || + value === "success" || + value === "error" || + value === "cancelled" || + value === "unsupported" + ? value + : null +} + +function readRunResultSubtype( + value: unknown, +): AgentRuntimeRunReceipt["resultSubtype"] | null { + return value === "success" || value === "error" || value === "cancelled" + ? value + : null +} 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..4f88b9580 --- /dev/null +++ b/src/main/lib/agent-runtime/session-actions.ts @@ -0,0 +1,675 @@ +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" + | "codex-app-server-thread" + | "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; + nativeRollbackTurnCount: number; + mode: MossSessionActionMode; + nativeSessionLinked: boolean; +} + +export interface BuildMossRollbackSnapshotInput { + engine: AgentEngineId; + nativeSessionId?: string | null; + messages: string | MossSessionMessage[] | null | undefined; + features?: AgentRuntimeFeature[]; + targetMessageId?: string; + targetSdkMessageUuid?: string; + strictTarget?: boolean; +} + +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 countUserTurnsAfterIndex( + messages: MossSessionMessage[], + targetIndex: number, +): number { + if (targetIndex < 0) return 0; + return messages + .slice(targetIndex + 1) + .filter((message) => messageRole(message) === "user").length; +} + +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")); + } + + if (engine === "codex") { + 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"), + ); + } + + if (engine === "codex") { + 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 latestMessageIndex = 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 rollbackTargetIndex = latestAssistantWithSdk + ? latestAssistantWithSdkIndex + : latestAssistant + ? latestAssistantIndex + : latestMessageIndex; + const rollbackTarget = + rollbackTargetIndex >= 0 ? messages[rollbackTargetIndex] : undefined; + const rollbackTargetRole = rollbackTarget + ? messageRole(rollbackTarget) + : undefined; + 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), + ) && + (input.engine !== "codex" || rollbackTargetRole === "assistant"); + + 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-app-server-thread" 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 app-server thread/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 === "codex" + ? "codex-app-server-thread" + : input.engine === "hermes" + ? "hermes-acp-session-control" + : "claude-code-session", + canRunHeadless: + input.engine === "codex" || input.engine === "hermes", + targetMessageId: latestAssistant + ? messageId(latestAssistant) + : undefined, + targetSdkMessageUuid: forkAtSdkUuid ?? undefined, + targetLabel: latestAssistant + ? messageLabel(latestAssistant, "Latest assistant message") + : undefined, + reason: + input.engine === "codex" + ? "Codex fork uses the app-server thread/fork bridge and keeps the native thread linked." + : 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" + : input.engine === "codex" + ? "codex-app-server-thread" + : "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." + : input.engine === "codex" + ? "Codex rollback uses the app-server thread/rollback bridge and keeps the native thread linked." + : "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, + strictTarget: boolean | 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; + } + if (strictTarget && (targetMessageId || targetSdkMessageUuid)) return -1; + 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, + input.strictTarget, + ); + if (targetIndex < 0) { + if ( + input.strictTarget && + (input.targetMessageId || input.targetSdkMessageUuid) + ) { + throw new Error( + "Strict rollback target was not found in the Moss transcript.", + ); + } + throw new Error("No transcript is available to roll back."); + } + + const target = messages[targetIndex]; + const targetSdkUuid = messageSdkUuid(target) ?? null; + const nativeRollbackTurnCount = countUserTurnsAfterIndex( + messages, + targetIndex, + ); + const nativeRollback = + supportsNativeRollbackBridge( + input.engine, + input.nativeSessionId, + input.features, + targetSdkUuid, + true, + ) && + (input.engine !== "codex" || messageRole(target) === "assistant"); + 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, + nativeRollbackTurnCount, + 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..7175019c2 --- /dev/null +++ b/src/main/lib/agent-runtime/session-records.ts @@ -0,0 +1,219 @@ +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; + strictTarget?: boolean; + 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, + strictTarget: input.strictTarget, + }); + 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, + nativeRollbackTurnCount: snapshot.nativeRollbackTurnCount, + ...(input.strictTarget ? { strictTarget: true } : {}), + 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..bbb83e6a5 --- /dev/null +++ b/src/main/lib/agent-runtime/session-store.ts @@ -0,0 +1,69 @@ +import { eq } from "drizzle-orm" +import { getDatabase, subChats } from "../db" +import type { AgentEngineId, AgentPermissionMode } from "./types" +import { + mergeAgentRuntimeLaunchPlanMetadata, + type AgentRuntimeLaunchPlan, +} from "./launch-plan" + +type RuntimeMetadata = Record + +export type PersistAgentRuntimeSessionInput = { + subChatId: string + engine: AgentEngineId + nativeSessionId?: string | null + configDir?: string | null + providerInstanceId?: string | null + modelId?: string | null + modelSelection?: { + instanceId: string + modelId: string + options?: Record + } | null + permissionMode?: AgentPermissionMode + metadata?: RuntimeMetadata + launchPlan?: AgentRuntimeLaunchPlan + updateLegacySessionId?: boolean +} + +function serializeMetadata( + input: PersistAgentRuntimeSessionInput, +): string { + const metadata = { + ...(input.metadata ?? {}), + ...(input.providerInstanceId + ? { providerInstanceId: input.providerInstanceId } + : {}), + ...(input.modelSelection ? { modelSelection: input.modelSelection } : {}), + ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), + updatedAt: new Date().toISOString(), + } + + return JSON.stringify( + input.launchPlan + ? mergeAgentRuntimeLaunchPlanMetadata(metadata, input.launchPlan) + : metadata, + ) +} + +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..72fd81d6a --- /dev/null +++ b/src/main/lib/agent-runtime/types.ts @@ -0,0 +1,1204 @@ +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" + | "thread-management"; + +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 type AgentRuntimeProviderInstanceStatus = + | "ready" + | "warning" + | "error" + | "disabled"; + +export interface AgentRuntimeProviderInstance { + instanceId: string; + engineId: AgentEngineId; + displayName?: string; + enabled: boolean; + installed: boolean; + status: AgentRuntimeProviderInstanceStatus; + isDefault?: boolean; + modelIds?: string[]; + version?: string | null; + versionAdvisory?: AgentRuntimeVersionAdvisory | null; + updateState?: AgentRuntimeUpdateState | null; +} + +export type AgentRuntimeVersionAdvisoryStatus = + | "unknown" + | "current" + | "behind_latest"; + +export interface AgentRuntimeVersionAdvisory { + status: AgentRuntimeVersionAdvisoryStatus; + currentVersion: string | null; + latestVersion: string | null; + updateCommand: string | null; + canUpdate: boolean; + checkedAt: string | null; + message: string | null; +} + +export type AgentRuntimeUpdateStatus = + | "idle" + | "queued" + | "running" + | "succeeded" + | "failed" + | "unchanged"; + +export interface AgentRuntimeUpdateState { + status: AgentRuntimeUpdateStatus; + startedAt: string | null; + finishedAt: string | null; + message: string | null; + output: string | null; +} + +export interface AgentRuntimeHealth { + availability: AgentRuntimeAvailability; + statusReason?: string; + authMethod?: AgentRuntimeAuthMethod; + models?: AgentRuntimeModelHealth[]; + providerInstances?: AgentRuntimeProviderInstance[]; + version?: string | null; + versionAdvisory?: AgentRuntimeVersionAdvisory | null; + updateState?: AgentRuntimeUpdateState | null; +} + +export interface AgentRuntimeManifest { + id: AgentEngineId; + label: string; + vendor: string; + availability: AgentRuntimeAvailability; + features: AgentRuntimeFeature[]; + defaultModelId?: string; + models?: AgentRuntimeModel[]; + providerInstances?: AgentRuntimeProviderInstance[]; + version?: string | null; + versionAdvisory?: AgentRuntimeVersionAdvisory | null; + updateState?: AgentRuntimeUpdateState | null; + configRoots: { + user?: string; + project?: string; + sessions?: string; + }; + notes?: string[]; +} + +export interface AgentRuntimeSessionRef { + subChatId: string; + chatId: string; + engineId: AgentEngineId; + providerInstanceId?: string | null; + nativeSessionId?: string | null; + modelId?: string | null; + modelSelection?: { + instanceId: string; + modelId: string; + options?: Record; + } | null; + permissionMode: AgentPermissionMode; + cwd: string; + projectPath?: string | null; + runtimeConfigDir?: string | null; + metadata?: Record; +} + +export interface AgentRuntimeStartRequest { + runId?: string; + session: AgentRuntimeSessionRef; + prompt: string; + images?: Array<{ + base64Data: string; + mediaType: string; + filename?: string; + }>; + forceNewSession?: boolean; +} + +export type AgentRuntimeRunAction = "start" | "resume"; + +export type AgentRuntimeRunStatus = + | "running" + | "success" + | "error" + | "cancelled" + | "unsupported"; + +export interface AgentRuntimeRunReceipt { + version: 1; + runId: string; + action: AgentRuntimeRunAction; + engineId: AgentEngineId; + subChatId: string; + chatId: string; + status: AgentRuntimeRunStatus; + nativeSessionId?: string | null; + resultSubtype?: "success" | "error" | "cancelled" | null; + startedAt: string; + updatedAt: string; + completedAt?: string | null; + error?: string; + metadata?: Record; +} + +export interface AgentRuntimeStopRequest { + session: AgentRuntimeSessionRef; + runId: string; + reason?: string; +} + +export interface AgentRuntimeToolResultRequest { + session: AgentRuntimeSessionRef; + runId: string; + toolCallId: string; + result: unknown; + isError?: boolean; +} + +export interface AgentRuntimeControlResult { + runId: string; + status: "accepted" | "cancelled" | "unsupported" | "not-found" | "error"; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadReadRequest { + session: AgentRuntimeSessionRef; + includeTurns?: boolean; +} + +export interface AgentRuntimeThreadReadResult { + status: "success" | "unsupported" | "error"; + threadId?: string | null; + thread?: unknown; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadForkRequest { + session: AgentRuntimeSessionRef; +} + +export interface AgentRuntimeThreadForkResult { + status: "success" | "unsupported" | "error"; + sourceThreadId?: string | null; + threadId?: string | null; + thread?: unknown; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadTurnDiffRequest { + session: AgentRuntimeSessionRef; + threadId?: string | null; + fromTurnCount: number; + toTurnCount: number; + ignoreWhitespace?: boolean; +} + +export interface AgentRuntimeThreadFullDiffRequest { + session: AgentRuntimeSessionRef; + threadId?: string | null; + toTurnCount: number; + ignoreWhitespace?: boolean; +} + +export interface AgentRuntimeThreadDiffResult { + status: "success" | "unsupported" | "error"; + threadId?: string | null; + fromTurnCount?: number; + toTurnCount?: number; + diff?: string; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadListRequest { + session: AgentRuntimeSessionRef; + archived?: boolean | null; + cursor?: string | null; + cwd?: string | string[] | null; + limit?: number | null; + modelProviders?: string[] | null; + searchTerm?: string | null; + sortDirection?: "asc" | "desc" | null; + sortKey?: "created_at" | "updated_at" | null; + sourceKinds?: string[] | null; + useStateDbOnly?: boolean; +} + +export interface AgentRuntimeThreadListResult { + status: "success" | "unsupported" | "error"; + threads?: unknown[]; + nextCursor?: string | null; + backwardsCursor?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadLoadedListRequest { + session: AgentRuntimeSessionRef; + cursor?: string | null; + limit?: number | null; +} + +export interface AgentRuntimeThreadLoadedListResult { + status: "success" | "unsupported" | "error"; + threadIds?: string[]; + nextCursor?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export type AgentRuntimeThreadControlAction = + | "archive" + | "unarchive" + | "delete"; + +export interface AgentRuntimeThreadControlRequest { + session: AgentRuntimeSessionRef; + action: AgentRuntimeThreadControlAction; + threadId: string; +} + +export interface AgentRuntimeThreadControlResult { + status: "success" | "unsupported" | "error"; + action: AgentRuntimeThreadControlAction; + threadId?: string | null; + thread?: unknown; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadNameSetRequest { + session: AgentRuntimeSessionRef; + threadId: string; + name: string; +} + +export interface AgentRuntimeThreadNameSetResult { + status: "success" | "unsupported" | "error"; + threadId?: string | null; + name?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadMetadataUpdateRequest { + session: AgentRuntimeSessionRef; + threadId: string; + gitInfo?: { + branch?: string | null; + originUrl?: string | null; + sha?: string | null; + } | null; +} + +export interface AgentRuntimeThreadMetadataUpdateResult { + status: "success" | "unsupported" | "error"; + threadId?: string | null; + thread?: unknown; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export type AgentRuntimeThreadGoalStatus = + | "active" + | "paused" + | "blocked" + | "usageLimited" + | "budgetLimited" + | "complete"; + +export interface AgentRuntimeThreadGoal { + createdAt?: number; + objective?: string; + status?: AgentRuntimeThreadGoalStatus | (string & {}); + threadId?: string; + timeUsedSeconds?: number; + tokenBudget?: number | null; + tokensUsed?: number; + updatedAt?: number; +} + +export interface AgentRuntimeThreadGoalGetRequest { + session: AgentRuntimeSessionRef; + threadId: string; +} + +export interface AgentRuntimeThreadGoalGetResult { + status: "success" | "unsupported" | "error"; + threadId?: string | null; + goal?: AgentRuntimeThreadGoal | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadGoalSetRequest { + session: AgentRuntimeSessionRef; + threadId: string; + objective?: string | null; + status?: AgentRuntimeThreadGoalStatus | null; + tokenBudget?: number | null; +} + +export interface AgentRuntimeThreadGoalSetResult { + status: "success" | "unsupported" | "error"; + threadId?: string | null; + goal?: AgentRuntimeThreadGoal | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadGoalClearRequest { + session: AgentRuntimeSessionRef; + threadId: string; +} + +export interface AgentRuntimeThreadGoalClearResult { + status: "success" | "unsupported" | "error"; + threadId?: string | null; + cleared?: boolean; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeThreadRollbackRequest { + session: AgentRuntimeSessionRef; + numTurns: number; +} + +export interface AgentRuntimeThreadRollbackResult { + status: "success" | "unsupported" | "error"; + threadId?: string | null; + numTurns: number; + thread?: unknown; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeConfigReadRequest { + session: AgentRuntimeSessionRef; + cwd?: string | null; + includeLayers?: boolean; +} + +export interface AgentRuntimeConfigReadResult { + status: "success" | "unsupported" | "error"; + config?: unknown; + layers?: unknown[] | null; + origins?: Record; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export type AgentRuntimeConfigWriteMergeStrategy = "replace" | "upsert"; + +export interface AgentRuntimeConfigWriteEdit { + keyPath: string; + mergeStrategy?: AgentRuntimeConfigWriteMergeStrategy; + value: unknown; +} + +export interface AgentRuntimeConfigValueWriteRequest { + session: AgentRuntimeSessionRef; + keyPath: string; + value: unknown; + mergeStrategy?: AgentRuntimeConfigWriteMergeStrategy; + filePath?: string | null; + expectedVersion?: string | null; +} + +export interface AgentRuntimeConfigBatchWriteRequest { + session: AgentRuntimeSessionRef; + edits: AgentRuntimeConfigWriteEdit[]; + filePath?: string | null; + expectedVersion?: string | null; + reloadUserConfig?: boolean; +} + +export interface AgentRuntimeConfigWriteResult { + status: "success" | "unsupported" | "error"; + filePath?: string | null; + writeStatus?: "ok" | "okOverridden" | (string & {}); + version?: string | null; + overriddenMetadata?: unknown | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeConfigRequirementsReadRequest { + session: AgentRuntimeSessionRef; +} + +export interface AgentRuntimeConfigRequirementsReadResult { + status: "success" | "unsupported" | "error"; + requirements?: unknown | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimePermissionProfileListRequest { + session: AgentRuntimeSessionRef; + cwd?: string | null; + cursor?: string | null; + limit?: number | null; +} + +export interface AgentRuntimePermissionProfileListResult { + status: "success" | "unsupported" | "error"; + profiles?: unknown[]; + nextCursor?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export type AgentRuntimeMcpServerStatusDetail = "full" | "toolsAndAuthOnly"; + +export interface AgentRuntimeMcpServerStatusListRequest { + session: AgentRuntimeSessionRef; + cursor?: string | null; + detail?: AgentRuntimeMcpServerStatusDetail | null; + limit?: number | null; + threadId?: string | null; +} + +export interface AgentRuntimeMcpServerStatusListResult { + status: "success" | "unsupported" | "error"; + servers?: unknown[]; + nextCursor?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeMcpServerConfigReloadRequest { + session: AgentRuntimeSessionRef; +} + +export interface AgentRuntimeMcpServerConfigReloadResult { + status: "success" | "unsupported" | "error"; + reloaded?: boolean; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeSkillListRequest { + session: AgentRuntimeSessionRef; + cwds?: string[] | null; + forceReload?: boolean | null; +} + +export interface AgentRuntimeSkillListResult { + status: "success" | "unsupported" | "error"; + entries?: unknown[]; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeHookListRequest { + session: AgentRuntimeSessionRef; + cwds?: string[] | null; +} + +export interface AgentRuntimeHookListResult { + status: "success" | "unsupported" | "error"; + entries?: unknown[]; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeAppListRequest { + session: AgentRuntimeSessionRef; + cursor?: string | null; + forceRefetch?: boolean | null; + limit?: number | null; + threadId?: string | null; +} + +export interface AgentRuntimeAppListResult { + status: "success" | "unsupported" | "error"; + apps?: unknown[]; + nextCursor?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export type AgentRuntimePluginMarketplaceKind = + | "local" + | "vertical" + | "workspace-directory" + | "shared-with-me"; + +export interface AgentRuntimePluginListRequest { + session: AgentRuntimeSessionRef; + cwds?: string[] | null; + marketplaceKinds?: AgentRuntimePluginMarketplaceKind[] | null; +} + +export interface AgentRuntimePluginListResult { + status: "success" | "unsupported" | "error"; + marketplaces?: unknown[]; + featuredPluginIds?: string[]; + marketplaceLoadErrors?: unknown[]; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimePluginInstalledRequest { + session: AgentRuntimeSessionRef; + cwds?: string[] | null; + installSuggestionPluginNames?: string[] | null; +} + +export interface AgentRuntimePluginInstalledResult { + status: "success" | "unsupported" | "error"; + marketplaces?: unknown[]; + marketplaceLoadErrors?: unknown[]; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimePluginReadRequest { + session: AgentRuntimeSessionRef; + pluginName: string; + marketplacePath?: string | null; + remoteMarketplaceName?: string | null; +} + +export interface AgentRuntimePluginReadResult { + status: "success" | "unsupported" | "error"; + plugin?: unknown; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimePluginInstallRequest { + session: AgentRuntimeSessionRef; + pluginName: string; + marketplacePath?: string | null; + remoteMarketplaceName?: string | null; +} + +export interface AgentRuntimePluginInstallResult { + status: "success" | "unsupported" | "error"; + appsNeedingAuth?: unknown[]; + authPolicy?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export type AgentRuntimeExternalAgentConfigMigrationItemType = + | "AGENTS_MD" + | "CONFIG" + | "SKILLS" + | "PLUGINS" + | "MCP_SERVER_CONFIG" + | "SUBAGENTS" + | "HOOKS" + | "COMMANDS" + | "SESSIONS"; + +export interface AgentRuntimeExternalAgentConfigMigrationDetails { + commands?: Array<{ name: string }>; + hooks?: Array<{ name: string }>; + mcpServers?: Array<{ name: string }>; + plugins?: Array<{ marketplaceName: string; pluginNames: string[] }>; + sessions?: Array<{ cwd: string; path: string; title?: string | null }>; + subagents?: Array<{ name: string }>; +} + +export interface AgentRuntimeExternalAgentConfigMigrationItem { + cwd?: string | null; + description: string; + details?: AgentRuntimeExternalAgentConfigMigrationDetails | null; + itemType: AgentRuntimeExternalAgentConfigMigrationItemType; +} + +export interface AgentRuntimeExternalAgentConfigDetectRequest { + session: AgentRuntimeSessionRef; + cwds?: string[] | null; + includeHome?: boolean | null; +} + +export interface AgentRuntimeExternalAgentConfigDetectResult { + status: "success" | "unsupported" | "error"; + items?: AgentRuntimeExternalAgentConfigMigrationItem[]; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeExternalAgentConfigImportRequest { + session: AgentRuntimeSessionRef; + migrationItems: AgentRuntimeExternalAgentConfigMigrationItem[]; +} + +export interface AgentRuntimeExternalAgentConfigImportResult { + status: "success" | "unsupported" | "error"; + importedCount?: number; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeMcpServerOauthLoginRequest { + session: AgentRuntimeSessionRef; + name: string; + scopes?: string[] | null; + timeoutSecs?: number | null; +} + +export interface AgentRuntimeMcpServerOauthLoginResult { + status: "success" | "unsupported" | "error"; + authorizationUrl?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeModelListRequest { + session: AgentRuntimeSessionRef; + cursor?: string | null; + includeHidden?: boolean | null; + limit?: number | null; +} + +export interface AgentRuntimeModelListResult { + status: "success" | "unsupported" | "error"; + models?: unknown[]; + nextCursor?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export type AgentRuntimeAccountLoginType = + | "apiKey" + | "chatgpt" + | "chatgptDeviceCode" + | "chatgptAuthTokens"; + +export interface AgentRuntimeAccountLoginStartRequest { + session: AgentRuntimeSessionRef; + type: AgentRuntimeAccountLoginType; + apiKey?: string | null; + codexStreamlinedLogin?: boolean; + accessToken?: string | null; + chatgptAccountId?: string | null; + chatgptPlanType?: string | null; +} + +export interface AgentRuntimeAccountLoginStartResult { + status: "success" | "unsupported" | "error"; + type?: AgentRuntimeAccountLoginType | (string & {}); + authUrl?: string | null; + loginId?: string | null; + verificationUrl?: string | null; + userCode?: string | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeAccountLoginCancelRequest { + session: AgentRuntimeSessionRef; + loginId: string; +} + +export interface AgentRuntimeAccountLoginCancelResult { + status: "success" | "unsupported" | "error"; + cancelStatus?: "canceled" | "notFound" | (string & {}); + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeAccountLogoutRequest { + session: AgentRuntimeSessionRef; +} + +export interface AgentRuntimeAccountLogoutResult { + status: "success" | "unsupported" | "error"; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeAccountReadRequest { + session: AgentRuntimeSessionRef; + refreshToken?: boolean; +} + +export interface AgentRuntimeAccountReadResult { + status: "success" | "unsupported" | "error"; + account?: unknown | null; + requiresOpenaiAuth?: boolean; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeAccountRateLimitsReadRequest { + session: AgentRuntimeSessionRef; +} + +export interface AgentRuntimeAccountRateLimitsReadResult { + status: "success" | "unsupported" | "error"; + rateLimits?: unknown; + rateLimitsByLimitId?: Record | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +export interface AgentRuntimeAccountUsageReadRequest { + session: AgentRuntimeSessionRef; +} + +export interface AgentRuntimeAccountUsageReadResult { + status: "success" | "unsupported" | "error"; + summary?: unknown; + dailyUsageBuckets?: unknown[] | null; + message?: string; + updatedAt: string; + metadata?: Record; +} + +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; + usedTokens?: number; + totalProcessedTokens?: number; + maxTokens?: number; + cachedInputTokens?: number; + reasoningOutputTokens?: number; + lastUsedTokens?: number; + lastInputTokens?: number; + lastCachedInputTokens?: number; + lastOutputTokens?: number; + lastReasoningOutputTokens?: number; + toolUses?: number; + durationMs?: number; + compactsAutomatically?: boolean; + } + | { + 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; + start(request: AgentRuntimeStartRequest): Promise; + resume(request: AgentRuntimeStartRequest): Promise; + stream( + request: AgentRuntimeStartRequest, + ): AsyncIterable; + stop(request: AgentRuntimeStopRequest): Promise; + submitToolResult( + request: AgentRuntimeToolResultRequest, + ): Promise; + readConfig?( + request: AgentRuntimeConfigReadRequest, + ): Promise; + writeConfigValue?( + request: AgentRuntimeConfigValueWriteRequest, + ): Promise; + batchWriteConfig?( + request: AgentRuntimeConfigBatchWriteRequest, + ): Promise; + readConfigRequirements?( + request: AgentRuntimeConfigRequirementsReadRequest, + ): Promise; + listPermissionProfiles?( + request: AgentRuntimePermissionProfileListRequest, + ): Promise; + listMcpServerStatuses?( + request: AgentRuntimeMcpServerStatusListRequest, + ): Promise; + reloadMcpServerConfig?( + request: AgentRuntimeMcpServerConfigReloadRequest, + ): Promise; + listSkills?( + request: AgentRuntimeSkillListRequest, + ): Promise; + listHooks?( + request: AgentRuntimeHookListRequest, + ): Promise; + listApps?( + request: AgentRuntimeAppListRequest, + ): Promise; + listPlugins?( + request: AgentRuntimePluginListRequest, + ): Promise; + listInstalledPlugins?( + request: AgentRuntimePluginInstalledRequest, + ): Promise; + readPlugin?( + request: AgentRuntimePluginReadRequest, + ): Promise; + installPlugin?( + request: AgentRuntimePluginInstallRequest, + ): Promise; + detectExternalAgentConfig?( + request: AgentRuntimeExternalAgentConfigDetectRequest, + ): Promise; + importExternalAgentConfig?( + request: AgentRuntimeExternalAgentConfigImportRequest, + ): Promise; + startMcpServerOauthLogin?( + request: AgentRuntimeMcpServerOauthLoginRequest, + ): Promise; + listModels?( + request: AgentRuntimeModelListRequest, + ): Promise; + startAccountLogin?( + request: AgentRuntimeAccountLoginStartRequest, + ): Promise; + cancelAccountLogin?( + request: AgentRuntimeAccountLoginCancelRequest, + ): Promise; + logoutAccount?( + request: AgentRuntimeAccountLogoutRequest, + ): Promise; + readAccount?( + request: AgentRuntimeAccountReadRequest, + ): Promise; + readAccountRateLimits?( + request: AgentRuntimeAccountRateLimitsReadRequest, + ): Promise; + readAccountUsage?( + request: AgentRuntimeAccountUsageReadRequest, + ): Promise; + readThread?( + request: AgentRuntimeThreadReadRequest, + ): Promise; + forkThread?( + request: AgentRuntimeThreadForkRequest, + ): Promise; + getThreadTurnDiff?( + request: AgentRuntimeThreadTurnDiffRequest, + ): Promise; + getThreadFullDiff?( + request: AgentRuntimeThreadFullDiffRequest, + ): Promise; + listThreads?( + request: AgentRuntimeThreadListRequest, + ): Promise; + listLoadedThreads?( + request: AgentRuntimeThreadLoadedListRequest, + ): Promise; + controlThread?( + request: AgentRuntimeThreadControlRequest, + ): Promise; + setThreadName?( + request: AgentRuntimeThreadNameSetRequest, + ): Promise; + updateThreadMetadata?( + request: AgentRuntimeThreadMetadataUpdateRequest, + ): Promise; + getThreadGoal?( + request: AgentRuntimeThreadGoalGetRequest, + ): Promise; + setThreadGoal?( + request: AgentRuntimeThreadGoalSetRequest, + ): Promise; + clearThreadGoal?( + request: AgentRuntimeThreadGoalClearRequest, + ): Promise; + rollbackThread?( + request: AgentRuntimeThreadRollbackRequest, + ): 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/status-fallback.ts b/src/main/lib/git/status-fallback.ts new file mode 100644 index 000000000..50fd6169c --- /dev/null +++ b/src/main/lib/git/status-fallback.ts @@ -0,0 +1,166 @@ +import type { ChangedFile, GitChangesStatus } from "../../../shared/changes-types"; +import fs from "node:fs"; +import * as isoGit from "isomorphic-git"; +import type { StatusRow } from "isomorphic-git"; + +const FALLBACK_SKIP_PREFIXES = [ + ".git/", + "node_modules/", + "dist/", + "out/", + "release/", + "release-", + "tmp/", + ".tmp/", + ".vite/", + "evidence/", + ".1code/", +]; + +const FALLBACK_MAX_FILES_PER_BUCKET = 500; + +interface FallbackStatusBuckets { + staged: ChangedFile[]; + unstaged: ChangedFile[]; + untracked: ChangedFile[]; +} + +export function isRecoverableGitStatusError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /xcode.*license|xcodebuild -license|have not agreed|spawn git ENOENT|unable to find git|cannot find git|git.*not found/i.test( + message, + ); +} + +export async function getReadOnlyGitStatusFallback( + worktreePath: string, + defaultBranch: string, +): Promise { + const [branch, statusMatrix] = await Promise.all([ + getCurrentBranch(worktreePath), + isoGit.statusMatrix({ + fs, + dir: worktreePath, + filter: shouldIncludeFallbackPath, + ignored: false, + refresh: false, + }), + ]); + const parsed = parseIsomorphicStatusMatrix(statusMatrix); + + return { + branch, + defaultBranch, + againstBase: [], + commits: [], + staged: parsed.staged, + unstaged: parsed.unstaged, + untracked: parsed.untracked, + ahead: 0, + behind: 0, + pushCount: 0, + pullCount: 0, + hasUpstream: false, + }; +} + +export function parseIsomorphicStatusMatrix( + statusMatrix: StatusRow[], +): FallbackStatusBuckets { + const staged: ChangedFile[] = []; + const unstaged: ChangedFile[] = []; + const untracked: ChangedFile[] = []; + + for (const [path, head, workdir, stage] of statusMatrix) { + if ((head === 1 && workdir === 1 && stage === 1) || (head === 0 && workdir === 0 && stage === 0)) { + continue; + } + + if (head === 0 && workdir === 2 && stage === 0) { + untracked.push(toChangedFile(path, "untracked")); + continue; + } + + if (head !== stage) { + staged.push(toChangedFile(path, getStagedStatus(head, stage))); + } + + if (workdir !== stage) { + unstaged.push(toChangedFile(path, getUnstagedStatus(head, workdir, stage))); + } + } + + return { + staged: limitChangedFilesForStatus(staged), + unstaged: limitChangedFilesForStatus(unstaged), + untracked: limitChangedFilesForStatus(untracked), + }; +} + +export function limitChangedFilesForStatus(files: ChangedFile[]): ChangedFile[] { + return files + .sort((a, b) => getPathSortRank(a.path) - getPathSortRank(b.path) || a.path.localeCompare(b.path)) + .slice(0, FALLBACK_MAX_FILES_PER_BUCKET); +} + +function shouldIncludeFallbackPath(path: string): boolean { + return !FALLBACK_SKIP_PREFIXES.some((prefix) => path === prefix.slice(0, -1) || path.startsWith(prefix)); +} + +async function getCurrentBranch(worktreePath: string): Promise { + try { + return ( + (await isoGit.currentBranch({ + fs, + dir: worktreePath, + fullname: false, + })) || "HEAD" + ); + } catch { + return "HEAD"; + } +} + +function getStagedStatus( + head: StatusRow[1], + stage: StatusRow[3], +): ChangedFile["status"] { + if (stage === 0) return "deleted"; + if (head === 0) return "added"; + return "modified"; +} + +function getUnstagedStatus( + head: StatusRow[1], + workdir: StatusRow[2], + stage: StatusRow[3], +): ChangedFile["status"] { + if (workdir === 0) return "deleted"; + if (head === 0 && stage === 0) return "untracked"; + return "modified"; +} + +function toChangedFile( + path: string, + status: ChangedFile["status"], +): ChangedFile { + return { + path, + status, + additions: 0, + deletions: 0, + }; +} + +function getPathSortRank(path: string): number { + if ( + path === "package.json" || + path === "bun.lock" || + path.startsWith("src/") || + path.startsWith("scripts/") + ) { + return 0; + } + if (path.startsWith(".")) return 2; + return 1; +} diff --git a/src/main/lib/git/status.ts b/src/main/lib/git/status.ts index 333660304..db0ef3318 100644 --- a/src/main/lib/git/status.ts +++ b/src/main/lib/git/status.ts @@ -3,6 +3,11 @@ import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../trpc"; import { assertRegisteredWorktree, secureFs } from "./security"; +import { + getReadOnlyGitStatusFallback, + isRecoverableGitStatusError, + limitChangedFilesForStatus, +} from "./status-fallback"; import { applyNumstatToFiles } from "./utils/apply-numstat"; import { parseGitLog, @@ -34,40 +39,59 @@ export const createStatusRouter = () => { const git = simpleGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; - const status = await git.status(); - const parsed = parseGitStatus(status); + let result: GitChangesStatus; + try { + const status = await git.status(["--untracked-files=normal"]); + const parsed = parseGitStatus(status); + const staged = limitChangedFilesForStatus(parsed.staged); + const unstaged = limitChangedFilesForStatus(parsed.unstaged); + const untracked = limitChangedFilesForStatus(parsed.untracked); + + // Run independent git operations in parallel (VS Code style) + const [branchComparison, trackingStatus] = await Promise.all([ + getBranchComparison(git, defaultBranch), + getTrackingBranchStatus(git), + ]); - // Run independent git operations in parallel (VS Code style) - const [branchComparison, trackingStatus] = await Promise.all([ - getBranchComparison(git, defaultBranch), - getTrackingBranchStatus(git), - ]); + // Run numstat operations in parallel + await Promise.all([ + applyNumstatToFiles(git, staged, [ + "diff", + "--cached", + "--numstat", + ]), + applyNumstatToFiles(git, unstaged, ["diff", "--numstat"]), + applyUntrackedLineCount(input.worktreePath, untracked), + ]); - // Run numstat operations in parallel - await Promise.all([ - applyNumstatToFiles(git, parsed.staged, [ - "diff", - "--cached", - "--numstat", - ]), - applyNumstatToFiles(git, parsed.unstaged, ["diff", "--numstat"]), - applyUntrackedLineCount(input.worktreePath, parsed.untracked), - ]); + result = { + branch: parsed.branch, + defaultBranch, + againstBase: branchComparison.againstBase, + commits: branchComparison.commits, + staged, + unstaged, + untracked, + ahead: branchComparison.ahead, + behind: branchComparison.behind, + pushCount: trackingStatus.pushCount, + pullCount: trackingStatus.pullCount, + hasUpstream: trackingStatus.hasUpstream, + }; + } catch (error) { + if (!isRecoverableGitStatusError(error)) { + throw error; + } - const result: GitChangesStatus = { - branch: parsed.branch, - defaultBranch, - againstBase: branchComparison.againstBase, - commits: branchComparison.commits, - staged: parsed.staged, - unstaged: parsed.unstaged, - untracked: parsed.untracked, - ahead: branchComparison.ahead, - behind: branchComparison.behind, - pushCount: trackingStatus.pushCount, - pullCount: trackingStatus.pullCount, - hasUpstream: trackingStatus.hasUpstream, - }; + console.warn("[getStatus] Native git unavailable; using read-only fallback:", { + worktreePath: input.worktreePath, + error: error instanceof Error ? error.message : String(error), + }); + result = await getReadOnlyGitStatusFallback( + input.worktreePath, + defaultBranch, + ); + } // Store in cache gitCache.setStatus(input.worktreePath, result); 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/mobile-gateway/database-sessions.ts b/src/main/lib/mobile-gateway/database-sessions.ts new file mode 100644 index 000000000..ba7ad053d --- /dev/null +++ b/src/main/lib/mobile-gateway/database-sessions.ts @@ -0,0 +1,36 @@ +import { desc, eq } from "drizzle-orm" +import { chats, getDatabase, projects, subChats } from "../db" +import { + mapMobileGatewaySessionRows, + type MobileGatewaySessionRow, +} from "./sessions" +import type { MobileGatewaySessionSummary } from "./facade" + +export function listMobileGatewaySessionsFromDatabase(): MobileGatewaySessionSummary[] { + const db = getDatabase() + const rows = db + .select({ + chatId: chats.id, + chatName: chats.name, + chatWorktreePath: chats.worktreePath, + projectName: projects.name, + projectPath: projects.path, + subChatId: subChats.id, + subChatName: subChats.name, + engine: subChats.engine, + modelId: subChats.modelId, + mode: subChats.mode, + runtimeMetadata: subChats.runtimeMetadata, + streamId: subChats.streamId, + engineSessionId: subChats.engineSessionId, + sessionId: subChats.sessionId, + updatedAt: subChats.updatedAt, + }) + .from(subChats) + .innerJoin(chats, eq(subChats.chatId, chats.id)) + .innerJoin(projects, eq(chats.projectId, projects.id)) + .orderBy(desc(subChats.updatedAt)) + .all() + + return mapMobileGatewaySessionRows(rows satisfies MobileGatewaySessionRow[]) +} diff --git a/src/main/lib/mobile-gateway/desktop-state.ts b/src/main/lib/mobile-gateway/desktop-state.ts new file mode 100644 index 000000000..72c671260 --- /dev/null +++ b/src/main/lib/mobile-gateway/desktop-state.ts @@ -0,0 +1,106 @@ +export interface DesktopMobileGatewayPairingStatus { + status: "available" | "unavailable" + isAvailable: boolean + url: string | null + pairingUrl: string | null + redactedPairingUrl: string | null + redactedToken: string | null + detail: string +} + +export interface DesktopMobileGatewayPairingSource { + url: string + getPairingToken(): string | undefined + getPairingUrl(): string | undefined +} + +let runningDesktopMobileGateway: DesktopMobileGatewayPairingSource | null = null + +export function setDesktopMobileGateway( + gateway: DesktopMobileGatewayPairingSource | null, +): void { + runningDesktopMobileGateway = gateway +} + +export function getDesktopMobileGateway(): DesktopMobileGatewayPairingSource | null { + return runningDesktopMobileGateway +} + +export function readDesktopMobileGatewayPairingStatus(): + DesktopMobileGatewayPairingStatus { + return buildDesktopMobileGatewayPairingStatus(runningDesktopMobileGateway) +} + +export function buildDesktopMobileGatewayPairingStatus( + gateway: DesktopMobileGatewayPairingSource | null, +): DesktopMobileGatewayPairingStatus { + if (!gateway) { + return { + status: "unavailable", + isAvailable: false, + url: null, + pairingUrl: null, + redactedPairingUrl: null, + redactedToken: null, + detail: "Mobile gateway is not running.", + } + } + + const token = cleanString(gateway.getPairingToken()) + const pairingUrl = cleanString(gateway.getPairingUrl()) + + if (!token || !pairingUrl) { + return { + status: "unavailable", + isAvailable: false, + url: gateway.url, + pairingUrl: null, + redactedPairingUrl: null, + redactedToken: null, + detail: "Mobile gateway is running without a pairing token.", + } + } + + const redactedToken = redactMobileGatewayToken(token) + + return { + status: "available", + isAvailable: true, + url: gateway.url, + pairingUrl, + redactedPairingUrl: redactPairingUrlToken(pairingUrl, token, redactedToken), + redactedToken, + detail: "Mobile gateway is ready for pairing.", + } +} + +export function redactMobileGatewayToken(token: string): string { + const trimmed = token.trim() + if (trimmed.length <= 8) { + return `${trimmed.slice(0, 2)}...${trimmed.slice(-2)}` + } + return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}` +} + +function redactPairingUrlToken( + pairingUrl: string, + token: string, + redactedToken: string, +): string { + try { + const parsed = new URL(pairingUrl) + if (parsed.searchParams.has("token")) { + parsed.searchParams.set("token", redactedToken) + return parsed.toString() + } + } catch { + // Fall through to a direct replacement for malformed debug strings. + } + + return pairingUrl.replace(token, redactedToken) +} + +function cleanString(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/lib/mobile-gateway/desktop.ts b/src/main/lib/mobile-gateway/desktop.ts new file mode 100644 index 000000000..d1882bdf9 --- /dev/null +++ b/src/main/lib/mobile-gateway/desktop.ts @@ -0,0 +1,70 @@ +import { randomUUID } from "node:crypto" +import { agentRuntimeAdapters } from "../agent-runtime/adapters" +import { createMobileGatewayFacade } from "./facade" +import { listMobileGatewaySessionsFromDatabase } from "./database-sessions" +import { + startMobileGatewayServer, + type RunningMobileGatewayServer, +} from "./server" + +export interface DesktopMobileGatewayOptions { + enabled?: boolean + host?: string + port?: number + pairingToken?: string +} + +export interface RunningDesktopMobileGateway extends RunningMobileGatewayServer { + pairingToken: string +} + +const DEFAULT_MOBILE_GATEWAY_PORT = 4177 + +export async function startDesktopMobileGateway( + options: DesktopMobileGatewayOptions = {}, +): Promise { + const enabled = options.enabled ?? process.env.ONECODE_MOBILE_GATEWAY === "1" + if (!enabled) return null + + const pairingToken = + cleanString(options.pairingToken) ?? + cleanString(process.env.ONECODE_MOBILE_GATEWAY_TOKEN) ?? + randomUUID() + const host = + cleanString(options.host) ?? + cleanString(process.env.ONECODE_MOBILE_GATEWAY_HOST) ?? + "127.0.0.1" + const port = + options.port ?? + parsePort(process.env.ONECODE_MOBILE_GATEWAY_PORT) ?? + DEFAULT_MOBILE_GATEWAY_PORT + const facade = createMobileGatewayFacade({ + sessions: listMobileGatewaySessionsFromDatabase, + adapters: agentRuntimeAdapters, + }) + const server = await startMobileGatewayServer({ + facade, + host, + port, + pairingToken, + }) + + return { + ...server, + get pairingToken() { + return server.getPairingToken() ?? pairingToken + }, + } +} + +function cleanString(value: string | null | undefined): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +function parsePort(value: string | null | undefined): number | undefined { + if (!value?.trim()) return undefined + const port = Number.parseInt(value, 10) + return Number.isSafeInteger(port) && port >= 0 && port <= 65535 + ? port + : undefined +} diff --git a/src/main/lib/mobile-gateway/facade.ts b/src/main/lib/mobile-gateway/facade.ts new file mode 100644 index 000000000..876910949 --- /dev/null +++ b/src/main/lib/mobile-gateway/facade.ts @@ -0,0 +1,1031 @@ +import type { + AgentEngineId, + AgentPermissionMode, + AgentRuntimeAdapter, + AgentRuntimeAvailability, + AgentRuntimeControlResult, + AgentRuntimeReceiptBus, + AgentRuntimeRunReceipt, + AgentRuntimeRunStatus, + AgentRuntimeHealth, + AgentRuntimeSessionRef, + AgentRuntimeStartRequest, + AgentRuntimeStreamEvent, +} from "../agent-runtime" + +export type MobileGatewaySessionStatus = + | "idle" + | "running" + | "needs-approval" + | "needs-auth" + | "offline" + | "error" + +export interface MobileGatewaySessionSummary { + chatId: string + subChatId: string + title: string + projectLabel: string + projectPath: string + detail: string + engineId: AgentEngineId + modelId: string | null + nativeSessionId?: string | null + permissionMode: AgentPermissionMode | string + status: MobileGatewaySessionStatus + updatedAt: string + pendingApprovals: number + unreadEvents: number +} + +export type MobileGatewaySessionSource = + | readonly MobileGatewaySessionSummary[] + | (() => readonly MobileGatewaySessionSummary[]) + +export type MobileGatewayRuntimeHealthStatus = AgentRuntimeAvailability + +export interface MobileGatewayRuntimeHealth { + engineId: AgentEngineId + status: MobileGatewayRuntimeHealthStatus + detail: string + checkedAt: string + modelId?: string | null + authMethod?: string +} + +export type MobileGatewayRuntimeHealthSource = + | readonly MobileGatewayRuntimeHealth[] + | (() => readonly MobileGatewayRuntimeHealth[] | Promise) + +export type MobileGatewayEvent = + | { + type: "turn-start" + engineId: AgentEngineId + modelId?: string | null + permissionMode: string + } + | { + type: "text-delta" + text: string + } + | { + type: "tool-call" + toolCallId?: string + name: string + input?: unknown + } + | { + type: "tool-result" + toolCallId?: string + name?: string + result?: unknown + isError?: boolean + } + | { + type: "permission-request" + requestId: string + title?: string + details?: unknown + } + | { + type: "block" + block: Record + } + | { + type: "block-patch" + id: string + patch: Record + } + | { + type: "usage" + inputTokens?: number + outputTokens?: number + totalTokens?: number + modelContextWindow?: number + usedTokens?: number + totalProcessedTokens?: number + maxTokens?: number + cachedInputTokens?: number + reasoningOutputTokens?: number + lastUsedTokens?: number + lastInputTokens?: number + lastCachedInputTokens?: number + lastOutputTokens?: number + lastReasoningOutputTokens?: number + toolUses?: number + durationMs?: number + compactsAutomatically?: boolean + } + | { + type: "auth-error" + message: string + recoverable: true + } + | { + type: "error" + message: string + recoverable?: boolean + } + | { + type: "finish" + resultSubtype?: "success" | "error" | "cancelled" + nativeSessionId?: string | null + } + +export interface MobileGatewayEnvelope { + seq: number + time: string + chatId: string + subChatId: string + runId?: string + event: MobileGatewayEvent +} + +export interface MobileGatewayStartRunInput { + chatId: string + subChatId: string + prompt: string + engineId?: AgentEngineId + modelId?: string | null + permissionMode?: AgentPermissionMode | string + images?: AgentRuntimeStartRequest["images"] + forceNewSession?: boolean +} + +export interface MobileGatewayRunReceipt { + runId: string + status: "running" + engineId: AgentEngineId + chatId: string + subChatId: string + startedAt: string +} + +export interface MobileGatewayStopRunInput { + runId: string + reason?: string +} + +export interface MobileGatewaySubmitToolResultInput { + runId: string + toolCallId: string + result: unknown + isError?: boolean +} + +export interface ReadMobileGatewayEventsInput { + subChatId: string + afterSeq?: number +} + +export interface MobileGatewayFacade { + listSessions(): MobileGatewaySessionSummary[] + listRuntimeHealth(): Promise + startRun(input: MobileGatewayStartRunInput): Promise + stopRun(input: MobileGatewayStopRunInput): Promise + submitToolResult( + input: MobileGatewaySubmitToolResultInput, + ): Promise + readEvents( + input: ReadMobileGatewayEventsInput, + ): AsyncIterable +} + +export interface MobileGatewayHttpRequest { + method: string + url: string + headers?: Record + body?: string +} + +export interface MobileGatewayHttpResponse { + status: number + headers: Record + body: string +} + +export type MobileGatewayPairingTokenSource = + | string + | (() => string | undefined) + +export interface MobileGatewayPairingTokenRotation { + token: string + rotatedAt: string +} + +export interface MobileGatewayHttpRequestOptions { + pairingToken?: MobileGatewayPairingTokenSource + rotatePairingToken?: () => MobileGatewayPairingTokenRotation + pairingBaseUrl?: string | ((requestUrl: URL) => string) + pairingLabel?: string +} + +type MobileGatewayRunState = { + receipt: MobileGatewayRunReceipt + request: AgentRuntimeStartRequest + history: MobileGatewayEnvelope[] + completed: boolean + streaming: boolean + quiescedReceiptPublished: boolean +} + +type MobileGatewayFinalRunStatus = Exclude +type MobileGatewayResultSubtype = AgentRuntimeRunReceipt["resultSubtype"] + +export function createMobileGatewayFacade(input: { + sessions: MobileGatewaySessionSource + adapters: Partial> + runtimeHealth?: MobileGatewayRuntimeHealthSource + runtimeReceipts?: AgentRuntimeReceiptBus + now?: () => Date +}): MobileGatewayFacade { + const now = input.now ?? (() => new Date()) + const getSessions = createSessionReader(input.sessions) + const getRuntimeHealth = createRuntimeHealthReader(input.runtimeHealth) + const runsBySubChat = new Map() + const runsByRunId = new Map() + const runtimeHealthOverrides = new Map() + + return { + listSessions() { + return getSessions() + }, + + async listRuntimeHealth() { + const sessions = getSessions() + const baseHealth = getRuntimeHealth + ? await getRuntimeHealth() + : await inspectRuntimeHealth({ + sessions, + adapters: input.adapters, + checkedAt: now().toISOString(), + }) + return mergeRuntimeHealthOverrides(baseHealth, runtimeHealthOverrides) + }, + + async startRun(runInput) { + const session = findSessionOrThrow(getSessions(), runInput) + const prompt = runInput.prompt.trim() + if (!prompt) { + throw new Error("prompt must be a non-empty string") + } + if (!input.adapters[session.engineId]) { + throw new Error(`No mobile gateway adapter configured for ${session.engineId}`) + } + + const startedAt = now().toISOString() + const receipt = { + runId: `mobile:${session.engineId}:${session.subChatId}:${Date.parse(startedAt)}`, + status: "running" as const, + engineId: session.engineId, + chatId: session.chatId, + subChatId: session.subChatId, + startedAt, + } + const request: AgentRuntimeStartRequest = { + runId: receipt.runId, + session: buildRuntimeSession(session, runInput), + prompt, + images: runInput.images, + forceNewSession: runInput.forceNewSession, + } + + const state = { + receipt, + request, + history: [], + completed: false, + streaming: false, + quiescedReceiptPublished: false, + } + runsBySubChat.set(session.subChatId, state) + runsByRunId.set(receipt.runId, state) + input.runtimeReceipts?.publish({ + type: "runtime.run.started", + runId: receipt.runId, + engineId: receipt.engineId, + chatId: receipt.chatId, + subChatId: receipt.subChatId, + createdAt: startedAt, + source: "mobile-gateway", + }) + + return receipt + }, + + async stopRun(stopInput) { + const run = runsByRunId.get(stopInput.runId) + if (!run) { + throw new MobileGatewayHttpError( + 404, + `Mobile gateway run not found: ${stopInput.runId}`, + ) + } + + const adapter = input.adapters[run.receipt.engineId] + if (!adapter) { + throw new Error(`No mobile gateway adapter configured for ${run.receipt.engineId}`) + } + + return adapter.stop({ + session: run.request.session, + runId: stopInput.runId, + reason: stopInput.reason, + }) + }, + + async submitToolResult(toolInput) { + const run = runsByRunId.get(toolInput.runId) + if (!run) { + throw new MobileGatewayHttpError( + 404, + `Mobile gateway run not found: ${toolInput.runId}`, + ) + } + + const adapter = input.adapters[run.receipt.engineId] + if (!adapter) { + throw new Error(`No mobile gateway adapter configured for ${run.receipt.engineId}`) + } + + return adapter.submitToolResult({ + session: run.request.session, + runId: toolInput.runId, + toolCallId: toolInput.toolCallId, + result: toolInput.result, + isError: toolInput.isError, + }) + }, + + async *readEvents(eventsInput) { + const run = runsBySubChat.get(eventsInput.subChatId) + if (!run) { + throw new Error(`No mobile gateway run is registered for ${eventsInput.subChatId}`) + } + + if (run.completed) { + yield* filterAfterSeq(run.history, eventsInput.afterSeq) + return + } + if (run.streaming) { + throw new Error(`Mobile gateway run is already streaming for ${eventsInput.subChatId}`) + } + + run.streaming = true + let finalStatus: MobileGatewayFinalRunStatus = "success" + let finalResultSubtype: MobileGatewayResultSubtype = "success" + let finalError: string | undefined + let nextSeq = run.history.length > 0 + ? run.history[run.history.length - 1]!.seq + 1 + : (eventsInput.afterSeq ?? -1) + 1 + const append = (event: MobileGatewayEvent): MobileGatewayEnvelope => { + const envelope = { + seq: nextSeq++, + time: now().toISOString(), + chatId: run.receipt.chatId, + subChatId: run.receipt.subChatId, + runId: run.receipt.runId, + event, + } + run.history.push(envelope) + return envelope + } + const publishQuiescedReceipt = () => { + if (!input.runtimeReceipts || run.quiescedReceiptPublished) return + if (!run.completed) return + run.quiescedReceiptPublished = true + input.runtimeReceipts.publish({ + type: "turn.processing.quiesced", + runId: run.receipt.runId, + engineId: run.receipt.engineId, + chatId: run.receipt.chatId, + subChatId: run.receipt.subChatId, + status: finalStatus, + resultSubtype: finalResultSubtype, + latestSequence: run.history.at(-1)?.seq ?? null, + eventCount: run.history.length, + createdAt: now().toISOString(), + error: finalError ?? null, + source: "mobile-gateway", + }) + } + const rememberTerminalMobileEvent = (event: MobileGatewayEvent) => { + if (event.type === "error" || event.type === "auth-error") { + finalStatus = "error" + finalResultSubtype = "error" + finalError = event.message + return + } + if (event.type !== "finish") return + finalResultSubtype = event.resultSubtype ?? finalResultSubtype + if (event.resultSubtype === "cancelled") { + finalStatus = "cancelled" + } else if (event.resultSubtype === "error") { + finalStatus = "error" + } else if (finalStatus !== "error") { + finalStatus = "success" + } + } + + try { + const adapter = input.adapters[run.receipt.engineId] + if (!adapter) { + const errorEvent: MobileGatewayEvent = { + type: "error", + message: `No mobile gateway adapter configured for ${run.receipt.engineId}`, + recoverable: false, + } + rememberTerminalMobileEvent(errorEvent) + yield append(errorEvent) + const finishEvent: MobileGatewayEvent = { type: "finish", resultSubtype: "error" } + rememberTerminalMobileEvent(finishEvent) + yield append(finishEvent) + run.completed = true + return + } + + yield append({ + type: "turn-start", + engineId: run.request.session.engineId, + modelId: run.request.session.modelId, + permissionMode: run.request.session.permissionMode, + }) + + for await (const runtimeEvent of adapter.stream(run.request)) { + const mobileEvent = mapRuntimeEventToMobileEvent(runtimeEvent) + if (mobileEvent) { + rememberRuntimeHealthFailure({ + event: mobileEvent, + run, + checkedAt: now().toISOString(), + overrides: runtimeHealthOverrides, + }) + rememberTerminalMobileEvent(mobileEvent) + yield append(mobileEvent) + if (mobileEvent.type === "finish" || mobileEvent.type === "auth-error") { + run.completed = true + } + } + } + run.completed = true + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const errorEvent: MobileGatewayEvent = { + type: "error", + message, + recoverable: false, + } + rememberRuntimeHealthFailure({ + event: errorEvent, + run, + checkedAt: now().toISOString(), + overrides: runtimeHealthOverrides, + }) + rememberTerminalMobileEvent(errorEvent) + yield append(errorEvent) + const finishEvent: MobileGatewayEvent = { + type: "finish", + nativeSessionId: run.request.session.nativeSessionId ?? null, + resultSubtype: "error", + } + rememberTerminalMobileEvent(finishEvent) + yield append(finishEvent) + run.completed = true + } finally { + publishQuiescedReceipt() + run.streaming = false + } + }, + } +} + +export async function collectMobileGatewayEvents( + events: AsyncIterable, +): Promise { + const collected: MobileGatewayEnvelope[] = [] + for await (const event of events) { + collected.push(event) + } + return collected +} + +export function serializeMobileGatewaySse( + envelopes: MobileGatewayEnvelope[], +): string { + return envelopes + .map((envelope) => `event: mobile-envelope\ndata: ${JSON.stringify(envelope)}\n\n`) + .join("") +} + +export async function handleMobileGatewayRequest( + facade: MobileGatewayFacade, + request: MobileGatewayHttpRequest, + options?: MobileGatewayHttpRequestOptions, +): Promise { + const url = new URL(request.url) + if (!isAuthorized(request.headers ?? {}, options?.pairingToken)) { + return jsonResponse(401, { error: "Unauthorized" }) + } + + try { + if ( + request.method === "POST" && + url.pathname === "/mobile/v1/pairing/token/rotate" + ) { + if (!options?.rotatePairingToken) { + return jsonResponse(404, { error: "Pairing token rotation is not configured" }) + } + + const rotation = options.rotatePairingToken() + const token = requireString(rotation.token, "token") + const baseUrl = resolvePairingBaseUrl(url, options.pairingBaseUrl) + return jsonResponse(200, { + token, + baseUrl, + pairingUrl: buildOneCodeMobilePairingUrl({ + baseUrl, + token, + label: options.pairingLabel ?? "Local 1code", + }), + rotatedAt: requireString(rotation.rotatedAt, "rotatedAt"), + expiresPreviousToken: true, + }) + } + + if (request.method === "GET" && url.pathname === "/mobile/v1/sessions") { + return jsonResponse(200, { sessions: facade.listSessions() }) + } + + if (request.method === "GET" && url.pathname === "/mobile/v1/runtime/health") { + return jsonResponse(200, { health: await facade.listRuntimeHealth() }) + } + + const stopMatch = url.pathname.match(/^\/mobile\/v1\/runs\/([^/]+)\/stop$/) + if (request.method === "POST" && stopMatch) { + const body = parseJsonBody(request.body) + const result = await facade.stopRun({ + runId: decodeURIComponent(stopMatch[1]!), + reason: optionalString(body.reason), + }) + return jsonResponse(200, result) + } + + const toolResultMatch = url.pathname.match( + /^\/mobile\/v1\/runs\/([^/]+)\/tool-results\/([^/]+)$/, + ) + if (request.method === "POST" && toolResultMatch) { + const body = parseJsonBody(request.body) + const result = await facade.submitToolResult({ + runId: decodeURIComponent(toolResultMatch[1]!), + toolCallId: decodeURIComponent(toolResultMatch[2]!), + result: body.result, + isError: optionalBoolean(body.isError), + }) + return jsonResponse(200, result) + } + + const runMatch = url.pathname.match(/^\/mobile\/v1\/sessions\/([^/]+)\/runs$/) + if (request.method === "POST" && runMatch) { + const body = parseJsonBody(request.body) + const receipt = await facade.startRun({ + chatId: requireString(body.chatId, "chatId"), + subChatId: decodeURIComponent(runMatch[1]!), + prompt: requireString(body.prompt, "prompt"), + engineId: optionalEngine(body.engineId), + modelId: optionalString(body.modelId), + permissionMode: optionalString(body.permissionMode), + forceNewSession: + typeof body.forceNewSession === "boolean" + ? body.forceNewSession + : undefined, + }) + return jsonResponse(200, receipt) + } + + const eventsMatch = url.pathname.match(/^\/mobile\/v1\/sessions\/([^/]+)\/events$/) + if (request.method === "GET" && eventsMatch) { + const afterSeq = parseOptionalSeq(url.searchParams.get("afterSeq")) + const envelopes = await collectMobileGatewayEvents( + facade.readEvents({ + subChatId: decodeURIComponent(eventsMatch[1]!), + afterSeq, + }), + ) + return { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }, + body: serializeMobileGatewaySse(envelopes), + } + } + + return jsonResponse(404, { error: "Not found" }) + } catch (error) { + if (error instanceof MobileGatewayHttpError) { + return jsonResponse(error.status, { error: error.message }) + } + return jsonResponse(400, { + error: error instanceof Error ? error.message : String(error), + }) + } +} + +class MobileGatewayHttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message) + } +} + +function createSessionReader( + source: MobileGatewaySessionSource, +): () => MobileGatewaySessionSummary[] { + return () => [...(typeof source === "function" ? source() : source)] +} + +function createRuntimeHealthReader( + source: MobileGatewayRuntimeHealthSource | undefined, +): (() => Promise) | undefined { + if (!source) return undefined + return async () => [...(typeof source === "function" ? await source() : source)] +} + +async function inspectRuntimeHealth(params: { + sessions: readonly MobileGatewaySessionSummary[] + adapters: Partial> + checkedAt: string +}): Promise { + const sessionsByEngine = new Map() + for (const session of params.sessions) { + if (!sessionsByEngine.has(session.engineId)) { + sessionsByEngine.set(session.engineId, session) + } + } + + const health: MobileGatewayRuntimeHealth[] = [] + for (const [engineId, session] of sessionsByEngine) { + const adapter = params.adapters[engineId] + if (!adapter) { + health.push({ + engineId, + modelId: session.modelId, + status: "unsupported", + detail: `No mobile gateway adapter configured for ${engineId}.`, + checkedAt: params.checkedAt, + }) + continue + } + + try { + const runtimeSession = buildInspectRuntimeSession(session) + const runtimeHealth = adapter.inspect + ? await adapter.inspect(runtimeSession) + : { availability: await adapter.canStart(runtimeSession) } satisfies AgentRuntimeHealth + health.push(toMobileRuntimeHealth({ + engineId, + modelId: session.modelId, + checkedAt: params.checkedAt, + runtimeHealth, + })) + } catch (error) { + health.push({ + engineId, + modelId: session.modelId, + status: "error", + detail: error instanceof Error ? error.message : String(error), + checkedAt: params.checkedAt, + }) + } + } + + return health +} + +function toMobileRuntimeHealth(params: { + engineId: AgentEngineId + modelId: string | null + checkedAt: string + runtimeHealth: AgentRuntimeHealth +}): MobileGatewayRuntimeHealth { + return { + engineId: params.engineId, + modelId: params.modelId, + status: params.runtimeHealth.availability, + detail: + params.runtimeHealth.statusReason ?? + `${params.engineId} runtime is ${params.runtimeHealth.availability}.`, + checkedAt: params.checkedAt, + authMethod: params.runtimeHealth.authMethod, + } +} + +function mergeRuntimeHealthOverrides( + health: readonly MobileGatewayRuntimeHealth[], + overrides: ReadonlyMap, +): MobileGatewayRuntimeHealth[] { + const merged = new Map() + for (const item of health) { + merged.set(item.engineId, item) + } + for (const [engineId, item] of overrides) { + merged.set(engineId, item) + } + return [...merged.values()] +} + +function rememberRuntimeHealthFailure(params: { + event: MobileGatewayEvent + run: MobileGatewayRunState + checkedAt: string + overrides: Map +}): void { + const message = params.event.type === "auth-error" || params.event.type === "error" + ? params.event.message + : undefined + if (!message || !isAuthFailureMessage(message)) return + + params.overrides.set(params.run.request.session.engineId, { + engineId: params.run.request.session.engineId, + modelId: params.run.request.session.modelId ?? null, + status: "needs-auth", + detail: message, + checkedAt: params.checkedAt, + authMethod: "not-authenticated", + }) +} + +function isAuthFailureMessage(message: string): boolean { + const normalized = message.toLowerCase() + return normalized.includes("auth_unavailable") || + normalized.includes("no auth available") || + normalized.includes("authentication token has been invalidated") || + normalized.includes("authentication is required") +} + +function findSessionOrThrow( + sessions: readonly MobileGatewaySessionSummary[], + input: MobileGatewayStartRunInput, +): MobileGatewaySessionSummary { + const session = sessions.find((candidate) => candidate.subChatId === input.subChatId) + if (!session) { + throw new Error(`Mobile gateway session not found: ${input.subChatId}`) + } + if (session.chatId !== input.chatId) { + throw new Error(`Mobile gateway chat mismatch for ${input.subChatId}`) + } + if (input.engineId && input.engineId !== session.engineId) { + throw new Error(`Mobile gateway engine mismatch for ${input.subChatId}`) + } + return session +} + +function buildRuntimeSession( + session: MobileGatewaySessionSummary, + input: MobileGatewayStartRunInput, +): AgentRuntimeSessionRef { + return { + chatId: session.chatId, + subChatId: session.subChatId, + engineId: session.engineId, + modelId: input.modelId ?? session.modelId, + nativeSessionId: session.nativeSessionId ?? null, + permissionMode: (input.permissionMode ?? session.permissionMode) as AgentPermissionMode, + cwd: session.projectPath, + projectPath: session.projectPath, + } +} + +function buildInspectRuntimeSession( + session: MobileGatewaySessionSummary, +): AgentRuntimeSessionRef { + return { + chatId: session.chatId, + subChatId: session.subChatId, + engineId: session.engineId, + modelId: session.modelId, + nativeSessionId: session.nativeSessionId ?? null, + permissionMode: session.permissionMode as AgentPermissionMode, + cwd: session.projectPath, + projectPath: session.projectPath, + } +} + +function filterAfterSeq( + history: MobileGatewayEnvelope[], + afterSeq: number | undefined, +): MobileGatewayEnvelope[] { + if (afterSeq === undefined) return [...history] + return history.filter((event) => event.seq > afterSeq) +} + +function compactEvent>(event: T): T { + return Object.fromEntries( + Object.entries(event).filter(([, value]) => value !== undefined), + ) as T +} + +function mapRuntimeEventToMobileEvent( + event: AgentRuntimeStreamEvent, +): MobileGatewayEvent | null { + switch (event.type) { + case "text": + return { type: "text-delta", text: event.text } + case "tool-call": + return { + type: "tool-call", + toolCallId: event.id, + name: event.name, + input: event.input, + } + case "tool-result": + return { + type: "tool-result", + toolCallId: event.id, + name: event.name, + result: event.result, + } + case "usage": + return compactEvent({ + type: "usage" as const, + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + totalTokens: event.totalTokens, + modelContextWindow: event.modelContextWindow, + usedTokens: event.usedTokens, + totalProcessedTokens: event.totalProcessedTokens, + maxTokens: event.maxTokens, + cachedInputTokens: event.cachedInputTokens, + reasoningOutputTokens: event.reasoningOutputTokens, + lastUsedTokens: event.lastUsedTokens, + lastInputTokens: event.lastInputTokens, + lastCachedInputTokens: event.lastCachedInputTokens, + lastOutputTokens: event.lastOutputTokens, + lastReasoningOutputTokens: event.lastReasoningOutputTokens, + toolUses: event.toolUses, + durationMs: event.durationMs, + compactsAutomatically: event.compactsAutomatically, + }) + case "conversation-block": + const block = event.block as unknown as Record + if (isPermissionBlock(block)) { + return { + type: "permission-request", + requestId: cleanString(block.id) ?? "permission-request", + title: cleanString(block.title) ?? cleanString(block.name), + details: block, + } + } + return { type: "block", block } + case "conversation-block-update": + return { + type: "block-patch", + id: event.id, + patch: event.patch as unknown as Record, + } + case "auth-error": + return { + type: "auth-error", + message: event.message, + recoverable: true, + } + case "error": + return { + type: "error", + message: event.message, + recoverable: false, + } + case "finish": + return { + type: "finish", + nativeSessionId: event.nativeSessionId ?? null, + resultSubtype: event.resultSubtype, + } + default: + return null + } +} + +function isPermissionBlock(block: unknown): block is Record { + return Boolean(block) && + typeof block === "object" && + !Array.isArray(block) && + (block as Record).type === "permission-request" +} + +function isAuthorized( + headers: Record, + pairingToken: MobileGatewayPairingTokenSource | undefined, +): boolean { + const token = readPairingToken(pairingToken) + if (!token) return true + return headerValue(headers, "authorization") === `Bearer ${token}` +} + +function readPairingToken( + pairingToken: MobileGatewayPairingTokenSource | undefined, +): string | undefined { + return typeof pairingToken === "function" ? pairingToken() : pairingToken +} + +function headerValue( + headers: Record, + name: string, +): string | undefined { + const target = name.toLowerCase() + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) return value + } + return undefined +} + +function jsonResponse(status: number, value: unknown): MobileGatewayHttpResponse { + return { + status, + headers: { "content-type": "application/json" }, + body: JSON.stringify(value), + } +} + +function resolvePairingBaseUrl( + requestUrl: URL, + pairingBaseUrl: MobileGatewayHttpRequestOptions["pairingBaseUrl"], +): string { + const rawBaseUrl = typeof pairingBaseUrl === "function" + ? pairingBaseUrl(requestUrl) + : pairingBaseUrl ?? requestUrl.origin + return normalizePairingBaseUrl(rawBaseUrl) +} + +function normalizePairingBaseUrl(baseUrl: string): string { + const parsed = new URL(baseUrl) + const pathname = parsed.pathname + .replace(/\/+$/, "") + .replace(/\/mobile\/v1$/, "") + return `${parsed.protocol}//${parsed.host}${pathname}` +} + +export function buildOneCodeMobilePairingUrl(input: { + baseUrl: string + token: string + label: string +}): string { + const params = new URLSearchParams({ + url: `${input.baseUrl}/mobile/v1/`, + token: input.token, + label: input.label, + }) + return `onecode-mobile://pair?${params.toString()}` +} + +function parseJsonBody(body: string | undefined): Record { + if (!body) return {} + const parsed = JSON.parse(body) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Request body must be a JSON object") + } + return parsed as Record +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string`) + } + return value +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined +} + +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined +} + +function cleanString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined +} + +function optionalEngine(value: unknown): AgentEngineId | undefined { + if ( + value === "claude-code" || + value === "codex" || + value === "hermes" || + value === "custom-acp" + ) { + return value + } + return undefined +} + +function parseOptionalSeq(value: string | null): number | undefined { + if (value === null || value === "") return undefined + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error("afterSeq must be a non-negative safe integer") + } + return parsed +} diff --git a/src/main/lib/mobile-gateway/server.ts b/src/main/lib/mobile-gateway/server.ts new file mode 100644 index 000000000..1a7a081c0 --- /dev/null +++ b/src/main/lib/mobile-gateway/server.ts @@ -0,0 +1,191 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "http" +import { randomUUID } from "node:crypto" +import type { AddressInfo } from "net" +import { + buildOneCodeMobilePairingUrl, + handleMobileGatewayRequest, + type MobileGatewayFacade, + type MobileGatewayHttpResponse, + type MobileGatewayPairingTokenRotation, +} from "./facade" + +export interface StartMobileGatewayServerOptions { + facade: MobileGatewayFacade + host?: string + port?: number + pairingToken?: string + pairingLabel?: string + corsOrigin?: string +} + +export interface RunningMobileGatewayServer { + server: Server + host: string + port: number + url: string + getPairingToken(): string | undefined + getPairingUrl(): string | undefined + close(): Promise +} + +type MobileGatewayPairingTokenState = { + getPairingToken(): string | undefined + rotatePairingToken(): MobileGatewayPairingTokenRotation +} + +export async function startMobileGatewayServer( + options: StartMobileGatewayServerOptions, +): Promise { + const host = options.host ?? "127.0.0.1" + const requestedPort = options.port ?? 0 + const pairingTokenState = createPairingTokenState(options.pairingToken) + const server = createMobileGatewayServer(options, pairingTokenState) + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening) + reject(error) + } + const onListening = () => { + server.off("error", onError) + resolve() + } + + server.once("error", onError) + server.once("listening", onListening) + server.listen(requestedPort, host) + }) + + const address = server.address() + if (!address || typeof address !== "object") { + await closeServer(server) + throw new Error("Mobile gateway server did not expose a TCP address") + } + + const port = (address as AddressInfo).port + return { + server, + host, + port, + url: `http://${host}:${port}`, + getPairingToken: pairingTokenState.getPairingToken, + getPairingUrl() { + const token = pairingTokenState.getPairingToken() + if (!token) return undefined + return buildOneCodeMobilePairingUrl({ + baseUrl: `http://${host}:${port}`, + token, + label: options.pairingLabel ?? "Local 1code", + }) + }, + close: () => closeServer(server), + } +} + +export function createMobileGatewayServer( + options: StartMobileGatewayServerOptions, + pairingTokenState = createPairingTokenState(options.pairingToken), +): Server { + return createServer(async (req, res) => { + if (req.method === "OPTIONS") { + writeMobileGatewayResponse(res, { + status: 204, + headers: {}, + body: "", + }, options.corsOrigin) + return + } + + try { + const body = await readRequestBody(req) + const response = await handleMobileGatewayRequest(options.facade, { + method: req.method ?? "GET", + url: buildRequestUrl(req, options.host ?? "127.0.0.1"), + headers: normalizeHeaders(req.headers), + body, + }, { + pairingToken: pairingTokenState.getPairingToken, + rotatePairingToken: pairingTokenState.rotatePairingToken, + pairingBaseUrl: (requestUrl) => requestUrl.origin, + pairingLabel: options.pairingLabel, + }) + writeMobileGatewayResponse(res, response, options.corsOrigin) + } catch (error) { + writeMobileGatewayResponse(res, { + status: 500, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }), + }, options.corsOrigin) + } + }) +} + +function createPairingTokenState( + initialToken: string | undefined, +): MobileGatewayPairingTokenState { + let currentToken = initialToken + return { + getPairingToken() { + return currentToken + }, + rotatePairingToken() { + currentToken = randomUUID() + return { + token: currentToken, + rotatedAt: new Date().toISOString(), + } + }, + } +} + +function buildRequestUrl(req: IncomingMessage, fallbackHost: string): string { + const host = req.headers.host ?? fallbackHost + return `http://${host}${req.url ?? "/"}` +} + +function normalizeHeaders( + headers: IncomingMessage["headers"], +): Record { + const normalized: Record = {} + for (const [key, value] of Object.entries(headers)) { + normalized[key] = Array.isArray(value) ? value.join(", ") : value + } + return normalized +} + +async function readRequestBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return chunks.length > 0 ? Buffer.concat(chunks).toString("utf8") : undefined +} + +function writeMobileGatewayResponse( + res: ServerResponse, + response: MobileGatewayHttpResponse, + corsOrigin = "*", +): void { + res.writeHead(response.status, { + ...response.headers, + "access-control-allow-origin": corsOrigin, + "access-control-allow-headers": "authorization, content-type", + "access-control-allow-methods": "GET, POST, OPTIONS", + }) + res.end(response.body) +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + if (!server.listening) { + resolve() + return + } + server.close((error) => { + if (error) reject(error) + else resolve() + }) + }) +} diff --git a/src/main/lib/mobile-gateway/sessions.ts b/src/main/lib/mobile-gateway/sessions.ts new file mode 100644 index 000000000..c640b33c5 --- /dev/null +++ b/src/main/lib/mobile-gateway/sessions.ts @@ -0,0 +1,122 @@ +import path from "path" +import { + AGENT_ENGINE_IDS, + DEFAULT_AGENT_ENGINE_ID, + type AgentEngineId, + type AgentPermissionMode, +} from "../agent-runtime/types" +import { getAgentRuntimeManifest } from "../agent-runtime/manifests" +import type { MobileGatewaySessionSummary } from "./facade" + +export interface MobileGatewaySessionRow { + chatId: string + chatName?: string | null + chatWorktreePath?: string | null + projectName?: string | null + projectPath: string + subChatId: string + subChatName?: string | null + engine?: string | null + modelId?: string | null + mode?: string | null + runtimeMetadata?: string | null + streamId?: string | null + engineSessionId?: string | null + sessionId?: string | null + updatedAt?: Date | number | string | null +} + +const permissionModes = new Set([ + "plan", + "agent", + "bypass", + "read-only", + "ask-approval", + "full-access", + "custom", +]) + +export function createMobileGatewaySessionSource( + readRows: () => readonly MobileGatewaySessionRow[], +): () => MobileGatewaySessionSummary[] { + return () => mapMobileGatewaySessionRows(readRows()) +} + +export function mapMobileGatewaySessionRows( + rows: readonly MobileGatewaySessionRow[], +): MobileGatewaySessionSummary[] { + return rows.map((row) => { + const engineId = normalizeEngineId(row.engine) + const manifest = getAgentRuntimeManifest(engineId) + const projectPath = row.chatWorktreePath || row.projectPath + const nativeSessionId = + row.engineSessionId ?? (engineId === "claude-code" ? row.sessionId : null) + + return { + chatId: row.chatId, + subChatId: row.subChatId, + title: cleanString(row.subChatName) ?? cleanString(row.chatName) ?? "Untitled chat", + projectLabel: + cleanString(row.projectName) ?? + cleanString(path.basename(row.projectPath)) ?? + "Workspace", + projectPath, + detail: nativeSessionId ? "Native session linked" : "Ready to start", + engineId, + modelId: cleanString(row.modelId) ?? manifest.defaultModelId ?? null, + nativeSessionId, + permissionMode: resolvePermissionMode(row), + status: row.streamId ? "running" : "idle", + updatedAt: toIsoString(row.updatedAt), + pendingApprovals: 0, + unreadEvents: 0, + } + }) +} + +function normalizeEngineId(value: string | null | undefined): AgentEngineId { + return AGENT_ENGINE_IDS.includes(value as AgentEngineId) + ? value as AgentEngineId + : DEFAULT_AGENT_ENGINE_ID +} + +function resolvePermissionMode(row: MobileGatewaySessionRow): AgentPermissionMode { + const metadata = parseMetadata(row.runtimeMetadata) + if (isPermissionMode(metadata.permissionMode)) { + return metadata.permissionMode + } + return isPermissionMode(row.mode) ? row.mode : "agent" +} + +function isPermissionMode(value: unknown): value is AgentPermissionMode { + return typeof value === "string" && permissionModes.has(value as AgentPermissionMode) +} + +function parseMetadata(value: string | null | undefined): Record { + if (!value) return {} + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {} + } catch { + return {} + } +} + +function cleanString(value: string | null | undefined): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +function toIsoString(value: Date | number | string | null | undefined): string { + if (value instanceof Date) return value.toISOString() + if (typeof value === "number") { + const millis = value < 10_000_000_000 ? value * 1000 : value + return new Date(millis).toISOString() + } + if (typeof value === "string" && value.trim()) { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString() + } + return new Date(0).toISOString() +} 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..193bb3142 --- /dev/null +++ b/src/main/lib/moss-source/provider-config.test.ts @@ -0,0 +1,415 @@ +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") + expect(hermes.env.HERMES_INFERENCE_MODEL).toBe("moss-custom") + + 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.env.HERMES_INFERENCE_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..29ba5d8f4 --- /dev/null +++ b/src/main/lib/moss-source/provider-config.ts @@ -0,0 +1,588 @@ +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 + env.HERMES_INFERENCE_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/pet-runtime-status.ts b/src/main/lib/pet-runtime-status.ts new file mode 100644 index 000000000..bb4dcbe2a --- /dev/null +++ b/src/main/lib/pet-runtime-status.ts @@ -0,0 +1,422 @@ +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +export type PetRuntimeAvailability = + | "ready" + | "not-configured" + | "missing-hook" + | "missing-runtime" + | "window-unavailable" + +export interface CodexPetDefinition { + id: string + displayName: string + description: string | null + directory: string + petJsonPath: string + spritesheetPath: string | null + spritesheetExists: boolean + selected: boolean +} + +export interface PetRuntimeLogEntry { + at: string | null + code?: string + event?: string + intent?: string + message?: string + reason?: string + shouldReact?: boolean + skipped?: string +} + +export interface PetRuntimeStatus { + petsDirectory: string + petsDirectoryExists: boolean + configPath: string + selectedAvatarId: string | null + selectedPetId: string | null + selectedPetName: string | null + selectedSource: "custom" | "builtin-or-unknown" | "none" + pets: CodexPetDefinition[] + runtime: { + directory: string + packageJsonPath: string + packageJsonExists: boolean + hookScriptPath: string + hookScriptExists: boolean + reactScriptPath: string + reactScriptExists: boolean + hookLogPath: string + hookLogExists: boolean + availability: PetRuntimeAvailability + } + hook: { + configPath: string + configExists: boolean + configured: boolean + command: string | null + statusMessage: string | null + timeoutSeconds: number | null + lastDecision: PetRuntimeLogEntry | null + lastError: PetRuntimeLogEntry | null + lastSkipped: PetRuntimeLogEntry | null + } + window: { + status: "available" | "unavailable" | "unknown" + reason: string | null + } +} + +interface PetRuntimeStatusOptions { + homeDir?: string + petsDirectory?: string + configPath?: string + hooksPath?: string + fallbackRuntimeDirectory?: string +} + +type UnknownRecord = Record + +function exists(filePath: string): boolean { + try { + return fs.existsSync(filePath) + } catch { + return false + } +} + +function readText(filePath: string): string | null { + try { + return fs.readFileSync(filePath, "utf8") + } catch { + return null + } +} + +function readJson(filePath: string): unknown | null { + const text = readText(filePath) + if (!text) return null + try { + return JSON.parse(text) + } catch { + return null + } +} + +function readTail(filePath: string, maxBytes = 256 * 1024): string | null { + try { + const stats = fs.statSync(filePath) + const length = Math.min(stats.size, maxBytes) + const buffer = Buffer.alloc(length) + const fd = fs.openSync(filePath, "r") + try { + fs.readSync(fd, buffer, 0, length, stats.size - length) + } finally { + fs.closeSync(fd) + } + return buffer.toString("utf8") + } catch { + return null + } +} + +function asRecord(value: unknown): UnknownRecord | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as UnknownRecord) + : null +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null +} + +function parseSelectedAvatarId(configText: string | null): string | null { + if (!configText) return null + const match = configText.match(/^\s*selected-avatar-id\s*=\s*["']([^"']+)["']/m) + return match?.[1]?.trim() || null +} + +function resolveSelectedPetId(selectedAvatarId: string | null): string | null { + if (!selectedAvatarId) return null + return selectedAvatarId.startsWith("custom:") + ? selectedAvatarId.slice("custom:".length) + : selectedAvatarId +} + +function readPetDefinitions( + petsDirectory: string, + selectedPetId: string | null, +): CodexPetDefinition[] { + if (!exists(petsDirectory)) return [] + + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(petsDirectory, { withFileTypes: true }) + } catch { + return [] + } + + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const directory = path.join(petsDirectory, entry.name) + const petJsonPath = path.join(directory, "pet.json") + const raw = asRecord(readJson(petJsonPath)) + if (!raw) return null + + const id = asString(raw.id) ?? entry.name + const displayName = asString(raw.displayName) ?? id + const description = asString(raw.description) + const spritesheetValue = asString(raw.spritesheetPath) + const spritesheetPath = spritesheetValue + ? path.resolve(directory, spritesheetValue) + : null + + return { + id, + displayName, + description, + directory, + petJsonPath, + spritesheetPath, + spritesheetExists: Boolean(spritesheetPath && exists(spritesheetPath)), + selected: selectedPetId === id, + } satisfies CodexPetDefinition + }) + .filter((pet): pet is CodexPetDefinition => Boolean(pet)) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) +} + +function parseHookScriptPath(command: string | null): string | null { + if (!command) return null + const match = command.match(/\bnode\s+(['"]?)(.+?hook\.mjs)\1(?:\s|$)/) + return match?.[2] ? path.resolve(match[2]) : null +} + +function findStopHook(hooksConfig: unknown): { + command: string | null + statusMessage: string | null + timeoutSeconds: number | null +} { + const root = asRecord(hooksConfig) + const hooks = asRecord(root?.hooks) + const stopHooks = Array.isArray(hooks?.Stop) ? hooks.Stop : [] + + for (const groupValue of stopHooks) { + const group = asRecord(groupValue) + const hookEntries = Array.isArray(group?.hooks) ? group.hooks : [] + for (const hookValue of hookEntries) { + const hook = asRecord(hookValue) + const command = asString(hook?.command) + if (!command) continue + if (!command.includes("codex-official-pet-runtime")) continue + return { + command, + statusMessage: asString(hook?.statusMessage), + timeoutSeconds: asNumber(hook?.timeout), + } + } + } + + return { + command: null, + statusMessage: null, + timeoutSeconds: null, + } +} + +function normalizeDecisionLogEntry(value: UnknownRecord): PetRuntimeLogEntry { + const decision = asRecord(value.decision) + return { + at: asString(value.at), + event: asString(value.event) ?? undefined, + intent: asString(decision?.intent) ?? undefined, + reason: asString(decision?.reason) ?? undefined, + shouldReact: + typeof decision?.shouldReact === "boolean" + ? decision.shouldReact + : undefined, + } +} + +function normalizeErrorLogEntry(value: UnknownRecord): PetRuntimeLogEntry { + const error = asRecord(value.error) + return { + at: asString(value.at), + code: asString(error?.code) ?? undefined, + message: asString(error?.message) ?? undefined, + } +} + +function normalizeSkippedLogEntry(value: UnknownRecord): PetRuntimeLogEntry { + return { + at: asString(value.at), + skipped: asString(value.skipped) ?? undefined, + } +} + +function parseHookLog(hookLogPath: string): { + lastDecision: PetRuntimeLogEntry | null + lastError: PetRuntimeLogEntry | null + lastSkipped: PetRuntimeLogEntry | null +} { + const text = readTail(hookLogPath) + if (!text) { + return { + lastDecision: null, + lastError: null, + lastSkipped: null, + } + } + + let lastDecision: PetRuntimeLogEntry | null = null + let lastError: PetRuntimeLogEntry | null = null + let lastSkipped: PetRuntimeLogEntry | null = null + + for (const line of text.split(/\r?\n/)) { + if (!line.trim()) continue + try { + const parsed = asRecord(JSON.parse(line)) + if (!parsed) continue + if (asRecord(parsed.decision)) { + lastDecision = normalizeDecisionLogEntry(parsed) + } + if (asRecord(parsed.error)) { + lastError = normalizeErrorLogEntry(parsed) + } + if (asString(parsed.skipped)) { + lastSkipped = normalizeSkippedLogEntry(parsed) + } + } catch { + continue + } + } + + return { + lastDecision, + lastError, + lastSkipped, + } +} + +function resolveRuntimeDirectory( + hookScriptPath: string | null, + fallbackRuntimeDirectory: string, +): string { + if (hookScriptPath) return path.dirname(path.dirname(hookScriptPath)) + return fallbackRuntimeDirectory +} + +function computeAvailability(input: { + hookConfigured: boolean + hookScriptExists: boolean + packageJsonExists: boolean + reactScriptExists: boolean + lastError: PetRuntimeLogEntry | null +}): PetRuntimeAvailability { + if (!input.hookConfigured) return "not-configured" + if (!input.hookScriptExists) return "missing-hook" + if (!input.packageJsonExists || !input.reactScriptExists) { + return "missing-runtime" + } + if (input.lastError?.code === "PET_WINDOW_NOT_FOUND") { + return "window-unavailable" + } + return "ready" +} + +export function readPetRuntimeStatus( + options: PetRuntimeStatusOptions = {}, +): PetRuntimeStatus { + const homeDir = options.homeDir ?? os.homedir() + const petsDirectory = + options.petsDirectory ?? path.join(homeDir, ".codex", "pets") + const configPath = options.configPath ?? path.join(homeDir, ".codex", "config.toml") + const hooksPath = options.hooksPath ?? path.join(homeDir, ".codex", "hooks.json") + const fallbackRuntimeDirectory = + options.fallbackRuntimeDirectory ?? + path.join(homeDir, "Codex", "tools", "codex-official-pet-runtime") + + const selectedAvatarId = parseSelectedAvatarId(readText(configPath)) + const selectedPetId = resolveSelectedPetId(selectedAvatarId) + const pets = readPetDefinitions(petsDirectory, selectedPetId) + const selectedPet = pets.find((pet) => pet.selected) ?? null + const selectedPetName = selectedPet?.displayName ?? selectedAvatarId ?? null + const selectedSource: PetRuntimeStatus["selectedSource"] = selectedPet + ? "custom" + : selectedAvatarId + ? "builtin-or-unknown" + : "none" + + const hookInfo = findStopHook(readJson(hooksPath)) + const hookScriptPath = parseHookScriptPath(hookInfo.command) + const runtimeDirectory = resolveRuntimeDirectory( + hookScriptPath, + fallbackRuntimeDirectory, + ) + const packageJsonPath = path.join(runtimeDirectory, "package.json") + const reactScriptPath = path.join(runtimeDirectory, "src", "react.mjs") + const resolvedHookScriptPath = + hookScriptPath ?? path.join(runtimeDirectory, "src", "hook.mjs") + const hookLogPath = path.join(runtimeDirectory, "hook.log") + const hookLog = parseHookLog(hookLogPath) + + const hookConfigured = Boolean(hookInfo.command && hookScriptPath) + const hookScriptExists = exists(resolvedHookScriptPath) + const packageJsonExists = exists(packageJsonPath) + const reactScriptExists = exists(reactScriptPath) + const availability = computeAvailability({ + hookConfigured, + hookScriptExists, + packageJsonExists, + reactScriptExists, + lastError: hookLog.lastError, + }) + + return { + petsDirectory, + petsDirectoryExists: exists(petsDirectory), + configPath, + selectedAvatarId, + selectedPetId, + selectedPetName, + selectedSource, + pets, + runtime: { + directory: runtimeDirectory, + packageJsonPath, + packageJsonExists, + hookScriptPath: resolvedHookScriptPath, + hookScriptExists, + reactScriptPath, + reactScriptExists, + hookLogPath, + hookLogExists: exists(hookLogPath), + availability, + }, + hook: { + configPath: hooksPath, + configExists: exists(hooksPath), + configured: hookConfigured, + command: hookInfo.command, + statusMessage: hookInfo.statusMessage, + timeoutSeconds: hookInfo.timeoutSeconds, + lastDecision: hookLog.lastDecision, + lastError: hookLog.lastError, + lastSkipped: hookLog.lastSkipped, + }, + window: { + status: + hookLog.lastError?.code === "PET_WINDOW_NOT_FOUND" + ? "unavailable" + : "unknown", + reason: hookLog.lastError?.message ?? null, + }, + } +} 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..5b12316d1 --- /dev/null +++ b/src/main/lib/shared-resources/governance.ts @@ -0,0 +1,722 @@ +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, + SharedResourceRuntimeGate, + SharedResourceRuntimeGateIssue, + 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, + } +} + +function runtimeGateIssue( + issue: Omit, +): SharedResourceRuntimeGateIssue { + const anchor = + issue.resourceId ?? issue.conflictKey ?? issue.kind + return { + id: `${issue.kind}:${anchor}`, + ...issue, + } +} + +function resourceIsConflictLoser(resource: SharedResource): boolean { + return Boolean( + resource.conflict && + resource.conflict.resolution === "winner-by-precedence" && + resource.conflict.winnerResourceId !== resource.id, + ) +} + +function resourceRequiresPendingApproval(resource: SharedResource): boolean { + return Boolean(resource.approval?.required && !resource.approval.approved) +} + +function mappingForResource( + projection: EngineResourceProjection | undefined, + resource: SharedResource, +): ResourcePathMapping | undefined { + return projection?.mappings.find((mapping) => mapping.resourceId === resource.id) +} + +export function buildSharedResourceRuntimeGate(params: { + engineId: AgentEngineId + snapshot: Pick +}): SharedResourceRuntimeGate { + const projection = params.snapshot.projections.find( + (item) => item.engineId === params.engineId, + ) + const blockers: SharedResourceRuntimeGateIssue[] = [] + const warnings: SharedResourceRuntimeGateIssue[] = [] + + if (!projection) { + blockers.push(runtimeGateIssue({ + kind: "missing-projection", + severity: "blocker", + message: `${params.engineId} has no shared-resource projection.`, + })) + } else if (projection.status === "unsupported") { + blockers.push(runtimeGateIssue({ + kind: "unsupported-projection", + severity: "blocker", + message: `${params.engineId} cannot launch with shared-resource projection status unsupported.`, + })) + } else if (projection.status === "partial") { + warnings.push(runtimeGateIssue({ + kind: "partial-projection", + severity: "warning", + message: `${params.engineId} starts with explicit projection warnings: ${projection.warnings.join(" ")}`, + })) + } + + for (const conflict of params.snapshot.conflicts) { + if (conflict.resolution !== "manual-review") continue + blockers.push(runtimeGateIssue({ + kind: "manual-review-conflict", + severity: "blocker", + message: `${conflict.name} requires manual resource conflict review before launch.`, + conflictKey: conflict.key, + })) + } + + let pendingApprovalCount = 0 + let shadowedResourceCount = 0 + let unsafeProjectedResourceCount = 0 + + for (const resource of params.snapshot.resources) { + const mapping = mappingForResource(projection, resource) + const pendingApproval = resourceRequiresPendingApproval(resource) + const shadowed = resourceIsConflictLoser(resource) + + if (pendingApproval) { + pendingApprovalCount += 1 + if (mapping) { + unsafeProjectedResourceCount += 1 + blockers.push(runtimeGateIssue({ + kind: "unsafe-projected-resource", + severity: "blocker", + message: `${resource.name} is pending approval but is still projected to ${params.engineId}.`, + resourceId: resource.id, + })) + } else { + warnings.push(runtimeGateIssue({ + kind: "withheld-approval", + severity: "warning", + message: `${resource.name} is withheld until approval is granted.`, + resourceId: resource.id, + })) + } + } + + if (shadowed) { + shadowedResourceCount += 1 + if (mapping) { + unsafeProjectedResourceCount += 1 + blockers.push(runtimeGateIssue({ + kind: "unsafe-projected-resource", + severity: "blocker", + message: `${resource.name} is shadowed by a higher precedence resource but is still projected to ${params.engineId}.`, + resourceId: resource.id, + })) + } else { + warnings.push(runtimeGateIssue({ + kind: "shadowed-resource", + severity: "warning", + message: `${resource.name} is shadowed and withheld from ${params.engineId}.`, + resourceId: resource.id, + })) + } + } + } + + return { + version: 1, + engineId: params.engineId, + status: blockers.length === 0 ? "passed" : "blocked", + resourceCount: params.snapshot.resources.length, + projectionStatus: projection?.status, + mappingCount: projection?.mappings.length ?? 0, + blockers, + warnings, + summary: { + conflictCount: params.snapshot.conflicts.length, + manualReviewConflictCount: params.snapshot.conflicts.filter( + (conflict) => conflict.resolution === "manual-review", + ).length, + pendingApprovalCount, + shadowedResourceCount, + unsafeProjectedResourceCount, + }, + } +} diff --git a/src/main/lib/shared-resources/index.ts b/src/main/lib/shared-resources/index.ts new file mode 100644 index 000000000..0dd11e1ea --- /dev/null +++ b/src/main/lib/shared-resources/index.ts @@ -0,0 +1,3 @@ +export * from "./types" +export * from "./registry" +export * from "./governance" 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..fb5372d2a --- /dev/null +++ b/src/main/lib/shared-resources/types.ts @@ -0,0 +1,134 @@ +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[] +} + +export type SharedResourceRuntimeGateStatus = "passed" | "blocked" + +export type SharedResourceRuntimeGateIssueKind = + | "missing-projection" + | "unsupported-projection" + | "manual-review-conflict" + | "unsafe-projected-resource" + | "withheld-approval" + | "shadowed-resource" + | "partial-projection" + +export interface SharedResourceRuntimeGateIssue { + id: string + kind: SharedResourceRuntimeGateIssueKind + severity: "blocker" | "warning" + message: string + resourceId?: string + conflictKey?: string +} + +export interface SharedResourceRuntimeGate { + version: 1 + engineId: AgentEngineId + status: SharedResourceRuntimeGateStatus + resourceCount: number + projectionStatus?: EngineResourceProjection["status"] + mappingCount: number + blockers: SharedResourceRuntimeGateIssue[] + warnings: SharedResourceRuntimeGateIssue[] + summary: { + conflictCount: number + manualReviewConflictCount: number + pendingApprovalCount: number + shadowedResourceCount: number + unsafeProjectedResourceCount: number + } +} 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..6f2cd4633 --- /dev/null +++ b/src/main/lib/trpc/routers/agent-runtime.ts @@ -0,0 +1,2640 @@ +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, + buildAgentRuntimeLaunchPlan, + buildHermesNativeSessionBridgePlan, + getMossSessionControlPlane, + getAgentRuntimeAdapter, + getAgentRuntimeManifest, + listAgentRuntimeManifests, + mergeMossSessionControlMetadata, + persistAgentRuntimeSession, + countLocalTranscriptUserTurns, + summarizeNativeThreadReadResult, + type AgentEngineId, + type AgentPermissionMode, + type AgentRuntimeExternalAgentConfigMigrationItem, + type AgentRuntimeSessionRef, + type AgentRuntimeMcpServerStatusDetail, + type AgentRuntimePluginMarketplaceKind, + type AgentRuntimeThreadControlAction, + type AgentRuntimeThreadGoalStatus, + type NativeThreadReadSummary, +} 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(), +}); +const runtimeModelSelectionSchema = z.object({ + instanceId: z.string().min(1), + modelId: z.string().min(1), + options: z.record(z.string(), z.unknown()).optional(), +}); +const nativeThreadSortDirectionSchema = z.enum(["asc", "desc"]); +const nativeThreadSortKeySchema = z.enum(["created_at", "updated_at"]); +const nativeThreadControlActionSchema = z.enum([ + "archive", + "unarchive", + "delete", +]); +const nativeMcpServerStatusDetailSchema = z.enum(["full", "toolsAndAuthOnly"]); +const nativeConfigWriteMergeStrategySchema = z.enum(["replace", "upsert"]); +const nativeConfigWriteValueSchema = z + .unknown() + .refine( + (value) => value !== undefined, + "Native config write value is required.", + ); +const nativeAccountLoginTypeSchema = z.enum([ + "apiKey", + "chatgpt", + "chatgptDeviceCode", + "chatgptAuthTokens", +]); +const nativePluginMarketplaceKindSchema = z.enum([ + "local", + "vertical", + "workspace-directory", + "shared-with-me", +]); +const nativeExternalAgentConfigMigrationItemTypeSchema = z.enum([ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "SESSIONS", +]); +const nativeExternalAgentConfigMigrationDetailsSchema = z.object({ + commands: z.array(z.object({ name: z.string().min(1) })).optional(), + hooks: z.array(z.object({ name: z.string().min(1) })).optional(), + mcpServers: z.array(z.object({ name: z.string().min(1) })).optional(), + plugins: z + .array( + z.object({ + marketplaceName: z.string().min(1), + pluginNames: z.array(z.string().min(1)), + }), + ) + .optional(), + sessions: z + .array( + z.object({ + cwd: z.string().min(1), + path: z.string().min(1), + title: z.string().nullable().optional(), + }), + ) + .optional(), + subagents: z.array(z.object({ name: z.string().min(1) })).optional(), +}); +const nativeExternalAgentConfigMigrationItemSchema = z.object({ + cwd: z.string().nullable().optional(), + description: z.string().min(1), + details: nativeExternalAgentConfigMigrationDetailsSchema + .nullable() + .optional(), + itemType: nativeExternalAgentConfigMigrationItemTypeSchema, +}); +const nativeThreadGoalStatusSchema = z.enum([ + "active", + "paused", + "blocked", + "usageLimited", + "budgetLimited", + "complete", +]); +const nativeThreadGitInfoPatchSchema = z.object({ + branch: z.string().nullable().optional(), + originUrl: z.string().nullable().optional(), + sha: z.string().nullable().optional(), +}); +const nativeThreadTurnDiffInputSchema = z + .object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + threadId: z.string().min(1), + fromTurnCount: z.number().int().nonnegative(), + toTurnCount: z.number().int().nonnegative(), + ignoreWhitespace: z.boolean().optional(), + }) + .refine((value) => value.fromTurnCount <= value.toTurnCount, { + message: "fromTurnCount must be less than or equal to toTurnCount.", + path: ["toTurnCount"], + }); +const nativeThreadFullDiffInputSchema = z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + threadId: z.string().min(1), + toTurnCount: z.number().int().nonnegative(), + ignoreWhitespace: z.boolean().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 parseRuntimeModelSelection( + value: unknown, +): z.infer | null { + const parsed = runtimeModelSelectionSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +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(); +} + +function summarizeNativeThreadControlResult(result: { + status: string; + sourceThreadId?: string | null; + threadId?: string | null; + numTurns?: number; + message?: string; + updatedAt: string; + metadata?: Record; +}): Record { + return { + status: result.status, + ...(result.sourceThreadId !== undefined + ? { sourceThreadId: result.sourceThreadId ?? null } + : {}), + threadId: result.threadId ?? null, + ...(typeof result.numTurns === "number" + ? { numTurns: result.numTurns } + : {}), + ...(result.message ? { message: result.message } : {}), + updatedAt: result.updatedAt, + ...(result.metadata ? { metadata: result.metadata } : {}), + }; +} + +function buildRuntimeSessionRefForSubChat(params: { + subChat: { + id: string; + chatId: string; + engineConfigDir: string | null; + modelId: string | null; + mode: string | null; + runtimeMetadata: string | null; + }; + engine: AgentEngineId; + nativeSessionId: string | null; + projectPath: string; +}): AgentRuntimeSessionRef { + const metadata = parseRuntimeMetadata(params.subChat.runtimeMetadata); + const modelSelection = parseRuntimeModelSelection(metadata.modelSelection); + const providerInstanceId = + typeof metadata.providerInstanceId === "string" + ? (cleanString(metadata.providerInstanceId) ?? null) + : (modelSelection?.instanceId ?? null); + + return { + subChatId: params.subChat.id, + chatId: params.subChat.chatId, + engineId: params.engine, + providerInstanceId, + nativeSessionId: params.nativeSessionId, + modelId: params.subChat.modelId, + modelSelection, + permissionMode: resolveSubChatPermissionMode(params.subChat), + cwd: params.projectPath, + projectPath: params.projectPath, + runtimeConfigDir: params.subChat.engineConfigDir, + metadata, + }; +} + +function buildRuntimeSessionRefForProject(params: { + engine: AgentEngineId; + projectPath: string; + permissionMode?: AgentPermissionMode; +}): AgentRuntimeSessionRef { + return { + subChatId: "", + chatId: "", + engineId: params.engine, + nativeSessionId: null, + permissionMode: params.permissionMode ?? "agent", + cwd: params.projectPath, + projectPath: params.projectPath, + metadata: {}, + }; +} + +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, + providerInstances: + healthByEngine[manifest.id]?.providerInstances ?? + manifest.providerInstances, + version: healthByEngine[manifest.id]?.version ?? manifest.version, + versionAdvisory: + healthByEngine[manifest.id]?.versionAdvisory ?? + manifest.versionAdvisory, + updateState: + healthByEngine[manifest.id]?.updateState ?? manifest.updateState, + })); + }), + + 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); + const modelSelection = parseRuntimeModelSelection( + metadata.modelSelection, + ); + const providerInstanceId = + typeof metadata.providerInstanceId === "string" + ? (cleanString(metadata.providerInstanceId) ?? null) + : (modelSelection?.instanceId ?? null); + + return { + subChatId: subChat.id, + chatId: subChat.chatId, + engine: subChat.engine as AgentEngineId, + legacySessionId: subChat.sessionId, + nativeSessionId: subChat.engineSessionId, + configDir: subChat.engineConfigDir, + modelId: subChat.modelId, + providerInstanceId, + modelSelection, + 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), + + refreshNativeThread: publicProcedure + .input( + z.object({ + subChatId: z.string(), + includeTurns: z.boolean().optional(), + }), + ) + .mutation(async ({ input }) => { + const { subChat, engine } = buildActionPlanForSubChat(input.subChatId); + const nativeSessionId = + subChat.engineSessionId ?? + (engine === "claude-code" ? subChat.sessionId : null); + if (!nativeSessionId) { + throw new Error( + "A native session id is required to refresh the thread.", + ); + } + + const adapter = getAgentRuntimeAdapter(engine); + if (!adapter.readThread) { + throw new Error(`${engine} does not support native thread refresh.`); + } + + const includeTurns = input.includeTurns ?? true; + const projectPath = getProjectPathForSubChat(input.subChatId); + const nativeResult = await adapter.readThread({ + includeTurns, + session: buildRuntimeSessionRefForSubChat({ + subChat, + engine, + nativeSessionId, + projectPath, + }), + }); + const nativeThreadRead = summarizeNativeThreadReadResult(nativeResult, { + includeTurns, + localMessages: subChat.messages, + }); + const nativeBridgePlan = { + engine, + action: "read", + bridge: nativeResult.metadata?.bridge ?? "native-thread", + method: nativeResult.metadata?.method ?? "thread/read", + sessionId: nativeSessionId, + cwd: projectPath, + includeTurns, + canRunHeadless: true, + }; + const updated = updateSessionControlMetadata({ + subChatId: input.subChatId, + runtimeMetadata: subChat.runtimeMetadata, + metadata: { + action: "refresh-native-thread", + mode: "native", + nativeSessionLinked: true, + nativeThreadRead, + nativeBridgePlan, + }, + }); + + return { + success: nativeResult.status === "success", + action: "refresh-native-thread" as const, + status: nativeResult.status, + nativeThreadRead, + nativeBridgePlan, + subChat: updated, + }; + }), + + listNativeThreads: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + archived: z.boolean().nullable().optional(), + cursor: z.string().nullable().optional(), + cwd: z + .union([z.string(), z.array(z.string())]) + .nullable() + .optional(), + limit: z.number().int().nonnegative().nullable().optional(), + modelProviders: z.array(z.string()).nullable().optional(), + searchTerm: z.string().nullable().optional(), + sortDirection: nativeThreadSortDirectionSchema.nullable().optional(), + sortKey: nativeThreadSortKeySchema.nullable().optional(), + sourceKinds: z.array(z.string()).nullable().optional(), + useStateDbOnly: z.boolean().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listThreads) { + throw new Error( + `${input.engine} does not support native thread listing.`, + ); + } + + const result = await adapter.listThreads({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + archived: input.archived, + cursor: input.cursor, + cwd: input.cwd === undefined ? input.projectPath : input.cwd, + limit: input.limit, + modelProviders: input.modelProviders, + searchTerm: input.searchTerm, + sortDirection: input.sortDirection, + sortKey: input.sortKey, + sourceKinds: input.sourceKinds, + useStateDbOnly: input.useStateDbOnly, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native thread listing failed.`, + ); + } + return result; + }), + + listLoadedNativeThreads: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cursor: z.string().nullable().optional(), + limit: z.number().int().nonnegative().nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listLoadedThreads) { + throw new Error( + `${input.engine} does not support loaded native thread listing.`, + ); + } + + const result = await adapter.listLoadedThreads({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cursor: input.cursor, + limit: input.limit, + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} loaded native thread listing failed.`, + ); + } + return result; + }), + + getNativeThreadTurnDiff: publicProcedure + .input(nativeThreadTurnDiffInputSchema) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.getThreadTurnDiff) { + throw new Error( + `${input.engine} does not support native thread turn diffs.`, + ); + } + + const result = await adapter.getThreadTurnDiff({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + threadId: input.threadId, + fromTurnCount: input.fromTurnCount, + toTurnCount: input.toTurnCount, + ignoreWhitespace: input.ignoreWhitespace, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native thread turn diff failed.`, + ); + } + return result; + }), + + getNativeThreadFullDiff: publicProcedure + .input(nativeThreadFullDiffInputSchema) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.getThreadFullDiff) { + throw new Error( + `${input.engine} does not support native thread full diffs.`, + ); + } + + const result = await adapter.getThreadFullDiff({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + threadId: input.threadId, + toTurnCount: input.toTurnCount, + ignoreWhitespace: input.ignoreWhitespace, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native thread full diff failed.`, + ); + } + return result; + }), + + readNativeConfig: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cwd: z.string().nullable().optional(), + includeLayers: z.boolean().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.readConfig) { + throw new Error( + `${input.engine} does not support native config reads.`, + ); + } + + const result = await adapter.readConfig({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cwd: input.cwd === undefined ? input.projectPath : input.cwd, + includeLayers: input.includeLayers, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native config read failed.`, + ); + } + return result; + }), + + readNativeConfigRequirements: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.readConfigRequirements) { + throw new Error( + `${input.engine} does not support native config requirements.`, + ); + } + + const result = await adapter.readConfigRequirements({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} native config requirements read failed.`, + ); + } + return result; + }), + + writeNativeConfigValue: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + keyPath: z.string().min(1), + value: nativeConfigWriteValueSchema, + mergeStrategy: nativeConfigWriteMergeStrategySchema.default("replace"), + filePath: z.string().nullable().optional(), + expectedVersion: z.string().nullable().optional(), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.writeConfigValue) { + throw new Error( + `${input.engine} does not support native config writes.`, + ); + } + + const result = await adapter.writeConfigValue({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + keyPath: input.keyPath, + value: input.value, + mergeStrategy: input.mergeStrategy, + filePath: input.filePath, + expectedVersion: input.expectedVersion, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native config write failed.`, + ); + } + return result; + }), + + batchWriteNativeConfig: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + edits: z + .array( + z.object({ + keyPath: z.string().min(1), + value: nativeConfigWriteValueSchema, + mergeStrategy: + nativeConfigWriteMergeStrategySchema.default("replace"), + }), + ) + .min(1), + filePath: z.string().nullable().optional(), + expectedVersion: z.string().nullable().optional(), + reloadUserConfig: z.boolean().optional(), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.batchWriteConfig) { + throw new Error( + `${input.engine} does not support native config batch writes.`, + ); + } + + const result = await adapter.batchWriteConfig({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + edits: input.edits, + filePath: input.filePath, + expectedVersion: input.expectedVersion, + reloadUserConfig: input.reloadUserConfig, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native config batch write failed.`, + ); + } + return result; + }), + + listNativePermissionProfiles: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cwd: z.string().nullable().optional(), + cursor: z.string().nullable().optional(), + limit: z.number().int().nonnegative().nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listPermissionProfiles) { + throw new Error( + `${input.engine} does not support native permission profiles.`, + ); + } + + const result = await adapter.listPermissionProfiles({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cwd: input.cwd === undefined ? input.projectPath : input.cwd, + cursor: input.cursor, + limit: input.limit, + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} native permission profile listing failed.`, + ); + } + return result; + }), + + listNativeMcpServerStatuses: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cursor: z.string().nullable().optional(), + detail: nativeMcpServerStatusDetailSchema.nullable().optional(), + limit: z.number().int().nonnegative().nullable().optional(), + threadId: z.string().nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listMcpServerStatuses) { + throw new Error( + `${input.engine} does not support native MCP status reads.`, + ); + } + + const result = await adapter.listMcpServerStatuses({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cursor: input.cursor, + detail: input.detail as + | AgentRuntimeMcpServerStatusDetail + | null + | undefined, + limit: input.limit, + threadId: input.threadId, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native MCP status read failed.`, + ); + } + return result; + }), + + reloadNativeMcpServerConfig: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.reloadMcpServerConfig) { + throw new Error( + `${input.engine} does not support native MCP config reload.`, + ); + } + + const result = await adapter.reloadMcpServerConfig({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native MCP config reload failed.`, + ); + } + return result; + }), + + listNativeSkills: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cwds: z.array(z.string().min(1)).nullable().optional(), + forceReload: z.boolean().nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listSkills) { + throw new Error( + `${input.engine} does not support native skill listing.`, + ); + } + + const result = await adapter.listSkills({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cwds: input.cwds, + forceReload: input.forceReload, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native skill listing failed.`, + ); + } + return result; + }), + + listNativeHooks: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cwds: z.array(z.string().min(1)).nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listHooks) { + throw new Error( + `${input.engine} does not support native hook listing.`, + ); + } + + const result = await adapter.listHooks({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cwds: input.cwds, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native hook listing failed.`, + ); + } + return result; + }), + + listNativeApps: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cursor: z.string().nullable().optional(), + forceRefetch: z.boolean().nullable().optional(), + limit: z.number().int().nonnegative().nullable().optional(), + threadId: z.string().nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listApps) { + throw new Error(`${input.engine} does not support native app listing.`); + } + + const result = await adapter.listApps({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cursor: input.cursor, + forceRefetch: input.forceRefetch, + limit: input.limit, + threadId: input.threadId, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native app listing failed.`, + ); + } + return result; + }), + + listNativePlugins: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cwds: z.array(z.string().min(1)).nullable().optional(), + marketplaceKinds: z + .array(nativePluginMarketplaceKindSchema) + .nullable() + .optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listPlugins) { + throw new Error( + `${input.engine} does not support native plugin listing.`, + ); + } + + const result = await adapter.listPlugins({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cwds: input.cwds, + marketplaceKinds: input.marketplaceKinds as + | AgentRuntimePluginMarketplaceKind[] + | null + | undefined, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native plugin listing failed.`, + ); + } + return result; + }), + + listNativeInstalledPlugins: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cwds: z.array(z.string().min(1)).nullable().optional(), + installSuggestionPluginNames: z + .array(z.string().min(1)) + .nullable() + .optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listInstalledPlugins) { + throw new Error( + `${input.engine} does not support native installed plugin listing.`, + ); + } + + const result = await adapter.listInstalledPlugins({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cwds: input.cwds, + installSuggestionPluginNames: input.installSuggestionPluginNames, + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} native installed plugin listing failed.`, + ); + } + return result; + }), + + readNativePlugin: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + pluginName: z.string().min(1), + marketplacePath: z.string().nullable().optional(), + remoteMarketplaceName: z.string().nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.readPlugin) { + throw new Error(`${input.engine} does not support native plugin read.`); + } + + const result = await adapter.readPlugin({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + pluginName: input.pluginName, + marketplacePath: input.marketplacePath, + remoteMarketplaceName: input.remoteMarketplaceName, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native plugin read failed.`, + ); + } + return result; + }), + + installNativePlugin: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + pluginName: z.string().min(1), + marketplacePath: z.string().nullable().optional(), + remoteMarketplaceName: z.string().nullable().optional(), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.installPlugin) { + throw new Error( + `${input.engine} does not support native plugin install.`, + ); + } + + const result = await adapter.installPlugin({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + pluginName: input.pluginName, + marketplacePath: input.marketplacePath, + remoteMarketplaceName: input.remoteMarketplaceName, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native plugin install failed.`, + ); + } + return result; + }), + + detectNativeExternalAgentConfig: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cwds: z.array(z.string().min(1)).nullable().optional(), + includeHome: z.boolean().nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.detectExternalAgentConfig) { + throw new Error( + `${input.engine} does not support native external config detection.`, + ); + } + + const result = await adapter.detectExternalAgentConfig({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cwds: input.cwds, + includeHome: input.includeHome, + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} native external config detection failed.`, + ); + } + return result; + }), + + importNativeExternalAgentConfig: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + migrationItems: z + .array(nativeExternalAgentConfigMigrationItemSchema) + .min(1), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.importExternalAgentConfig) { + throw new Error( + `${input.engine} does not support native external config import.`, + ); + } + + const result = await adapter.importExternalAgentConfig({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + migrationItems: + input.migrationItems as AgentRuntimeExternalAgentConfigMigrationItem[], + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} native external config import failed.`, + ); + } + return result; + }), + + startNativeMcpServerOauthLogin: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + name: z.string().min(1), + scopes: z.array(z.string().min(1)).nullable().optional(), + timeoutSecs: z.number().int().nonnegative().nullable().optional(), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.startMcpServerOauthLogin) { + throw new Error( + `${input.engine} does not support native MCP OAuth login.`, + ); + } + + const result = await adapter.startMcpServerOauthLogin({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + name: input.name, + scopes: input.scopes, + timeoutSecs: input.timeoutSecs, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native MCP OAuth login failed.`, + ); + } + return result; + }), + + listNativeModels: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + cursor: z.string().nullable().optional(), + includeHidden: z.boolean().nullable().optional(), + limit: z.number().int().nonnegative().nullable().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.listModels) { + throw new Error( + `${input.engine} does not support native model listing.`, + ); + } + + const result = await adapter.listModels({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + cursor: input.cursor, + includeHidden: input.includeHidden, + limit: input.limit, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native model listing failed.`, + ); + } + return result; + }), + + startNativeAccountLogin: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + type: nativeAccountLoginTypeSchema.default("chatgpt"), + apiKey: z.string().nullable().optional(), + codexStreamlinedLogin: z.boolean().optional(), + accessToken: z.string().nullable().optional(), + chatgptAccountId: z.string().nullable().optional(), + chatgptPlanType: z.string().nullable().optional(), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.startAccountLogin) { + throw new Error( + `${input.engine} does not support native account login.`, + ); + } + + const result = await adapter.startAccountLogin({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + type: input.type, + apiKey: input.apiKey, + codexStreamlinedLogin: input.codexStreamlinedLogin, + accessToken: input.accessToken, + chatgptAccountId: input.chatgptAccountId, + chatgptPlanType: input.chatgptPlanType, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native account login failed.`, + ); + } + return result; + }), + + cancelNativeAccountLogin: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + loginId: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.cancelAccountLogin) { + throw new Error( + `${input.engine} does not support native account login cancel.`, + ); + } + + const result = await adapter.cancelAccountLogin({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + loginId: input.loginId, + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} native account login cancel failed.`, + ); + } + return result; + }), + + logoutNativeAccount: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.logoutAccount) { + throw new Error( + `${input.engine} does not support native account logout.`, + ); + } + + const result = await adapter.logoutAccount({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native account logout failed.`, + ); + } + return result; + }), + + readNativeAccount: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + refreshToken: z.boolean().optional(), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.readAccount) { + throw new Error( + `${input.engine} does not support native account reads.`, + ); + } + + const result = await adapter.readAccount({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + refreshToken: input.refreshToken, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native account read failed.`, + ); + } + return result; + }), + + readNativeAccountRateLimits: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.readAccountRateLimits) { + throw new Error( + `${input.engine} does not support native account rate limits.`, + ); + } + + const result = await adapter.readAccountRateLimits({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} native account rate-limit read failed.`, + ); + } + return result; + }), + + readNativeAccountUsage: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.readAccountUsage) { + throw new Error( + `${input.engine} does not support native account usage.`, + ); + } + + const result = await adapter.readAccountUsage({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native account usage read failed.`, + ); + } + return result; + }), + + controlNativeThread: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + action: nativeThreadControlActionSchema, + threadId: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.controlThread) { + throw new Error( + `${input.engine} does not support native thread control.`, + ); + } + + const result = await adapter.controlThread({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + action: input.action as AgentRuntimeThreadControlAction, + threadId: input.threadId, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native thread control failed.`, + ); + } + return result; + }), + + setNativeThreadName: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + threadId: z.string().min(1), + name: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.setThreadName) { + throw new Error( + `${input.engine} does not support native thread name updates.`, + ); + } + + const result = await adapter.setThreadName({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + threadId: input.threadId, + name: input.name, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native thread name update failed.`, + ); + } + return result; + }), + + updateNativeThreadMetadata: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + threadId: z.string().min(1), + gitInfo: nativeThreadGitInfoPatchSchema.nullable().optional(), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.updateThreadMetadata) { + throw new Error( + `${input.engine} does not support native thread metadata updates.`, + ); + } + + const result = await adapter.updateThreadMetadata({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + threadId: input.threadId, + gitInfo: input.gitInfo, + }); + if (result.status !== "success") { + throw new Error( + result.message || + `${input.engine} native thread metadata update failed.`, + ); + } + return result; + }), + + getNativeThreadGoal: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + threadId: z.string().min(1), + }), + ) + .query(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.getThreadGoal) { + throw new Error( + `${input.engine} does not support native thread goals.`, + ); + } + + const result = await adapter.getThreadGoal({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + threadId: input.threadId, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native thread goal read failed.`, + ); + } + return result; + }), + + setNativeThreadGoal: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + threadId: z.string().min(1), + objective: z.string().nullable().optional(), + status: nativeThreadGoalStatusSchema.nullable().optional(), + tokenBudget: z.number().int().nonnegative().nullable().optional(), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.setThreadGoal) { + throw new Error( + `${input.engine} does not support native thread goal updates.`, + ); + } + + const result = await adapter.setThreadGoal({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + threadId: input.threadId, + objective: input.objective, + status: input.status as AgentRuntimeThreadGoalStatus | null | undefined, + tokenBudget: input.tokenBudget, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native thread goal update failed.`, + ); + } + return result; + }), + + clearNativeThreadGoal: publicProcedure + .input( + z.object({ + engine: agentEngineSchema.default("codex"), + projectPath: z.string().min(1), + threadId: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const adapter = getAgentRuntimeAdapter(input.engine); + if (!adapter.clearThreadGoal) { + throw new Error( + `${input.engine} does not support native thread goal clearing.`, + ); + } + + const result = await adapter.clearThreadGoal({ + session: buildRuntimeSessionRefForProject({ + engine: input.engine, + projectPath: input.projectPath, + }), + threadId: input.threadId, + }); + if (result.status !== "success") { + throw new Error( + result.message || `${input.engine} native thread goal clear failed.`, + ); + } + return result; + }), + + 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 + ? ({ + engine: "codex", + action: "resume", + sessionId: nativeSessionId, + bridge: "codex-app-server-thread", + mode: "headless-app-server", + cwd: getProjectPathForSubChat(input.subChatId), + modelId: subChat.modelId, + permissionMode, + canRunHeadless: true, + notes: ["Codex native resume uses app-server thread/resume."], + } as const) + : engine === "hermes" && nativeSessionId + ? buildHermesNativeSessionBridgePlan({ + action: "resume", + sessionId: nativeSessionId, + cwd: getProjectPathForSubChat(input.subChatId), + modelId: subChat.modelId, + permissionMode, + }) + : undefined; + const nativeBridgeRunner = + nativeBridgePlan?.bridge === "codex-app-server-thread" + ? { + kind: "codex-app-server-thread", + runner: "streamCodexAppServerRuntimeRun", + 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 projectPath = getProjectPathForSubChat(input.subChatId); + let nativeBridgePlan: unknown = + engine === "codex" && sourceNativeSessionId + ? { + engine: "codex", + action: "fork", + sessionId: sourceNativeSessionId, + bridge: "codex-app-server-thread", + mode: "headless-app-server", + cwd: projectPath, + modelId: sourceSubChat.modelId, + permissionMode, + targetMessageId: input.messageId ?? null, + targetMessageIndex: input.messageIndex ?? null, + canRunHeadless: true, + notes: [ + "Codex native fork uses app-server thread/fork.", + "When the requested fork point is not the latest turn, the forked thread is rolled back after creation.", + ], + } + : engine === "hermes" && sourceNativeSessionId + ? buildHermesNativeSessionBridgePlan({ + action: "fork", + sessionId: sourceNativeSessionId, + cwd: projectPath, + 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 nativeThreadRead: NativeThreadReadSummary | null = null; + let forkedCodexThreadId: string | null = null; + + if (nativeSessionLinked && engine === "codex" && sourceNativeSessionId) { + const adapter = getAgentRuntimeAdapter("codex"); + if (!adapter.forkThread) { + throw new Error("Codex adapter does not support native thread fork."); + } + + const runtimeSession = buildRuntimeSessionRefForSubChat({ + subChat: sourceSubChat, + engine: "codex", + nativeSessionId: sourceNativeSessionId, + projectPath, + }); + const sourceUserTurnCount = countLocalTranscriptUserTurns( + sourceSubChat.messages, + ); + const forkUserTurnCount = countLocalTranscriptUserTurns( + JSON.stringify(snapshot.messages), + ); + const nativeForkRollbackTurns = Math.max( + 0, + sourceUserTurnCount - forkUserTurnCount, + ); + + if (typeof nativeBridgePlan === "object" && nativeBridgePlan !== null) { + nativeBridgePlan = { + ...nativeBridgePlan, + nativeForkRollbackTurns, + }; + } + + const nativeForkResult = await adapter.forkThread({ + session: runtimeSession, + }); + forkedCodexThreadId = cleanString(nativeForkResult.threadId) ?? null; + if (nativeForkResult.status !== "success" || !forkedCodexThreadId) { + throw new Error( + nativeForkResult.message || "Codex native thread fork failed.", + ); + } + + const targetRuntimeSession: AgentRuntimeSessionRef = { + ...runtimeSession, + subChatId: targetSubChatId, + nativeSessionId: forkedCodexThreadId, + }; + let nativeBridgeMetadata: Record = { + targetEngineSessionId: forkedCodexThreadId, + nativeForkRollbackTurns, + nativeBridgeResult: + summarizeNativeThreadControlResult(nativeForkResult), + }; + + if (nativeForkRollbackTurns > 0) { + if (!adapter.rollbackThread) { + throw new Error( + "Codex adapter does not support native thread rollback.", + ); + } + + const nativeRollbackResult = await adapter.rollbackThread({ + numTurns: nativeForkRollbackTurns, + session: targetRuntimeSession, + }); + if (nativeRollbackResult.status !== "success") { + throw new Error( + nativeRollbackResult.message || + "Codex native fork rollback failed.", + ); + } + + nativeBridgeMetadata = { + ...nativeBridgeMetadata, + nativeForkRollbackResult: + summarizeNativeThreadControlResult(nativeRollbackResult), + }; + } + + if (adapter.readThread) { + const nativeReadResult = await adapter.readThread({ + includeTurns: true, + session: targetRuntimeSession, + }); + nativeThreadRead = summarizeNativeThreadReadResult(nativeReadResult, { + includeTurns: true, + localMessages: JSON.stringify(snapshot.messages), + }); + nativeBridgeMetadata = { + ...nativeBridgeMetadata, + nativeThreadRead, + }; + } + + forkRecord = buildMossForkSubChatRecord({ + sourceSubChat, + targetSubChatId, + targetName: forkName, + targetMessageId: input.messageId, + targetMessageIndex: input.messageIndex, + nativeBridgePlan, + metadata: nativeBridgeMetadata, + }); + snapshot = forkRecord.snapshot; + nativeSessionLinked = forkRecord.nativeSessionLinked; + } + + let newSubChat = getDatabase() + .insert(subChats) + .values({ + ...forkRecord.insertValues, + ...(forkedCodexThreadId + ? { engineSessionId: forkedCodexThreadId } + : {}), + }) + .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, + nativeThreadRead, + }; + }), + + rollbackSession: publicProcedure + .input( + z.object({ + subChatId: z.string(), + targetMessageId: z.string().optional(), + targetSdkMessageUuid: z.string().optional(), + strictTarget: z.boolean().optional(), + nativeCheckpointTurnCount: z.number().int().nonnegative().optional(), + nativeCheckpointRef: 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); + let nativeBridgePlan: unknown = + engine === "hermes" && sourceNativeSessionId + ? buildHermesNativeSessionBridgePlan({ + action: "rollback", + sessionId: sourceNativeSessionId, + cwd: getProjectPathForSubChat(input.subChatId), + modelId: subChat.modelId, + permissionMode, + targetMessageId: input.targetMessageId, + targetSdkMessageUuid: input.targetSdkMessageUuid, + }) + : undefined; + let rollbackRecord = buildMossRollbackSubChatUpdate({ + subChat, + targetMessageId: input.targetMessageId, + targetSdkMessageUuid: input.targetSdkMessageUuid, + strictTarget: input.strictTarget, + appliedGitCheckpoint: Boolean(input.applyGitCheckpoint), + nativeBridgePlan, + }); + let snapshot = rollbackRecord.snapshot; + let nativeThreadRead: NativeThreadReadSummary | null = null; + + 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."); + } + } + + if ( + engine === "codex" && + sourceNativeSessionId && + snapshot.nativeSessionLinked + ) { + const projectPath = getProjectPathForSubChat(input.subChatId); + const adapter = getAgentRuntimeAdapter("codex"); + const runtimeSession = buildRuntimeSessionRefForSubChat({ + subChat, + engine: "codex", + nativeSessionId: sourceNativeSessionId, + projectPath, + }); + const nativeCheckpointTurnCount = input.nativeCheckpointTurnCount; + const nativeCheckpointRef = input.nativeCheckpointRef?.trim() || null; + let numTurns = snapshot.nativeRollbackTurnCount; + let nativeThreadReadBeforeRollback: NativeThreadReadSummary | null = + null; + let nativeCheckpointCurrentTurnCount: number | null = null; + + if (nativeCheckpointTurnCount !== undefined) { + if (!adapter.readThread) { + throw new Error( + "Codex adapter does not support native thread reads for checkpoint rollback.", + ); + } + + const nativeReadResult = await adapter.readThread({ + includeTurns: true, + session: runtimeSession, + }); + nativeThreadReadBeforeRollback = summarizeNativeThreadReadResult( + nativeReadResult, + { + includeTurns: true, + localMessages: JSON.stringify(snapshot.messages), + }, + ); + + if (nativeReadResult.status !== "success") { + throw new Error( + nativeThreadReadBeforeRollback.message || + nativeReadResult.message || + "Codex native thread read failed before checkpoint rollback.", + ); + } + + if (typeof nativeThreadReadBeforeRollback.turnCount !== "number") { + throw new Error( + "Codex native thread read did not include a turn count for checkpoint rollback.", + ); + } + + nativeCheckpointCurrentTurnCount = + nativeThreadReadBeforeRollback.turnCount; + if (nativeCheckpointTurnCount > nativeCheckpointCurrentTurnCount) { + throw new Error( + `Checkpoint turn count (${nativeCheckpointTurnCount}) is newer than Codex native thread turn count (${nativeCheckpointCurrentTurnCount}).`, + ); + } + numTurns = + nativeCheckpointCurrentTurnCount - nativeCheckpointTurnCount; + } + + const nativeCheckpointRevertPlan = + nativeCheckpointTurnCount !== undefined + ? { + type: "thread.checkpoint.revert", + turnCount: nativeCheckpointTurnCount, + checkpointRef: nativeCheckpointRef, + currentTurnCount: nativeCheckpointCurrentTurnCount, + numTurns, + } + : null; + + nativeBridgePlan = { + engine: "codex", + action: "rollback", + bridge: "codex-app-server-thread", + mode: "headless-app-server", + sessionId: sourceNativeSessionId, + cwd: projectPath, + modelId: subChat.modelId, + permissionMode, + numTurns, + ...(nativeCheckpointRevertPlan + ? { + checkpointTurnCount: nativeCheckpointTurnCount, + checkpointRef: nativeCheckpointRef, + nativeCheckpointRevert: nativeCheckpointRevertPlan, + } + : {}), + canRunHeadless: true, + notes: [ + "Codex native rollback uses app-server thread/rollback.", + nativeCheckpointRevertPlan + ? "Rollback turns were computed from the selected native checkpoint turn count." + : "Rollback turns were computed from the Moss transcript boundary.", + numTurns > 0 + ? "The native thread will be rolled back before the Moss transcript is persisted." + : "No later Codex turns were found after the rollback target; the native thread call is skipped.", + ], + }; + + let nativeBridgeMetadata: Record = { + nativeBridgeSkipped: + numTurns <= 0 ? "No Codex turns after rollback target." : false, + ...(nativeCheckpointRevertPlan + ? { nativeCheckpointRevert: nativeCheckpointRevertPlan } + : {}), + ...(nativeThreadReadBeforeRollback + ? { nativeThreadReadBeforeRollback } + : {}), + }; + + if (numTurns > 0) { + if (!adapter.rollbackThread) { + throw new Error( + "Codex adapter does not support native thread rollback.", + ); + } + + const nativeResult = await adapter.rollbackThread({ + numTurns, + session: runtimeSession, + }); + + if (nativeResult.status !== "success") { + throw new Error( + nativeResult.message || "Codex native thread rollback failed.", + ); + } + + nativeBridgeMetadata = { + ...nativeBridgeMetadata, + nativeBridgeResult: + summarizeNativeThreadControlResult(nativeResult), + }; + + if (adapter.readThread) { + const nativeReadResult = await adapter.readThread({ + includeTurns: true, + session: runtimeSession, + }); + nativeThreadRead = summarizeNativeThreadReadResult( + nativeReadResult, + { + includeTurns: true, + localMessages: JSON.stringify(snapshot.messages), + }, + ); + nativeBridgeMetadata = { + ...nativeBridgeMetadata, + nativeThreadRead, + }; + } + } + + rollbackRecord = buildMossRollbackSubChatUpdate({ + subChat, + targetMessageId: input.targetMessageId, + targetSdkMessageUuid: input.targetSdkMessageUuid, + strictTarget: input.strictTarget, + appliedGitCheckpoint: Boolean(input.applyGitCheckpoint), + nativeBridgePlan, + metadata: nativeBridgeMetadata, + }); + snapshot = rollbackRecord.snapshot; + } + + 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, + nativeThreadRead, + }; + }), + + 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(), + providerInstanceId: z.string().nullable().optional(), + modelId: z.string().nullable().optional(), + modelSelection: runtimeModelSelectionSchema.nullable().optional(), + permissionMode: permissionModeSchema.optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }), + ) + .mutation(async ({ input }) => { + const existingSubChat = getSubChatOrThrow(input.subChatId); + const projectPath = getProjectPathForSubChat(input.subChatId); + const manifest = getAgentRuntimeManifest(input.engine); + const providerInstanceId = + input.providerInstanceId ?? input.modelSelection?.instanceId ?? null; + const metadata = { + ...(input.metadata ?? {}), + ...(providerInstanceId ? { providerInstanceId } : {}), + ...(input.modelSelection + ? { modelSelection: input.modelSelection } + : {}), + }; + const launchPlan = buildAgentRuntimeLaunchPlan({ + runId: `engine-selection:${input.subChatId}:${Date.now()}`, + session: { + subChatId: input.subChatId, + chatId: existingSubChat.chatId, + engineId: input.engine, + providerInstanceId, + nativeSessionId: input.nativeSessionId ?? null, + modelId: input.modelId ?? manifest.defaultModelId ?? null, + modelSelection: input.modelSelection ?? null, + permissionMode: + input.permissionMode ?? + resolveSubChatPermissionMode(existingSubChat), + cwd: projectPath, + projectPath, + runtimeConfigDir: + input.configDir ?? manifest.configRoots.user ?? null, + metadata, + }, + nativeSessionStrategy: input.nativeSessionId ? "resume" : "start", + transport: "settings-selection", + providerRoute: "settings-selection", + }); + + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: input.engine, + nativeSessionId: input.nativeSessionId, + configDir: input.configDir, + providerInstanceId, + modelId: input.modelId, + modelSelection: input.modelSelection, + permissionMode: input.permissionMode, + metadata, + launchPlan, + updateLegacySessionId: input.engine === "claude-code", + }); + + 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..b79d23ab4 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 @@ -22,7 +25,7 @@ export interface ParsedAgent { // Agent with source/path metadata export interface FileAgent extends ParsedAgent { - source: "user" | "project" | "plugin" + source: "moss" | "user" | "project" | "plugin" pluginName?: string path: string } @@ -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) } /** @@ -125,6 +130,7 @@ export async function loadAgent( cwd?: string ): Promise { const locations = [ + ...(cwd ? [path.join(cwd, ".moss", "subagents")] : []), path.join(os.homedir(), ".claude", "agents"), ...(cwd ? [path.join(cwd, ".claude", "agents")] : []), ] @@ -188,7 +194,7 @@ export async function loadAgent( */ export async function scanAgentsDirectory( dir: string, - source: "user" | "project" | "plugin", + source: "moss" | "user" | "project" | "plugin", basePath?: string // For project agents, the cwd to make paths relative to ): Promise { const agents: FileAgent[] = [] @@ -217,9 +223,9 @@ export async function scanAgentsDirectory( const parsed = parseAgentMd(content, entry.name) if (parsed.description && parsed.prompt) { - // For project agents, show relative path; for user agents, show ~/.claude/... path + // For project and Moss agents, show relative path; for user agents, show ~/.claude/... path let displayPath: string - if (source === "project" && basePath) { + if ((source === "project" || source === "moss") && basePath) { displayPath = path.relative(basePath, agentPath) } else { // For user agents, show ~/.claude/agents/... format diff --git a/src/main/lib/trpc/routers/agents.ts b/src/main/lib/trpc/routers/agents.ts index a2b19cedf..f665f4407 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 @@ -26,9 +27,12 @@ const listAgentsProcedure = publicProcedure const userAgentsDir = path.join(os.homedir(), ".claude", "agents") const userAgentsPromise = scanAgentsDirectory(userAgentsDir, "user") + let mossAgentsPromise = Promise.resolve([]) let projectAgentsPromise = Promise.resolve([]) if (input?.cwd) { + const mossSubagentsDir = path.join(input.cwd, ".moss", "subagents") const projectAgentsDir = path.join(input.cwd, ".claude", "agents") + mossAgentsPromise = scanAgentsDirectory(mossSubagentsDir, "moss", input.cwd) projectAgentsPromise = scanAgentsDirectory(projectAgentsDir, "project", input.cwd) } @@ -51,17 +55,40 @@ const listAgentsProcedure = publicProcedure }) // Scan all directories in parallel - const [userAgents, projectAgents, ...pluginAgentsArrays] = + const [mossAgents, userAgents, projectAgents, ...pluginAgentsArrays] = await Promise.all([ + mossAgentsPromise, userAgentsPromise, projectAgentsPromise, ...pluginAgentsPromises, ]) const pluginAgents = pluginAgentsArrays.flat() - return [...projectAgents, ...userAgents, ...pluginAgents] + return [...mossAgents, ...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 @@ -82,6 +109,14 @@ export const agentsRouter = router({ .input(z.object({ name: z.string(), cwd: z.string().optional() })) .query(async ({ input }) => { const locations = [ + ...(input.cwd + ? [ + { + dir: path.join(input.cwd, ".moss", "subagents"), + source: "moss" as const, + }, + ] + : []), { dir: path.join(os.homedir(), ".claude", "agents"), source: "user" as const, @@ -306,10 +341,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 +354,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..935597109 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,9 +64,153 @@ type WorktreeSetupFailurePayload = { projectId: string } +type WorktreeSetupEventPayload = { + kind: "create-started" | "created" | "setup-complete" | "setup-failed" | "create-failed" + chatId: string + projectId: string + clientRequestId?: string | null + projectName?: string | null + worktreePath?: string | null + branch?: string | null + baseBranch?: string | null + output?: string[] + errors?: string[] + message?: 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, +): void { + sendWorktreeSetupPayload(windowId, "worktree:setup-failed", payload) +} + +function sendWorktreeSetupEvent( + windowId: number | null, + payload: WorktreeSetupEventPayload, +): void { + sendWorktreeSetupPayload(windowId, "worktree:setup-event", payload) +} + +function sendWorktreeSetupPayload( + windowId: number | null, + channel: "worktree:setup-failed" | "worktree:setup-event", + payload: WorktreeSetupFailurePayload | WorktreeSetupEventPayload, ): void { const targets: BrowserWindow[] = [] @@ -53,7 +227,164 @@ function sendWorktreeSetupFailure( for (const window of targets) { if (window.isDestroyed()) continue - window.webContents.send("worktree:setup-failed", payload) + window.webContents.send(channel, payload) + } +} + +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), + }) } } @@ -229,6 +560,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 +643,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 +677,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 @@ -309,7 +705,9 @@ export const chatsRouter = router({ baseBranch: z.string().optional(), // Branch to base the worktree off branchType: z.enum(["local", "remote"]).optional(), // Whether baseBranch is local or remote useWorktree: z.boolean().default(true), // If false, work directly in project dir + clientRequestId: z.string().optional(), mode: z.enum(["plan", "agent"]).default("agent"), + permissionMode: permissionModeSchema.optional(), }), ) .mutation(async ({ input, ctx }) => { @@ -364,11 +762,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) @@ -388,6 +789,14 @@ export const chatsRouter = router({ "type:", input.branchType, ) + sendWorktreeSetupEvent(requestingWindowId, { + kind: "create-started", + chatId: chat.id, + projectId: project.id, + clientRequestId: input.clientRequestId ?? null, + projectName: project.name, + branch: input.baseBranch ?? null, + }) const result = await createWorktreeForChat( project.path, sanitizeProjectName(project.name), @@ -395,7 +804,38 @@ export const chatsRouter = router({ input.baseBranch, input.branchType, { + onCreated: ({ worktreePath, branch, baseBranch }) => { + sendWorktreeSetupEvent(requestingWindowId, { + kind: "created", + chatId: chat.id, + projectId: project.id, + clientRequestId: input.clientRequestId ?? null, + projectName: project.name, + worktreePath, + branch: branch ?? null, + baseBranch: baseBranch ?? input.baseBranch ?? null, + output: [`Created worktree at ${worktreePath}`], + }) + return materializeMossWorkspaceForChat({ + sourceProjectPath: project.path, + workspacePath: worktreePath, + chatId: chat.id, + }) + }, onSetupComplete: (setupResult: WorktreeSetupResult) => { + sendWorktreeSetupEvent(requestingWindowId, { + kind: setupResult.success ? "setup-complete" : "setup-failed", + chatId: chat.id, + projectId: project.id, + clientRequestId: input.clientRequestId ?? null, + projectName: project.name, + output: setupResult.output, + errors: setupResult.errors, + message: setupResult.success + ? "Worktree setup complete." + : setupResult.errors[0] || + "Worktree setup failed. Check your setup commands.", + }) if (setupResult.success) return const message = setupResult.errors[0] || @@ -426,6 +866,15 @@ export const chatsRouter = router({ } } else { console.warn(`[Worktree] Failed: ${result.error}`) + sendWorktreeSetupEvent(requestingWindowId, { + kind: "create-failed", + chatId: chat.id, + projectId: project.id, + clientRequestId: input.clientRequestId ?? null, + projectName: project.name, + message: result.error || "Worktree creation failed.", + errors: result.error ? [result.error] : [], + }) sendWorktreeSetupFailure(requestingWindowId, { kind: "create-failed", message: result.error || "Worktree creation failed.", @@ -437,6 +886,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,10 +900,16 @@ 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 = { ...chat, + clientRequestId: input.clientRequestId ?? null, worktreePath: worktreeResult.worktreePath || project.path, branch: worktreeResult.branch, baseBranch: worktreeResult.baseBranch, @@ -683,15 +1143,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 +1180,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 +1189,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 +1238,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 +1280,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 +1346,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..4832bf9c2 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,13 @@ import { } from "../../mcp-auth" import { fetchOAuthMetadata, getMcpBaseUrl } from "../../oauth" import { discoverPluginMcpServers } from "../../plugins" +import { buildAgentRuntimeLaunchPlan } from "../../agent-runtime/launch-plan" +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 +153,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 +444,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 +469,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 +517,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 +1003,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 +1035,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 +1175,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 +1457,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 +1497,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 +1925,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 +1998,7 @@ ${prompt} result: errorMessage, } as UIMessageChunk) return { - behavior: "deny", + behavior: "deny" as const, message: errorMessage, } } @@ -1945,6 +2006,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 +2023,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 +2073,84 @@ ${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, + } + const launchRunId = `claude:${input.subChatId}:${Date.now()}` + const claudeProviderRoute = isUsingMossProviderConfig + ? "moss-provider" + : finalCustomConfig + ? isUsingOllama + ? "ollama-compatible" + : "custom-api" + : "claude-code" + const buildClaudeLaunchPlan = (params: { + nativeSessionId?: string | null + resultSubtype: "running" | "success" | "error" | "cancelled" + error?: string | null + }) => + buildAgentRuntimeLaunchPlan({ + runId: launchRunId, + session: { + subChatId: input.subChatId, + chatId: input.chatId, + engineId: "claude-code", + nativeSessionId: + params.nativeSessionId ?? resumeSessionId ?? null, + modelId: resolvedModel ?? null, + permissionMode: input.mode, + cwd: input.cwd, + projectPath: input.projectPath ?? null, + runtimeConfigDir: isolatedConfigDir, + }, + nativeSessionStrategy: resumeSessionId ? "resume" : "continue", + transport: "claude-code-sdk", + providerRoute: claudeProviderRoute, + projectionStatus: "ready", + resultSubtype: params.resultSubtype, + metadata: { + offlineMode: isUsingOllama, + historyEnabled, + mcpServerCount: mcpServersFiltered + ? Object.keys(mcpServersFiltered).length + : 0, + agentCount: Object.keys(agentsOption).length, + mossProviderStatus: mossProvider.status, + usingMossProviderConfig: isUsingMossProviderConfig, + ...(params.error ? { error: params.error } : {}), + }, + }) + + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "claude-code", + nativeSessionId: resumeSessionId ?? null, + configDir: isolatedConfigDir, + modelId: resolvedModel ?? null, + permissionMode: input.mode, + metadata: { + ...runtimeMetadataBase, + resultSubtype: "running", + }, + launchPlan: buildClaudeLaunchPlan({ + nativeSessionId: resumeSessionId ?? null, + resultSubtype: "running", + }), + updateLegacySessionId: true, + }) // eslint-disable-next-line no-constant-condition while (true) { @@ -2505,7 +2650,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() @@ -2622,6 +2767,37 @@ ${prompt} } } + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "claude-code", + nativeSessionId: + metadata.sessionId ?? currentSessionId ?? resumeSessionId ?? null, + configDir: isolatedConfigDir, + modelId: resolvedModel ?? null, + permissionMode: input.mode, + metadata: { + ...runtimeMetadataBase, + resultSubtype: abortController.signal.aborted + ? "cancelled" + : "error", + sdkMessageUuid: metadata.sdkMessageUuid ?? null, + errorCategory, + error: errorContext, + }, + launchPlan: buildClaudeLaunchPlan({ + nativeSessionId: + metadata.sessionId ?? + currentSessionId ?? + resumeSessionId ?? + null, + resultSubtype: abortController.signal.aborted + ? "cancelled" + : "error", + error: errorContext, + }), + updateLegacySessionId: true, + }) + console.log( `[SD] M:END sub=${subId} reason=stream_error cat=${errorCategory} n=${chunkCount} last=${lastChunkType}`, ) @@ -2652,6 +2828,26 @@ ${prompt} console.log( `[SD] M:END sub=${subId} reason=no_response n=${chunkCount}`, ) + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "claude-code", + nativeSessionId: metadata.sessionId ?? resumeSessionId ?? null, + configDir: isolatedConfigDir, + modelId: resolvedModel ?? null, + permissionMode: input.mode, + metadata: { + ...runtimeMetadataBase, + resultSubtype: "error", + sdkMessageUuid: metadata.sdkMessageUuid ?? null, + error: "No response received from Claude", + }, + launchPlan: buildClaudeLaunchPlan({ + nativeSessionId: metadata.sessionId ?? resumeSessionId ?? null, + resultSubtype: "error", + error: "No response received from Claude", + }), + updateLegacySessionId: true, + }) safeEmit({ type: "finish" } as UIMessageChunk) safeComplete() return @@ -2669,6 +2865,28 @@ ${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, + }, + launchPlan: buildClaudeLaunchPlan({ + nativeSessionId: savedSessionId ?? null, + resultSubtype: abortController.signal.aborted + ? "cancelled" + : "success", + }), + updateLegacySessionId: true, + }) if (parts.length > 0) { const assistantMessage = { @@ -2939,7 +3157,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 +3235,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..f85e0ce40 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,52 @@ 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 { buildAgentRuntimeLaunchPlan } from "../../agent-runtime/launch-plan" +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 +69,62 @@ const imageAttachmentSchema = z.object({ filename: z.string().optional(), }) +const permissionModeSchema = z.enum([ + "plan", + "agent", + "bypass", + "read-only", + "ask-approval", + "full-access", + "custom", +]) + +const runtimeModelSelectionSchema = z.object({ + instanceId: z.string().min(1), + modelId: z.string().min(1), + options: z.record(z.string(), z.unknown()).optional(), +}) + +type RuntimeModelSelectionInput = z.infer + +function resolveRuntimeModelSelection(input: { + providerInstanceId?: string | null + modelSelection?: RuntimeModelSelectionInput | null + fallbackModelId: string +}): { + providerInstanceId: string | null + modelSelection: RuntimeModelSelectionInput | null +} { + const modelSelection = + input.modelSelection ?? + (input.providerInstanceId + ? { + instanceId: input.providerInstanceId, + modelId: input.fallbackModelId, + } + : null) + return { + providerInstanceId: + input.providerInstanceId ?? modelSelection?.instanceId ?? null, + modelSelection, + } +} + 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 +141,7 @@ type CodexLoginSession = { exitCode: number | null } -type CodexIntegrationState = +export type CodexIntegrationState = | "connected_chatgpt" | "connected_api_key" | "not_logged_in" @@ -66,6 +154,7 @@ type CodexMcpServerForSession = command: string args: string[] env: Array<{ name: string; value: string }> + cwd?: string } | { name: string @@ -80,6 +169,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 +208,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 +245,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 +285,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 +349,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 +360,141 @@ 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 + } + + return parseCodexCliVersion(`${result.stdout}\n${result.stderr}`) +} + +function getSelectedCodexCliVersion(): string | null { + try { + return getCodexCliVersion(resolveCodexCliPath()) + } catch { + return null } +} - const hint = app.isPackaged - ? "Binary is missing from bundled resources." - : "Run `bun run codex:download` to download it for local dev." +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), + ) + } - throw new Error( - `[codex] Bundled Codex CLI not found at ${binaryPath}. ${hint}`, + 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 +596,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 +737,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 +992,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 +1163,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 +1180,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 +1259,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 +1285,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 +1328,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 +1352,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 +1398,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 +1494,30 @@ function normalizeCodexIntegrationState(rawOutput: string): CodexIntegrationStat return "unknown" } +export async function getCodexIntegrationStatus(): Promise<{ + state: CodexIntegrationState + isConnected: boolean + rawOutput: string + exitCode: number | null + version: string | 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, + version: getSelectedCodexCliVersion(), + } +} + function parseStoredMessages(raw: string | null | undefined): any[] { if (!raw) return [] try { @@ -1100,7 +1571,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 +1582,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 +1618,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 +1828,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 +1847,7 @@ function getOrCreateProvider(params: { provider, cwd: params.cwd, authFingerprint, + mossProviderFingerprint, mcpFingerprint: params.mcpFingerprint, }) @@ -1286,21 +1864,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 +1905,7 @@ export const codexRouter = router({ return toLoginSessionResponse(existingSession) } - const codexCliPath = resolveBundledCodexCliPath() + const codexCliPath = resolveCodexCliPath() const sessionId = crypto.randomUUID() const child = spawn(codexCliPath, ["login"], { @@ -1563,21 +2127,28 @@ export const codexRouter = router({ runId: z.string(), prompt: z.string(), model: z.string().optional(), + providerInstanceId: z.string().min(1).optional(), + modelSelection: runtimeModelSelectionSchema.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(), 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 +2165,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,14 +2201,67 @@ 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 + const { + providerInstanceId, + modelSelection: runtimeModelSelection, + } = resolveRuntimeModelSelection({ + providerInstanceId: input.providerInstanceId ?? null, + modelSelection: input.modelSelection ?? null, + fallbackModelId: selectedModelId, + }) const lastMessage = existingMessages[existingMessages.length - 1] const isDuplicatePrompt = @@ -1663,15 +2289,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 +2375,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 +2402,905 @@ 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) } + const providerRoute = input.authConfig + ? `api-key:${input.authConfig.authMethodId ?? "codex-api-key"}` + : effectiveAuthConfig + ? "moss-provider" + : "codex-cli" + const buildCodexLaunchPlan = (params: { + nativeSessionId?: string | null + nativeSessionStrategy?: "start" | "resume" + transport: string + resultSubtype: "running" | "success" | "error" | "cancelled" + nativeBridge?: string | null + error?: string | null + }) => + buildAgentRuntimeLaunchPlan({ + runId: input.runId, + session: { + subChatId: input.subChatId, + chatId: input.chatId, + engineId: "codex", + providerInstanceId, + nativeSessionId: params.nativeSessionId ?? null, + modelId: metadataModel, + modelSelection: runtimeModelSelection, + permissionMode: runtimePermissionMode, + cwd: input.cwd, + projectPath: input.projectPath ?? null, + runtimeConfigDir: join(homedir(), ".codex"), + }, + nativeSessionStrategy: params.nativeSessionStrategy, + transport: params.transport, + providerRoute, + mcpFingerprint: mcpSnapshot.fingerprint, + resultSubtype: params.resultSubtype, + metadata: { + mossProviderStatus: mossProvider.status, + mossProviderId: mossProvider.providerId ?? null, + forceNewSession: Boolean(input.forceNewSession), + ...(params.nativeBridge + ? { nativeBridge: params.nativeBridge } + : {}), + ...(params.error ? { error: params.error } : {}), + }, + }) + + 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"), + providerInstanceId, + modelId: metadataModel, + modelSelection: runtimeModelSelection, + 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 } : {}), + }, + launchPlan: buildCodexLaunchPlan({ + nativeSessionId: latestNativeSessionId, + nativeSessionStrategy: existingRunSessionId ? "resume" : "start", + transport: "codex-native-exec", + nativeBridge: options?.nativeBridge ?? runningNativeBridge, + resultSubtype, + error: options?.error ?? null, + }), + }) + 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"), + providerInstanceId, + modelId: metadataModel, + modelSelection: runtimeModelSelection, + 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, + }, + launchPlan: buildCodexLaunchPlan({ + nativeSessionId: + provider.getSessionId() || latestSessionId || null, + nativeSessionStrategy: existingRunSessionId ? "resume" : "start", + transport: "codex-acp", + resultSubtype, + }), + }) + + return true + } + + persistCodexRuntimeSession() const resolveUsageOnce = (): Promise => { if (usagePromise) return usagePromise @@ -1780,6 +3335,7 @@ export const codexRouter = router({ const sessionId = provider.getSessionId() || undefined if (sessionId) { latestSessionId = sessionId + latestKnownCodexSessionId = sessionId } if (part.type === "finish") { @@ -1816,7 +3372,9 @@ export const codexRouter = router({ cleanAssistantMessageForPersistence(responseWithUsage) if (!cleanedResponseMessage) { - persistSubChatMessages(messagesForStream) + if (persistSubChatMessages(messagesForStream)) { + persistCodexRuntimeSession() + } return } @@ -1827,9 +3385,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 +3434,63 @@ export const codexRouter = router({ safeEmit({ type: "finish" }) } + persistSubChatStreamId(null) safeComplete() } catch (error) { const normalized = extractCodexError(error) + const { + providerInstanceId: errorProviderInstanceId, + modelSelection: errorRuntimeModelSelection, + } = resolveRuntimeModelSelection({ + providerInstanceId: input.providerInstanceId ?? null, + modelSelection: input.modelSelection ?? null, + fallbackModelId: input.model ?? DEFAULT_CODEX_MODEL, + }) console.error("[codex] chat stream error:", error) + try { + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "codex", + nativeSessionId: latestKnownCodexSessionId, + configDir: join(homedir(), ".codex"), + providerInstanceId: errorProviderInstanceId, + modelId: input.model ?? DEFAULT_CODEX_MODEL, + modelSelection: errorRuntimeModelSelection, + permissionMode: runtimePermissionMode, + metadata: { + cwd: input.cwd, + projectPath: input.projectPath ?? null, + resultSubtype: "error", + error: normalized.message, + }, + launchPlan: buildAgentRuntimeLaunchPlan({ + runId: input.runId, + session: { + subChatId: input.subChatId, + chatId: input.chatId, + engineId: "codex", + providerInstanceId: errorProviderInstanceId, + nativeSessionId: latestKnownCodexSessionId, + modelId: input.model ?? DEFAULT_CODEX_MODEL, + modelSelection: errorRuntimeModelSelection, + permissionMode: runtimePermissionMode, + cwd: input.cwd, + projectPath: input.projectPath ?? null, + runtimeConfigDir: join(homedir(), ".codex"), + }, + nativeSessionStrategy: latestKnownCodexSessionId + ? "resume" + : "start", + transport: "codex-chat", + providerRoute: input.authConfig ? "api-key" : "unknown", + resultSubtype: "error", + metadata: { error: normalized.message }, + }), + }) + } catch { + // Best-effort runtime index update only. + } if (isCodexAuthError(normalized)) { safeEmit({ type: "auth-error", errorText: normalized.message }) } else { @@ -1893,6 +3506,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..6ee11bd12 --- /dev/null +++ b/src/main/lib/trpc/routers/hermes.ts @@ -0,0 +1,917 @@ +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 { buildAgentRuntimeLaunchPlan } from "../../agent-runtime/launch-plan" +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", +]) + +const runtimeModelSelectionSchema = z.object({ + instanceId: z.string().min(1), + modelId: z.string().min(1), + options: z.record(z.string(), z.unknown()).optional(), +}) + +type RuntimeModelSelectionInput = z.infer + +function resolveRuntimeModelSelection(input: { + providerInstanceId?: string | null + modelSelection?: RuntimeModelSelectionInput | null + fallbackModelId: string +}): { + providerInstanceId: string | null + modelSelection: RuntimeModelSelectionInput | null +} { + const modelSelection = + input.modelSelection ?? + (input.providerInstanceId + ? { + instanceId: input.providerInstanceId, + modelId: input.fallbackModelId, + } + : null) + return { + providerInstanceId: + input.providerInstanceId ?? modelSelection?.instanceId ?? null, + modelSelection, + } +} + +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(), + providerInstanceId: z.string().min(1).optional(), + modelSelection: runtimeModelSelectionSchema.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 { + providerInstanceId, + modelSelection: runtimeModelSelection, + } = resolveRuntimeModelSelection({ + providerInstanceId: input.providerInstanceId ?? null, + modelSelection: input.modelSelection ?? null, + fallbackModelId: 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"), + providerInstanceId, + modelId: metadataModel, + modelSelection: runtimeModelSelection, + 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, + }, + launchPlan: buildAgentRuntimeLaunchPlan({ + runId: input.runId, + session: { + subChatId: input.subChatId, + chatId: input.chatId, + engineId: "hermes", + providerInstanceId, + nativeSessionId: + provider.getSessionId() || latestSessionId || null, + modelId: metadataModel, + modelSelection: runtimeModelSelection, + permissionMode: runtimePermissionMode, + cwd: input.cwd, + projectPath: input.projectPath ?? null, + runtimeConfigDir: join(homedir(), ".hermes"), + }, + nativeSessionStrategy: existingRunSessionId ? "resume" : "start", + transport: "hermes-acp", + providerRoute: + mossProvider.status === "resolved" + ? "moss-provider" + : "hermes-default", + projectionStatus: + mossRuntimeContext.status === "ready" ? "ready" : "partial", + runtimeContextFingerprint: mossRuntimeContext.fingerprint, + resultSubtype, + metadata: { + mossProviderStatus: mossProvider.status, + mossHookStatus: mossHookSummary.status, + }, + }), + }) + + 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) + const { + providerInstanceId: errorProviderInstanceId, + modelSelection: errorRuntimeModelSelection, + } = resolveRuntimeModelSelection({ + providerInstanceId: input.providerInstanceId ?? null, + modelSelection: input.modelSelection ?? null, + fallbackModelId: input.model ?? DEFAULT_HERMES_MODEL, + }) + + console.error("[hermes] chat stream error:", error) + try { + persistAgentRuntimeSession({ + subChatId: input.subChatId, + engine: "hermes", + nativeSessionId: null, + configDir: join(homedir(), ".hermes"), + providerInstanceId: errorProviderInstanceId, + modelId: input.model ?? DEFAULT_HERMES_MODEL, + modelSelection: errorRuntimeModelSelection, + permissionMode: runtimePermissionMode, + metadata: { + cwd: input.cwd, + projectPath: input.projectPath ?? null, + resultSubtype: "error", + error: normalized.message, + }, + launchPlan: buildAgentRuntimeLaunchPlan({ + runId: input.runId, + session: { + subChatId: input.subChatId, + chatId: input.chatId, + engineId: "hermes", + providerInstanceId: errorProviderInstanceId, + nativeSessionId: null, + modelId: input.model ?? DEFAULT_HERMES_MODEL, + modelSelection: errorRuntimeModelSelection, + permissionMode: runtimePermissionMode, + cwd: input.cwd, + projectPath: input.projectPath ?? null, + runtimeConfigDir: join(homedir(), ".hermes"), + }, + nativeSessionStrategy: "start", + transport: "hermes-acp", + providerRoute: "hermes-error", + resultSubtype: "error", + metadata: { 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..e8b6ed764 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,12 @@ 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 { petRuntimeRouter } from "./pet-runtime" +import { releaseReadinessRouter } from "./release-readiness" +import { mossAccountRouter } from "./moss-account" +import { mobileGatewayRouter } from "./mobile-gateway" import { createGitRouter } from "../../git" import { BrowserWindow } from "electron" @@ -35,6 +42,7 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { anthropicAccounts: anthropicAccountsRouter, ollama: ollamaRouter, codex: codexRouter, + hermes: hermesRouter, terminal: terminalRouter, external: externalRouter, files: filesRouter, @@ -46,6 +54,12 @@ export function createAppRouter(getWindow: () => BrowserWindow | null) { commands: commandsRouter, voice: voiceRouter, plugins: pluginsRouter, + sharedResources: sharedResourcesRouter, + agentRuntime: agentRuntimeRouter, + petRuntime: petRuntimeRouter, + releaseReadiness: releaseReadinessRouter, + mossAccount: mossAccountRouter, + mobileGateway: mobileGatewayRouter, // Git operations - named "changes" to match Superset API changes: createGitRouter(), }) diff --git a/src/main/lib/trpc/routers/mobile-gateway.ts b/src/main/lib/trpc/routers/mobile-gateway.ts new file mode 100644 index 000000000..c83f8d683 --- /dev/null +++ b/src/main/lib/trpc/routers/mobile-gateway.ts @@ -0,0 +1,8 @@ +import { readDesktopMobileGatewayPairingStatus } from "../../mobile-gateway/desktop-state" +import { publicProcedure, router } from "../index" + +export const mobileGatewayRouter = router({ + getPairingStatus: publicProcedure.query(() => + readDesktopMobileGatewayPairingStatus(), + ), +}) 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/pet-runtime.ts b/src/main/lib/trpc/routers/pet-runtime.ts new file mode 100644 index 000000000..ca7d4d1e8 --- /dev/null +++ b/src/main/lib/trpc/routers/pet-runtime.ts @@ -0,0 +1,35 @@ +import { publicProcedure, router } from "../index" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { z } from "zod" +import { readPetRuntimeStatus } from "../../pet-runtime-status" + +const execFileAsync = promisify(execFile) + +export const petRuntimeRouter = router({ + getStatus: publicProcedure.query(() => readPetRuntimeStatus()), + + react: publicProcedure + .input(z.object({ intent: z.enum(["waving", "goodnight"]) })) + .mutation(async ({ input }) => { + const status = readPetRuntimeStatus() + if (!status.runtime.reactScriptExists) { + throw new Error("Codex official pet runtime is not installed") + } + + const result = await execFileAsync( + process.execPath, + [status.runtime.reactScriptPath, input.intent], + { + cwd: status.runtime.directory, + timeout: 15_000, + }, + ) + + return { + success: true, + stdout: result.stdout, + stderr: result.stderr, + } + }), +}) 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/assets/app-icons/computer-use-plugin-icon.png b/src/renderer/assets/app-icons/computer-use-plugin-icon.png new file mode 100644 index 000000000..7a1db0454 Binary files /dev/null and b/src/renderer/assets/app-icons/computer-use-plugin-icon.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-chrome.png b/src/renderer/assets/plugin-icons/codex-connected-chrome.png new file mode 100644 index 000000000..b2558a37f Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-chrome.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-codex-labs.png b/src/renderer/assets/plugin-icons/codex-connected-codex-labs.png new file mode 100644 index 000000000..2ae20c128 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-codex-labs.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-cursor.png b/src/renderer/assets/plugin-icons/codex-connected-cursor.png new file mode 100644 index 000000000..2f29882ce Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-cursor.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-docs.png b/src/renderer/assets/plugin-icons/codex-connected-docs.png new file mode 100644 index 000000000..371299502 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-docs.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-gmail.png b/src/renderer/assets/plugin-icons/codex-connected-gmail.png new file mode 100644 index 000000000..7ca099ac1 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-gmail.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-huggingface.png b/src/renderer/assets/plugin-icons/codex-connected-huggingface.png new file mode 100644 index 000000000..8f27e1dfd Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-huggingface.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-linear.png b/src/renderer/assets/plugin-icons/codex-connected-linear.png new file mode 100644 index 000000000..729044515 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-linear.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-more.png b/src/renderer/assets/plugin-icons/codex-connected-more.png new file mode 100644 index 000000000..ef9a6e14c Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-more.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-plugin-grid.png b/src/renderer/assets/plugin-icons/codex-connected-plugin-grid.png new file mode 100644 index 000000000..9abf49586 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-plugin-grid.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-plugin-store.png b/src/renderer/assets/plugin-icons/codex-connected-plugin-store.png new file mode 100644 index 000000000..bf10e99d3 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-plugin-store.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-purple-dots.png b/src/renderer/assets/plugin-icons/codex-connected-purple-dots.png new file mode 100644 index 000000000..203836657 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-purple-dots.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-record-replay.png b/src/renderer/assets/plugin-icons/codex-connected-record-replay.png new file mode 100644 index 000000000..10a3ccac7 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-record-replay.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-sheets.png b/src/renderer/assets/plugin-icons/codex-connected-sheets.png new file mode 100644 index 000000000..18b39fd6f Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-sheets.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-slides.png b/src/renderer/assets/plugin-icons/codex-connected-slides.png new file mode 100644 index 000000000..dc94361ab Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-slides.png differ diff --git a/src/renderer/assets/plugin-icons/codex-connected-striped.png b/src/renderer/assets/plugin-icons/codex-connected-striped.png new file mode 100644 index 000000000..0e8766578 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-connected-striped.png differ diff --git a/src/renderer/assets/plugin-icons/codex-marketplace-actively.png b/src/renderer/assets/plugin-icons/codex-marketplace-actively.png new file mode 100644 index 000000000..1a1f42327 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-marketplace-actively.png differ diff --git a/src/renderer/assets/plugin-icons/codex-plugin-detail-gradient.png b/src/renderer/assets/plugin-icons/codex-plugin-detail-gradient.png new file mode 100644 index 000000000..9424c6c5b Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-plugin-detail-gradient.png differ diff --git a/src/renderer/assets/plugin-icons/codex-record-replay-plugin-icon.png b/src/renderer/assets/plugin-icons/codex-record-replay-plugin-icon.png new file mode 100644 index 000000000..dd892d948 Binary files /dev/null and b/src/renderer/assets/plugin-icons/codex-record-replay-plugin-icon.png differ 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} > - + ( + "preferences:default-agent-permission-profile", + "ask-approval", + undefined, + { getOnInit: true }, + ) + +export const defaultAgentApprovalPolicyAtom = + atomWithStorage( + "preferences:default-agent-approval-policy", + "on-request", + undefined, + { getOnInit: true }, + ) + +export function workModeToDefaultPermissionProfile( + mode: AgentMode, + agentDefault: AgentPermissionProfile = "ask-approval", +): AgentPermissionProfile { + if (mode === "plan") return "read-only" + return agentDefault === "read-only" ? "ask-approval" : agentDefault +} + +export function permissionProfileToRuntimePermissionMode( + profile: AgentPermissionProfile, + fallbackMode: AgentMode = "agent", +): AgentRuntimePermissionMode { + if (profile === "approve-for-me") { + return fallbackMode === "plan" ? "read-only" : "ask-approval" + } + return profile +} + // Get next mode in cycle (for Shift+Tab toggle) export function getNextMode(current: AgentMode): AgentMode { const idx = AGENT_MODES.indexOf(current) @@ -206,7 +284,7 @@ export const selectedProjectAtom = atomWithWindowStorage( export const lastSelectedAgentIdAtom = atomWithStorage( "agents:lastSelectedAgentId", - "claude-code", + "hermes", undefined, { getOnInit: true }, ) @@ -220,20 +298,65 @@ 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 }, +) + +export const lastSelectedProviderInstanceIdsAtom = atomWithStorage< + Partial> +>( + "agents:lastSelectedProviderInstanceIds", + {}, undefined, { getOnInit: true }, ) +// Storage for per-subChat provider instance selection. +// This keeps T3-style instance routing independent from the selected model slug. +const subChatProviderInstanceIdsStorageAtom = atomWithStorage< + Record +>( + "agents:subChatProviderInstanceIds", + {}, + undefined, + { getOnInit: true }, +) + +export const subChatProviderInstanceIdAtomFamily = atomFamily((subChatId: string) => + atom( + (get) => { + if (!subChatId) return null + return get(subChatProviderInstanceIdsStorageAtom)[subChatId] ?? null + }, + (get, set, newProviderInstanceId: string | null | undefined) => { + if (!subChatId) return + const current = get(subChatProviderInstanceIdsStorageAtom) + if (!newProviderInstanceId) { + if (!(subChatId in current)) return + const next = { ...current } + delete next[subChatId] + set(subChatProviderInstanceIdsStorageAtom, next) + return + } + if (current[subChatId] === newProviderInstanceId) return + set(subChatProviderInstanceIdsStorageAtom, { + ...current, + [subChatId]: newProviderInstanceId, + }) + }, + ), +) + // Storage for per-subChat Claude model selection. // Falls back to lastSelectedModelIdAtom when sub-chat has no explicit selection yet. const subChatModelIdsStorageAtom = atomWithStorage>( @@ -342,6 +465,31 @@ 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(defaultAgentPermissionProfileAtom), + ) + }, + (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 +557,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 +809,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 +858,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 +1172,32 @@ 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 const pluginEntryNavigationNonceAtom = atom(0) +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 +1232,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 +1240,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/commands/slash-command-trigger.ts b/src/renderer/features/agents/commands/slash-command-trigger.ts new file mode 100644 index 000000000..69992e6e1 --- /dev/null +++ b/src/renderer/features/agents/commands/slash-command-trigger.ts @@ -0,0 +1,36 @@ +export interface SlashCommandTextTrigger { + query: string + rangeStart: number + rangeEnd: number +} + +function clampCursor(text: string, cursorInput: number): number { + if (!Number.isFinite(cursorInput)) { + return text.length + } + return Math.max(0, Math.min(text.length, Math.floor(cursorInput))) +} + +export function detectSlashCommandTextTrigger( + text: string, + cursorInput: number, +): SlashCommandTextTrigger | null { + const cursor = clampCursor(text, cursorInput) + const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1 + const linePrefix = text.slice(lineStart, cursor) + + if (!linePrefix.startsWith("/")) { + return null + } + + const commandMatch = /^\/(\S*)$/.exec(linePrefix) + if (!commandMatch) { + return null + } + + return { + query: commandMatch[1] ?? "", + rangeStart: lineStart, + rangeEnd: cursor, + } +} 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..d41bc685f --- /dev/null +++ b/src/renderer/features/agents/lib/agent-runtime.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, test } from "bun:test" +import { + AGENT_ENGINE_UI_DEFINITIONS, + CUSTOM_ACP_DEFAULT_MODEL_ID, + DEFAULT_RUNNABLE_AGENT_ENGINE_ID, + buildRuntimeEngineListState, + formatRuntimeModelLabel, + getAgentRuntimeProviderUpdateNotice, + isAgentRuntimeUpdateActive, + isAgentRuntimeUpdateCandidate, + 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("preserves T3-style provider update advisory state from runtime health", () => { + const [codex] = mapRuntimeEnginesToUiDefinitions([ + { + id: "codex", + label: "OpenAI Codex", + availability: "available", + defaultModelId: CODEX_DEFAULT_MODEL_ID, + version: "0.139.0", + versionAdvisory: { + status: "behind_latest", + currentVersion: "0.139.0", + latestVersion: "0.140.0", + updateCommand: "npm i -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-06-27T07:00:00.000Z", + message: "Update before long sessions.", + }, + updateState: { + status: "idle", + startedAt: null, + finishedAt: null, + message: null, + output: null, + }, + }, + ]) + + expect(codex).toMatchObject({ + id: "codex", + disabled: false, + version: "0.139.0", + versionAdvisory: { + status: "behind_latest", + latestVersion: "0.140.0", + canUpdate: true, + }, + updateState: { + status: "idle", + }, + }) + expect(isAgentRuntimeUpdateCandidate(codex!)).toBe(true) + expect(isAgentRuntimeUpdateActive(codex!)).toBe(false) + }) + + test("projects provider update advisory into a user-visible notice", () => { + const [codex] = mapRuntimeEnginesToUiDefinitions([ + { + id: "codex", + label: "OpenAI Codex", + availability: "available", + versionAdvisory: { + status: "behind_latest", + currentVersion: "0.139.0", + latestVersion: "0.140.0", + updateCommand: "npm i -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-06-27T07:00:00.000Z", + message: null, + }, + }, + ]) + + expect(getAgentRuntimeProviderUpdateNotice(codex!)).toEqual({ + phase: "available", + tone: "warning", + icon: "warning", + titleKey: "runtime.update.available.title", + bodyKey: "runtime.update.available.body", + values: { + engine: "OpenAI Codex", + currentVersion: "0.139.0", + latestVersion: "0.140.0", + message: "", + }, + action: "runtime", + actionLabelKey: "runtime.openRuntime", + }) + }) + + test("prioritizes active and terminal provider update state over the stale-version advisory", () => { + const [running] = mapRuntimeEnginesToUiDefinitions([ + { + id: "codex", + label: "OpenAI Codex", + availability: "available", + versionAdvisory: { + status: "behind_latest", + currentVersion: "0.139.0", + latestVersion: "0.140.0", + updateCommand: "npm i -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-06-27T07:00:00.000Z", + message: null, + }, + updateState: { + status: "running", + startedAt: "2026-06-27T07:01:00.000Z", + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }, + ]) + const [failed] = mapRuntimeEnginesToUiDefinitions([ + { + id: "codex", + label: "OpenAI Codex", + availability: "available", + updateState: { + status: "failed", + startedAt: "2026-06-27T07:01:00.000Z", + finishedAt: "2026-06-27T07:02:00.000Z", + message: "permission denied", + output: "permission denied", + }, + }, + ]) + + expect(isAgentRuntimeUpdateActive(running!)).toBe(true) + expect(getAgentRuntimeProviderUpdateNotice(running!)).toMatchObject({ + phase: "running", + tone: "info", + icon: "loading", + titleKey: "runtime.update.running.title", + }) + expect(getAgentRuntimeProviderUpdateNotice(failed!)).toMatchObject({ + phase: "failed", + tone: "error", + bodyKey: "runtime.update.failed.bodyWithMessage", + values: { + engine: "OpenAI Codex", + currentVersion: "current version", + latestVersion: "latest version", + message: "permission denied", + }, + }) + }) + + 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..c3a33b6b4 --- /dev/null +++ b/src/renderer/features/agents/lib/agent-runtime.ts @@ -0,0 +1,492 @@ +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 AgentRuntimeVersionAdvisoryStatus = + | "unknown" + | "current" + | "behind_latest" + +export type AgentRuntimeUpdateStatus = + | "idle" + | "queued" + | "running" + | "succeeded" + | "failed" + | "unchanged" + +export type AgentRuntimeVersionAdvisory = { + status: AgentRuntimeVersionAdvisoryStatus + currentVersion: string | null + latestVersion: string | null + updateCommand: string | null + canUpdate: boolean + checkedAt: string | null + message: string | null +} + +export type AgentRuntimeUpdateState = { + status: AgentRuntimeUpdateStatus + startedAt: string | null + finishedAt: string | null + message: string | null + output: string | null +} + +export type AgentRuntimeProviderInstanceStatus = + | "ready" + | "warning" + | "error" + | "disabled" + +export type AgentRuntimeProviderInstanceLike = { + instanceId: string + engineId?: string + displayName?: string + enabled: boolean + installed: boolean + status: AgentRuntimeProviderInstanceStatus + isDefault?: boolean + modelIds?: string[] + version?: string | null + versionAdvisory?: AgentRuntimeVersionAdvisory | null + updateState?: AgentRuntimeUpdateState | null +} + +export type AgentRuntimeProviderUpdateNoticePhase = + | "available" + | "queued" + | "running" + | "succeeded" + | "failed" + | "unchanged" + +export type AgentRuntimeProviderUpdateNoticeTone = + | "info" + | "warning" + | "error" + | "muted" + +export type AgentRuntimeProviderUpdateNotice = { + phase: AgentRuntimeProviderUpdateNoticePhase + tone: AgentRuntimeProviderUpdateNoticeTone + icon: "loading" | "warning" | "settings" + titleKey: string + bodyKey: string + values?: Record + action: "models" | "runtime" + actionLabelKey: string +} + +export type AgentEngineUiDefinition = { + id: AgentEngineId + name: string + disabled?: boolean + availability?: string + statusLabel?: string + statusReason?: string + authMethod?: string + defaultModelLabel?: string + fallback?: boolean + version?: string | null + versionAdvisory?: AgentRuntimeVersionAdvisory | null + updateState?: AgentRuntimeUpdateState | null + providerInstances?: AgentRuntimeProviderInstanceLike[] +} + +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 + version?: string | null + versionAdvisory?: AgentRuntimeVersionAdvisory | null + updateState?: AgentRuntimeUpdateState | null + providerInstances?: AgentRuntimeProviderInstanceLike[] +} + +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, + version: engine.version ?? undefined, + versionAdvisory: engine.versionAdvisory ?? undefined, + updateState: engine.updateState ?? undefined, + providerInstances: engine.providerInstances ?? undefined, + } + }) +} + +export function isAgentRuntimeUpdateActive( + engine: Pick, +): boolean { + return ( + engine.updateState?.status === "queued" || + engine.updateState?.status === "running" + ) +} + +export function isAgentRuntimeUpdateCandidate( + engine: Pick, +): boolean { + return ( + engine.disabled !== true && + engine.versionAdvisory?.status === "behind_latest" && + typeof engine.versionAdvisory.latestVersion === "string" && + engine.versionAdvisory.latestVersion.trim().length > 0 + ) +} + +function providerUpdateValues( + engine: Pick< + AgentEngineUiDefinition, + "name" | "versionAdvisory" | "updateState" + >, +): Record { + return { + engine: engine.name, + currentVersion: + engine.versionAdvisory?.currentVersion || "current version", + latestVersion: + engine.versionAdvisory?.latestVersion || "latest version", + message: + engine.updateState?.message || + engine.versionAdvisory?.message || + "", + } +} + +export function getAgentRuntimeProviderUpdateNotice( + engine: AgentEngineUiDefinition | undefined, +): AgentRuntimeProviderUpdateNotice | null { + if (!engine) return null + + const values = providerUpdateValues(engine) + switch (engine.updateState?.status) { + case "queued": + return { + phase: "queued", + tone: "info", + icon: "loading", + titleKey: "runtime.update.queued.title", + bodyKey: "runtime.update.queued.body", + values, + action: "runtime", + actionLabelKey: "runtime.openRuntime", + } + case "running": + return { + phase: "running", + tone: "info", + icon: "loading", + titleKey: "runtime.update.running.title", + bodyKey: "runtime.update.running.body", + values, + action: "runtime", + actionLabelKey: "runtime.openRuntime", + } + case "failed": + return { + phase: "failed", + tone: "error", + icon: "warning", + titleKey: "runtime.update.failed.title", + bodyKey: values.message + ? "runtime.update.failed.bodyWithMessage" + : "runtime.update.failed.body", + values, + action: "runtime", + actionLabelKey: "runtime.openRuntime", + } + case "unchanged": + return { + phase: "unchanged", + tone: "warning", + icon: "warning", + titleKey: "runtime.update.unchanged.title", + bodyKey: "runtime.update.unchanged.body", + values, + action: "runtime", + actionLabelKey: "runtime.openRuntime", + } + case "succeeded": + return { + phase: "succeeded", + tone: "muted", + icon: "settings", + titleKey: "runtime.update.succeeded.title", + bodyKey: "runtime.update.succeeded.body", + values, + action: "runtime", + actionLabelKey: "runtime.openRuntime", + } + default: + break + } + + if (!isAgentRuntimeUpdateCandidate(engine)) return null + + return { + phase: "available", + tone: "warning", + icon: "warning", + titleKey: engine.versionAdvisory?.canUpdate + ? "runtime.update.available.title" + : "runtime.update.manual.title", + bodyKey: engine.versionAdvisory?.canUpdate + ? "runtime.update.available.body" + : "runtime.update.manual.body", + values, + action: "runtime", + actionLabelKey: "runtime.openRuntime", + } +} + +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/lib/shared-agent-support.ts b/src/renderer/features/agents/lib/shared-agent-support.ts new file mode 100644 index 000000000..a447b2867 --- /dev/null +++ b/src/renderer/features/agents/lib/shared-agent-support.ts @@ -0,0 +1,27 @@ +import { + AGENT_ENGINE_UI_DEFINITIONS, + isRunnableAgentEngineId, + type RunnableAgentEngineId, +} from "./agent-runtime" + +export const SHARED_RUNNABLE_AGENT_IDS = [ + "hermes", + "claude-code", + "codex", + "custom-acp", +] as const satisfies readonly RunnableAgentEngineId[] + +export const SHARED_RUNNABLE_AGENT_SUPPORT_KEY = + SHARED_RUNNABLE_AGENT_IDS.join(",") + +const SHARED_AGENT_LABELS_BY_ID = new Map( + AGENT_ENGINE_UI_DEFINITIONS + .filter((engine) => isRunnableAgentEngineId(engine.id)) + .map((engine) => [engine.id, engine.name]), +) + +export function getSharedRunnableAgentLabels(): string[] { + return SHARED_RUNNABLE_AGENT_IDS.map( + (engineId) => SHARED_AGENT_LABELS_BY_ID.get(engineId) ?? engineId, + ) +} diff --git a/src/renderer/features/agents/mentions/agents-mentions-editor.tsx b/src/renderer/features/agents/mentions/agents-mentions-editor.tsx index 548334e42..7895304b2 100644 --- a/src/renderer/features/agents/mentions/agents-mentions-editor.tsx +++ b/src/renderer/features/agents/mentions/agents-mentions-editor.tsx @@ -1,6 +1,7 @@ "use client" import { cn } from "../../../lib/utils" +import { detectSlashCommandTextTrigger } from "../commands/slash-command-trigger" import { forwardRef, useCallback, @@ -29,7 +30,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 } @@ -471,10 +472,13 @@ function walkTreeOnce(root: HTMLElement, range: Range | null): TreeWalkResult { } } - // Validate / trigger - check if space/newline after it + // Validate / trigger using the same line-scoped parser as command search tests. if (slashIndex !== -1) { - const afterSlash = textBeforeCursor.slice(slashIndex + 1) - if (afterSlash.includes(" ") || afterSlash.includes("\n")) { + const slashTrigger = detectSlashCommandTextTrigger( + textBeforeCursor, + textBeforeCursor.length, + ) + if (!slashTrigger || slashTrigger.rangeStart !== slashIndex) { slashIndex = -1 slashPosition = null } @@ -1365,7 +1369,7 @@ export const AgentsMentionsEditor = memo( return (
{!hasContent && placeholder && ( -
+
{placeholder}
)} @@ -1383,8 +1387,9 @@ export const AgentsMentionsEditor = memo( }} onFocus={onFocus} onBlur={onBlur} + data-codex-composer-editor 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-agent-support.tsx b/src/renderer/features/plugins/plugin-agent-support.tsx new file mode 100644 index 000000000..ff3f419e3 --- /dev/null +++ b/src/renderer/features/plugins/plugin-agent-support.tsx @@ -0,0 +1,52 @@ +import { + SHARED_RUNNABLE_AGENT_IDS, + SHARED_RUNNABLE_AGENT_SUPPORT_KEY, + getSharedRunnableAgentLabels, +} from "../agents/lib/shared-agent-support" + +export const PLUGIN_RUNNABLE_AGENT_IDS = SHARED_RUNNABLE_AGENT_IDS + +export const PLUGIN_RUNNABLE_AGENT_SUPPORT_KEY = + SHARED_RUNNABLE_AGENT_SUPPORT_KEY + +export function getPluginSupportedAgentLabels(): string[] { + return getSharedRunnableAgentLabels() +} + +export function PluginAgentSupportPills({ + className, + compact = false, + idPrefix, +}: { + className?: string + compact?: boolean + idPrefix: string +}) { + const classes = [ + "codex-plugin-agent-strip", + compact ? "codex-plugin-agent-strip-compact" : null, + className, + ] + .filter(Boolean) + .join(" ") + + return ( +
+ {PLUGIN_RUNNABLE_AGENT_IDS.map((engineId) => ( + + {getSharedRunnableAgentLabels()[ + PLUGIN_RUNNABLE_AGENT_IDS.indexOf(engineId) + ] ?? engineId} + + ))} +
+ ) +} 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..291e7e1bd --- /dev/null +++ b/src/renderer/features/plugins/plugin-entry-surfaces.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import { + buildPluginEntrySurfaceRoute, + coercePluginManageTabForSurface, + getDesktopViewForSettingsIntegration, + getPluginEntrySurfaceDefaults, + getPluginEntrySurfaceForDesktopView, + getPluginEntrySurfaceManageTabIds, +} from "./plugin-entry-surfaces" + +describe("Codex plugin entry surfaces", () => { + test("keeps Settings integrations inside the Settings page", () => { + expect(getDesktopViewForSettingsIntegration("plugins")).toBeNull() + expect(getDesktopViewForSettingsIntegration("skills")).toBeNull() + expect(getDesktopViewForSettingsIntegration("mcp")).toBeNull() + 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("allows the full Plugins page to inspect all managed resource tabs", () => { + expect(getPluginEntrySurfaceManageTabIds("plugins")).toEqual([ + "plugins", + "apps", + "mcps", + "skills", + "marketplace", + ]) + expect(getPluginEntrySurfaceManageTabIds("skills")).toEqual(["skills"]) + expect(getPluginEntrySurfaceManageTabIds("mcp-settings")).toEqual(["mcps"]) + + expect(coercePluginManageTabForSurface("plugins", "mcps")).toBe("mcps") + expect(coercePluginManageTabForSurface("plugins", "skills")).toBe("skills") + 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", + }) + }) + + test("resets plugin entry state when the sidebar reopens the same surface", async () => { + const source = await readFile( + new URL("./plugin-entry-view.tsx", import.meta.url), + "utf8", + ) + + expect(source).toContain("pluginEntryNavigationNonceAtom") + expect(source).toContain("const resetPluginEntrySurface = useCallback") + expect(source).toContain("setSelectedPlugin(null)") + expect(source).toContain("setPluginRouteState(buildPluginEntrySurfaceRoute(surface))") + expect(source).toContain("[pluginEntryNavigationNonce, resetPluginEntrySurface]") + }) + + test("keeps plugin detail resource actions wired", async () => { + const source = await readFile( + new URL("./plugin-entry-view.tsx", import.meta.url), + "utf8", + ) + + expect(source).toContain('data-plugin-detail-resource-action="configure-mcp"') + expect(source).toContain('onConfigureMcp={() => openPluginManageTab("mcps")}') + expect(source).toContain('data-plugin-detail-resource-action="toggle-skill"') + expect(source).toContain('data-plugin-detail-copy-state={copiedLink ? "copied" : "idle"}') + expect(source).toContain("toggleCatalogPluginSkill(selectedPlugin, skillName, nextEnabled)") + expect(source).toContain("getDetailSkillKey(plugin.id, skillName)") + }) +}) 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..b9ccb3845 --- /dev/null +++ b/src/renderer/features/plugins/plugin-entry-surfaces.ts @@ -0,0 +1,90 @@ +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", "mcps", "skills", "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 { + // Settings integration tabs render inside SettingsContent. Standalone + // plugin/skill/MCP pages are opened from the main sidebar and deep links. + return null +} diff --git a/src/renderer/features/plugins/plugin-entry-view.tsx b/src/renderer/features/plugins/plugin-entry-view.tsx new file mode 100644 index 000000000..c9caa8476 --- /dev/null +++ b/src/renderer/features/plugins/plugin-entry-view.tsx @@ -0,0 +1,2741 @@ +"use client" + +import type { CSSProperties, KeyboardEvent, ReactNode } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useAtom, useAtomValue, useSetAtom } from "jotai" +import { + ArrowRight, + Check, + ChevronLeft, + ChevronDown, + ChevronRight, + ExternalLink, + MessageCircle, + MoreHorizontal, + PanelLeftClose, + Plus, + RefreshCw, + Search, + Settings, +} from "lucide-react" +import computerUsePluginIcon from "../../assets/app-icons/computer-use-plugin-icon.png" +import activelyMarketplaceTile from "../../assets/plugin-icons/codex-marketplace-actively.png" +import connectedChromeTile from "../../assets/plugin-icons/codex-connected-chrome.png" +import connectedCodexLabsTile from "../../assets/plugin-icons/codex-connected-codex-labs.png" +import connectedCursorTile from "../../assets/plugin-icons/codex-connected-cursor.png" +import connectedDocsTile from "../../assets/plugin-icons/codex-connected-docs.png" +import connectedGmailTile from "../../assets/plugin-icons/codex-connected-gmail.png" +import connectedHuggingFaceTile from "../../assets/plugin-icons/codex-connected-huggingface.png" +import connectedLinearTile from "../../assets/plugin-icons/codex-connected-linear.png" +import connectedMoreTile from "../../assets/plugin-icons/codex-connected-more.png" +import connectedPluginGridTile from "../../assets/plugin-icons/codex-connected-plugin-grid.png" +import connectedPluginStoreTile from "../../assets/plugin-icons/codex-connected-plugin-store.png" +import connectedPurpleDotsTile from "../../assets/plugin-icons/codex-connected-purple-dots.png" +import connectedSheetsTile from "../../assets/plugin-icons/codex-connected-sheets.png" +import connectedSlidesTile from "../../assets/plugin-icons/codex-connected-slides.png" +import connectedStripedTile from "../../assets/plugin-icons/codex-connected-striped.png" +import pluginDetailGradient from "../../assets/plugin-icons/codex-plugin-detail-gradient.png" +import recordReplayPluginIcon from "../../assets/plugin-icons/codex-record-replay-plugin-icon.png" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../../components/ui/dropdown-menu" +import { useI18n } from "../../lib/i18n" +import { trpc } from "../../lib/trpc" +import { cn } from "../../lib/utils" +import { PluginAgentSupportPills } from "./plugin-agent-support" +import { + agentsSidebarOpenAtom, + desktopViewAtom, + pendingPluginActionAtom, + pluginEntryNavigationNonceAtom, + selectedAgentChatIdAtom, + selectedDraftIdAtom, + selectedProjectAtom, + showNewChatFormAtom, +} from "../agents/atoms" +import { buildPluginDeepLinkUrl } from "../../../shared/plugin-deep-link" +import { generateDraftId, saveNewChatDraft } from "../agents/lib/drafts" +import { + buildPluginBrowseRoute, + cancelPluginUninstallRoute, + closePluginOverlayRoute, + finishPluginInstallRoute, + finishPluginUninstallRoute, + getPluginRouteDialogKind, + getPluginRouteOrigin, + getPluginRouteOriginFromState, + openPluginResourceRoute, + openPluginDetailRoute, + requestPluginUninstallRoute, + serializePluginRouteOrigin, + serializePluginRouteState, + startPluginInstallRoute, + startPluginTryInChatRoute, + startPluginUninstallRoute, + type PluginEntryMode, + type PluginManageTabId, + type PluginRouteState, + type PluginTopTabId, +} from "./plugin-route-state" +import { + BUSINESS_PLUGINS, + CATALOG_PLUGIN_DETAILS, + CONNECTED_PLUGINS, + FEATURED_PLUGINS, + MARKETPLACE_PLUGINS, + PRODUCTIVITY_PLUGINS, + RECOMMENDED_SKILLS, + buildPluginOnboardingSuggestions, + findCatalogPluginById, + findRuntimePluginForCatalog, + formatRuntimeLabel, + canRunManagedResourceAction, + getCatalogPluginDetail, + getManagedResourceRouteTarget, + getManagedResourcesForTab, + getPluginManageTabs, + getRuntimePluginResourceCount, + getRuntimeSkillLogo, + type CatalogPlugin, + type ConnectedPlugin, + type ManagedResource, + type PluginLogoId, + type PluginOnboardingRoleId, + type PluginOnboardingRoleSuggestion, + type RuntimePluginData, + type RuntimeSkillData, +} from "./plugin-resource-model" +import { + buildPluginEntrySurfaceRoute, + coercePluginManageTabForSurface, + getPluginEntrySurfaceDefaults, + getPluginEntrySurfaceManageTabIds, + type PluginEntrySurface, +} from "./plugin-entry-surfaces" + +const APP_PROTOCOL = import.meta.env.DEV + ? "twentyfirst-agents-dev" + : "twentyfirst-agents" + +function getCatalogPluginLink(plugin: CatalogPlugin) { + return buildPluginDeepLinkUrl({ + pluginId: plugin.id, + protocol: APP_PROTOCOL, + }) +} + +async function writeClipboardText(text: string) { + if (window.desktopApi?.clipboardWrite) { + await window.desktopApi.clipboardWrite(text) + return + } + + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return + } catch { + // Electron CDP can reject Clipboard API writes when the document is not focused. + // Fall through to the selection-based copy path used by desktop shells. + } + } + + const textArea = document.createElement("textarea") + textArea.value = text + textArea.setAttribute("readonly", "true") + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + textArea.style.top = "0" + document.body.append(textArea) + textArea.select() + + try { + if (!document.execCommand("copy")) { + throw new Error("Clipboard copy command failed") + } + } finally { + textArea.remove() + } +} + +function waitForPluginMutationFrame() { + return new Promise((resolve) => window.setTimeout(resolve, 450)) +} + +const CODEX_2026_GOOGLE_LOGO_SVGS: Partial> = { + docs: ``, + sheets: ``, + slides: ``, + gmail: ``, + calendar: ``, + drive: ``, +} + +const CODEX_2026_GOOGLE_LOGO_DATA_URIS = new Map() +const INLINE_GOOGLE_DOCUMENT_LOGOS = new Set(["docs", "sheets", "slides"]) +const CODEX_CONNECTED_TILE_IMAGES: Partial> = { + chrome: connectedChromeTile, + "codex-labs": connectedCodexLabsTile, + cursor: connectedCursorTile, + docs: connectedDocsTile, + gmail: connectedGmailTile, + huggingface: connectedHuggingFaceTile, + linear: connectedLinearTile, + "plugin-grid": connectedPluginGridTile, + "plugin-store": connectedPluginStoreTile, + "purple-dots": connectedPurpleDotsTile, + sheets: connectedSheetsTile, + slides: connectedSlidesTile, + striped: connectedStripedTile, +} + +function PluginFilterIcon() { + return ( + + ) +} + +function getCodex2026GoogleLogoDataUri(id: PluginLogoId) { + if (INLINE_GOOGLE_DOCUMENT_LOGOS.has(id)) { + return null + } + + const cachedLogo = CODEX_2026_GOOGLE_LOGO_DATA_URIS.get(id) + if (cachedLogo) { + return cachedLogo + } + + const source = CODEX_2026_GOOGLE_LOGO_SVGS[id] + if (!source) { + return null + } + + const frame = id === "docs" + ? { x: 0, y: 0, size: 192 } + : { x: 24, y: 24, size: 144 } + const innerLogo = source + .trim() + .replace( + /^]*>/u, + ``, + ) + const dataUri = `data:image/svg+xml,${encodeURIComponent( + `${innerLogo}`, + )}` + CODEX_2026_GOOGLE_LOGO_DATA_URIS.set(id, dataUri) + return dataUri +} + +function PluginLogo({ id }: { id: PluginLogoId }) { + const codex2026GoogleLogo = getCodex2026GoogleLogoDataUri(id) + if (codex2026GoogleLogo) { + return ( + + ) + } + + if (id === "github") { + return ( + + ) + } + + if (id === "slack") { + return ( + + ) + } + + if (id === "gmail") { + return ( + + ) + } + + if (id === "drive") { + return ( + + ) + } + + if (id === "docs") { + return ( + + ) + } + + if (id === "sheets") { + return ( + + ) + } + + if (id === "slides") { + return ( + + ) + } + + if (id === "chrome") { + return ( + + ) + } + + if (id === "cursor") { + return ( + + ) + } + + if (id === "linear") { + return ( + + ) + } + + if (id === "codex-labs") { + return ( + + ) + } + + if (id === "record-replay") { + return ( + + ) + } + + if (id === "skill") { + return ( + + ) + } + + if (id === "purple-dots") { + return ( + + ) + } + + if (id === "calendar") { + return ( + + ) + } + + if (id === "notion") { + return ( + + ) + } + + if (id === "actively") { + return ( + + ) + } + + if (id === "apollo") { + return ( + + ) + } + + if (id === "salesforce") { + return ( + + ) + } + + if (id === "hubspot") { + return ( + + ) + } + + if (id === "huggingface") { + return ( + + ) + } + + if (id === "plugin-store") { + return ( + + ) + } + + if (id === "striped") { + return ( + + ) + } + + if (id === "plugin-grid") { + return ( + + ) + } + + const label = { + "codex-labs": "", + figma: "F", + "purple-dots": "", + }[id] + + return ( + + ) +} + +function PluginConnectedLogo({ id }: { id: PluginLogoId }) { + const referenceTile = CODEX_CONNECTED_TILE_IMAGES[id] + + if (referenceTile) { + return ( + + ) + } + + return +} + +function CatalogPluginRow({ + isInstalled, + plugin, + onAdd, + onOpen, + onTryInChat, +}: { + isInstalled?: boolean + plugin: CatalogPlugin + onAdd: () => void + onOpen: () => void + onTryInChat?: () => void +}) { + const { t } = useI18n() + const actionLabel = isInstalled + ? t("pluginsPage.detail.tryInChat") + : t("pluginsPage.add") + + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + onOpen() + } + }} + > + +
+
{t(plugin.titleKey)}
+
+ {t(plugin.descriptionKey)} +
+ +
+ +
+ ) +} + +function ManagedResourceRow({ + resource, + onAction, +}: { + resource: ManagedResource + onAction?: (resource: ManagedResource) => void +}) { + const { t } = useI18n() + const actionTarget = getManagedResourceRouteTarget(resource) + const actionEnabled = canRunManagedResourceAction(resource) + + return ( +
+ +
+
{resource.title}
+
+ {t(resource.descriptionKey)} +
+ +
+ + {t(resource.statusKey)} + + +
+ ) +} + +function ConnectedPluginRow({ plugin }: { plugin: ConnectedPlugin }) { + const { t } = useI18n() + + return ( +
+ +
+
{plugin.label}
+
+ {t("pluginsPage.manage.connectedDescription")} +
+ +
+ + {t("pluginsPage.status.connected")} + + +
+ ) +} + +function RuntimePluginManageRow({ + catalogPlugin, + onOpenCatalogPlugin, + runtimePlugin, +}: { + catalogPlugin?: CatalogPlugin | null + onOpenCatalogPlugin?: (plugin: CatalogPlugin) => void + runtimePlugin: RuntimePluginData +}) { + const { t } = useI18n() + const title = catalogPlugin + ? t(catalogPlugin.titleKey) + : formatRuntimeLabel(runtimePlugin.name) + const statusKey = runtimePlugin.isDisabled + ? "pluginsPage.status.disabled" + : "pluginsPage.status.enabled" + const resourceCount = getRuntimePluginResourceCount(runtimePlugin) + + return ( +
+
+ ) +} + +function RuntimeSkillRow({ + catalogPlugin, + onTryInChat, + skill, +}: { + catalogPlugin?: CatalogPlugin | null + onTryInChat: (skill: RuntimeSkillData) => void + skill: RuntimeSkillData +}) { + const { t } = useI18n() + const title = formatRuntimeLabel(skill.name) + const description = + skill.description || skill.path || t("pluginsPage.skills.noDescription") + + return ( +
+ +
+
{title}
+
+ {description} +
+ +
+ + {t("pluginsPage.status.enabled")} + + +
+ ) +} + +function RuntimeSkillEmptyRow({ isLoading }: { isLoading: boolean }) { + const { t } = useI18n() + + return ( +
+ +
+
+ {isLoading + ? t("pluginsPage.skills.loading") + : t("pluginsPage.skills.empty.title")} +
+
+ {isLoading + ? t("pluginsPage.skills.loadingDescription") + : t("pluginsPage.skills.empty.description")} +
+
+
+ ) +} + +function PluginDetailResourceSection({ + disabledItems, + descriptions, + id, + items, + logo, + onMcpConfigure, + onResourceOpen, + onSkillToggle, + statusLabel, + title, + variant = "default", +}: { + disabledItems?: ReadonlySet + descriptions?: Record + id: string + items: string[] + logo: PluginLogoId + onMcpConfigure?: () => void + onResourceOpen?: (item: string) => void + onSkillToggle?: (item: string, nextEnabled: boolean) => void + statusLabel?: string + title: string + variant?: "apps" | "default" | "mcps" | "skills" +}) { + const { t } = useI18n() + + if (items.length === 0) return null + + return ( +
+
+

{title}

+ {items.length} +
+
+ {items.map((item) => { + const description = descriptions?.[item] + const skillEnabled = !disabledItems?.has(item) + const resourceCopyProps = + variant === "skills" && onResourceOpen + ? { + onClick: () => onResourceOpen(item), + onKeyDown: (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + onResourceOpen(item) + }, + role: "button", + tabIndex: 0, + } + : {} + + return ( +
+ +
+
{item}
+ {description ? ( +

{description}

+ ) : null} +
+ {statusLabel ? ( + {statusLabel} + ) : variant === "mcps" ? ( + + ) : variant === "skills" ? ( + + ) : null} +
+ ) + })} +
+
+ ) +} + +function PluginDetailInfoRow({ + children, + label, + value, +}: { + children?: ReactNode + label: string + value?: string | null +}) { + if (!children && !value) return null + + return ( +
+ {label} + {children ?? value} +
+ ) +} + +function PluginDetailInfoExternalLink({ + href, + label, +}: { + href?: string | null + label: string +}) { + if (!href) return null + + return ( + + + ) +} + +function PluginDetailPage({ + copiedLink, + disabledSkillNames, + installed, + installing, + mutationError, + onClose, + onConfigureMcp, + onCopyLink, + onOpenSkills, + onInstall, + onStartPrompt, + onToggleSkill, + onTryInChat, + onUninstall, + plugin, + runtimePlugin, + uninstalling, +}: { + copiedLink: boolean + disabledSkillNames?: ReadonlySet + installed: boolean + installing: boolean + mutationError?: string | null + onClose: () => void + onConfigureMcp: () => void + onCopyLink: () => void + onOpenSkills: () => void + onInstall: () => void + onStartPrompt: (promptKey: string) => void + onToggleSkill: (skillName: string, nextEnabled: boolean) => void + onTryInChat: () => void + onUninstall: () => void + plugin: CatalogPlugin + runtimePlugin?: RuntimePluginData | null + uninstalling: boolean +}) { + const { t } = useI18n() + const setSidebarOpen = useSetAtom(agentsSidebarOpenAtom) + const detail = getCatalogPluginDetail(plugin, runtimePlugin) + const title = t(plugin.titleKey) + const promptExampleKeys = + detail.promptExampleKeys && detail.promptExampleKeys.length > 0 + ? detail.promptExampleKeys + : [detail.promptKey] + const longDescription = detail.longDescriptionKey + ? t(detail.longDescriptionKey) + : t(plugin.descriptionKey) + + return ( +
+
+
+ + + +
+
+ +
+
+ +
+
+ + + + + + + {copiedLink + ? t("pluginsPage.detail.copiedLink") + : t("pluginsPage.detail.copyLink")} + + {installed ? ( + + {uninstalling + ? t("pluginsPage.detail.removingFromCodex") + : t("pluginsPage.detail.removeFromCodex")} + + ) : null} + + + +
+ +
+ +

{title}

+

{t(plugin.descriptionKey)}

+
+ +
+ {promptExampleKeys.map((promptKey) => ( + + ))} +
+ +

+ {longDescription} +

+ + {mutationError ? ( +
+ {mutationError} +
+ ) : null} + + + + + +
+

{t("pluginsPage.detail.info")}

+
+ + + + + + + + + + + + +
+
+ +
+
+ ) +} + +function PluginUninstallDialog({ + mutationError, + onCancel, + onConfirm, + plugin, + runtimePlugin, + uninstalling, +}: { + mutationError?: string | null + onCancel: () => void + onConfirm: () => void + plugin: CatalogPlugin + runtimePlugin?: RuntimePluginData | null + uninstalling: boolean +}) { + const { t } = useI18n() + const detail = getCatalogPluginDetail(plugin, runtimePlugin) + const title = t(plugin.titleKey) + const impactItems = [ + { + key: "apps", + label: t("pluginsPage.uninstall.apps"), + value: detail.apps.join(", "), + }, + { + key: "skills", + label: t("pluginsPage.uninstall.skills"), + value: detail.skills.join(", "), + }, + { + key: "mcps", + label: t("pluginsPage.uninstall.mcps"), + value: detail.mcps.join(", "), + }, + ].filter((item) => item.value.length > 0) + + return ( +
{ + if (!uninstalling) onCancel() + }} + > +
event.stopPropagation()} + role="dialog" + > +
+ +
+

+ {t("pluginsPage.uninstall.title", { plugin: title })} +

+

+ {t("pluginsPage.uninstall.body", { plugin: title })} +

+
+
+ +
+
+ {t("pluginsPage.uninstall.impact")} +
+
+ {impactItems.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+
+ + {mutationError ? ( +
+ {mutationError} +
+ ) : null} + +
+ + +
+
+
+ ) +} + +interface PluginEntryViewProps { + ariaLabel?: string + className?: string + surface?: PluginEntrySurface +} + +function getPluginSurfaceTitleKey(surface: PluginEntrySurface) { + if (surface === "skills") return "pluginsPage.skills" + if (surface === "mcp-settings") return "pluginsPage.manage.mcps" + return "pluginsPage.title" +} + +function getPluginSurfaceSubtitleKey( + surface: PluginEntrySurface, + entryMode: PluginEntryMode, +) { + if (surface === "skills") return "pluginsPage.skills.subtitle" + if (surface === "mcp-settings") return "pluginsPage.mcp.subtitle" + if (entryMode === "manage") return "pluginsPage.manage.subtitle" + return "pluginsPage.subtitle" +} + +function getPluginSurfaceSearchKey(surface: PluginEntrySurface) { + if (surface === "skills") return "pluginsPage.skills.search" + if (surface === "mcp-settings") return "pluginsPage.mcp.search" + return "pluginsPage.catalogSearch" +} + +function getDetailSkillKey(pluginId: string, skillName: string) { + return `${pluginId}::${skillName}` +} + +export function PluginEntryView({ + ariaLabel = "Plugin entry page", + className, + surface = "plugins", +}: PluginEntryViewProps) { + const surfaceDefaults = getPluginEntrySurfaceDefaults(surface) + const [pageTab, setPageTab] = useState( + () => surfaceDefaults.pageTab, + ) + const [entryMode, setEntryMode] = useState( + () => surfaceDefaults.entryMode, + ) + const [manageTab, setManageTab] = useState( + () => surfaceDefaults.manageTab, + ) + const [pluginRouteState, setPluginRouteState] = useState( + () => buildPluginEntrySurfaceRoute(surface), + ) + const [selectedPlugin, setSelectedPlugin] = useState(null) + const [installedPluginIds, setInstalledPluginIds] = useState>( + () => new Set(), + ) + const [installingPluginId, setInstallingPluginId] = useState( + null, + ) + const [uninstallCandidate, setUninstallCandidate] = + useState(null) + const [uninstallingPluginId, setUninstallingPluginId] = useState< + string | null + >(null) + const [pluginMutationError, setPluginMutationError] = useState( + null, + ) + const [copiedPluginLinkId, setCopiedPluginLinkId] = useState( + null, + ) + const [disabledDetailSkillKeys, setDisabledDetailSkillKeys] = useState< + Set + >(() => new Set()) + const catalogMainRef = useRef(null) + const [selectedOnboardingRoleId, setSelectedOnboardingRoleId] = + useState("developer") + const { t } = useI18n() + const selectedProject = useAtomValue(selectedProjectAtom) + const pluginEntryNavigationNonce = useAtomValue(pluginEntryNavigationNonceAtom) + const setDesktopView = useSetAtom(desktopViewAtom) + const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) + const setSelectedDraftId = useSetAtom(selectedDraftIdAtom) + const setShowNewChatForm = useSetAtom(showNewChatFormAtom) + const [pendingPluginAction, setPendingPluginAction] = useAtom( + pendingPluginActionAtom, + ) + const { + data: runtimePlugins = [], + isFetching: isFetchingRuntimePlugins, + refetch: refetchRuntimePlugins, + } = trpc.plugins.list.useQuery(undefined, { + staleTime: 5 * 60 * 1000, + }) + const { + data: runtimeSkills = [], + isFetching: isFetchingRuntimeSkills, + refetch: refetchRuntimeSkills, + } = trpc.skills.list.useQuery( + selectedProject?.path ? { cwd: selectedProject.path } : undefined, + { + staleTime: 5 * 60 * 1000, + }, + ) + const setPluginEnabledMutation = + trpc.claudeSettings.setPluginEnabled.useMutation() + const runtimePluginByCatalogId = useMemo(() => { + const next = new Map() + + for (const plugin of MARKETPLACE_PLUGINS) { + const runtimePlugin = findRuntimePluginForCatalog(plugin, runtimePlugins) + if (runtimePlugin) next.set(plugin.id, runtimePlugin) + } + + return next + }, [runtimePlugins]) + const catalogPluginByRuntimeSource = useMemo(() => { + const next = new Map() + + for (const [catalogPluginId, runtimePlugin] of runtimePluginByCatalogId) { + const catalogPlugin = findCatalogPluginById(catalogPluginId) + if (catalogPlugin) next.set(runtimePlugin.source, catalogPlugin) + } + + return next + }, [runtimePluginByCatalogId]) + const effectiveInstalledPluginIds = useMemo(() => { + const next = new Set(installedPluginIds) + + for (const plugin of CONNECTED_PLUGINS) { + next.add(plugin.id) + } + + for (const [pluginId, runtimePlugin] of runtimePluginByCatalogId) { + if (!runtimePlugin.isDisabled) next.add(pluginId) + } + + return next + }, [installedPluginIds, runtimePluginByCatalogId]) + const selectedPluginRuntimePlugin = selectedPlugin + ? runtimePluginByCatalogId.get(selectedPlugin.id) ?? null + : null + const selectedPluginDisabledSkillNames = useMemo(() => { + const next = new Set() + if (!selectedPlugin) return next + + const detail = getCatalogPluginDetail(selectedPlugin, selectedPluginRuntimePlugin) + for (const skillName of detail.skills) { + if (disabledDetailSkillKeys.has(getDetailSkillKey(selectedPlugin.id, skillName))) { + next.add(skillName) + } + } + return next + }, [disabledDetailSkillKeys, selectedPlugin, selectedPluginRuntimePlugin]) + const uninstallCandidateRuntimePlugin = uninstallCandidate + ? runtimePluginByCatalogId.get(uninstallCandidate.id) ?? null + : null + const pluginManageTabs = useMemo( + () => + getPluginManageTabs({ + runtimePluginCount: runtimePlugins.length, + runtimeSkillCount: runtimeSkills.length, + }), + [runtimePlugins.length, runtimeSkills.length], + ) + const allowedManageTabIds = useMemo( + () => getPluginEntrySurfaceManageTabIds(surface), + [surface], + ) + const visiblePluginManageTabs = useMemo( + () => pluginManageTabs.filter((tab) => allowedManageTabIds.includes(tab.id)), + [allowedManageTabIds, pluginManageTabs], + ) + const pluginOnboardingSuggestions = useMemo( + () => buildPluginOnboardingSuggestions(), + [], + ) + const selectedOnboardingRole = + pluginOnboardingSuggestions.find( + (suggestion) => suggestion.id === selectedOnboardingRoleId, + ) ?? pluginOnboardingSuggestions[0] + const isRefreshingRuntime = isFetchingRuntimePlugins || isFetchingRuntimeSkills + const showOnboardingSuggestions = false + + const resetPluginEntrySurface = useCallback(() => { + const nextDefaults = getPluginEntrySurfaceDefaults(surface) + setPageTab(nextDefaults.pageTab) + setEntryMode(nextDefaults.entryMode) + setManageTab(nextDefaults.manageTab) + setSelectedPlugin(null) + setUninstallCandidate(null) + setCopiedPluginLinkId(null) + setPluginMutationError(null) + setPluginRouteState(buildPluginEntrySurfaceRoute(surface)) + }, [surface]) + + useEffect(() => { + resetPluginEntrySurface() + }, [resetPluginEntrySurface]) + + useEffect(() => { + resetPluginEntrySurface() + }, [pluginEntryNavigationNonce, resetPluginEntrySurface]) + + useEffect(() => { + if ( + pluginRouteState.surface !== "detail" && + pluginRouteState.surface !== "install" && + pluginRouteState.surface !== "uninstall" + ) { + setSelectedPlugin(null) + return + } + + const plugin = findCatalogPluginById(pluginRouteState.pluginId) + if (!plugin) return + + setSelectedPlugin((current) => + current?.id === plugin.id ? current : plugin, + ) + + if (pluginRouteState.surface === "uninstall") { + setUninstallCandidate((current) => + current?.id === plugin.id ? current : plugin, + ) + } + }, [pluginRouteState]) + + useEffect(() => { + if (!selectedPlugin) return + catalogMainRef.current?.scrollTo({ top: 0 }) + }, [selectedPlugin]) + + const openPluginManage = () => { + const nextManageTab = coercePluginManageTabForSurface(surface, "plugins") + setPageTab("plugins") + setEntryMode("manage") + setManageTab(nextManageTab) + setPluginRouteState( + buildPluginBrowseRoute({ + pageTab: "plugins", + entryMode: "manage", + manageTab: nextManageTab, + }), + ) + } + + const openPluginManageTab = (tab: PluginManageTabId) => { + const nextManageTab = coercePluginManageTabForSurface(surface, tab) + setManageTab(nextManageTab) + setPluginRouteState( + buildPluginBrowseRoute({ + pageTab: "plugins", + entryMode: "manage", + manageTab: nextManageTab, + }), + ) + } + + const openPluginTopTab = (tab: PluginTopTabId) => { + if (tab === "skills") { + setPageTab("skills") + setEntryMode("browse") + setManageTab("skills") + setPluginRouteState( + buildPluginBrowseRoute({ + pageTab: "skills", + entryMode: "browse", + manageTab: "skills", + }), + ) + return + } + + setPageTab("plugins") + setEntryMode("browse") + setManageTab("plugins") + setPluginRouteState( + buildPluginBrowseRoute({ + pageTab: "plugins", + entryMode: "browse", + manageTab: "plugins", + }), + ) + } + + const getActivePluginRouteOrigin = () => + getPluginRouteOriginFromState(pluginRouteState) + + const closeCatalogPluginDetail = () => { + setSelectedPlugin(null) + setCopiedPluginLinkId(null) + setPluginMutationError(null) + setPluginRouteState(closePluginOverlayRoute(pluginRouteState)) + } + + const toggleCatalogPluginSkill = ( + plugin: CatalogPlugin, + skillName: string, + nextEnabled: boolean, + ) => { + setPluginMutationError(null) + setDisabledDetailSkillKeys((current) => { + const next = new Set(current) + const key = getDetailSkillKey(plugin.id, skillName) + if (nextEnabled) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + const startNewChatWithText = (prompt: string) => { + const draftId = generateDraftId() + saveNewChatDraft(draftId, prompt) + setSelectedChatId(null) + setSelectedDraftId(draftId) + setShowNewChatForm(false) + setDesktopView(null) + } + + const startNewChatWithPrompt = (promptKey: string) => { + startNewChatWithText(t(promptKey)) + } + + const startRuntimeSkillChat = (skill: RuntimeSkillData) => { + startNewChatWithText( + t("pluginsPage.skills.runtimePrompt", { + skill: formatRuntimeLabel(skill.name), + }), + ) + } + + const startNewSkillChat = () => { + startNewChatWithPrompt("pluginsPage.skills.createPrompt") + } + + const startOnboardingRoleChat = ( + role: PluginOnboardingRoleSuggestion, + ) => { + setPluginMutationError(null) + startNewChatWithPrompt(role.starterPromptKey) + } + + const handleManagedResourceAction = (resource: ManagedResource) => { + const targetId = getManagedResourceRouteTarget(resource) + setPluginMutationError(null) + setPluginRouteState( + openPluginResourceRoute({ + actionId: resource.actionId, + origin: getActivePluginRouteOrigin(), + promptKey: resource.promptKey, + resourceId: resource.id, + resourceKind: resource.kind, + targetId, + }), + ) + + if (resource.actionId === "try-in-chat" && resource.promptKey) { + startNewChatWithPrompt(resource.promptKey) + } + } + + const openCatalogPluginDetail = (plugin: CatalogPlugin) => { + setPluginMutationError(null) + setPluginRouteState(openPluginDetailRoute(plugin.id, getActivePluginRouteOrigin())) + setSelectedPlugin(plugin) + } + + useEffect(() => { + if (!pendingPluginAction) return + + const plugin = findCatalogPluginById(pendingPluginAction.pluginId) + setPendingPluginAction(null) + + if (!plugin) return + + const origin = getPluginRouteOrigin({ + pageTab: "plugins", + entryMode: "browse", + manageTab: "plugins", + }) + + setPluginMutationError(null) + setCopiedPluginLinkId(null) + setPageTab("plugins") + setEntryMode("browse") + setManageTab("plugins") + + if (pendingPluginAction.action === "try-in-chat") { + const detail = CATALOG_PLUGIN_DETAILS[plugin.id] + setSelectedPlugin(null) + setPluginRouteState( + startPluginTryInChatRoute(plugin.id, origin, detail.promptKey), + ) + startNewChatWithPrompt(detail.promptKey) + return + } + + setPluginRouteState( + openPluginDetailRoute(plugin.id, origin), + ) + setSelectedPlugin(plugin) + }, [pendingPluginAction, setPendingPluginAction]) + + const startCatalogPluginChat = (plugin: CatalogPlugin) => { + const detail = CATALOG_PLUGIN_DETAILS[plugin.id] + setPluginRouteState( + startPluginTryInChatRoute( + plugin.id, + getActivePluginRouteOrigin(), + detail.promptKey, + ), + ) + startNewChatWithPrompt(detail.promptKey) + } + + const installCatalogPlugin = async (plugin: CatalogPlugin) => { + const title = t(plugin.titleKey) + const runtimePlugin = runtimePluginByCatalogId.get(plugin.id) ?? null + const origin = getActivePluginRouteOrigin() + setPluginMutationError(null) + setInstallingPluginId(plugin.id) + setPluginRouteState(startPluginInstallRoute(plugin.id, origin)) + + try { + await waitForPluginMutationFrame() + + if (runtimePlugin) { + await setPluginEnabledMutation.mutateAsync({ + pluginSource: runtimePlugin.source, + enabled: true, + }) + await refetchRuntimePlugins() + } else { + setInstalledPluginIds((current) => { + const next = new Set(current) + next.add(plugin.id) + return next + }) + } + setPluginRouteState(finishPluginInstallRoute(plugin.id, origin, "success")) + } catch { + setPluginMutationError( + t("pluginsPage.detail.installFailed", { plugin: title }), + ) + setPluginRouteState(finishPluginInstallRoute(plugin.id, origin, "error")) + } finally { + setInstallingPluginId(null) + } + } + + const uninstallCatalogPlugin = (plugin: CatalogPlugin) => { + setPluginMutationError(null) + setPluginRouteState( + requestPluginUninstallRoute(plugin.id, getActivePluginRouteOrigin()), + ) + setUninstallCandidate(plugin) + } + + const confirmUninstallCatalogPlugin = async (plugin: CatalogPlugin) => { + const title = t(plugin.titleKey) + const runtimePlugin = runtimePluginByCatalogId.get(plugin.id) ?? null + const origin = getActivePluginRouteOrigin() + setPluginMutationError(null) + setUninstallingPluginId(plugin.id) + setPluginRouteState(startPluginUninstallRoute(plugin.id, origin)) + + try { + await waitForPluginMutationFrame() + + if (runtimePlugin) { + await setPluginEnabledMutation.mutateAsync({ + pluginSource: runtimePlugin.source, + enabled: false, + }) + await refetchRuntimePlugins() + } else { + setInstalledPluginIds((current) => { + const next = new Set(current) + next.delete(plugin.id) + return next + }) + } + + setUninstallCandidate(null) + setPluginRouteState( + finishPluginUninstallRoute(plugin.id, origin, "success"), + ) + } catch { + setPluginMutationError( + t("pluginsPage.detail.uninstallFailed", { plugin: title }), + ) + setPluginRouteState(finishPluginUninstallRoute(plugin.id, origin, "error")) + } finally { + setUninstallingPluginId(null) + } + } + + const copyCatalogPluginLink = async (plugin: CatalogPlugin) => { + const title = t(plugin.titleKey) + setPluginMutationError(null) + + try { + await writeClipboardText(getCatalogPluginLink(plugin)) + setCopiedPluginLinkId(plugin.id) + window.setTimeout(() => { + setCopiedPluginLinkId((current) => + current === plugin.id ? null : current, + ) + }, 1600) + } catch { + setPluginMutationError( + t("pluginsPage.detail.copyLinkFailed", { plugin: title }), + ) + } + } + + const cancelUninstallCatalogPlugin = () => { + if (uninstallingPluginId) return + if (uninstallCandidate) { + setPluginRouteState( + cancelPluginUninstallRoute( + uninstallCandidate.id, + getActivePluginRouteOrigin(), + ), + ) + } + setPluginMutationError(null) + setUninstallCandidate(null) + } + + const isSkillsPage = surface === "skills" || pageTab === "skills" + const isMcpSettingsPage = surface === "mcp-settings" + const surfaceTitleKey = getPluginSurfaceTitleKey(surface) + const surfaceSubtitleKey = getPluginSurfaceSubtitleKey(surface, entryMode) + const surfaceSearchKey = getPluginSurfaceSearchKey(surface) + + return ( +
+ {!selectedPlugin ? ( +
+
+ {t(surfaceTitleKey)} +
+ {!isMcpSettingsPage ? ( +
+ + +
+ ) : null} +
+ {!isMcpSettingsPage ? ( + + ) : null} + +
+
+ ) : null} + +
+
+ {selectedPlugin ? ( + openPluginManageTab("mcps")} + onCopyLink={() => copyCatalogPluginLink(selectedPlugin)} + onOpenSkills={() => openPluginManageTab("skills")} + onInstall={() => { + void installCatalogPlugin(selectedPlugin) + }} + onStartPrompt={startNewChatWithPrompt} + onToggleSkill={(skillName, nextEnabled) => + toggleCatalogPluginSkill(selectedPlugin, skillName, nextEnabled) + } + onTryInChat={() => startCatalogPluginChat(selectedPlugin)} + onUninstall={() => uninstallCatalogPlugin(selectedPlugin)} + runtimePlugin={selectedPluginRuntimePlugin} + uninstalling={uninstallingPluginId === selectedPlugin.id} + /> + ) : ( + <> +

+ {t(surfaceTitleKey)} +

+

+ {t(surfaceSubtitleKey)} +

+ +
+ + +
+ + {isSkillsPage ? ( + <> +
+

+ {t("pluginsPage.skills.installed")} +

+
+ {runtimeSkills.length > 0 ? ( + runtimeSkills.map((skill) => ( + + )) + ) : ( + + )} +
+
+ +
+
+ + +
+
+ {RECOMMENDED_SKILLS.map((resource) => ( + + ))} +
+
+ + ) : entryMode === "manage" ? ( + <> + {visiblePluginManageTabs.length > 1 ? ( +
+ {visiblePluginManageTabs.map((tab) => ( + + ))} +
+ ) : null} + + {manageTab === "plugins" && ( +
+
+

+ {t("pluginsPage.manage.installed")} +

+ +
+
+ {runtimePlugins.length > 0 + ? runtimePlugins.map((runtimePlugin) => ( + + )) + : CONNECTED_PLUGINS.map((plugin) => ( + + ))} +
+
+ )} + + {manageTab === "apps" && ( +
+

+ {t("pluginsPage.manage.apps")} +

+
+ {getManagedResourcesForTab("apps").map((resource) => ( + + ))} +
+
+ )} + + {manageTab === "mcps" && ( +
+

+ {t("pluginsPage.manage.mcps")} +

+
+ {getManagedResourcesForTab("mcps").map((resource) => ( + + ))} +
+
+ )} + + {manageTab === "skills" && ( +
+

+ {t("pluginsPage.manage.skills")} +

+
+ {runtimeSkills.length > 0 ? ( + runtimeSkills.map((skill) => ( + + )) + ) : !isFetchingRuntimeSkills ? ( + getManagedResourcesForTab("skills").map((resource) => ( + + )) + ) : ( + + )} +
+
+ )} + + {manageTab === "marketplace" && ( +
+

+ {t("pluginsPage.manage.marketplace")} +

+
+ {MARKETPLACE_PLUGINS.map((plugin) => ( + openCatalogPluginDetail(plugin)} + onOpen={() => openCatalogPluginDetail(plugin)} + onTryInChat={() => startCatalogPluginChat(plugin)} + /> + ))} +
+
+ )} + + ) : ( + <> + {showOnboardingSuggestions && selectedOnboardingRole ? ( +
+
+
+

+ {t("pluginsPage.onboarding.title")} +

+

+ {t("pluginsPage.onboarding.description")} +

+
+ +
+ +
+ {pluginOnboardingSuggestions.map((role) => ( + + ))} +
+ +

+ {t(selectedOnboardingRole.descriptionKey)} +

+
+ {selectedOnboardingRole.resources.map((resource) => ( + + ))} + {selectedOnboardingRole.catalogPlugins.map((plugin) => ( + openCatalogPluginDetail(plugin)} + onOpen={() => openCatalogPluginDetail(plugin)} + onTryInChat={() => startCatalogPluginChat(plugin)} + /> + ))} +
+
+ ) : null} + +
+ + + +
+ +
+
+

+ {t("pluginsPage.connected")} +

+ +
+
+ {CONNECTED_PLUGINS.map((plugin) => ( + + + + ))} + + + {t("pluginsPage.connectedMore")} + +
+
+ +
+ +
+ {FEATURED_PLUGINS.map((plugin) => ( + openCatalogPluginDetail(plugin)} + onOpen={() => openCatalogPluginDetail(plugin)} + onTryInChat={() => startCatalogPluginChat(plugin)} + /> + ))} +
+ +
+ +
+

+ {t("pluginsPage.productivity")} +

+
+ {PRODUCTIVITY_PLUGINS.map((plugin) => ( + openCatalogPluginDetail(plugin)} + onOpen={() => openCatalogPluginDetail(plugin)} + onTryInChat={() => startCatalogPluginChat(plugin)} + /> + ))} +
+
+ +
+

+ {t("pluginsPage.business")} +

+
+ {BUSINESS_PLUGINS.map((plugin) => ( + openCatalogPluginDetail(plugin)} + onOpen={() => openCatalogPluginDetail(plugin)} + onTryInChat={() => startCatalogPluginChat(plugin)} + /> + ))} +
+
+ + )} + + )} +
+
+ {uninstallCandidate ? ( + { + void confirmUninstallCatalogPlugin(uninstallCandidate) + }} + /> + ) : null} +
+ ) +} + +export function SkillsEntryView() { + return +} + +export function McpSettingsEntryView() { + return +} diff --git a/src/renderer/features/plugins/plugin-resource-model.ts b/src/renderer/features/plugins/plugin-resource-model.ts new file mode 100644 index 000000000..8c219fb2f --- /dev/null +++ b/src/renderer/features/plugins/plugin-resource-model.ts @@ -0,0 +1,794 @@ +import type { + PluginManageTabId, + PluginResourceRouteActionId, + PluginResourceRouteKind, +} from "./plugin-route-state" + +export type PluginLogoId = + | "actively" + | "apollo" + | "calendar" + | "chrome" + | "codex-labs" + | "cursor" + | "docs" + | "drive" + | "figma" + | "github" + | "gmail" + | "hubspot" + | "huggingface" + | "linear" + | "notion" + | "plugin-grid" + | "plugin-store" + | "purple-dots" + | "record-replay" + | "salesforce" + | "sheets" + | "skill" + | "slack" + | "slides" + | "striped" + +export type CatalogPlugin = { + id: string + logo: PluginLogoId + titleKey: string + descriptionKey: string +} + +export type CatalogPluginDetail = { + developer: string + apps: string[] + skills: string[] + mcps: string[] + capabilities: string[] + promptKey: string + category?: string + componentDescriptions?: { + apps?: Record + skills?: Record + mcps?: Record + } + longDescriptionKey?: string + promptExampleKeys?: string[] + termsOfService?: string + version?: string + website?: string + privacyPolicy?: string +} + +export type RuntimePluginComponent = { + name: string + description?: string +} + +export type RuntimePluginData = { + name: string + version: string + description?: string + path: string + source: string + marketplace: string + category?: string + homepage?: string + tags?: string[] + isDisabled: boolean + components: { + commands: RuntimePluginComponent[] + skills: RuntimePluginComponent[] + agents: RuntimePluginComponent[] + mcpServers: string[] + } +} + +export type RuntimeSkillData = { + name: string + description: string + source: "moss" | "user" | "project" | "plugin" + pluginName?: string + path: string + content: string +} + +export type ConnectedPlugin = { + id: string + logo: PluginLogoId + label: string +} + +export type ManagedResourceKind = PluginResourceRouteKind +export type ManagedResourceActionId = PluginResourceRouteActionId +export type ManagedResourceConnectionState = + | "connected" + | "enabled" + | "disabled" + | "needs-auth" + | "pending" + | "recommended" + | "unavailable" + +export type ManagedResource = { + id: string + kind: ManagedResourceKind + actionId: ManagedResourceActionId + connectionState: ManagedResourceConnectionState + logo: PluginLogoId + title: string + descriptionKey: string + statusKey: string + actionKey: string + capabilityIds?: string[] + connectorId?: string + mcpServerName?: string + promptKey?: string + targetId?: string +} + +export type PluginManageTab = { + id: PluginManageTabId + labelKey: string + count: number +} + +export type PluginOnboardingRoleId = "developer" | "operator" | "researcher" + +type PluginOnboardingRoleDefinition = { + id: PluginOnboardingRoleId + titleKey: string + descriptionKey: string + starterPromptKey: string + resourceIds: string[] + catalogPluginIds: string[] +} + +export type PluginOnboardingRoleSuggestion = PluginOnboardingRoleDefinition & { + resources: ManagedResource[] + catalogPlugins: CatalogPlugin[] +} + +export const CONNECTED_PLUGINS: ConnectedPlugin[] = [ + { id: "docs", logo: "docs", label: "Google Docs" }, + { id: "sheets", logo: "sheets", label: "Google Sheets" }, + { id: "slides", logo: "slides", label: "Google Slides" }, + { id: "cursor", logo: "cursor", label: "Cursor" }, + { id: "chrome", logo: "chrome", label: "Chrome" }, + { id: "codex-labs", logo: "codex-labs", label: "Codex Labs" }, + { id: "record-and-replay", logo: "record-replay", label: "Record & Replay" }, + { id: "purple-dots", logo: "purple-dots", label: "Research" }, + { id: "linear", logo: "linear", label: "Linear" }, + { id: "gmail", logo: "gmail", label: "Gmail" }, + { id: "huggingface", logo: "huggingface", label: "Hugging Face" }, + { id: "plugin-store", logo: "plugin-store", label: "Plugin Store" }, + { id: "striped", logo: "striped", label: "Raycast" }, + { id: "plugin-grid", logo: "plugin-grid", label: "Workflows" }, +] + +export const FEATURED_PLUGINS: CatalogPlugin[] = [ + { + id: "github", + logo: "github", + titleKey: "pluginsPage.catalog.github.title", + descriptionKey: "pluginsPage.catalog.github.description", + }, + { + id: "slack", + logo: "slack", + titleKey: "pluginsPage.catalog.slack.title", + descriptionKey: "pluginsPage.catalog.slack.description", + }, + { + id: "notion", + logo: "notion", + titleKey: "pluginsPage.catalog.notion.title", + descriptionKey: "pluginsPage.catalog.notion.description", + }, + { + id: "google-calendar", + logo: "calendar", + titleKey: "pluginsPage.catalog.googleCalendar.title", + descriptionKey: "pluginsPage.catalog.googleCalendar.description", + }, + { + id: "drive", + logo: "drive", + titleKey: "pluginsPage.catalog.googleDrive.title", + descriptionKey: "pluginsPage.catalog.googleDrive.description", + }, +] + +export const PRODUCTIVITY_PLUGINS: CatalogPlugin[] = [ + { + id: "record-and-replay", + logo: "record-replay", + titleKey: "pluginsPage.catalog.recordReplay.title", + descriptionKey: "pluginsPage.catalog.recordReplay.description", + }, +] + +export const BUSINESS_PLUGINS: CatalogPlugin[] = [ + { + id: "actively", + logo: "actively", + titleKey: "pluginsPage.catalog.actively.title", + descriptionKey: "pluginsPage.catalog.actively.description", + }, + { + id: "apollo", + logo: "apollo", + titleKey: "pluginsPage.catalog.apollo.title", + descriptionKey: "pluginsPage.catalog.apollo.description", + }, + { + id: "salesforce", + logo: "salesforce", + titleKey: "pluginsPage.catalog.salesforce.title", + descriptionKey: "pluginsPage.catalog.salesforce.description", + }, + { + id: "hubspot", + logo: "hubspot", + titleKey: "pluginsPage.catalog.hubspot.title", + descriptionKey: "pluginsPage.catalog.hubspot.description", + }, +] + +export const MARKETPLACE_PLUGINS = [ + ...FEATURED_PLUGINS, + ...PRODUCTIVITY_PLUGINS, + ...BUSINESS_PLUGINS, +] + +export const CATALOG_PLUGIN_DETAILS: Record = { + github: { + developer: "OpenAI", + apps: ["GitHub"], + skills: ["GitHub Fix CI", "GitHub", "Review Follow-up", "Publish Changes"], + mcps: ["GitHub"], + capabilities: ["Interactive", "Write"], + category: "Developer Tools", + componentDescriptions: { + apps: { + GitHub: + "Access repositories, issues, and pull requests. Required for some features such as Codex", + }, + skills: { + "GitHub Fix CI": "Debug failing GitHub Actions CI", + GitHub: + "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.", + "Review Follow-up": "Address actionable PR feedback", + "Publish Changes": "Commit, push, and open a PR", + }, + }, + longDescriptionKey: "pluginsPage.catalog.github.longDescription", + promptKey: "pluginsPage.catalog.github.prompt", + promptExampleKeys: ["pluginsPage.catalog.github.promptExample"], + privacyPolicy: "https://openai.com/policies/privacy-policy", + termsOfService: "https://openai.com/policies/terms-of-use", + website: "https://github.com", + }, + slack: { + developer: "OpenAI", + apps: ["Slack"], + skills: ["Messages"], + mcps: ["Slack"], + capabilities: ["Channels", "Threads", "Team context"], + promptKey: "pluginsPage.catalog.slack.prompt", + }, + notion: { + developer: "OpenAI", + apps: ["Notion"], + skills: ["Knowledge capture"], + mcps: ["Notion"], + capabilities: ["Docs", "Databases", "Meeting notes"], + promptKey: "pluginsPage.catalog.notion.prompt", + }, + "google-calendar": { + developer: "OpenAI", + apps: ["Google Calendar"], + skills: ["Scheduling"], + mcps: ["Calendar"], + capabilities: ["Events", "Availability", "Meeting prep"], + promptKey: "pluginsPage.catalog.googleCalendar.prompt", + }, + drive: { + developer: "OpenAI", + apps: ["Google Drive", "Docs", "Sheets", "Slides"], + skills: ["Documents"], + mcps: ["Drive"], + capabilities: ["Files", "Docs", "Sheets", "Slides"], + promptKey: "pluginsPage.catalog.googleDrive.prompt", + }, + "record-and-replay": { + developer: "OpenAI", + apps: [], + skills: ["Record and Replay"], + mcps: ["Event-stream"], + capabilities: ["Interactive", "Write"], + category: "Productivity", + componentDescriptions: { + skills: { + "Record and Replay": + "Record the user's actions on their Mac with Record & Replay, and turn it into a reusable Codex skill from the captured event stream.", + }, + }, + longDescriptionKey: "pluginsPage.catalog.recordReplay.longDescription", + promptKey: "pluginsPage.catalog.recordReplay.prompt", + promptExampleKeys: [ + "pluginsPage.catalog.recordReplay.promptExample.workflow", + "pluginsPage.catalog.recordReplay.promptExample.task", + "pluginsPage.catalog.recordReplay.promptExample.fileExpense", + ], + privacyPolicy: "https://openai.com/policies/row-privacy-policy/", + termsOfService: "https://openai.com/policies/row-terms-of-use/", + version: "1.0.857", + website: "https://openai.com/", + }, + actively: { + developer: "OpenAI", + apps: ["Actively"], + skills: ["Account research"], + mcps: ["Actively"], + capabilities: ["Prospects", "Signals", "Account context"], + promptKey: "pluginsPage.catalog.actively.prompt", + }, + apollo: { + developer: "OpenAI", + apps: ["Apollo"], + skills: ["Sales research"], + mcps: ["Apollo"], + capabilities: ["Leads", "Companies", "Engagement"], + promptKey: "pluginsPage.catalog.apollo.prompt", + }, + salesforce: { + developer: "OpenAI", + apps: ["Salesforce"], + skills: ["CRM"], + mcps: ["Salesforce"], + capabilities: ["Accounts", "Opportunities", "Records"], + promptKey: "pluginsPage.catalog.salesforce.prompt", + }, + hubspot: { + developer: "OpenAI", + apps: ["HubSpot"], + skills: ["CRM"], + mcps: ["HubSpot"], + capabilities: ["Contacts", "Deals", "Notes"], + promptKey: "pluginsPage.catalog.hubspot.prompt", + }, +} + +export const MANAGED_APPS: ManagedResource[] = [ + { + id: "google-drive", + kind: "app", + actionId: "manage", + connectionState: "connected", + connectorId: "google-drive", + logo: "drive", + title: "Google Drive", + descriptionKey: "pluginsPage.manage.apps.drive.description", + statusKey: "pluginsPage.status.connected", + actionKey: "pluginsPage.manage", + }, + { + id: "gmail", + kind: "app", + actionId: "manage", + connectionState: "connected", + connectorId: "gmail", + logo: "gmail", + title: "Gmail", + descriptionKey: "pluginsPage.manage.apps.gmail.description", + statusKey: "pluginsPage.status.connected", + actionKey: "pluginsPage.manage", + }, + { + id: "google-calendar", + kind: "app", + actionId: "manage", + connectionState: "connected", + connectorId: "google-calendar", + logo: "calendar", + title: "Google Calendar", + descriptionKey: "pluginsPage.manage.apps.calendar.description", + statusKey: "pluginsPage.status.connected", + actionKey: "pluginsPage.manage", + }, + { + id: "chrome", + kind: "app", + actionId: "manage", + connectionState: "enabled", + connectorId: "chrome", + logo: "chrome", + title: "Chrome", + descriptionKey: "pluginsPage.manage.apps.chrome.description", + statusKey: "pluginsPage.status.enabled", + actionKey: "pluginsPage.manage", + }, +] + +export const MANAGED_MCPS: ManagedResource[] = [ + { + id: "github-mcp", + kind: "mcp", + actionId: "open-mcp-capability", + connectionState: "enabled", + capabilityIds: ["pull-requests", "issues", "ci", "releases"], + logo: "github", + mcpServerName: "GitHub", + title: "GitHub", + descriptionKey: "pluginsPage.manage.mcps.github.description", + statusKey: "pluginsPage.status.enabled", + actionKey: "pluginsPage.manage", + }, + { + id: "linear-mcp", + kind: "mcp", + actionId: "open-mcp-capability", + connectionState: "enabled", + capabilityIds: ["issues", "projects", "cycles"], + logo: "linear", + mcpServerName: "Linear", + title: "Linear", + descriptionKey: "pluginsPage.manage.mcps.linear.description", + statusKey: "pluginsPage.status.enabled", + actionKey: "pluginsPage.manage", + }, + { + id: "slack-mcp", + kind: "mcp", + actionId: "open-mcp-capability", + connectionState: "enabled", + capabilityIds: ["channels", "threads", "messages"], + logo: "slack", + mcpServerName: "Slack", + title: "Slack", + descriptionKey: "pluginsPage.manage.mcps.slack.description", + statusKey: "pluginsPage.status.enabled", + actionKey: "pluginsPage.manage", + }, + { + id: "notion-mcp", + kind: "mcp", + actionId: "connect-oauth", + connectionState: "needs-auth", + capabilityIds: ["docs", "databases"], + logo: "notion", + mcpServerName: "Notion", + title: "Notion", + descriptionKey: "pluginsPage.manage.mcps.notion.description", + statusKey: "pluginsPage.status.disabled", + actionKey: "pluginsPage.add", + }, +] + +export const MANAGED_SKILLS: ManagedResource[] = [ + { + id: "docs-skill", + kind: "managed-skill", + actionId: "try-in-chat", + connectionState: "enabled", + logo: "docs", + title: "Documents", + descriptionKey: "pluginsPage.manage.skills.documents.description", + statusKey: "pluginsPage.status.enabled", + actionKey: "pluginsPage.skills.try", + promptKey: "pluginsPage.skills.documents.prompt", + }, + { + id: "research-skill", + kind: "managed-skill", + actionId: "try-in-chat", + connectionState: "enabled", + logo: "purple-dots", + title: "Research", + descriptionKey: "pluginsPage.manage.skills.research.description", + statusKey: "pluginsPage.status.enabled", + actionKey: "pluginsPage.skills.try", + promptKey: "pluginsPage.skills.research.prompt", + }, + { + id: "workflow-skill", + kind: "managed-skill", + actionId: "try-in-chat", + connectionState: "enabled", + logo: "plugin-grid", + title: "Workflows", + descriptionKey: "pluginsPage.manage.skills.workflows.description", + statusKey: "pluginsPage.status.enabled", + actionKey: "pluginsPage.skills.try", + promptKey: "pluginsPage.skills.workflows.prompt", + }, +] + +export const RECOMMENDED_SKILLS: ManagedResource[] = [ + { + id: "browser-qa", + kind: "recommended-skill", + actionId: "install-skill", + connectionState: "recommended", + logo: "chrome", + title: "Browser QA", + descriptionKey: "pluginsPage.skills.recommended.browserQa.description", + statusKey: "pluginsPage.skills.status.recommended", + actionKey: "pluginsPage.skills.install", + }, + { + id: "github-ops", + kind: "recommended-skill", + actionId: "install-skill", + connectionState: "recommended", + logo: "github", + title: "GitHub Ops", + descriptionKey: "pluginsPage.skills.recommended.githubOps.description", + statusKey: "pluginsPage.skills.status.recommended", + actionKey: "pluginsPage.skills.install", + }, + { + id: "data-analytics", + kind: "recommended-skill", + actionId: "install-skill", + connectionState: "recommended", + logo: "plugin-store", + title: "Data Analytics", + descriptionKey: "pluginsPage.skills.recommended.dataAnalytics.description", + statusKey: "pluginsPage.skills.status.recommended", + actionKey: "pluginsPage.skills.install", + }, +] + +export const PLUGIN_ONBOARDING_ROLE_DEFINITIONS: PluginOnboardingRoleDefinition[] = [ + { + id: "developer", + titleKey: "pluginsPage.onboarding.roles.developer.title", + descriptionKey: "pluginsPage.onboarding.roles.developer.description", + starterPromptKey: "pluginsPage.onboarding.roles.developer.prompt", + resourceIds: ["github-mcp", "browser-qa", "github-ops", "chrome"], + catalogPluginIds: ["github"], + }, + { + id: "operator", + titleKey: "pluginsPage.onboarding.roles.operator.title", + descriptionKey: "pluginsPage.onboarding.roles.operator.description", + starterPromptKey: "pluginsPage.onboarding.roles.operator.prompt", + resourceIds: ["gmail", "google-calendar", "slack-mcp", "workflow-skill"], + catalogPluginIds: ["slack", "google-calendar"], + }, + { + id: "researcher", + titleKey: "pluginsPage.onboarding.roles.researcher.title", + descriptionKey: "pluginsPage.onboarding.roles.researcher.description", + starterPromptKey: "pluginsPage.onboarding.roles.researcher.prompt", + resourceIds: ["google-drive", "notion-mcp", "research-skill", "data-analytics"], + catalogPluginIds: ["drive", "notion"], + }, +] + +export const PLUGIN_MANAGE_TABS: PluginManageTab[] = [ + { + id: "plugins", + labelKey: "pluginsPage.manage.tabs.plugins", + count: CONNECTED_PLUGINS.length, + }, + { + id: "apps", + labelKey: "pluginsPage.manage.tabs.apps", + count: MANAGED_APPS.length, + }, + { + id: "mcps", + labelKey: "pluginsPage.manage.tabs.mcps", + count: MANAGED_MCPS.length, + }, + { + id: "skills", + labelKey: "pluginsPage.manage.tabs.skills", + count: MANAGED_SKILLS.length, + }, + { + id: "marketplace", + labelKey: "pluginsPage.manage.tabs.marketplace", + count: MARKETPLACE_PLUGINS.length, + }, +] + +export function findCatalogPluginById(pluginId: string) { + return MARKETPLACE_PLUGINS.find((plugin) => plugin.id === pluginId) +} + +export function normalizePluginHandle(value: string) { + return value + .toLowerCase() + .replace(/^@/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +export function formatRuntimeLabel(value: string) { + return value + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (character) => character.toUpperCase()) +} + +export function uniqueNonEmpty(items: Array) { + const seen = new Set() + const result: string[] = [] + + for (const item of items) { + const value = item?.trim() + if (!value) continue + + const key = value.toLowerCase() + if (seen.has(key)) continue + + seen.add(key) + result.push(value) + } + + return result +} + +export function getCatalogPluginRuntimeAliases(plugin: CatalogPlugin) { + if (plugin.id === "drive") return ["drive", "google-drive"] + if (plugin.id === "google-calendar") return ["google-calendar", "calendar"] + if (plugin.id === "record-and-replay") { + return ["record-and-replay", "record-replay"] + } + + return [plugin.id] +} + +export function findRuntimePluginForCatalog( + plugin: CatalogPlugin, + runtimePlugins: RuntimePluginData[], +) { + const aliases = new Set( + getCatalogPluginRuntimeAliases(plugin).map(normalizePluginHandle), + ) + + return ( + runtimePlugins.find((runtimePlugin) => { + const sourceName = runtimePlugin.source.split(":").pop() ?? runtimePlugin.source + const pathName = runtimePlugin.path.split("/").pop() ?? runtimePlugin.path + const candidates = uniqueNonEmpty([ + runtimePlugin.name, + runtimePlugin.source, + sourceName, + pathName, + runtimePlugin.category, + runtimePlugin.homepage, + ...(runtimePlugin.tags ?? []), + ]).map(normalizePluginHandle) + + return candidates.some((candidate) => aliases.has(candidate)) + }) ?? null + ) +} + +export function getCatalogPluginDetail( + plugin: CatalogPlugin, + runtimePlugin?: RuntimePluginData | null, +) { + const detail = CATALOG_PLUGIN_DETAILS[plugin.id] + if (!runtimePlugin) return detail + const runtimeApps = + detail.apps.length > 0 ? [...detail.apps, runtimePlugin.name] : detail.apps + const runtimeCategoryCapability = + detail.category == null && runtimePlugin.category + ? formatRuntimeLabel(runtimePlugin.category) + : null + + return { + ...detail, + apps: uniqueNonEmpty(runtimeApps), + skills: uniqueNonEmpty([ + ...detail.skills, + ...runtimePlugin.components.skills.map((skill) => skill.name), + ...runtimePlugin.components.commands.map((command) => command.name), + ]), + mcps: uniqueNonEmpty([ + ...detail.mcps, + ...runtimePlugin.components.mcpServers, + ]), + capabilities: uniqueNonEmpty([ + ...detail.capabilities, + runtimeCategoryCapability, + ...runtimePlugin.components.agents.map((agent) => agent.name), + ]), + } +} + +export function getRuntimePluginResourceCount( + runtimePlugin: RuntimePluginData, +) { + return ( + runtimePlugin.components.commands.length + + runtimePlugin.components.skills.length + + runtimePlugin.components.agents.length + + runtimePlugin.components.mcpServers.length + ) +} + +export function getRuntimeSkillLogo( + skill: RuntimeSkillData, + catalogPlugin?: CatalogPlugin | null, +): PluginLogoId { + if (catalogPlugin) return catalogPlugin.logo + if (skill.source === "moss") return "codex-labs" + if (skill.source === "project") return "docs" + if (skill.source === "user") return "purple-dots" + return "plugin-store" +} + +export function getManagedResourcesForTab(tab: PluginManageTabId) { + if (tab === "apps") return MANAGED_APPS + if (tab === "mcps") return MANAGED_MCPS + if (tab === "skills") return MANAGED_SKILLS + return [] +} + +export function getManagedResourceRouteTarget(resource: ManagedResource) { + return ( + resource.targetId ?? + resource.connectorId ?? + resource.mcpServerName ?? + resource.promptKey ?? + resource.id + ) +} + +export function canRunManagedResourceAction(resource: ManagedResource) { + return resource.actionId !== "unavailable" +} + +export function buildPluginOnboardingSuggestions(): PluginOnboardingRoleSuggestion[] { + const resourceById = new Map( + [ + ...MANAGED_APPS, + ...MANAGED_MCPS, + ...MANAGED_SKILLS, + ...RECOMMENDED_SKILLS, + ].map((resource) => [resource.id, resource]), + ) + + return PLUGIN_ONBOARDING_ROLE_DEFINITIONS.map((definition) => ({ + ...definition, + resourceIds: [...definition.resourceIds], + catalogPluginIds: [...definition.catalogPluginIds], + resources: definition.resourceIds + .map((resourceId) => resourceById.get(resourceId)) + .filter((resource): resource is ManagedResource => Boolean(resource)), + catalogPlugins: definition.catalogPluginIds + .map(findCatalogPluginById) + .filter((plugin): plugin is CatalogPlugin => Boolean(plugin)), + })) +} + +export function getPluginManageTabs({ + runtimePluginCount, + runtimeSkillCount, +}: { + runtimePluginCount: number + runtimeSkillCount: number +}) { + return PLUGIN_MANAGE_TABS.map((tab) => { + if (tab.id === "plugins") { + return { + ...tab, + count: runtimePluginCount || CONNECTED_PLUGINS.length, + } + } + + if (tab.id === "skills") { + return { + ...tab, + count: runtimeSkillCount || MANAGED_SKILLS.length, + } + } + + return tab + }) +} 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..cce663cb3 100644 --- a/src/renderer/lib/atoms/index.ts +++ b/src/renderer/lib/atoms/index.ts @@ -13,6 +13,8 @@ export { lastSelectedModelIdAtom, lastSelectedAgentIdAtom, lastSelectedRepoAtom, + defaultAgentApprovalPolicyAtom, + defaultAgentPermissionProfileAtom, selectedProjectAtom, agentsUnseenChangesAtom, agentsSubChatUnseenChangesAtom, @@ -68,6 +70,8 @@ export { type AgentsDebugMode, type SubChatFileChange, type AgentMode, + type AgentApprovalPolicy, + type AgentPermissionProfile, // Mode utilities AGENT_MODES, @@ -185,17 +189,39 @@ export type SettingsTab = | "profile" | "appearance" | "preferences" + | "capabilities" + | "agent" + | "billing" + | "code-review" | "models" + | "runtime" + | "desktop" + | "release" + | "configuration" + | "personalization" + | "pets" + | "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 +236,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 +811,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/i18n.ts b/src/renderer/lib/i18n.ts new file mode 100644 index 000000000..c139dc80f --- /dev/null +++ b/src/renderer/lib/i18n.ts @@ -0,0 +1,1620 @@ +import { useAtomValue } from "jotai"; +import { appLanguageAtom, type AppLanguage } from "./atoms"; + +type Dictionary = Record; + +const zhCN: Dictionary = { + "common.back": "返回", + "common.forward": "前进", + "common.refresh": "刷新", + "common.ready": "就绪", + "common.off": "关闭", + "common.enabled": "已启用", + "common.details": "详情", + "common.default": "默认", + + "settings.tabs.preferences": "通用", + "settings.tabs.capabilities": "能力", + "settings.tabs.profile": "个人资料", + "settings.tabs.account": "账户", + "settings.tabs.models": "模型", + "settings.tabs.usage": "用量", + "settings.tabs.billing": "用量和计费", + "settings.tabs.appearance": "外观", + "settings.tabs.agent": "Agent", + "settings.tabs.pets": "宠物", + "settings.tabs.keyboard": "键盘快捷键", + "settings.tabs.beta": "Beta", + "settings.tabs.projects": "项目", + "settings.tabs.runtime": "运行时", + "settings.tabs.desktop": "桌面", + "settings.tabs.release": "发布", + "settings.tabs.configuration": "配置", + "settings.tabs.personalization": "个性化", + "settings.tabs.environment": "环境", + "settings.tabs.memory": "记忆", + "settings.tabs.skills": "技能", + "settings.tabs.agents": "自定义智能体", + "settings.tabs.mcp": "MCP 服务器", + "settings.tabs.hooks": "钩子", + "settings.tabs.codeReview": "代码审查", + "settings.tabs.plugins": "插件", + "settings.tabs.archived": "已归档对话", + "settings.tabs.debug": "调试", + "settings.groups.personal": "个人", + "settings.groups.integrations": "集成", + "settings.groups.coding": "编码", + "settings.groups.advanced": "开发者", + + "preferences.title": "通用", + "preferences.description": "配置 Moss 的基础行为、语言和桌面体验", + "preferences.language.title": "语言", + "preferences.language.description": "切换应用界面语言", + "preferences.language.zh": "中文", + "preferences.language.en": "English", + "preferences.extendedThinking.title": "深度思考", + "preferences.extendedThinking.description": + "启用更深入的推理,会使用更多额度。", + "preferences.extendedThinking.note": "会关闭响应流式输出。", + "preferences.defaultMode.title": "默认模式", + "preferences.defaultMode.description": + "新对话的默认工作方式:计划只读,Agent 可修改文件", + "preferences.defaultMode.agent": "Agent", + "preferences.defaultMode.plan": "计划", + "preferences.coAuthored.title": "包含 Co-Authored-By", + "preferences.coAuthored.description": "Claude 提交代码时附加共同作者信息", + "preferences.desktopNotifications.title": "桌面通知", + "preferences.desktopNotifications.description": + "智能体需要输入或完成工作时显示系统通知", + "preferences.soundNotifications.title": "声音提醒", + "preferences.soundNotifications.description": + "你离开时智能体完成工作后播放提示音", + "preferences.notifyFocused.title": "窗口聚焦时也通知", + "preferences.notifyFocused.description": "应用窗口处于激活状态时仍显示通知", + "preferences.quickSwitch.title": "快速切换", + "preferences.quickSwitch.description": "设置 Ctrl+Tab 在哪些内容之间切换", + "preferences.quickSwitch.workspaces": "工作区", + "preferences.quickSwitch.agents": "智能体", + "preferences.autoAdvance.title": "归档后跳转", + "preferences.autoAdvance.description": "归档工作区之后跳转到哪里", + "preferences.autoAdvance.next": "下一个工作区", + "preferences.autoAdvance.previous": "上一个工作区", + "preferences.autoAdvance.close": "关闭工作区", + "preferences.preferredEditor.title": "默认打开应用", + "preferences.preferredEditor.description": "打开工作区时默认使用的应用", + "preferences.analytics.title": "共享使用分析", + "preferences.analytics.description": + "共享匿名使用数据以帮助改进 Moss。只记录功能使用和应用性能,不记录代码、提示词或消息,也不会用于训练。", + + "agentSettings.title": "Agent", + "agentSettings.description": + "设置新对话默认使用的审批策略、沙盒范围和本地依赖来源。", + "agentSettings.config.title": "Custom config.toml settings", + "agentSettings.config.description": + "自定义权限仍由 Codex 配置文件统一接管。", + "agentSettings.config.project.title": "Project config", + "agentSettings.config.project.description": + "当前工作区的项目级 Codex 配置。", + "agentSettings.config.global.title": "Global config", + "agentSettings.config.global.description": + "用户和管理级 Codex 配置的全局范围。", + "agentSettings.config.user.title": "User config", + "agentSettings.config.user.description": + "当前用户的 Codex config.toml 设置。", + "agentSettings.config.admin.title": "Admin config", + "agentSettings.config.admin.description": + "由管理员或托管策略提供的 Codex 设置。", + "agentSettings.config.admin.value": "Managed", + "agentSettings.defaultMode.title": "默认模式", + "agentSettings.defaultMode.description": + "新对话进入 Agent 或 Plan 的默认工作方式。", + "agentSettings.approval.title": "Approval policy", + "agentSettings.approval.description": + "新 Agent 对话默认如何请求操作确认。", + "agentSettings.approval.onRequest": "On request", + "agentSettings.approval.onFailure": "On failure", + "agentSettings.approval.untrusted": "Untrusted", + "agentSettings.approval.never": "Never", + "agentSettings.sandbox.title": "Sandbox settings", + "agentSettings.sandbox.description": + "新 Agent 对话默认可访问的本地范围。", + "agentSettings.sandbox.readOnly": "Read only", + "agentSettings.sandbox.workspaceWrite": "Workspace write", + "agentSettings.sandbox.fullAccess": "Full access", + "agentSettings.network.title": "Allow network access", + "agentSettings.network.description": + "当前运行时只能在 Full access 下同时放开网络。", + "agentSettings.dependencies.title": "Workspace Dependencies", + "agentSettings.dependencies.description": + "Codex 会话使用的本地 Workspace 运行时依赖。", + "agentSettings.dependencies.version.title": "Current version", + "agentSettings.dependencies.version.description": + "使用 1code 打包和运行时检测到的本地依赖。", + "agentSettings.dependencies.version.value": "Bundled", + "agentSettings.dependencies.codex.title": "Codex dependencies", + "agentSettings.dependencies.codex.description": + "新 Codex 会话使用应用内管理的运行时依赖。", + "agentSettings.dependencies.diagnose.title": + "Diagnose issues in Codex Workspace", + "agentSettings.dependencies.diagnose.description": + "本地诊断 API 尚未接入;此按钮保留 Codex Desktop 的入口位置。", + "agentSettings.dependencies.diagnose.label": + "Diagnose issues in Codex Workspace", + "agentSettings.dependencies.reset.title": "Reset and install Workspace", + "agentSettings.dependencies.reset.description": + "本地重装 API 尚未接入;避免执行没有后端保证的假重置。", + "agentSettings.dependencies.reset.label": "Reset and install Workspace", + "agentSettings.experimental.title": "Experimental features (Beta)", + "agentSettings.experimental.description": + "实验功能入口保持为状态展示,具体开关由 Beta 页面承接。", + + "settings.personalization.pets.title": "宠物", + "settings.personalization.pets.current": "已选择 {petName}", + "settings.personalization.pets.openPet": "唤醒宠物", + "settings.personalization.pets.tuckAwayPet": "收起宠物", + "settings.pets.custom.title": "自定义宠物", + "settings.pets.custom.openFolder": "打开文件夹", + "settings.pets.custom.openFolderError": "无法打开宠物文件夹", + "settings.pets.loadCustomError": "无法加载自定义宠物", + "petSettings.description": + "读取本机 Codex 宠物目录、Stop hook 和官方 pointer runtime 的当前状态。", + "petSettings.selectedAvatar.description": "Codex 配置中的当前头像:", + "petSettings.loading": "加载中", + "petSettings.none": "无", + "petSettings.noPets": "没有发现自定义宠物", + "petSettings.custom.count": "发现 {count} 个", + "petSettings.custom.loading": "正在读取自定义宠物目录。", + "petSettings.custom.selected": "已选择", + "petSettings.custom.available": "可用", + "petSettings.stopHook.title": "Stop hook", + "petSettings.runtime.title": "Official runtime", + "petSettings.runtime.description": "官方宠物 pointer runtime。", + "petSettings.window.title": "Official pet window", + "petSettings.window.description": "最近的 hook 日志没有窗口错误。", + "petSettings.lastDecision.title": "Last hook decision", + "petSettings.action.success": "宠物动作已发送", + "petSettings.status.ready": "就绪", + "petSettings.status.configured": "已配置", + "petSettings.status.available": "可用", + "petSettings.status.unknown": "未知", + "petSettings.status.missing": "缺失", + "petSettings.status.notConfigured": "未配置", + "petSettings.status.missingHook": "缺少 hook", + "petSettings.status.missingRuntime": "缺少 runtime", + "petSettings.status.windowUnavailable": "窗口不可用", + + "capabilities.title": "能力", + "capabilities.description": + "一套 Moss 配置同时服务 Hermes、Claude Code、Codex 和自定义智能体。", + "capabilities.unifiedSource.title": "Moss 统一源", + "capabilities.unifiedSource.description": + "安装一次,所有智能体都获得同一套工作区能力。", + "capabilities.memory.title": "记忆", + "capabilities.memory.description": "个人记忆和项目记忆在所有引擎间共享。", + "capabilities.skills.title": "技能和命令", + "capabilities.skills.description": "已安装技能可以从同一个输入框使用。", + "capabilities.mcp.title": "MCP 服务器", + "capabilities.mcp.description": "工具和连接器通过同一套审批策略路由。", + "capabilities.plugins.title": "插件", + "capabilities.plugins.description": "插件包可以同时提供技能、工具和智能体。", + "capabilities.hooks.title": "钩子", + "capabilities.hooks.description": "本地自动化只通过共享安全策略运行。", + "capabilities.subagents.title": "子智能体", + "capabilities.subagents.description": "需要时自动选择专门的智能体。", + "capabilities.providers.title": "提供商路由", + "capabilities.providers.description": "一份 provider 配置驱动所有引擎。", + "capabilities.projections.title": "引擎映射", + "capabilities.projections.description": + "Moss 在启动时把统一源映射给每个引擎。", + "capabilities.approval.title": "新工具访问前询问", + "capabilities.approval.description": "新的 MCP 或钩子能力在使用前需要确认。", + "capabilities.nativeScope.label": "Codex 原生和云端能力范围", + "capabilities.native.globalDictation.title": "全局听写", + "capabilities.native.globalDictation.description": + "麦克风采集保持门控,直到 Moss 拥有跨引擎的原生听写能力。", + "capabilities.native.realtime.title": "实时语音", + "capabilities.native.realtime.description": + "Realtime 会话需要 Moss 麦克风权限、传输层和引擎支持后才启用输入框控制。", + "capabilities.native.cloudEnvironments.title": "云环境", + "capabilities.native.cloudEnvironments.description": + "Codex 云端 setup script、secrets 和网络规则映射到 Moss 环境配置档。", + "capabilities.native.avatarOverlay.title": "头像浮层", + "capabilities.native.avatarOverlay.description": + "官方宠物保持 hook 驱动;1Code 不会从这个设置页启动第二个浮层。", + "capabilities.native.status.gated": "门控", + "capabilities.native.status.scoped": "已限定", + "capabilities.native.status.deferred": "延后", + "capabilities.status.enabled": "{count} 个已启用", + "capabilities.status.readyCount": "{ready}/{total} 就绪", + + "hotkeyWindow.ariaLabel": "Codex 快捷键窗口", + "hotkeyWindow.title": "键盘快捷键", + "hotkeyWindow.description": "Codex 风格的独立快捷键窗口。", + "hotkeyWindow.closeHint": "关闭窗口", + "hotkeyWindow.keyboardSettings": "键盘设置", + "hotkeyWindow.groups.general": "通用", + "hotkeyWindow.groups.start": "开始", + "hotkeyWindow.groups.navigation": "导航", + "hotkeyWindow.groups.view": "视图", + "hotkeyWindow.groups.workspace": "工作区", + "hotkeyWindow.actions.showShortcuts.title": "显示快捷键", + "hotkeyWindow.actions.showShortcuts.description": "打开这个独立窗口。", + "hotkeyWindow.actions.newConversation.title": "新对话", + "hotkeyWindow.actions.newConversation.description": "从当前工作区开始新的任务。", + "hotkeyWindow.actions.goToFile.title": "转到文件", + "hotkeyWindow.actions.goToFile.description": "搜索并打开工作区文件。", + "hotkeyWindow.actions.searchChat.title": "搜索当前对话", + "hotkeyWindow.actions.searchChat.description": "在当前消息流里查找文本。", + "hotkeyWindow.actions.toggleSidebar.title": "切换侧边栏", + "hotkeyWindow.actions.toggleSidebar.description": "显示或隐藏 Codex 风格主侧边栏。", + "hotkeyWindow.actions.settings.title": "设置", + "hotkeyWindow.actions.settings.description": "打开桌面设置。", + "hotkeyWindow.actions.openEditor.title": "在编辑器中打开", + "hotkeyWindow.actions.openEditor.description": "用首选编辑器打开工作区。", + "hotkeyWindow.actions.openPreviewFile.title": "打开预览文件", + "hotkeyWindow.actions.openPreviewFile.description": "在编辑器中打开当前预览文件。", + "hotkeyWindow.pages.back": "返回", + "hotkeyWindow.pages.newThread.title": "新对话", + "hotkeyWindow.pages.newThread.description": + "从当前工作区打开一个新的 Codex 任务,而不离开快捷键窗口的上下文。", + "hotkeyWindow.pages.newThread.primaryAction": "开始新对话", + "hotkeyWindow.pages.newThread.composerHint": "输入任务,提交后会在这里显示 worktree 状态。", + "hotkeyWindow.pages.worktreeInit.title": "正在创建 worktree", + "hotkeyWindow.pages.worktreeInit.description": + "创建隔离工作区并运行环境设置。", + "hotkeyWindow.pages.worktreeInit.primaryAction": "在编辑器中打开", + "hotkeyWindow.pages.worktreeInit.phase.idle": "等待任务", + "hotkeyWindow.pages.worktreeInit.phase.creating": "创建中", + "hotkeyWindow.pages.worktreeInit.phase.created": "已创建", + "hotkeyWindow.pages.worktreeInit.phase.setup": "运行设置", + "hotkeyWindow.pages.worktreeInit.phase.ready": "就绪", + "hotkeyWindow.pages.worktreeInit.phase.failed": "需要处理", + "hotkeyWindow.pages.worktreeInit.projectLabel": "项目", + "hotkeyWindow.pages.worktreeInit.branchLabel": "分支", + "hotkeyWindow.pages.worktreeInit.pathLabel": "路径", + "hotkeyWindow.pages.worktreeInit.createStep": "创建 worktree", + "hotkeyWindow.pages.worktreeInit.setupStep": "运行环境设置", + "hotkeyWindow.pages.worktreeInit.waitingOutput": "等待输出...", + "hotkeyWindow.pages.worktreeInit.editEnvironment": "编辑环境", + "hotkeyWindow.pages.worktreeInit.autoFix": "自动修复", + "hotkeyWindow.pages.worktreeInit.retry": "重试", + "hotkeyWindow.pages.worktreeInit.workLocally": "改用本地工作区", + "hotkeyWindow.pages.worktreeInit.cancel": "取消", + "hotkeyWindow.pages.worktreeInit.continueAnyway": "继续", + "hotkeyWindow.pages.worktreeInit.openWorktree": "在编辑器中打开", + "hotkeyWindow.pages.worktreeInit.creatingOutput": "正在基于 {branch} 创建 worktree...", + "hotkeyWindow.pages.worktreeInit.readyOutput": "worktree 就绪: {path}", + + "pluginsPage.title": "插件", + "pluginsPage.subtitle": "在你常用的工具中使用 Codex", + "pluginsPage.modeTabs": "插件页面类型", + "pluginsPage.mcp.subtitle": "配置 Codex 可调用的 MCP 服务器", + "pluginsPage.mcp.search": "搜索 MCP 服务器", + "pluginsPage.skills": "技能", + "pluginsPage.skills.subtitle": "给 Codex 添加可复用能力", + "pluginsPage.skills.search": "搜索技能", + "pluginsPage.skills.installed": "已安装", + "pluginsPage.skills.recommended": "推荐", + "pluginsPage.skills.createSkill": "新技能", + "pluginsPage.skills.try": "在聊天中试用", + "pluginsPage.skills.install": "安装", + "pluginsPage.skills.loading": "正在加载技能", + "pluginsPage.skills.loadingDescription": "正在读取本地 Codex 技能源。", + "pluginsPage.skills.empty.title": "还没有已安装技能", + "pluginsPage.skills.empty.description": + "启用带技能的插件,或在当前项目中创建一个技能。", + "pluginsPage.skills.noDescription": "本地技能", + "pluginsPage.skills.createPrompt": + "帮我创建一个适合当前项目的 Codex 技能,并列出触发词、工作流和文件结构。", + "pluginsPage.skills.runtimePrompt": + "使用 {skill} 技能处理当前项目,先说明它能做什么、需要哪些文件或授权,然后给出第一步。", + "pluginsPage.skills.documents.prompt": + "使用 Documents 技能帮我处理当前项目里的文档,先列出可执行的编辑、导出和校验步骤。", + "pluginsPage.skills.research.prompt": + "使用 Research 技能帮我围绕当前项目做调研,先列出要查证的问题、来源和结论格式。", + "pluginsPage.skills.workflows.prompt": + "使用 Workflows 技能帮我把当前项目的重复工作整理成可复用流程,并给出执行步骤。", + "pluginsPage.skills.status.recommended": "推荐", + "pluginsPage.skills.recommended.browserQa.description": + "检查本地网页体验、截图和可访问性", + "pluginsPage.skills.recommended.githubOps.description": + "处理 issue、PR、CI 和发布流程", + "pluginsPage.skills.recommended.dataAnalytics.description": + "分析数据、生成图表和报告", + "pluginsPage.onboarding.title": "按角色开始", + "pluginsPage.onboarding.description": + "从现有插件、连接器和技能中选择一组推荐入口。", + "pluginsPage.onboarding.rolesLabel": "插件入门角色", + "pluginsPage.onboarding.start": "用这个角色开始", + "pluginsPage.onboarding.roles.developer.title": "开发者", + "pluginsPage.onboarding.roles.developer.description": + "GitHub、浏览器 QA 和本地 Chrome 上下文,适合代码、PR、CI 和页面验证。", + "pluginsPage.onboarding.roles.developer.prompt": + "为当前项目设置 GitHub 和浏览器 QA 工作流。先检查哪些连接器或技能可用,再建议第一个安全动作。", + "pluginsPage.onboarding.roles.operator.title": "运营", + "pluginsPage.onboarding.roles.operator.description": + "邮件、日历、Slack 和工作流技能,适合会议、团队上下文和重复流程。", + "pluginsPage.onboarding.roles.operator.prompt": + "为当前项目设置运营工作流,优先使用日历、邮件和团队上下文。先列出需要连接的工具和本地可替代方案。", + "pluginsPage.onboarding.roles.researcher.title": "研究", + "pluginsPage.onboarding.roles.researcher.description": + "Drive、Notion、Research 和数据分析,适合资料检索、知识库和报告。", + "pluginsPage.onboarding.roles.researcher.prompt": + "为当前项目设置研究工作流,优先使用文档、Drive 或 Notion 上下文以及数据分析。先列出来源和第一步检索动作。", + "pluginsPage.add": "添加", + "pluginsPage.refresh": "刷新", + "pluginsPage.prompt": "我们应该在当前项目中做些什么?", + "pluginsPage.promptWithProject": "我们应该在{project}中做些什么?", + "pluginsPage.composerPlaceholder": "随心输入", + "pluginsPage.fullAccess": "完全访问", + "pluginsPage.model": "5.5 超高", + "pluginsPage.newChatWithTool": "使用工具开始新对话", + "pluginsPage.sharedAcross": + "由 Hermes、Claude Code 和 Codex 共享", + "pluginsPage.openSettings": "管理插件", + "pluginsPage.installed": "已安装", + "pluginsPage.installedPlugins": "已安装插件", + "pluginsPage.enabled": "已启用", + "pluginsPage.mcpServers": "MCP 服务器", + "pluginsPage.availableTools": "可用工具", + "pluginsPage.search": "搜索插件...", + "pluginsPage.catalogSearch": "搜索插件", + "pluginsPage.filter": "筛选", + "pluginsPage.sourceTabs": "插件来源", + "pluginsPage.source.openai": "由 OpenAI 提供", + "pluginsPage.source.workspace": "由你的工作空间提供", + "pluginsPage.source.personal": "个人", + "pluginsPage.source.mine": "个人", + "pluginsPage.connected": "已连接", + "pluginsPage.manage": "管理", + "pluginsPage.connectedMore": "另有 14 个", + "pluginsPage.manage.subtitle": "管理已连接的工具、应用、MCP 和技能", + "pluginsPage.manage.tabsLabel": "插件管理类型", + "pluginsPage.manage.tabs.plugins": "插件", + "pluginsPage.manage.tabs.apps": "应用", + "pluginsPage.manage.tabs.mcps": "MCP", + "pluginsPage.manage.tabs.skills": "技能", + "pluginsPage.manage.tabs.marketplace": "市场", + "pluginsPage.manage.installed": "已安装", + "pluginsPage.manage.marketplace": "市场", + "pluginsPage.manage.browseMarketplace": "浏览市场", + "pluginsPage.manage.connectedDescription": "可在 Codex 中使用", + "pluginsPage.manage.apps": "应用", + "pluginsPage.manage.mcps": "MCP 服务器", + "pluginsPage.manage.skills": "技能", + "pluginsPage.manage.apps.drive.description": "连接文件、文档、表格和幻灯片", + "pluginsPage.manage.apps.gmail.description": "把邮件上下文带入 Codex", + "pluginsPage.manage.apps.calendar.description": "管理日程、会议和可用时间", + "pluginsPage.manage.apps.chrome.description": "使用浏览器页面和会话上下文", + "pluginsPage.manage.mcps.github.description": "提供 PR、issue、CI 和发布工具", + "pluginsPage.manage.mcps.linear.description": "读取 issue、项目和路线图状态", + "pluginsPage.manage.mcps.slack.description": "从团队讨论中读取上下文", + "pluginsPage.manage.mcps.notion.description": "连接规格、研究和知识库页面", + "pluginsPage.manage.skills.documents.description": "处理文档、编辑和导出工作流", + "pluginsPage.manage.skills.research.description": "组织调研、来源和结论", + "pluginsPage.manage.skills.workflows.description": "运行项目内的可复用工作流", + "pluginsPage.catalog": "插件目录", + "pluginsPage.catalogDescription": + "浏览已安装插件、连接器、技能、命令和 MCP 服务器。", + "pluginsPage.featured": "Featured", + "pluginsPage.featuredDescription": + "连接器从同一套 Moss 记忆、钩子、技能和 MCP 审批读取能力。", + "pluginsPage.viewMoreFeatured": "查看 Teams, SharePoint 和另外 4 个", + "pluginsPage.productivity": "Productivity", + "pluginsPage.business": "Business & Operations", + "pluginsPage.catalog.github.title": "GitHub", + "pluginsPage.catalog.github.description": + "Triage PRs, issues, CI, and publish flows", + "pluginsPage.catalog.slack.title": "Slack", + "pluginsPage.catalog.slack.description": "Read and manage Slack", + "pluginsPage.catalog.notion.title": "Notion", + "pluginsPage.catalog.notion.description": + "Notion workflows for specs, research, meetings, and knowledge capture", + "pluginsPage.catalog.googleCalendar.title": "Google Calendar", + "pluginsPage.catalog.googleCalendar.description": + "Manage Google Calendar events and schedules", + "pluginsPage.catalog.googleDrive.title": "Google Drive", + "pluginsPage.catalog.googleDrive.description": + "Work across Drive, Docs, Sheets, and Slides", + "pluginsPage.catalog.recordReplay.title": "Record & Replay", + "pluginsPage.catalog.recordReplay.description": + "Record what I'm doing on my Mac and turn it into a Skill", + "pluginsPage.catalog.actively.title": "Actively", + "pluginsPage.catalog.actively.description": + "Account intelligence and prospecting signals from Actively", + "pluginsPage.catalog.apollo.title": "Apollo", + "pluginsPage.catalog.apollo.description": + "B2B sales intelligence and engagement from Apollo", + "pluginsPage.catalog.salesforce.title": "Salesforce", + "pluginsPage.catalog.salesforce.description": + "Work with Salesforce accounts, opportunities, and records", + "pluginsPage.catalog.hubspot.title": "HubSpot", + "pluginsPage.catalog.hubspot.description": + "Manage CRM contacts, companies, deals, and notes", + "pluginsPage.catalog.github.longDescription": + "Use GitHub to inspect repositories, review pull requests, address feedback, debug failing Actions checks, and prepare code changes for review through a connector-first workflow with targeted CLI fallbacks.", + "pluginsPage.catalog.recordReplay.longDescription": + "Record & Replay lets Codex record your actions on your Mac to create skills for more automated workflows. When you choose to start a recording, Codex will record your mouse clicks, text you type, and the content in windows you interact with until you stop it (up to 30 minutes). You can stop or cancel recording at any time, and cancelling will discard any existing recording. Avoid recording sensitive workflows.", + "pluginsPage.catalog.github.prompt": + "使用 GitHub 插件帮我检查当前项目的 PR、issues、CI 和发布风险,先列出需要连接或授权的步骤。", + "pluginsPage.catalog.github.promptExample": + "Inspect PRs, triage issues, debug failing checks, and prepare code changes for review", + "pluginsPage.catalog.slack.prompt": + "使用 Slack 插件帮我整理近期团队讨论、决策和未解决问题,先列出需要连接或授权的步骤。", + "pluginsPage.catalog.notion.prompt": + "使用 Notion 插件帮我整理规格、调研和会议记录,先列出需要连接或授权的步骤。", + "pluginsPage.catalog.googleCalendar.prompt": + "使用 Google Calendar 插件帮我检查日程、会议准备和可用时间,先列出需要连接或授权的步骤。", + "pluginsPage.catalog.googleDrive.prompt": + "使用 Google Drive 插件帮我审查 Drive、Docs、Sheets 和 Slides 里的项目资料,先列出需要连接或授权的步骤。", + "pluginsPage.catalog.recordReplay.prompt": + "Record my workflow and turn it into a reusable skill", + "pluginsPage.catalog.recordReplay.promptExample.workflow": + "Record my workflow and turn it into a reusable skill", + "pluginsPage.catalog.recordReplay.promptExample.task": + "Watch me do this task and create a skill from it", + "pluginsPage.catalog.recordReplay.promptExample.fileExpense": + "Record what I'm doing and make a skill called 'File Expense'", + "pluginsPage.catalog.actively.prompt": + "使用 Actively 插件帮我整理账号情报、潜在客户信号和下一步动作,先列出需要连接或授权的步骤。", + "pluginsPage.catalog.apollo.prompt": + "使用 Apollo 插件帮我分析销售线索、公司信息和触达计划,先列出需要连接或授权的步骤。", + "pluginsPage.catalog.salesforce.prompt": + "使用 Salesforce 插件帮我检查账号、机会和记录,先列出需要连接或授权的步骤。", + "pluginsPage.catalog.hubspot.prompt": + "使用 HubSpot 插件帮我整理联系人、公司、交易和备注,先列出需要连接或授权的步骤。", + "pluginsPage.detail.installTitle": "安装 {plugin}", + "pluginsPage.detail.installedTitle": "{plugin} 已添加", + "pluginsPage.detail.developedBy": "由 {developer} 开发", + "pluginsPage.detail.about": "关于", + "pluginsPage.detail.localStatus": "本地状态", + "pluginsPage.detail.includes": "包括", + "pluginsPage.detail.includes.apps": "应用", + "pluginsPage.detail.includes.skills": "技能", + "pluginsPage.detail.includes.mcps": "MCP 服务器", + "pluginsPage.detail.availableAgents": "可用智能体", + "pluginsPage.detail.availableAgentsDescription": + "安装一次后,Hermes、Claude Code 和 Codex 都能识别并使用这套插件资源。", + "pluginsPage.detail.capabilities": "能力", + "pluginsPage.detail.info": "信息", + "pluginsPage.detail.features": "功能", + "pluginsPage.detail.developer": "开发者", + "pluginsPage.detail.category": "类别", + "pluginsPage.detail.website": "网站", + "pluginsPage.detail.policies": "", + "pluginsPage.detail.privacyPolicy": "隐私政策", + "pluginsPage.detail.termsOfService": "服务条款", + "pluginsPage.detail.addToCodex": "添加到 Codex", + "pluginsPage.detail.addingToCodex": "正在添加到 Codex", + "pluginsPage.detail.tryInChat": "在对话中试用", + "pluginsPage.detail.copyLink": "复制链接", + "pluginsPage.detail.copiedLink": "已复制", + "pluginsPage.detail.breadcrumbNavigation": "面包屑导航", + "pluginsPage.detail.configureMcp": "在 MCP 设置中配置", + "pluginsPage.detail.disableSkill": "禁用技能", + "pluginsPage.detail.enableSkill": "启用技能", + "pluginsPage.detail.moreActions": "更多操作", + "pluginsPage.detail.removeFromCodex": "从 Codex 移除", + "pluginsPage.detail.removingFromCodex": "正在从 Codex 移除", + "pluginsPage.detail.installFailed": "无法添加 {plugin},请稍后再试。", + "pluginsPage.detail.uninstallFailed": "无法移除 {plugin},请稍后再试。", + "pluginsPage.detail.copyLinkFailed": "无法复制 {plugin} 链接。", + "pluginsPage.detail.close": "关闭插件详情", + "pluginsPage.uninstall.title": "移除 {plugin}?", + "pluginsPage.uninstall.body": + "Codex 将停止使用此插件提供的应用、技能和 MCP 服务器。", + "pluginsPage.uninstall.impact": "将停用", + "pluginsPage.uninstall.apps": "应用", + "pluginsPage.uninstall.skills": "技能", + "pluginsPage.uninstall.mcps": "MCP 服务器", + "pluginsPage.uninstall.cancel": "取消", + "pluginsPage.uninstall.confirm": "移除插件", + "pluginsPage.uninstall.removing": "正在移除", + "pluginsPage.tool.slack.title": "连接消息传送", + "pluginsPage.tool.slack.description": "从近期团队讨论中获取背景信息", + "pluginsPage.tool.slack.prompt": + "使用 Slack 帮我跟进近期决策和待解决问题", + "pluginsPage.tool.gmail.title": "连接电子邮件", + "pluginsPage.tool.gmail.description": "总结电子邮件中利益相关方的请求", + "pluginsPage.tool.gmail.prompt": + "使用 Gmail 总结高管和利益相关方的请求", + "pluginsPage.tool.drive.title": "连接文件", + "pluginsPage.tool.drive.description": "审查结果、研究资料和计划", + "pluginsPage.tool.drive.prompt": + "使用 Google Drive 审查最新结果和研究资料,并标出机会", + "pluginsPage.tool.connected": "已连接", + "pluginsPage.tool.request": "请求", + "pluginsPage.noResults": "没有匹配的插件。", + "pluginsPage.loading": "正在加载插件...", + "pluginsPage.resources": "{count} 个资源", + "pluginsPage.status.enabled": "已启用", + "pluginsPage.status.disabled": "已关闭", + "pluginsPage.status.connected": "已连接", + "pluginsPage.action.signIn": "登录", + "pluginsPage.detail.version": "版本", + "pluginsPage.detail.source": "来源", + "pluginsPage.detail.homepage": "主页", + "pluginsPage.detail.tags": "标签", + "pluginsPage.detail.commands": "命令", + "pluginsPage.detail.skills": "技能", + "pluginsPage.detail.agents": "智能体", + "pluginsPage.detail.mcpServers": "MCP 服务器", + "pluginsPage.detail.select": "选择一个插件查看详情", + "pluginsPage.empty.title": "还没有插件", + "pluginsPage.empty.description": "安装插件后会显示在这里。", + "pluginsPage.empty.installPath": "安装插件到 ~/.claude/plugins/marketplaces/", + "pluginsPage.toast.authenticated": "{name} 已认证", + "pluginsPage.toast.authFailed": "认证失败", + "pluginsPage.toast.enabled": "插件已启用", + "pluginsPage.toast.disabled": "插件已关闭", + "pluginsPage.toast.updateFailed": "更新插件失败", + + "settingsResources.fromConfig": "来自配置", + "settingsResources.userConfig": "用户配置", + "settingsResources.count": "{count} 个", + "settingsResources.status.on": "已启用", + "settingsResources.status.off": "已关闭", + "settingsResources.status.review": "待确认", + + "mcpSettings.title": "MCP 服务器", + "mcpSettings.description": + "通过一套 Moss 配置为 Hermes、Claude Code、Codex 提供工具。", + "mcpSettings.enable.title": "启用 MCP", + "mcpSettings.enable.description": "让所有智能体从 Moss 统一源读取可用工具。", + "mcpSettings.approval.title": "新工具访问前询问", + "mcpSettings.approval.description": "首次连接或新增工具时先让你确认。", + "mcpSettings.connected.title": "已配置服务器", + "mcpSettings.connected.description": "来自 .moss/mcp 和引擎映射的服务器。", + "mcpSettings.approved.title": "已可用", + "mcpSettings.approved.description": "无需额外确认即可被智能体使用。", + "mcpSettings.projections.title": "引擎映射", + "mcpSettings.projections.description": + "Moss 映射到 Claude Code、Codex 和 Hermes 的状态。", + "mcpSettings.source.description": "{count} 个 MCP 服务器", + "mcpSettings.modal.title": "用户配置", + "mcpSettings.modal.description": + "这些 MCP 服务器来自 Moss 统一源,并映射给各智能体。", + "mcpSettings.empty": "还没有配置 MCP 服务器。", + + "hooksSettings.title": "钩子", + "hooksSettings.description": "通过配置和已启用的钩子管理生命周期自动化。", + "hooksSettings.runner.title": "启用钩子", + "hooksSettings.runner.description": "允许 Moss 运行已批准的本地自动化。", + "hooksSettings.confirmation.title": "执行前确认", + "hooksSettings.confirmation.description": + "钩子执行命令或修改文件前需要确认。", + "hooksSettings.enabled.title": "已启用钩子", + "hooksSettings.enabled.description": "当前可被 Moss 运行的钩子数量。", + "hooksSettings.environment.title": "环境变量", + "hooksSettings.environment.description": "允许传递给本地自动化的变量数量。", + "hooksSettings.source.description": "{count} 个钩子", + "hooksSettings.modal.title": "用户配置", + "hooksSettings.modal.description": + "这些钩子来自 .moss/hooks,并由 Moss 适配给不同智能体。", + "hooksSettings.empty": "还没有配置钩子。", + + "memorySettings.title": "个性化", + "memorySettings.description": + "把规则、记忆和项目指令收口为一套 Moss 统一源。", + "memorySettings.enable.title": "启用记忆", + "memorySettings.enable.description": "从聊天和项目上下文保留可复用记忆。", + "memorySettings.unified.title": "共享给所有智能体", + "memorySettings.unified.description": + "Claude Code、Codex 和 Hermes 使用同一套真实记忆。", + "memorySettings.memory.title": "记忆", + "memorySettings.memory.description": "来自 Moss 统一源的用户和项目记忆。", + "memorySettings.instructions.title": "自定义指令", + "memorySettings.instructions.description": + "会映射为 CLAUDE.md、AGENTS.md 或运行时注入。", + "memorySettings.projections.title": "引擎映射", + "memorySettings.projections.description": "Moss 将记忆投影给各智能体的状态。", + "memorySettings.source.description": "{count} 条记忆和指令", + "memorySettings.modal.title": "用户配置", + "memorySettings.modal.description": + "这里展示 Moss 统一源里的记忆和指令,不维护第二份数据。", + "memorySettings.empty": "还没有记忆或指令。", + + "configurationSettings.title": "配置", + "configurationSettings.description": + "项目指令和智能体运行开关来自 Moss,然后映射到 Hermes、Claude Code、Codex 和自定义 ACP。", + "configurationSettings.refresh": "刷新映射", + "configurationSettings.instructionFiles.title": "指令文件", + "configurationSettings.instructionFiles.description": + "使用 .moss/source/moss.md 作为工作区规则的唯一来源。", + "configurationSettings.agentsMd.description": + "项目 AGENTS.md 解析到 Moss 指令源。", + "configurationSettings.claudeMd.description": + "Claude Code 通过受管映射读取同一份 Moss 源。", + "configurationSettings.webSearch.title": "网页搜索", + "configurationSettings.webSearch.description": + "通过共享 provider 策略暴露浏览权限。", + "configurationSettings.transcriptExport.title": "转录导出", + "configurationSettings.transcriptExport.description": + "允许会话导出可回放日志,但不复制记忆。", + "configurationSettings.slashArguments.title": "斜杠命令参数", + "configurationSettings.slashArguments.description": + "在 Claude Code、Codex 或 Hermes 接收前规范化命令占位符。", + "configurationSettings.mossSource": "Moss 源", + "configurationSettings.slashArgumentToken": "斜杠参数令牌", + "configurationSettings.projections.title": "指令映射", + "configurationSettings.projections.description": + "只有在每个引擎需要时才投影协议专用路径,真实源只有一份。", + "configurationSettings.projections.empty": "这个引擎没有返回指令映射。", + "configurationSettings.projections.loading": "正在加载映射...", + "configurationSettings.projections.none": "没有找到映射。", + "configurationSettings.runtimeInjection": "运行时注入", + + "archivedSettings.title": "已归档对话", + "archivedSettings.description": + "已归档会话保留自己的转录,但仍由共享 Moss 源治理。", + "archivedSettings.refresh": "刷新已归档对话", + "archivedSettings.search": "搜索已归档对话...", + "archivedSettings.localArchive": "本地归档", + "archivedSettings.empty.title": "没有已归档对话", + "archivedSettings.empty.description": + "恢复后或活跃会话会显示在工作区侧边栏。", + "archivedSettings.restore": "恢复", + "archivedSettings.restored": "对话已恢复", + "archivedSettings.restoreFailed": "恢复失败", + "archivedSettings.newWorkspace": "新工作区", + "archivedSettings.noBranch": "无分支", + "archivedSettings.archived": "已归档", + + "sidebar.search": "搜索", + "sidebar.quickChat": "快速对话", + "sidebar.plugins": "插件", + "sidebar.skills": "技能", + "sidebar.mcpServers": "MCP 服务器", + "sidebar.inbox": "收件箱", + "sidebar.automations": "已安排", + "sidebar.projects": "项目", + "sidebar.library": "资料库", + "sidebar.pullRequests": "拉取请求", + "sidebar.kanban": "看板", + "sidebar.workspaces": "对话", + "sidebar.pinned": "置顶", + "sidebar.recentWorkspaces": "最近对话", + "sidebar.pinnedWorkspaces": "置顶对话", + "sidebar.drafts": "草稿", + "sidebar.newWorkspace": "新对话", + "sidebar.startNewWorkspace": "开始一个新的对话", + "sidebar.searchWorkspaces": "搜索对话...", + "sidebar.expandShow": "展开显示", + "sidebar.collapseShow": "收起显示", + "sidebar.collapseAll": "全部收起", + "sidebar.projectActions": "{project} 的项目操作", + "sidebar.projectNewChat": "在 {project} 中开始新对话", + "sidebar.projectTasks": "{project}中的已安排任务", + "sidebar.projectsSidebarOptions": "项目侧边栏选项", + "sidebar.addProject": "添加新项目", + "sidebar.expandProject": "展开项目", + "sidebar.collapseProject": "折叠项目", + "sidebar.pinnedConversations": "置顶对话", + "sidebar.scheduledTaskFolder": "已安排任务文件夹", + "sidebar.filterConversations": "筛选侧边栏对话", + "sidebar.noChats": "暂无聊天", + "sidebar.noConversations": "暂无对话", + "sidebar.settings": "设置", + "sidebar.openSettings": "打开Settings", + "sidebar.hideSidebar": "隐藏边栏", + "sidebar.update": "更新", + "sidebar.help": "帮助", + "sidebar.archive": "归档", + "sidebar.feedback": "反馈", + "sidebar.selected": "{count} 个已选择", + "sidebar.cancel": "取消", + "sidebar.archiving": "归档中...", + "sidebar.archiveAction": "归档", + + "chat.newChatOrPlan": "新建对话或计划", + "chat.newChat": "新建对话", + "chat.newPlan": "新建计划", + "chat.fullAccess": "完全访问", + "chat.permissions.change": "更改权限", + "chat.permissions.title": "如何批准 Codex 操作?", + "chat.permissions.learnMore": "了解更多", + "chat.permissions.readOnly.label": "只读", + "chat.permissions.readOnly.description": "读取文件并检查工作区,不编辑", + "chat.permissions.askApproval.label": "请求批准", + "chat.permissions.askApproval.description": "编辑外部文件和使用网络前始终询问", + "chat.permissions.approveForMe.label": "替我审批", + "chat.permissions.approveForMe.description": "仅在检测到潜在不安全操作时询问", + "chat.permissions.approveForMe.disabled": "此工作区需要默认沙盒权限", + "chat.permissions.fullAccess.label": "完全访问", + "chat.permissions.fullAccess.description": "不受限制地访问网络和电脑上的任何文件", + "chat.permissions.custom.label": "自定义 (config.toml)", + "chat.permissions.custom.description": "使用 config.toml 中定义的权限", + "chat.permissions.fullAccessConfirm.title": "确定吗?", + "chat.permissions.fullAccessConfirm.body": + "完全访问允许 Codex 在不询问的情况下访问网络并编辑电脑上的任何文件。这可能带来数据丢失或提示注入风险。", + "chat.permissions.fullAccessConfirm.cancel": "取消", + "chat.permissions.fullAccessConfirm.confirm": "开启完全访问", + "chat.newWorkspacePrompt": "你想完成什么?", + "chat.inputPlaceholder": "输入计划,@ 添加上下文,/ 使用命令", + "chat.queuePlaceholder": "添加到队列", + "chat.selectRepo": "选择仓库", + "chat.opening": "打开中...", + + "runtime.checking.title": "正在检查智能体运行时", + "runtime.checking.body": + "Moss 正在检查 Hermes、Claude Code、Codex 和 Custom ACP,确认后即可运行此对话。", + "runtime.empty.title": "没有可用智能体引擎", + "runtime.empty.body": "Moss 运行时没有返回引擎清单。", + "runtime.healthUnavailable.title": "运行时健康状态不可用", + "runtime.healthUnavailable.body": "Moss 无法读取智能体运行时健康状态。", + "runtime.engineMissing.title": "引擎不存在", + "runtime.engineMissing.body": "当前选择的引擎不在 Moss 运行时清单中。", + "runtime.fallback.title": "{engine} 正在使用备用元数据", + "runtime.fallback.body": "Moss 没有收到这个引擎的完整运行状态。", + "runtime.needsAuth.title": "{engine} 需要登录", + "runtime.needsAuth.body": "发送前请连接账号,或配置共享 Moss provider。", + "runtime.notInstalled.title": "{engine} 未安装", + "runtime.notInstalled.body": "Moss 找不到这个引擎需要的本地运行时。", + "runtime.unsupported.title": "{engine} 当前不可用", + "runtime.unsupported.body": "这个运行时暂不支持当前工作区。", + "runtime.error.title": "{engine} 运行时错误", + "runtime.error.body": "Moss 无法验证这个运行时。", + "runtime.checkingEngine.title": "正在检查 {engine}", + "runtime.checkingEngine.body": "Moss 正在检查这个运行时,确认后即可发送。", + "runtime.manifestMissing.title": "{engine} 清单缺失", + "runtime.manifestMissing.body": "Moss 运行时没有返回这个引擎的清单。", + "runtime.update.available.title": "{engine} 有可用更新", + "runtime.update.available.body": + "当前版本 {currentVersion},最新版本 {latestVersion}。新会话可以继续运行;建议更新运行时后再做长任务。", + "runtime.update.manual.title": "{engine} 有可用更新", + "runtime.update.manual.body": + "当前版本 {currentVersion},最新版本 {latestVersion}。请到运行时设置中查看更新方式。", + "runtime.update.queued.title": "{engine} 更新已排队", + "runtime.update.queued.body": "Moss 正在等待前一个 provider 更新完成。", + "runtime.update.running.title": "正在更新 {engine}", + "runtime.update.running.body": "更新命令正在运行,新会话会在更新完成后使用新版本。", + "runtime.update.failed.title": "{engine} 更新失败", + "runtime.update.failed.body": "Provider 更新命令未完成。请到运行时设置中查看详情。", + "runtime.update.failed.bodyWithMessage": "{message}", + "runtime.update.unchanged.title": "{engine} 仍需更新", + "runtime.update.unchanged.body": + "更新后仍检测到旧版本 {currentVersion}。请到运行时设置中查看详情。", + "runtime.update.succeeded.title": "{engine} 已更新", + "runtime.update.succeeded.body": "新会话会使用更新后的 provider。", + "runtime.openRuntime": "打开运行时", + "runtime.openModels": "打开模型设置", +}; + +const enUS: Dictionary = { + "common.back": "Back", + "common.forward": "Forward", + "common.refresh": "Refresh", + "common.ready": "Ready", + "common.off": "Off", + "common.enabled": "enabled", + "common.details": "Details", + "common.default": "Default", + + "settings.tabs.preferences": "General", + "settings.tabs.capabilities": "Capabilities", + "settings.tabs.profile": "Profile", + "settings.tabs.account": "Account", + "settings.tabs.models": "Models", + "settings.tabs.usage": "Usage", + "settings.tabs.billing": "Billing", + "settings.tabs.appearance": "Appearance", + "settings.tabs.agent": "Agent", + "settings.tabs.pets": "Pets", + "settings.tabs.keyboard": "Keyboard", + "settings.tabs.beta": "Beta", + "settings.tabs.projects": "Projects", + "settings.tabs.runtime": "Runtime", + "settings.tabs.desktop": "Desktop", + "settings.tabs.release": "Release", + "settings.tabs.configuration": "Configuration", + "settings.tabs.personalization": "Personalization", + "settings.tabs.environment": "Environment", + "settings.tabs.memory": "Memory", + "settings.tabs.skills": "Skills", + "settings.tabs.agents": "Custom Agents", + "settings.tabs.mcp": "MCP Servers", + "settings.tabs.hooks": "Hooks", + "settings.tabs.codeReview": "Code Review", + "settings.tabs.plugins": "Plugins", + "settings.tabs.archived": "Archived Conversations", + "settings.tabs.debug": "Debug", + "settings.groups.personal": "Personal", + "settings.groups.integrations": "Integrations", + "settings.groups.coding": "Coding", + "settings.groups.advanced": "Developer", + + "preferences.title": "General", + "preferences.description": + "Configure Moss core behavior, language, and desktop features", + "preferences.language.title": "Language", + "preferences.language.description": "Switch the app display language", + "preferences.language.zh": "中文", + "preferences.language.en": "English", + "preferences.extendedThinking.title": "Extended Thinking", + "preferences.extendedThinking.description": + "Enable deeper reasoning with more thinking tokens.", + "preferences.extendedThinking.note": "Disables response streaming.", + "preferences.defaultMode.title": "Default Mode", + "preferences.defaultMode.description": + "Mode for new agents: Plan is read-only, Agent can edit files", + "preferences.defaultMode.agent": "Agent", + "preferences.defaultMode.plan": "Plan", + "preferences.coAuthored.title": "Include Co-Authored-By", + "preferences.coAuthored.description": + "Add co-author metadata to git commits made by Claude", + "preferences.desktopNotifications.title": "Desktop Notifications", + "preferences.desktopNotifications.description": + "Show system notifications when an agent needs input or completes work", + "preferences.soundNotifications.title": "Sound Notifications", + "preferences.soundNotifications.description": + "Play a sound when an agent completes work while you're away", + "preferences.notifyFocused.title": "Notify When Focused", + "preferences.notifyFocused.description": + "Show notifications even when the app window is active", + "preferences.quickSwitch.title": "Quick Switch", + "preferences.quickSwitch.description": "What Ctrl+Tab switches between", + "preferences.quickSwitch.workspaces": "Workspaces", + "preferences.quickSwitch.agents": "Agents", + "preferences.autoAdvance.title": "Auto-advance", + "preferences.autoAdvance.description": + "Where to go after archiving a workspace", + "preferences.autoAdvance.next": "Go to next workspace", + "preferences.autoAdvance.previous": "Go to previous workspace", + "preferences.autoAdvance.close": "Close workspace", + "preferences.preferredEditor.title": "Preferred Editor", + "preferences.preferredEditor.description": + "Default app for opening workspaces", + "preferences.analytics.title": "Share Usage Analytics", + "preferences.analytics.description": + "Help improve Moss by sharing anonymous usage data. We only track feature usage and app performance, never your code, prompts, or messages. No AI training on your data.", + + "agentSettings.title": "Agent", + "agentSettings.description": + "Configure default approval, sandbox, and local dependency behavior for new conversations.", + "agentSettings.config.title": "Custom config.toml settings", + "agentSettings.config.description": + "Custom permission overrides still defer to Codex config files.", + "agentSettings.config.project.title": "Project config", + "agentSettings.config.project.description": + "Project-level Codex configuration for the current workspace.", + "agentSettings.config.global.title": "Global config", + "agentSettings.config.global.description": + "Global scope for user and admin Codex configuration.", + "agentSettings.config.user.title": "User config", + "agentSettings.config.user.description": + "Codex config.toml settings for the current user.", + "agentSettings.config.admin.title": "Admin config", + "agentSettings.config.admin.description": + "Codex settings supplied by admin or managed policy.", + "agentSettings.config.admin.value": "Managed", + "agentSettings.defaultMode.title": "Default mode", + "agentSettings.defaultMode.description": + "Choose whether new conversations start in Agent or Plan mode.", + "agentSettings.approval.title": "Approval policy", + "agentSettings.approval.description": + "How new Agent conversations request confirmation by default.", + "agentSettings.approval.onRequest": "On request", + "agentSettings.approval.onFailure": "On failure", + "agentSettings.approval.untrusted": "Untrusted", + "agentSettings.approval.never": "Never", + "agentSettings.sandbox.title": "Sandbox settings", + "agentSettings.sandbox.description": + "Default local access scope for new Agent conversations.", + "agentSettings.sandbox.readOnly": "Read only", + "agentSettings.sandbox.workspaceWrite": "Workspace write", + "agentSettings.sandbox.fullAccess": "Full access", + "agentSettings.network.title": "Allow network access", + "agentSettings.network.description": + "The current runtime only enables network access together with Full access.", + "agentSettings.dependencies.title": "Workspace Dependencies", + "agentSettings.dependencies.description": + "Local Workspace runtime dependencies used by Codex sessions.", + "agentSettings.dependencies.version.title": "Current version", + "agentSettings.dependencies.version.description": + "Uses the local dependencies detected by 1code packaging and runtime setup.", + "agentSettings.dependencies.version.value": "Bundled", + "agentSettings.dependencies.codex.title": "Codex dependencies", + "agentSettings.dependencies.codex.description": + "New Codex sessions use the runtime dependencies managed by the app.", + "agentSettings.dependencies.diagnose.title": + "Diagnose issues in Codex Workspace", + "agentSettings.dependencies.diagnose.description": + "The local diagnostics API is not wired yet; this preserves the Codex Desktop entry point.", + "agentSettings.dependencies.diagnose.label": + "Diagnose issues in Codex Workspace", + "agentSettings.dependencies.reset.title": "Reset and install Workspace", + "agentSettings.dependencies.reset.description": + "The local reinstall API is not wired yet, so the reset action is intentionally disabled.", + "agentSettings.dependencies.reset.label": "Reset and install Workspace", + "agentSettings.experimental.title": "Experimental features (Beta)", + "agentSettings.experimental.description": + "Experimental feature status is shown here; detailed toggles stay in the Beta page.", + + "settings.personalization.pets.title": "Pets", + "settings.personalization.pets.current": "{petName} selected", + "settings.personalization.pets.openPet": "Wake pet", + "settings.personalization.pets.tuckAwayPet": "Tuck away pet", + "settings.pets.custom.title": "Custom pets", + "settings.pets.custom.openFolder": "Open folder", + "settings.pets.custom.openFolderError": "Unable to open pet folder", + "settings.pets.loadCustomError": "Unable to load custom pets", + "petSettings.description": + "Reads the current local Codex pets directory, Stop hook, and official pointer runtime state.", + "petSettings.selectedAvatar.description": "Current avatar from Codex config:", + "petSettings.loading": "Loading", + "petSettings.none": "None", + "petSettings.noPets": "No custom pets found", + "petSettings.custom.count": "{count} found", + "petSettings.custom.loading": "Reading the custom pets directory.", + "petSettings.custom.selected": "Selected", + "petSettings.custom.available": "Available", + "petSettings.stopHook.title": "Stop hook", + "petSettings.runtime.title": "Official runtime", + "petSettings.runtime.description": "Official pet pointer runtime.", + "petSettings.window.title": "Official pet window", + "petSettings.window.description": "The latest hook log has no window error.", + "petSettings.lastDecision.title": "Last hook decision", + "petSettings.action.success": "Pet action sent", + "petSettings.status.ready": "Ready", + "petSettings.status.configured": "Configured", + "petSettings.status.available": "Available", + "petSettings.status.unknown": "Unknown", + "petSettings.status.missing": "Missing", + "petSettings.status.notConfigured": "Not configured", + "petSettings.status.missingHook": "Missing hook", + "petSettings.status.missingRuntime": "Missing runtime", + "petSettings.status.windowUnavailable": "Window unavailable", + + "capabilities.title": "Capabilities", + "capabilities.description": + "One Moss setup is shared by Hermes, Claude Code, Codex, and custom agents.", + "capabilities.unifiedSource.title": "Moss Unified Source", + "capabilities.unifiedSource.description": + "Install once, then every agent receives the same workspace abilities.", + "capabilities.memory.title": "Memory", + "capabilities.memory.description": + "Personal and project memory stay shared across engines.", + "capabilities.skills.title": "Skills and commands", + "capabilities.skills.description": + "Installed skills are available from the same composer.", + "capabilities.mcp.title": "MCP servers", + "capabilities.mcp.description": + "Tools and connectors are routed through one approval policy.", + "capabilities.plugins.title": "Plugins", + "capabilities.plugins.description": + "Plugin bundles can add skills, tools, and agents together.", + "capabilities.hooks.title": "Hooks", + "capabilities.hooks.description": + "Local automation runs only through the shared safety policy.", + "capabilities.subagents.title": "Subagents", + "capabilities.subagents.description": + "Specialized agents are selected automatically when useful.", + "capabilities.providers.title": "Provider routing", + "capabilities.providers.description": + "One provider configuration can power every engine.", + "capabilities.projections.title": "Engine projections", + "capabilities.projections.description": + "Moss maps the shared source into each engine at launch time.", + "capabilities.approval.title": "Ask before new tool access", + "capabilities.approval.description": + "New MCP or hook capabilities require approval before use.", + "capabilities.nativeScope.label": "Codex native and cloud capability scope", + "capabilities.native.globalDictation.title": "Global dictation", + "capabilities.native.globalDictation.description": + "Microphone capture stays gated until Moss owns a native dictation capability across engines.", + "capabilities.native.realtime.title": "Realtime voice", + "capabilities.native.realtime.description": + "Realtime sessions require Moss microphone permission, transport, and engine support before composer controls are enabled.", + "capabilities.native.cloudEnvironments.title": "Cloud environments", + "capabilities.native.cloudEnvironments.description": + "Codex cloud setup scripts, secrets, and network rules map to Moss environment profiles.", + "capabilities.native.avatarOverlay.title": "Avatar overlay", + "capabilities.native.avatarOverlay.description": + "The official pet remains hook-driven; 1Code will not start a second overlay from this settings surface.", + "capabilities.native.status.gated": "Gated", + "capabilities.native.status.scoped": "Scoped", + "capabilities.native.status.deferred": "Deferred", + "capabilities.status.enabled": "{count} enabled", + "capabilities.status.readyCount": "{ready}/{total} ready", + + "hotkeyWindow.ariaLabel": "Codex Hotkey Window", + "hotkeyWindow.title": "Keyboard shortcuts", + "hotkeyWindow.description": "A Codex-style standalone hotkey window.", + "hotkeyWindow.closeHint": "Close window", + "hotkeyWindow.keyboardSettings": "Keyboard settings", + "hotkeyWindow.groups.general": "General", + "hotkeyWindow.groups.start": "Start", + "hotkeyWindow.groups.navigation": "Navigation", + "hotkeyWindow.groups.view": "View", + "hotkeyWindow.groups.workspace": "Workspace", + "hotkeyWindow.actions.showShortcuts.title": "Show shortcuts", + "hotkeyWindow.actions.showShortcuts.description": "Open this standalone window.", + "hotkeyWindow.actions.newConversation.title": "New conversation", + "hotkeyWindow.actions.newConversation.description": "Start a new task from the current workspace.", + "hotkeyWindow.actions.goToFile.title": "Go to file", + "hotkeyWindow.actions.goToFile.description": "Search and open a workspace file.", + "hotkeyWindow.actions.searchChat.title": "Search current chat", + "hotkeyWindow.actions.searchChat.description": "Find text in the current message stream.", + "hotkeyWindow.actions.toggleSidebar.title": "Toggle sidebar", + "hotkeyWindow.actions.toggleSidebar.description": "Show or hide the Codex-style primary sidebar.", + "hotkeyWindow.actions.settings.title": "Settings", + "hotkeyWindow.actions.settings.description": "Open desktop settings.", + "hotkeyWindow.actions.openEditor.title": "Open in editor", + "hotkeyWindow.actions.openEditor.description": "Open the workspace in the preferred editor.", + "hotkeyWindow.actions.openPreviewFile.title": "Open preview file", + "hotkeyWindow.actions.openPreviewFile.description": "Open the current previewed file in the editor.", + "hotkeyWindow.pages.back": "Back", + "hotkeyWindow.pages.newThread.title": "New conversation", + "hotkeyWindow.pages.newThread.description": + "Open a new Codex task from the current workspace without leaving the hotkey window context.", + "hotkeyWindow.pages.newThread.primaryAction": "Start new conversation", + "hotkeyWindow.pages.newThread.composerHint": "Enter a task; worktree status appears here after submit.", + "hotkeyWindow.pages.worktreeInit.title": "Creating worktree", + "hotkeyWindow.pages.worktreeInit.description": + "Create an isolated workspace and run environment setup.", + "hotkeyWindow.pages.worktreeInit.primaryAction": "Open in editor", + "hotkeyWindow.pages.worktreeInit.phase.idle": "Waiting", + "hotkeyWindow.pages.worktreeInit.phase.creating": "Creating", + "hotkeyWindow.pages.worktreeInit.phase.created": "Created", + "hotkeyWindow.pages.worktreeInit.phase.setup": "Running setup", + "hotkeyWindow.pages.worktreeInit.phase.ready": "Ready", + "hotkeyWindow.pages.worktreeInit.phase.failed": "Needs attention", + "hotkeyWindow.pages.worktreeInit.projectLabel": "Project", + "hotkeyWindow.pages.worktreeInit.branchLabel": "Branch", + "hotkeyWindow.pages.worktreeInit.pathLabel": "Path", + "hotkeyWindow.pages.worktreeInit.createStep": "Create worktree", + "hotkeyWindow.pages.worktreeInit.setupStep": "Run environment setup", + "hotkeyWindow.pages.worktreeInit.waitingOutput": "Waiting for output...", + "hotkeyWindow.pages.worktreeInit.editEnvironment": "Edit environment", + "hotkeyWindow.pages.worktreeInit.autoFix": "Auto-fix", + "hotkeyWindow.pages.worktreeInit.retry": "Retry", + "hotkeyWindow.pages.worktreeInit.workLocally": "Work locally instead", + "hotkeyWindow.pages.worktreeInit.cancel": "Cancel", + "hotkeyWindow.pages.worktreeInit.continueAnyway": "Continue anyway", + "hotkeyWindow.pages.worktreeInit.openWorktree": "Open in editor", + "hotkeyWindow.pages.worktreeInit.creatingOutput": "Creating worktree from {branch}...", + "hotkeyWindow.pages.worktreeInit.readyOutput": "Worktree ready: {path}", + + "pluginsPage.title": "Plugins", + "pluginsPage.subtitle": "Use Codex in your everyday tools", + "pluginsPage.modeTabs": "Plugin page mode", + "pluginsPage.mcp.subtitle": "Configure MCP servers Codex can use", + "pluginsPage.mcp.search": "Search MCP servers", + "pluginsPage.skills": "Skills", + "pluginsPage.skills.subtitle": "Give Codex reusable abilities", + "pluginsPage.skills.search": "Search skills", + "pluginsPage.skills.installed": "Installed", + "pluginsPage.skills.recommended": "Recommended", + "pluginsPage.skills.createSkill": "New skill", + "pluginsPage.skills.try": "Try in Chat", + "pluginsPage.skills.install": "Install", + "pluginsPage.skills.loading": "Loading skills", + "pluginsPage.skills.loadingDescription": "Reading local Codex skill sources.", + "pluginsPage.skills.empty.title": "No installed skills", + "pluginsPage.skills.empty.description": + "Enable a plugin that provides skills, or create one in the current project.", + "pluginsPage.skills.noDescription": "Local skill", + "pluginsPage.skills.createPrompt": + "Help me create a Codex skill for this project, including triggers, workflow, and file structure.", + "pluginsPage.skills.runtimePrompt": + "Use the {skill} skill in this project. Start by explaining what it can do, any files or access it needs, and the first step.", + "pluginsPage.skills.documents.prompt": + "Use the Documents skill to handle documents in this project. Start by listing the editing, export, and validation steps.", + "pluginsPage.skills.research.prompt": + "Use the Research skill to investigate this project. Start with the questions to verify, sources, and conclusion format.", + "pluginsPage.skills.workflows.prompt": + "Use the Workflows skill to turn repeated project work into a reusable workflow with execution steps.", + "pluginsPage.skills.status.recommended": "Recommended", + "pluginsPage.skills.recommended.browserQa.description": + "Inspect local web experiences, screenshots, and accessibility", + "pluginsPage.skills.recommended.githubOps.description": + "Handle issues, PRs, CI, and publishing flows", + "pluginsPage.skills.recommended.dataAnalytics.description": + "Analyze data and generate charts and reports", + "pluginsPage.onboarding.title": "Start by role", + "pluginsPage.onboarding.description": + "Choose a recommended set from the existing plugins, connectors, and skills.", + "pluginsPage.onboarding.rolesLabel": "Plugin starter roles", + "pluginsPage.onboarding.start": "Start with this role", + "pluginsPage.onboarding.roles.developer.title": "Developer", + "pluginsPage.onboarding.roles.developer.description": + "GitHub, browser QA, and local Chrome context for code, PRs, CI, and page checks.", + "pluginsPage.onboarding.roles.developer.prompt": + "Set up the GitHub and browser QA workflow for this project. Start by checking which connector or skill is available, then suggest the first safe action.", + "pluginsPage.onboarding.roles.operator.title": "Operator", + "pluginsPage.onboarding.roles.operator.description": + "Email, calendar, Slack, and workflow skills for meetings, team context, and repeated processes.", + "pluginsPage.onboarding.roles.operator.prompt": + "Set up the operations workflow for this project using calendar, email, and team context where available. Start by listing required connections and local-only fallbacks.", + "pluginsPage.onboarding.roles.researcher.title": "Researcher", + "pluginsPage.onboarding.roles.researcher.description": + "Drive, Notion, Research, and data analysis for source retrieval, knowledge bases, and reports.", + "pluginsPage.onboarding.roles.researcher.prompt": + "Set up the research workflow for this project using docs, Drive or Notion context, and data analysis where available. Start by listing sources and the first retrieval step.", + "pluginsPage.add": "Add", + "pluginsPage.refresh": "Refresh", + "pluginsPage.prompt": "What should we do in this project?", + "pluginsPage.promptWithProject": "What should we do in {project}?", + "pluginsPage.composerPlaceholder": "Ask anything", + "pluginsPage.fullAccess": "Full access", + "pluginsPage.model": "5.5 high", + "pluginsPage.newChatWithTool": "New chat with a tool", + "pluginsPage.sharedAcross": + "Shared across Hermes, Claude Code, and Codex", + "pluginsPage.openSettings": "Manage plugins", + "pluginsPage.installed": "Installed", + "pluginsPage.installedPlugins": "Installed plugins", + "pluginsPage.enabled": "Enabled", + "pluginsPage.mcpServers": "MCP servers", + "pluginsPage.availableTools": "Available tools", + "pluginsPage.search": "Search plugins...", + "pluginsPage.catalogSearch": "Search plugins", + "pluginsPage.filter": "Filter", + "pluginsPage.sourceTabs": "Plugin source", + "pluginsPage.source.openai": "Built by OpenAI", + "pluginsPage.source.workspace": "Built by your workspace", + "pluginsPage.source.personal": "Personal", + "pluginsPage.source.mine": "Personal", + "pluginsPage.connected": "Connected", + "pluginsPage.manage": "Manage", + "pluginsPage.connectedMore": "14 more", + "pluginsPage.manage.subtitle": "Manage connected tools, apps, MCPs, and skills", + "pluginsPage.manage.tabsLabel": "Plugin management type", + "pluginsPage.manage.tabs.plugins": "Plugins", + "pluginsPage.manage.tabs.apps": "Apps", + "pluginsPage.manage.tabs.mcps": "MCPs", + "pluginsPage.manage.tabs.skills": "Skills", + "pluginsPage.manage.tabs.marketplace": "Marketplace", + "pluginsPage.manage.installed": "Installed", + "pluginsPage.manage.marketplace": "Marketplace", + "pluginsPage.manage.browseMarketplace": "Browse marketplace", + "pluginsPage.manage.connectedDescription": "Available in Codex", + "pluginsPage.manage.apps": "Apps", + "pluginsPage.manage.mcps": "MCP servers", + "pluginsPage.manage.skills": "Skills", + "pluginsPage.manage.apps.drive.description": "Connect files, docs, sheets, and slides", + "pluginsPage.manage.apps.gmail.description": "Bring email context into Codex", + "pluginsPage.manage.apps.calendar.description": "Manage schedules, meetings, and availability", + "pluginsPage.manage.apps.chrome.description": "Use browser pages and session context", + "pluginsPage.manage.mcps.github.description": "Provide PR, issue, CI, and publishing tools", + "pluginsPage.manage.mcps.linear.description": "Read issue, project, and roadmap state", + "pluginsPage.manage.mcps.slack.description": "Read context from team discussions", + "pluginsPage.manage.mcps.notion.description": "Connect specs, research, and knowledge pages", + "pluginsPage.manage.skills.documents.description": "Handle document editing and export workflows", + "pluginsPage.manage.skills.research.description": "Organize research, sources, and conclusions", + "pluginsPage.manage.skills.workflows.description": "Run reusable project workflows", + "pluginsPage.catalog": "Plugin catalog", + "pluginsPage.catalogDescription": + "Browse installed plugins, connectors, skills, commands, and MCP servers.", + "pluginsPage.featured": "Featured", + "pluginsPage.featuredDescription": + "Connectors inherit the same Moss memory, hooks, skills, and MCP approvals.", + "pluginsPage.viewMoreFeatured": "View Teams, SharePoint, and 4 more", + "pluginsPage.productivity": "Productivity", + "pluginsPage.business": "Business & Operations", + "pluginsPage.catalog.github.title": "GitHub", + "pluginsPage.catalog.github.description": + "Triage PRs, issues, CI, and publish flows", + "pluginsPage.catalog.slack.title": "Slack", + "pluginsPage.catalog.slack.description": "Read and manage Slack", + "pluginsPage.catalog.notion.title": "Notion", + "pluginsPage.catalog.notion.description": + "Notion workflows for specs, research, meetings, and knowledge capture", + "pluginsPage.catalog.googleCalendar.title": "Google Calendar", + "pluginsPage.catalog.googleCalendar.description": + "Manage Google Calendar events and schedules", + "pluginsPage.catalog.googleDrive.title": "Google Drive", + "pluginsPage.catalog.googleDrive.description": + "Work across Drive, Docs, Sheets, and Slides", + "pluginsPage.catalog.recordReplay.title": "Record & Replay", + "pluginsPage.catalog.recordReplay.description": + "Record what I'm doing on my Mac and turn it into a Skill", + "pluginsPage.catalog.actively.title": "Actively", + "pluginsPage.catalog.actively.description": + "Account intelligence and prospecting signals from Actively", + "pluginsPage.catalog.apollo.title": "Apollo", + "pluginsPage.catalog.apollo.description": + "B2B sales intelligence and engagement from Apollo", + "pluginsPage.catalog.salesforce.title": "Salesforce", + "pluginsPage.catalog.salesforce.description": + "Work with Salesforce accounts, opportunities, and records", + "pluginsPage.catalog.hubspot.title": "HubSpot", + "pluginsPage.catalog.hubspot.description": + "Manage CRM contacts, companies, deals, and notes", + "pluginsPage.catalog.github.longDescription": + "Use GitHub to inspect repositories, review pull requests, address feedback, debug failing Actions checks, and prepare code changes for review through a connector-first workflow with targeted CLI fallbacks.", + "pluginsPage.catalog.recordReplay.longDescription": + "Record & Replay lets Codex record your actions on your Mac to create skills for more automated workflows. When you choose to start a recording, Codex will record your mouse clicks, text you type, and the content in windows you interact with until you stop it (up to 30 minutes). You can stop or cancel recording at any time, and cancelling will discard any existing recording. Avoid recording sensitive workflows.", + "pluginsPage.catalog.github.prompt": + "Use the GitHub plugin to review this project's PRs, issues, CI, and release risks. Start by listing any connection or authorization steps.", + "pluginsPage.catalog.github.promptExample": + "Inspect PRs, triage issues, debug failing checks, and prepare code changes for review", + "pluginsPage.catalog.slack.prompt": + "Use the Slack plugin to summarize recent team discussions, decisions, and open questions. Start by listing any connection or authorization steps.", + "pluginsPage.catalog.notion.prompt": + "Use the Notion plugin to organize specs, research, and meeting notes. Start by listing any connection or authorization steps.", + "pluginsPage.catalog.googleCalendar.prompt": + "Use the Google Calendar plugin to review schedule, meeting prep, and availability. Start by listing any connection or authorization steps.", + "pluginsPage.catalog.googleDrive.prompt": + "Use the Google Drive plugin to review project materials across Drive, Docs, Sheets, and Slides. Start by listing any connection or authorization steps.", + "pluginsPage.catalog.recordReplay.prompt": + "Record my workflow and turn it into a reusable skill", + "pluginsPage.catalog.recordReplay.promptExample.workflow": + "Record my workflow and turn it into a reusable skill", + "pluginsPage.catalog.recordReplay.promptExample.task": + "Watch me do this task and create a skill from it", + "pluginsPage.catalog.recordReplay.promptExample.fileExpense": + "Record what I'm doing and make a skill called 'File Expense'", + "pluginsPage.catalog.actively.prompt": + "Use the Actively plugin to organize account intelligence, prospecting signals, and next steps. Start by listing any connection or authorization steps.", + "pluginsPage.catalog.apollo.prompt": + "Use the Apollo plugin to analyze leads, company data, and engagement plans. Start by listing any connection or authorization steps.", + "pluginsPage.catalog.salesforce.prompt": + "Use the Salesforce plugin to review accounts, opportunities, and records. Start by listing any connection or authorization steps.", + "pluginsPage.catalog.hubspot.prompt": + "Use the HubSpot plugin to organize contacts, companies, deals, and notes. Start by listing any connection or authorization steps.", + "pluginsPage.detail.installTitle": "Install {plugin}", + "pluginsPage.detail.installedTitle": "{plugin} added", + "pluginsPage.detail.developedBy": "Developed by {developer}", + "pluginsPage.detail.about": "About", + "pluginsPage.detail.localStatus": "Local status", + "pluginsPage.detail.includes": "Includes", + "pluginsPage.detail.includes.apps": "Apps", + "pluginsPage.detail.includes.skills": "Skills", + "pluginsPage.detail.includes.mcps": "MCP servers", + "pluginsPage.detail.availableAgents": "Available agents", + "pluginsPage.detail.availableAgentsDescription": + "Install once; Hermes, Claude Code, and Codex can all recognize and use these plugin resources.", + "pluginsPage.detail.capabilities": "Capabilities", + "pluginsPage.detail.info": "Info", + "pluginsPage.detail.features": "Features", + "pluginsPage.detail.developer": "Developer", + "pluginsPage.detail.category": "Category", + "pluginsPage.detail.website": "Website", + "pluginsPage.detail.policies": "", + "pluginsPage.detail.privacyPolicy": "Privacy Policy", + "pluginsPage.detail.termsOfService": "Terms of Service", + "pluginsPage.detail.addToCodex": "Add to Codex", + "pluginsPage.detail.addingToCodex": "Adding to Codex", + "pluginsPage.detail.tryInChat": "Try in Chat", + "pluginsPage.detail.copyLink": "Copy link", + "pluginsPage.detail.copiedLink": "Copied", + "pluginsPage.detail.breadcrumbNavigation": "Breadcrumb navigation", + "pluginsPage.detail.configureMcp": "Configure in MCP Settings", + "pluginsPage.detail.disableSkill": "Disable skill", + "pluginsPage.detail.enableSkill": "Enable skill", + "pluginsPage.detail.moreActions": "More actions", + "pluginsPage.detail.removeFromCodex": "Remove from Codex", + "pluginsPage.detail.removingFromCodex": "Removing from Codex", + "pluginsPage.detail.installFailed": "Could not add {plugin}. Try again.", + "pluginsPage.detail.uninstallFailed": "Could not remove {plugin}. Try again.", + "pluginsPage.detail.copyLinkFailed": "Could not copy {plugin} link.", + "pluginsPage.detail.close": "Close plugin detail", + "pluginsPage.uninstall.title": "Remove {plugin}?", + "pluginsPage.uninstall.body": + "Codex will stop using the apps, skills, and MCP servers provided by this plugin.", + "pluginsPage.uninstall.impact": "Will disable", + "pluginsPage.uninstall.apps": "Apps", + "pluginsPage.uninstall.skills": "Skills", + "pluginsPage.uninstall.mcps": "MCP servers", + "pluginsPage.uninstall.cancel": "Cancel", + "pluginsPage.uninstall.confirm": "Remove plugin", + "pluginsPage.uninstall.removing": "Removing", + "pluginsPage.tool.slack.title": "Connect messages", + "pluginsPage.tool.slack.description": "Get context from recent team discussions", + "pluginsPage.tool.slack.prompt": + "Use Slack to catch me up on recent decisions and open questions", + "pluginsPage.tool.gmail.title": "Connect email", + "pluginsPage.tool.gmail.description": "Summarize stakeholder asks from email", + "pluginsPage.tool.gmail.prompt": + "Use Gmail to summarize exec and stakeholder requests", + "pluginsPage.tool.drive.title": "Connect files", + "pluginsPage.tool.drive.description": "Review results, research, and plans", + "pluginsPage.tool.drive.prompt": + "Use Google Drive to review the latest results and research, and flag opportunities", + "pluginsPage.tool.connected": "Connected", + "pluginsPage.tool.request": "Request", + "pluginsPage.noResults": "No plugins match this search.", + "pluginsPage.loading": "Loading plugins...", + "pluginsPage.resources": "{count} resources", + "pluginsPage.status.enabled": "Enabled", + "pluginsPage.status.disabled": "Disabled", + "pluginsPage.status.connected": "Connected", + "pluginsPage.action.signIn": "Sign in", + "pluginsPage.detail.version": "Version", + "pluginsPage.detail.source": "Source", + "pluginsPage.detail.homepage": "Homepage", + "pluginsPage.detail.tags": "Tags", + "pluginsPage.detail.commands": "Commands", + "pluginsPage.detail.skills": "Skills", + "pluginsPage.detail.agents": "Agents", + "pluginsPage.detail.mcpServers": "MCP Servers", + "pluginsPage.detail.select": "Select a plugin to view details", + "pluginsPage.empty.title": "No plugins", + "pluginsPage.empty.description": "Installed plugins will appear here.", + "pluginsPage.empty.installPath": "Install plugins to ~/.claude/plugins/marketplaces/", + "pluginsPage.toast.authenticated": "{name} authenticated", + "pluginsPage.toast.authFailed": "Authentication failed", + "pluginsPage.toast.enabled": "Plugin enabled", + "pluginsPage.toast.disabled": "Plugin disabled", + "pluginsPage.toast.updateFailed": "Failed to update plugin", + + "settingsResources.fromConfig": "From configuration", + "settingsResources.userConfig": "User configuration", + "settingsResources.count": "{count} items", + "settingsResources.status.on": "Enabled", + "settingsResources.status.off": "Off", + "settingsResources.status.review": "Needs review", + + "mcpSettings.title": "MCP Servers", + "mcpSettings.description": + "Use one Moss configuration for tools across Hermes, Claude Code, and Codex.", + "mcpSettings.enable.title": "Enable MCP", + "mcpSettings.enable.description": + "Let every agent read available tools from the Moss unified source.", + "mcpSettings.approval.title": "Ask before new tool access", + "mcpSettings.approval.description": + "Confirm first connections and newly added tools before use.", + "mcpSettings.connected.title": "Configured servers", + "mcpSettings.connected.description": + "Servers from .moss/mcp and engine mappings.", + "mcpSettings.approved.title": "Available", + "mcpSettings.approved.description": + "Servers agents can use without another confirmation.", + "mcpSettings.projections.title": "Engine projections", + "mcpSettings.projections.description": + "Mapping status for Claude Code, Codex, and Hermes.", + "mcpSettings.source.description": "{count} MCP servers", + "mcpSettings.modal.title": "User configuration", + "mcpSettings.modal.description": + "These MCP servers come from the Moss unified source and are mapped into each agent.", + "mcpSettings.empty": "No MCP servers configured yet.", + + "hooksSettings.title": "Hooks", + "hooksSettings.description": + "Manage lifecycle automation through configured and enabled hooks.", + "hooksSettings.runner.title": "Enable hooks", + "hooksSettings.runner.description": + "Allow Moss to run approved local automation.", + "hooksSettings.confirmation.title": "Confirm before running", + "hooksSettings.confirmation.description": + "Ask before hooks run commands or modify files.", + "hooksSettings.enabled.title": "Enabled hooks", + "hooksSettings.enabled.description": + "Hooks currently available to the Moss runner.", + "hooksSettings.environment.title": "Environment variables", + "hooksSettings.environment.description": + "Variables allowed to pass into local automation.", + "hooksSettings.source.description": "{count} hooks", + "hooksSettings.modal.title": "User configuration", + "hooksSettings.modal.description": + "These hooks come from .moss/hooks and are adapted for each agent.", + "hooksSettings.empty": "No hooks configured yet.", + + "memorySettings.title": "Personalization", + "memorySettings.description": + "Keep rules, memory, and project instructions in one Moss unified source.", + "memorySettings.enable.title": "Enable memory", + "memorySettings.enable.description": + "Keep reusable memory from chats and project context.", + "memorySettings.unified.title": "Share with every agent", + "memorySettings.unified.description": + "Claude Code, Codex, and Hermes use the same real memory source.", + "memorySettings.memory.title": "Memory", + "memorySettings.memory.description": + "User and project memory from the Moss unified source.", + "memorySettings.instructions.title": "Custom instructions", + "memorySettings.instructions.description": + "Mapped as CLAUDE.md, AGENTS.md, or runtime injection.", + "memorySettings.projections.title": "Engine projections", + "memorySettings.projections.description": + "How Moss projects memory into each agent.", + "memorySettings.source.description": "{count} memories and instructions", + "memorySettings.modal.title": "User configuration", + "memorySettings.modal.description": + "This shows memory and instructions from the Moss unified source without a second data copy.", + "memorySettings.empty": "No memory or instructions yet.", + + "configurationSettings.title": "Configuration", + "configurationSettings.description": + "Project instructions and agent runtime switches are sourced from Moss, then projected into Hermes, Claude Code, Codex, and Custom ACP.", + "configurationSettings.refresh": "Refresh projections", + "configurationSettings.instructionFiles.title": "Instruction files", + "configurationSettings.instructionFiles.description": + "Use .moss/source/moss.md as the single source for workspace rules.", + "configurationSettings.agentsMd.description": + "Project AGENTS.md resolves to the Moss instruction source.", + "configurationSettings.claudeMd.description": + "Claude Code reads the same Moss source through a managed projection.", + "configurationSettings.webSearch.title": "Web search", + "configurationSettings.webSearch.description": + "Expose browsing permissions through the shared provider policy.", + "configurationSettings.transcriptExport.title": "Transcript export", + "configurationSettings.transcriptExport.description": + "Allow sessions to export replayable logs without copying memory.", + "configurationSettings.slashArguments.title": "Slash command arguments", + "configurationSettings.slashArguments.description": + "Normalize command placeholders before Claude Code, Codex, or Hermes receives them.", + "configurationSettings.mossSource": "Moss source", + "configurationSettings.slashArgumentToken": "Slash argument token", + "configurationSettings.projections.title": "Instruction projections", + "configurationSettings.projections.description": + "One real source, protocol-specific paths only where each engine needs them.", + "configurationSettings.projections.empty": + "No instruction mappings returned for this engine.", + "configurationSettings.projections.loading": "Loading projections...", + "configurationSettings.projections.none": "No projections found.", + "configurationSettings.runtimeInjection": "runtime injection", + + "archivedSettings.title": "Archived Conversations", + "archivedSettings.description": + "Archived sessions keep their own transcript while remaining governed by the shared Moss source.", + "archivedSettings.refresh": "Refresh archived conversations", + "archivedSettings.search": "Search archived conversations...", + "archivedSettings.localArchive": "Local archive", + "archivedSettings.empty.title": "No archived conversations", + "archivedSettings.empty.description": + "Restored and active sessions will appear in the workspace sidebar.", + "archivedSettings.restore": "Restore", + "archivedSettings.restored": "Conversation restored", + "archivedSettings.restoreFailed": "Restore failed", + "archivedSettings.newWorkspace": "New workspace", + "archivedSettings.noBranch": "No branch", + "archivedSettings.archived": "Archived", + + "sidebar.search": "Search", + "sidebar.quickChat": "Quick Chat", + "sidebar.plugins": "Plugins", + "sidebar.skills": "Skills", + "sidebar.mcpServers": "MCP Servers", + "sidebar.inbox": "Inbox", + "sidebar.automations": "Scheduled", + "sidebar.projects": "Projects", + "sidebar.library": "Library", + "sidebar.pullRequests": "Pull Requests", + "sidebar.kanban": "Kanban", + "sidebar.workspaces": "Conversations", + "sidebar.pinned": "Pinned", + "sidebar.recentWorkspaces": "Recent conversations", + "sidebar.pinnedWorkspaces": "Pinned conversations", + "sidebar.drafts": "Drafts", + "sidebar.newWorkspace": "New chat", + "sidebar.startNewWorkspace": "Start a new conversation", + "sidebar.searchWorkspaces": "Search conversations...", + "sidebar.expandShow": "Show more", + "sidebar.collapseShow": "Show less", + "sidebar.collapseAll": "Collapse all", + "sidebar.projectActions": "{project} project actions", + "sidebar.projectNewChat": "Start new chat in {project}", + "sidebar.projectTasks": "Scheduled tasks in {project}", + "sidebar.projectsSidebarOptions": "Projects sidebar options", + "sidebar.addProject": "Add new project", + "sidebar.expandProject": "Expand project", + "sidebar.collapseProject": "Collapse project", + "sidebar.pinnedConversations": "Pinned conversations", + "sidebar.scheduledTaskFolder": "Scheduled task folder", + "sidebar.filterConversations": "Filter sidebar conversations", + "sidebar.noChats": "No chats", + "sidebar.noConversations": "No conversations", + "sidebar.settings": "Settings", + "sidebar.openSettings": "Open Settings", + "sidebar.hideSidebar": "Hide sidebar", + "sidebar.update": "Update", + "sidebar.help": "Help", + "sidebar.archive": "Archive", + "sidebar.feedback": "Feedback", + "sidebar.selected": "{count} selected", + "sidebar.cancel": "Cancel", + "sidebar.archiving": "Archiving...", + "sidebar.archiveAction": "Archive", + + "chat.newChatOrPlan": "New chat or plan", + "chat.newChat": "New chat", + "chat.newPlan": "New plan", + "chat.fullAccess": "Full access", + "chat.permissions.change": "Change permissions", + "chat.permissions.title": "How should Codex actions be approved?", + "chat.permissions.learnMore": "Learn more", + "chat.permissions.readOnly.label": "Read only", + "chat.permissions.readOnly.description": "Read files and inspect the workspace without editing", + "chat.permissions.askApproval.label": "Ask for approval", + "chat.permissions.askApproval.description": "Always ask to edit external files and use the internet", + "chat.permissions.approveForMe.label": "Approve for me", + "chat.permissions.approveForMe.description": "Only ask for actions detected as potentially unsafe", + "chat.permissions.approveForMe.disabled": "Requires default sandboxed permissions in this workspace", + "chat.permissions.fullAccess.label": "Full access", + "chat.permissions.fullAccess.description": "Unrestricted access to the internet and any file on your computer", + "chat.permissions.custom.label": "Custom (config.toml)", + "chat.permissions.custom.description": "Uses permissions defined in config.toml", + "chat.permissions.fullAccessConfirm.title": "Are you sure?", + "chat.permissions.fullAccessConfirm.body": + "Full access lets Codex access the internet and edit any file on your computer without asking. This can risk data loss or prompt injection.", + "chat.permissions.fullAccessConfirm.cancel": "Cancel", + "chat.permissions.fullAccessConfirm.confirm": "Turn on full access", + "chat.newWorkspacePrompt": "What do you want to get done?", + "chat.inputPlaceholder": "Plan, @ for context, / for commands", + "chat.queuePlaceholder": "Add to the queue", + "chat.selectRepo": "Select repo", + "chat.opening": "Opening...", + + "runtime.checking.title": "Checking agent runtimes", + "runtime.checking.body": + "Moss is checking Hermes, Claude Code, Codex, and Custom ACP before this chat can run.", + "runtime.empty.title": "No agent engines", + "runtime.empty.body": "Moss runtime returned no engine manifests.", + "runtime.healthUnavailable.title": "Runtime health unavailable", + "runtime.healthUnavailable.body": "Moss could not read agent runtime health.", + "runtime.engineMissing.title": "Engine missing", + "runtime.engineMissing.body": + "The selected engine is not present in the Moss runtime manifest.", + "runtime.fallback.title": "{engine} is using fallback metadata", + "runtime.fallback.body": + "Moss did not receive a complete runtime status for this engine.", + "runtime.needsAuth.title": "{engine} needs authentication", + "runtime.needsAuth.body": + "Connect an account or configure the shared Moss provider before sending.", + "runtime.notInstalled.title": "{engine} is not installed", + "runtime.notInstalled.body": + "Moss cannot find the local runtime needed for this engine.", + "runtime.unsupported.title": "{engine} is unavailable", + "runtime.unsupported.body": + "This runtime is currently unsupported on this workspace.", + "runtime.error.title": "{engine} runtime error", + "runtime.error.body": "Moss could not verify this runtime.", + "runtime.checkingEngine.title": "Checking {engine}", + "runtime.checkingEngine.body": + "Moss is checking this runtime before sending.", + "runtime.manifestMissing.title": "{engine} is missing", + "runtime.manifestMissing.body": + "Moss runtime returned no manifest for this engine.", + "runtime.update.available.title": "{engine} update available", + "runtime.update.available.body": + "Current version {currentVersion}; latest version {latestVersion}. New sessions can still run, but update before long tasks.", + "runtime.update.manual.title": "{engine} update available", + "runtime.update.manual.body": + "Current version {currentVersion}; latest version {latestVersion}. Review runtime settings for the update path.", + "runtime.update.queued.title": "{engine} update queued", + "runtime.update.queued.body": + "Moss is waiting for the previous provider update to finish.", + "runtime.update.running.title": "Updating {engine}", + "runtime.update.running.body": + "The provider update command is running. New sessions will use the new version after it finishes.", + "runtime.update.failed.title": "{engine} update failed", + "runtime.update.failed.body": + "The provider update command did not finish. Check runtime settings for details.", + "runtime.update.failed.bodyWithMessage": "{message}", + "runtime.update.unchanged.title": "{engine} still needs an update", + "runtime.update.unchanged.body": + "The runtime still reports {currentVersion} after updating. Check runtime settings for details.", + "runtime.update.succeeded.title": "{engine} updated", + "runtime.update.succeeded.body": + "New sessions will use the updated provider.", + "runtime.openRuntime": "Open Runtime", + "runtime.openModels": "Open Models", +}; + +const dictionaries: Record = { + "zh-CN": zhCN, + "en-US": enUS, +}; + +export function translate( + language: AppLanguage, + key: string, + values?: Record, +) { + const template = dictionaries[language][key] ?? enUS[key] ?? key; + if (!values) return template; + return template.replace(/\{(\w+)\}/g, (_match, name) => + String(values[name] ?? ""), + ); +} + +export function useI18n() { + const language = useAtomValue(appLanguageAtom); + return { + language, + t: (key: string, values?: Record) => + translate(language, key, values), + }; +} 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", + } +}