diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..5171090 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "clawcode-local", + "interface": { + "displayName": "ClawCode Local", + "shortDescription": "Local Codex build of ClawCode" + }, + "plugins": [ + { + "name": "clawcode", + "source": { + "source": "local", + "path": "./plugins/clawcode" + }, + "policy": { + "installation": "AVAILABLE" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..756e16f --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,46 @@ +{ + "name": "clawcode", + "version": "1.7.6-codex.0", + "description": "Persistent local agent layer for Codex: workspace memory, identity files, reminders, voice, WebChat, and MCP tools.", + "author": { + "name": "Juan Cristobal Andrews", + "url": "https://github.com/crisandrews" + }, + "homepage": "https://github.com/crisandrews/ClawCode", + "repository": "https://github.com/crisandrews/ClawCode", + "license": "MIT", + "keywords": [ + "agent", + "personality", + "memory", + "codex", + "mcp", + "clawcode" + ], + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "interface": { + "displayName": "ClawCode for Codex", + "shortDescription": "Persistent agent memory and identity for Codex", + "longDescription": "Codex-compatible packaging for ClawCode. The MCP memory, identity, reminder registry, Codex service runner, voice, WebChat, doctor, and skill surfaces can run under Codex. Claude Code channel plugins and Claude launch flags are reported as Claude-only instead of being advertised as Codex features.", + "developerName": "Juan Cristobal Andrews", + "category": "Developer Tools", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://github.com/crisandrews/ClawCode", + "privacyPolicyURL": "https://github.com/crisandrews/ClawCode/blob/main/PRIVACY.md", + "termsOfServiceURL": "https://github.com/crisandrews/ClawCode/blob/main/LICENSE", + "composerIcon": "./assets/clawcode.png", + "logo": "./assets/clawcode.png", + "defaultPrompt": [ + "Create a persistent Codex agent in this workspace", + "Search this agent's memory", + "Run ClawCode doctor for this workspace" + ], + "brandColor": "#3B82F6", + "screenshots": [] + } +} diff --git a/.gitignore b/.gitignore index 15fb116..59cd77a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ memory/.scoped/ memory/.crons-lock/ memory/.reconciling memory/.cron-last-stamp +memory/.codex-cron-runner-state.json +memory/.codex-cron-runner.lock/ +**/memory/.codex-cron-runner-state.json +**/memory/.codex-cron-runner.lock/ memory/crons-errors.jsonl memory/crons-pending.jsonl memory/crons.json.corrupt-* diff --git a/.mcp.json b/.mcp.json index e1d1d62..161ace3 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,10 +1,10 @@ { "mcpServers": { "clawcode": { + "cwd": ".", "command": "bash", "args": [ - "-c", - "cd \"${CLAUDE_PLUGIN_ROOT}\" && if [ ! -f node_modules/.bin/tsx ]; then echo '[clawcode] Installing dependencies (first run)...' >&2; npm install --prefix \"${CLAUDE_PLUGIN_ROOT}\" 2>&1 | tail -5 >&2; fi && if [ ! -f node_modules/.bin/tsx ]; then echo '[clawcode] ERROR: npm install failed. Check Node.js v18+ is installed: node --version' >&2; echo '[clawcode] Try manually: npm install --prefix \"${CLAUDE_PLUGIN_ROOT}\"' >&2; exit 1; fi && exec node_modules/.bin/tsx server.ts" + "./bin/clawcode-mcp.sh" ] } } diff --git a/README.md b/README.md index 0e77ec8..d1ed0c1 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ Coming from OpenClaw? `/agent:import` brings your agent's personality, memory, s - [Node.js](https://nodejs.org/) v18+ - Windows: Claude Code must be run from inside [WSL2](docs/wsl2.md) (not native PowerShell / cmd). +## [Codex support](#codex-support) + +This fork includes an OpenAI Codex packaging layer: `.codex-plugin/plugin.json`, +`.agents/plugins/marketplace.json`, and a runtime-aware MCP launcher. The core +MCP memory/identity tools, Codex-aware skill paths, WebChat/voice surfaces, and +registry-backed reminders work under Codex. Claude Code channel plugins, Claude +launch flags, and `claude --continue` service mode remain Claude-only. + +See [`docs/codex.md`](docs/codex.md) for local install and runtime behavior. + ## [Quick Setup](#quick-setup) **1. Create a folder for your agent.** diff --git a/bin/clawcode-codex-cron-runner.mjs b/bin/clawcode-codex-cron-runner.mjs new file mode 100644 index 0000000..ecf0b53 --- /dev/null +++ b/bin/clawcode-codex-cron-runner.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true"; + out[key] = value; + } + return out; +} + +function parseField(field, min, max) { + const values = new Set(); + for (const part of String(field).split(",")) { + if (!part) continue; + const [rangePart, stepPart] = part.split("/"); + const step = stepPart ? Number(stepPart) : 1; + if (!Number.isInteger(step) || step <= 0) return null; + + let start; + let end; + if (rangePart === "*") { + start = min; + end = max; + } else if (rangePart.includes("-")) { + const pieces = rangePart.split("-").map(Number); + if (pieces.length !== 2) return null; + [start, end] = pieces; + } else { + start = Number(rangePart); + end = Number(rangePart); + } + + if (!Number.isInteger(start) || !Number.isInteger(end)) return null; + if (start < min || end > max || start > end) return null; + for (let v = start; v <= end; v += step) values.add(v); + } + return values; +} + +export function cronMatchesDate(cron, date = new Date()) { + const fields = String(cron).trim().split(/\s+/); + if (fields.length !== 5) return false; + const [minute, hour, day, month, dow] = fields; + const parsed = [ + parseField(minute, 0, 59), + parseField(hour, 0, 23), + parseField(day, 1, 31), + parseField(month, 1, 12), + parseField(dow === "7" ? "0" : dow, 0, 7), + ]; + if (parsed.some((p) => p === null)) return false; + const localDow = date.getDay(); + return ( + parsed[0].has(date.getMinutes()) && + parsed[1].has(date.getHours()) && + parsed[2].has(date.getDate()) && + parsed[3].has(date.getMonth() + 1) && + (parsed[4].has(localDow) || (localDow === 0 && parsed[4].has(7))) + ); +} + +export function dueEntries(registry, state, now = new Date()) { + const minuteKey = Math.floor(now.getTime() / 60000); + const nowEpoch = Math.floor(now.getTime() / 1000); + const fired = state.fired || {}; + const entries = Array.isArray(registry.entries) ? registry.entries : []; + return entries.filter((entry) => { + if (!entry || entry.paused || entry.tombstone) return false; + if (entry.recurring === false && Number.isFinite(entry.targetEpoch)) { + return entry.targetEpoch <= nowEpoch && fired[entry.key] !== "oneshot"; + } + if (!cronMatchesDate(entry.cron, now)) return false; + return fired[entry.key] !== minuteKey; + }); +} + +function readJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return fallback; + } +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.tmp.${process.pid}`; + fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`); + fs.renameSync(tmp, file); +} + +function runWriteback(pluginRoot, workspace, args) { + const script = path.join(pluginRoot, "skills", "crons", "writeback.sh"); + return spawnSync("bash", [script, ...args], { + cwd: workspace, + env: { + ...process.env, + CLAWCODE_RUNTIME: "codex", + CLAWCODE_WORKSPACE: workspace, + CLAUDE_PROJECT_DIR: workspace, + CLAWCODE_PLUGIN_ROOT: pluginRoot, + CLAUDE_PLUGIN_ROOT: pluginRoot, + }, + encoding: "utf8", + }); +} + +function runCodex(codexBin, workspace, prompt) { + return spawnSync( + codexBin, + [ + "exec", + "-C", + workspace, + "--skip-git-repo-check", + "--ask-for-approval", + "never", + "--dangerously-bypass-approvals-and-sandbox", + prompt, + ], + { + cwd: workspace, + env: { + ...process.env, + CLAWCODE_RUNTIME: "codex", + CLAWCODE_WORKSPACE: workspace, + CLAUDE_PROJECT_DIR: workspace, + }, + encoding: "utf8", + stdio: "inherit", + } + ); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const workspace = path.resolve(args.workspace || process.env.CLAWCODE_WORKSPACE || process.cwd()); + const pluginRoot = path.resolve(args["plugin-root"] || process.env.CLAWCODE_PLUGIN_ROOT || path.join(path.dirname(fileURLToPath(import.meta.url)), "..")); + const codexBin = args["codex-bin"] || process.env.CODEX_BIN || "codex"; + const memoryDir = path.join(workspace, "memory"); + const registryPath = path.join(memoryDir, "crons.json"); + const statePath = path.join(memoryDir, ".codex-cron-runner-state.json"); + const lockDir = path.join(memoryDir, ".codex-cron-runner.lock"); + + fs.mkdirSync(memoryDir, { recursive: true }); + try { + fs.mkdirSync(lockDir); + } catch { + return; + } + + try { + runWriteback(pluginRoot, workspace, ["seed-defaults"]); + const registry = readJson(registryPath, { entries: [] }); + const state = readJson(statePath, { fired: {} }); + const now = new Date(); + const minuteKey = Math.floor(now.getTime() / 60000); + const due = dueEntries(registry, state, now); + + for (const entry of due) { + const result = runCodex(codexBin, workspace, entry.prompt); + if (result.status === 0) { + state.fired = state.fired || {}; + state.fired[entry.key] = entry.recurring === false && Number.isFinite(entry.targetEpoch) + ? "oneshot" + : minuteKey; + state.updatedAt = new Date().toISOString(); + writeJson(statePath, state); + if (entry.recurring === false && Number.isFinite(entry.targetEpoch)) { + runWriteback(pluginRoot, workspace, ["tombstone", "--key", entry.key]); + } + } + } + } finally { + fs.rmSync(lockDir, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/bin/clawcode-mcp.sh b/bin/clawcode-mcp.sh new file mode 100755 index 0000000..20ac88a --- /dev/null +++ b/bin/clawcode-mcp.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +export CLAWCODE_PLUGIN_ROOT="${CLAWCODE_PLUGIN_ROOT:-$PLUGIN_ROOT}" +export CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$CLAWCODE_PLUGIN_ROOT}" + +if [[ -z "${CLAWCODE_RUNTIME:-}" ]]; then + if [[ -n "${CODEX_HOME:-}" ]]; then + export CLAWCODE_RUNTIME="codex" + else + export CLAWCODE_RUNTIME="claude" + fi +fi + +if [[ -z "${CLAWCODE_WORKSPACE:-}" ]]; then + if [[ -n "${CODEX_PROJECT_DIR:-}" ]]; then + export CLAWCODE_WORKSPACE="$CODEX_PROJECT_DIR" + elif [[ -n "${CODEX_WORKSPACE_ROOT:-}" ]]; then + export CLAWCODE_WORKSPACE="$CODEX_WORKSPACE_ROOT" + elif [[ -n "${CODEX_WORKSPACE:-}" ]]; then + export CLAWCODE_WORKSPACE="$CODEX_WORKSPACE" + elif [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + export CLAWCODE_WORKSPACE="$CLAUDE_PROJECT_DIR" + elif [[ -n "${OLDPWD:-}" && "$(cd "$OLDPWD" 2>/dev/null && pwd || true)" != "$PLUGIN_ROOT" ]]; then + export CLAWCODE_WORKSPACE="$OLDPWD" + elif [[ "$(pwd)" != "$PLUGIN_ROOT" ]]; then + export CLAWCODE_WORKSPACE="$(pwd)" + else + export CLAWCODE_WORKSPACE="$PLUGIN_ROOT" + fi +fi + +export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$CLAWCODE_WORKSPACE}" + +cd "$PLUGIN_ROOT" + +if [[ ! -f node_modules/.bin/tsx ]]; then + echo "[clawcode] Installing dependencies (first run)..." >&2 + npm install --prefix "$PLUGIN_ROOT" 2>&1 | tail -5 >&2 +fi + +if [[ ! -f node_modules/.bin/tsx ]]; then + echo "[clawcode] ERROR: npm install failed. Check Node.js v18+ is installed: node --version" >&2 + echo "[clawcode] Try manually: npm install --prefix \"$PLUGIN_ROOT\"" >&2 + exit 1 +fi + +exec "$PLUGIN_ROOT/node_modules/.bin/tsx" "$PLUGIN_ROOT/server.ts" diff --git a/docs/codex.md b/docs/codex.md new file mode 100644 index 0000000..e3db08f --- /dev/null +++ b/docs/codex.md @@ -0,0 +1,66 @@ +# Codex Compatibility + +This fork adds an OpenAI Codex packaging layer for ClawCode. + +## Status + +Supported under Codex: + +- Codex plugin manifest (`.codex-plugin/plugin.json`) +- Local Codex marketplace manifest (`.agents/plugins/marketplace.json`) +- ClawCode MCP server over stdio +- Workspace memory tools: `memory_search`, `memory_get`, `memory_context` +- Agent config/status/doctor tools +- Manual `dream` runs +- Registry-backed reminders and dreaming through the ClawCode Codex runner +- Voice and WebChat tools when configured +- Skill listing/install/remove with Codex-aware skill paths +- Service planning for macOS launchd and Linux systemd timers + +Claude Code-specific surfaces are kept separate: + +- Claude Code channel launch flags +- Claude Code messaging plugins such as `claude-whatsapp` +- Native Claude Code cron tools (`CronCreate`, `CronList`, `CronDelete`) +- Claude Code always-on service mode (`claude --continue`) +- Plugin-packaged lifecycle hooks under Codex + +Codex uses its own adapters instead of those Claude Code APIs. In particular, +`/agent:crons` writes `memory/crons.json`, and `/agent:service install` creates +a one-minute launchd/systemd timer that runs `bin/clawcode-codex-cron-runner.mjs` +with `codex exec`. + +## Local Install + +From this repository root: + +```sh +codex plugin marketplace add "$(pwd)" +codex plugin add clawcode@clawcode-local +``` + +Then restart Codex. + +For development without installing the plugin, register the MCP server with an +explicit workspace: + +```sh +CLAWCODE_RUNTIME=codex \ +CLAWCODE_WORKSPACE=/absolute/path/to/agent-workspace \ +bash ./bin/clawcode-mcp.sh +``` + +Codex should normally provide `CODEX_HOME`. If it does not expose the project +directory to MCP servers, set `CLAWCODE_WORKSPACE` in the MCP config so memory +is stored in the intended agent workspace rather than the plugin directory. + +## Runtime Paths + +Under Codex: + +- User skills: `$CODEX_HOME/skills` or `~/.codex/skills` +- Project skills: `/.codex/skills` +- Agent memory: `/memory` +- Codex runner state: `/memory/.codex-cron-runner-state.json` + +Under Claude Code, existing `.claude` paths are preserved. diff --git a/lib/command-discovery.ts b/lib/command-discovery.ts index 79cb4d7..92f576b 100644 --- a/lib/command-discovery.ts +++ b/lib/command-discovery.ts @@ -10,6 +10,7 @@ import fs from "fs"; import path from "path"; import { parseFrontmatter, scopeDir, type InstallScope } from "./skill-manager.ts"; +import { detectRuntime, runtimeInfo } from "./runtime.ts"; export type CommandScope = InstallScope | "mcp"; @@ -217,13 +218,14 @@ export function formatCommandsCompact(commands: CommandRecord[]): string { } function labelFor(scope: CommandScope): string { + const info = runtimeInfo(detectRuntime()); switch (scope) { case "plugin": return "Core + imported (./skills/)"; case "project": - return "Project (.claude/skills/)"; + return `Project (${info.projectSkillsDirName}/skills/)`; case "user": - return "User (~/.claude/skills/)"; + return `User (${info.homeDir}/skills/)`; case "mcp": return "MCP tools (agent-invocable)"; } diff --git a/lib/runtime.ts b/lib/runtime.ts new file mode 100644 index 0000000..199173b --- /dev/null +++ b/lib/runtime.ts @@ -0,0 +1,106 @@ +import os from "os"; +import path from "path"; + +export type RuntimeName = "claude" | "codex"; + +export interface RuntimeInfo { + name: RuntimeName; + displayName: string; + homeDir: string; + projectSkillsDirName: ".claude" | ".codex"; + userSkillsDir: string; + reloadInstruction: string; +} + +export function detectRuntime(env: NodeJS.ProcessEnv = process.env): RuntimeName { + const explicit = String(env.CLAWCODE_RUNTIME || "").toLowerCase(); + if (explicit === "codex" || explicit === "claude") return explicit; + if (env.CODEX_HOME) return "codex"; + return "claude"; +} + +export function resolvePluginRoot( + env: NodeJS.ProcessEnv = process.env, + cwd = process.cwd() +): string { + return path.resolve( + env.CLAWCODE_PLUGIN_ROOT || + env.CLAUDE_PLUGIN_ROOT || + cwd + ); +} + +export function resolveWorkspaceRoot( + pluginRoot: string, + env: NodeJS.ProcessEnv = process.env, + cwd = process.cwd() +): string { + const candidates = [ + env.CLAWCODE_WORKSPACE, + env.CODEX_PROJECT_DIR, + env.CODEX_WORKSPACE_ROOT, + env.CODEX_WORKSPACE, + env.CLAUDE_PROJECT_DIR, + ]; + + for (const candidate of candidates) { + if (candidate && candidate.trim()) return path.resolve(candidate); + } + + const resolvedPluginRoot = path.resolve(pluginRoot); + if (env.OLDPWD && path.resolve(env.OLDPWD) !== resolvedPluginRoot) { + return path.resolve(env.OLDPWD); + } + + const resolvedCwd = path.resolve(cwd); + if (resolvedCwd !== resolvedPluginRoot) return resolvedCwd; + + return resolvedCwd; +} + +export function runtimeInfo( + runtime: RuntimeName = detectRuntime(), + env: NodeJS.ProcessEnv = process.env +): RuntimeInfo { + if (runtime === "codex") { + const homeDir = path.resolve(env.CODEX_HOME || path.join(os.homedir(), ".codex")); + return { + name: "codex", + displayName: "OpenAI Codex", + homeDir, + projectSkillsDirName: ".codex", + userSkillsDir: path.join(homeDir, "skills"), + reloadInstruction: "Restart Codex, or reload the MCP server if your Codex UI exposes that control.", + }; + } + + const homeDir = path.resolve(env.CLAUDE_HOME || path.join(os.homedir(), ".claude")); + return { + name: "claude", + displayName: "Claude Code", + homeDir, + projectSkillsDirName: ".claude", + userSkillsDir: path.join(homeDir, "skills"), + reloadInstruction: "Run `/mcp reconnect clawcode` or `/mcp` to apply.", + }; +} + +export function projectSkillsDir(workspace: string, runtime: RuntimeName): string { + return path.join(workspace, runtimeInfo(runtime).projectSkillsDirName, "skills"); +} + +export function pluginManifestPaths(pluginRoot: string, runtime: RuntimeName): string[] { + const first = runtime === "codex" ? ".codex-plugin" : ".claude-plugin"; + const second = runtime === "codex" ? ".claude-plugin" : ".codex-plugin"; + return [ + path.join(pluginRoot, first, "plugin.json"), + path.join(pluginRoot, second, "plugin.json"), + ]; +} + +export function runtimeToolInstruction(runtime: RuntimeName): string { + if (runtime === "codex") { + return "Use the tools Codex exposes in this session (shell/exec, file editing, MCP tools, web/search tools, and sub-agents when available). If an imported instruction names a Claude-only tool, translate it to the closest Codex capability instead of inventing a tool."; + } + return "Use Claude Code tools: Bash, Read, Write, Edit, Grep, Glob, Agent, WebSearch, WebFetch."; +} diff --git a/lib/service-generator.ts b/lib/service-generator.ts index c93a0cb..c2c9b3a 100644 --- a/lib/service-generator.ts +++ b/lib/service-generator.ts @@ -15,14 +15,21 @@ import os from "os"; import path from "path"; export type Platform = "darwin" | "linux" | "unsupported"; +export type ServiceRuntime = "claude" | "codex"; export type ServiceAction = "install" | "status" | "uninstall" | "logs"; export interface ServiceOptions { /** Absolute workspace path (agent's directory). */ workspace: string; + /** Runtime to wrap. Claude keeps the historical service; Codex uses a cron runner adapter. */ + runtime?: ServiceRuntime; /** Full path to the `claude` binary. Usually `/usr/local/bin/claude` or similar. */ claudeBin: string; + /** Full path to the `codex` binary. Usually `/usr/local/bin/codex` or similar. */ + codexBin?: string; + /** Absolute plugin path. Required for Codex's cron runner service. */ + pluginRoot?: string; /** Overrides — useful for tests and power users. */ logPath?: string; slug?: string; @@ -129,6 +136,29 @@ export function serviceFilePath(platform: Platform, slug: string): string { return ""; } +export function codexServiceLabel(platform: Platform, slug: string): string { + if (platform === "darwin") return `com.clawcode.codex.${slug}`; + return `clawcode-codex-${slug}`; +} + +export function codexServiceFilePath(platform: Platform, slug: string): string { + const home = os.homedir(); + if (platform === "darwin") { + return path.join(home, "Library", "LaunchAgents", `com.clawcode.codex.${slug}.plist`); + } + if (platform === "linux") { + return path.join(home, ".config", "systemd", "user", `clawcode-codex-${slug}.service`); + } + return ""; +} + +export function codexTimerFilePath(platform: Platform, slug: string): string { + if (platform === "linux") { + return path.join(os.homedir(), ".config", "systemd", "user", `clawcode-codex-${slug}.timer`); + } + return ""; +} + export function defaultLogPath(slug: string): string { // Persistent per-user location. `/tmp` is wiped on reboot, making it // near-useless for diagnosing failures that survived a restart cycle. @@ -723,6 +753,119 @@ WantedBy=default.target `; } +export function generateCodexLaunchdPlist(opts: { + label: string; + workspace: string; + pluginRoot: string; + codexBin: string; + logPath: string; +}): string { + const runner = path.join(opts.pluginRoot, "bin", "clawcode-codex-cron-runner.mjs"); + const argsXml = [ + "/usr/bin/env", + "node", + runner, + "--workspace", + opts.workspace, + "--plugin-root", + opts.pluginRoot, + "--codex-bin", + opts.codexBin, + ] + .map((a) => ` ${xmlEscape(a)}`) + .join("\n"); + + return ` + + + + Label + ${xmlEscape(opts.label)} + ProgramArguments + +${argsXml} + + WorkingDirectory + ${xmlEscape(opts.workspace)} + EnvironmentVariables + + HOME + ${xmlEscape(os.homedir())} + CLAWCODE_RUNTIME + codex + CLAWCODE_WORKSPACE + ${xmlEscape(opts.workspace)} + CLAWCODE_PLUGIN_ROOT + ${xmlEscape(opts.pluginRoot)} + + RunAtLoad + + StartInterval + 60 + StandardOutPath + ${xmlEscape(opts.logPath)} + StandardErrorPath + ${xmlEscape(opts.logPath)} + + +`; +} + +export function generateCodexSystemdService(opts: { + slug: string; + workspace: string; + pluginRoot: string; + codexBin: string; + logPath: string; +}): string { + const runner = path.join(opts.pluginRoot, "bin", "clawcode-codex-cron-runner.mjs"); + const runnerCmd = [ + "/usr/bin/env", + "node", + runner, + "--workspace", + opts.workspace, + "--plugin-root", + opts.pluginRoot, + "--codex-bin", + opts.codexBin, + ].map((a) => (/\s/.test(a) ? `"${shellEscape(a)}"` : a)).join(" "); + + return `[Unit] +Description=ClawCode Codex Cron Runner (${opts.slug}) +After=network.target + +[Service] +Type=oneshot +WorkingDirectory=${opts.workspace} +Environment=HOME=${os.homedir()} +Environment=CLAWCODE_RUNTIME=codex +Environment=CLAWCODE_WORKSPACE=${opts.workspace} +Environment=CLAWCODE_PLUGIN_ROOT=${opts.pluginRoot} +ExecStart=${runnerCmd} +StandardOutput=append:${opts.logPath} +StandardError=append:${opts.logPath} + +[Install] +WantedBy=default.target +`; +} + +export function generateCodexSystemdTimer(opts: { slug: string }): string { + return `[Unit] +Description=ClawCode Codex Cron Timer (${opts.slug}) + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min +Unit=clawcode-codex-${opts.slug}.service +AccuracySec=5s + +[Install] +WantedBy=timers.target +`; +} + // --------------------------------------------------------------------------- // Plan builder // --------------------------------------------------------------------------- @@ -730,6 +873,9 @@ WantedBy=default.target export function buildPlan(action: ServiceAction, opts: ServiceOptions): ServicePlan { const platform = opts.platform ?? detectPlatform(); const slug = opts.slug ?? slugifyWorkspace(opts.workspace); + if (opts.runtime === "codex") { + return buildCodexPlan(action, opts, platform, slug); + } const label = serviceLabel(platform, slug); const filePath = serviceFilePath(platform, slug); const logPath = opts.logPath ?? defaultLogPath(slug); @@ -1042,3 +1188,133 @@ export function buildPlan(action: ServiceAction, opts: ServiceOptions): ServiceP error: `Unknown action: ${action}`, }; } + +function buildCodexPlan( + action: ServiceAction, + opts: ServiceOptions, + platform: Platform, + slug: string +): ServicePlan { + const label = codexServiceLabel(platform, slug); + const filePath = codexServiceFilePath(platform, slug); + const timerPath = codexTimerFilePath(platform, slug); + const logPath = opts.logPath ?? defaultLogPath(`codex-${slug}`); + const pluginRoot = path.resolve(opts.pluginRoot || process.cwd()); + const codexBin = opts.codexBin || "codex"; + + if (platform === "unsupported") { + return { + platform, + slug, + label, + filePath: "", + logPath, + fileContent: "", + commands: [], + error: + "Unsupported OS. Codex service planning supports macOS (launchd) and Linux (systemd --user). On Windows, install inside WSL2.", + }; + } + + if (action === "install") { + const commands: PlanCommand[] = [ + { label: "Create log directory", cmd: `mkdir -p "${path.dirname(logPath)}"` }, + ]; + let fileContent = ""; + const extraFiles: ExtraFile[] = []; + + if (platform === "darwin") { + fileContent = generateCodexLaunchdPlist({ + label, + workspace: opts.workspace, + pluginRoot, + codexBin, + logPath, + }); + commands.push( + { label: "Create LaunchAgents directory", cmd: `mkdir -p "${path.dirname(filePath)}"` }, + { label: "Unload previous Codex runner (ignored if not loaded)", cmd: `launchctl unload "${filePath}" 2>/dev/null || true` }, + { label: "Load the Codex runner", cmd: `launchctl load "${filePath}"` } + ); + } else { + fileContent = generateCodexSystemdService({ + slug, + workspace: opts.workspace, + pluginRoot, + codexBin, + logPath, + }); + extraFiles.push({ + path: timerPath, + content: generateCodexSystemdTimer({ slug }), + }); + commands.push( + { label: "Create systemd user directory", cmd: `mkdir -p "${path.dirname(filePath)}"` }, + { label: "Reload systemd", cmd: `systemctl --user daemon-reload` }, + { label: "Enable + start the Codex runner timer", cmd: `systemctl --user enable --now clawcode-codex-${slug}.timer` } + ); + } + + return { + platform, + slug, + label, + filePath, + logPath, + fileContent, + extraFiles: extraFiles.length > 0 ? extraFiles : undefined, + commands, + }; + } + + if (action === "uninstall") { + const commands: PlanCommand[] = []; + if (platform === "darwin") { + commands.push( + { label: "Unload the Codex runner (ignored if not loaded)", cmd: `launchctl unload "${filePath}" 2>/dev/null || true` }, + { label: "Remove the Codex runner plist", cmd: `rm -f "${filePath}"` } + ); + } else { + commands.push( + { label: "Stop + disable the Codex runner timer (ignored if not enabled)", cmd: `systemctl --user disable --now clawcode-codex-${slug}.timer 2>/dev/null || true` }, + { label: "Remove the Codex runner unit files", cmd: `rm -f "${filePath}" "${timerPath}"` }, + { label: "Reload systemd", cmd: `systemctl --user daemon-reload` } + ); + } + return { + platform, + slug, + label, + filePath, + logPath, + fileContent: "", + commands, + }; + } + + if (action === "status") { + const cmd = + platform === "darwin" + ? `launchctl list | grep "${label}" || true` + : `systemctl --user status clawcode-codex-${slug}.timer clawcode-codex-${slug}.service --no-pager 2>&1 || true`; + return { + platform, + slug, + label, + filePath, + logPath, + fileContent: "", + commands: [{ label: "Check Codex runner status", cmd }], + }; + } + + return { + platform, + slug, + label, + filePath, + logPath, + fileContent: "", + commands: [{ label: "Tail logs", cmd: `tail -n 80 "${logPath}" 2>/dev/null || echo "No log yet at ${logPath}"` }], + }; +} diff --git a/lib/skill-manager.ts b/lib/skill-manager.ts index 0a48a66..1d61cf6 100644 --- a/lib/skill-manager.ts +++ b/lib/skill-manager.ts @@ -2,11 +2,11 @@ * Skill manager — install, list, and remove community skills. * * A skill is a directory containing SKILL.md with YAML frontmatter (name, - * description, ...) — the same format Claude Code natively supports. We + * description, ...) — the same format Claude Code and Codex support. We * install into one of three scopes: * - "plugin" → ./skills// in the current workspace (ClawCode-managed) - * - "project" → .claude/skills// (Claude Code native, per-project) - * - "user" → ~/.claude/skills// (Claude Code native, global) + * - "project" → .claude/skills// or .codex/skills/ + * - "user" → ~/.claude/skills// or ~/.codex/skills/ * * Sources accepted: * - user/repo (GitHub shorthand) @@ -24,6 +24,12 @@ import { execFileSync } from "child_process"; import fs from "fs"; import os from "os"; import path from "path"; +import { + detectRuntime, + projectSkillsDir, + runtimeInfo, + type RuntimeName, +} from "./runtime.ts"; export type InstallScope = "plugin" | "project" | "user"; @@ -285,7 +291,7 @@ export function detectFormat(skillDir: string): DetectedFormat { format: "openclaw", frontmatter: fm, reason: - "SKILL.md references OpenClaw-specific tokens not available in Claude Code", + "SKILL.md references OpenClaw-specific tokens not available in this runtime", evidence, }; } @@ -491,10 +497,14 @@ function compareVersions(a: string, b: string): number { // Scope → install directory // --------------------------------------------------------------------------- -export function scopeDir(workspace: string, scope: InstallScope): string { +export function scopeDir( + workspace: string, + scope: InstallScope, + runtime: RuntimeName = detectRuntime() +): string { if (scope === "plugin") return path.join(workspace, "skills"); - if (scope === "project") return path.join(workspace, ".claude", "skills"); - return path.join(os.homedir(), ".claude", "skills"); + if (scope === "project") return projectSkillsDir(workspace, runtime); + return runtimeInfo(runtime).userSkillsDir; } // --------------------------------------------------------------------------- diff --git a/package.json b/package.json index d3d3b88..99558d4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "start": "npm install --silent >/dev/null 2>&1; exec npx tsx server.ts", - "test": "npx tsx tests/service-generator-smoke.test.ts && npx tsx tests/scope-exec-gate.test.ts && npx tsx tests/scope-exec-gate-e2e.test.ts && npx tsx tests/scope-exec-gate-doctor.test.ts && npx tsx tests/scope-trust.test.ts && npx tsx tests/scope-trust-workspace.test.ts && npx tsx tests/scope-trust-legacy-hook.test.ts", + "test": "npx tsx tests/scope-runtime.test.ts && npx tsx tests/service-generator-smoke.test.ts && npx tsx tests/scope-exec-gate.test.ts && npx tsx tests/scope-exec-gate-e2e.test.ts && npx tsx tests/scope-exec-gate-doctor.test.ts && npx tsx tests/scope-trust.test.ts && npx tsx tests/scope-trust-workspace.test.ts && npx tsx tests/scope-trust-legacy-hook.test.ts", "build:hook": "node scripts/build-exec-gate-hook.mjs" }, "dependencies": { diff --git a/plugins/clawcode b/plugins/clawcode new file mode 120000 index 0000000..a96aa0e --- /dev/null +++ b/plugins/clawcode @@ -0,0 +1 @@ +.. \ No newline at end of file diff --git a/server.ts b/server.ts index 3cb474e..f54aa12 100644 --- a/server.ts +++ b/server.ts @@ -49,6 +49,14 @@ import { import { extractKeywords } from "./lib/keywords.ts"; import { MemoryDB } from "./lib/memory-db.ts"; import { QmdManager } from "./lib/qmd-manager.ts"; +import { + detectRuntime, + pluginManifestPaths, + resolvePluginRoot, + resolveWorkspaceRoot, + runtimeInfo, + runtimeToolInstruction, +} from "./lib/runtime.ts"; import { classifyAgentConfigKey } from "./lib/scope/agent-config-guard.ts"; import { makeForegroundContext } from "./lib/scope/context.ts"; import { @@ -76,17 +84,12 @@ import type { SearchResult } from "./lib/types.ts"; // WORKSPACE = where the agent's personality files live (user's project dir) // --------------------------------------------------------------------------- -const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || process.cwd(); -// WORKSPACE = user's project dir. .mcp.json's launch wrapper `cd`s into -// PLUGIN_ROOT to find node_modules before exec'ing tsx, which makes -// process.cwd() resolve to the plugin dir instead of the user's project. -// OLDPWD is set by that `cd` and reliably points to Claude Code's original -// cwd (the user's project). Prefer CLAUDE_PROJECT_DIR if Claude Code exports -// it, then OLDPWD, then process.cwd() as a last resort. -const WORKSPACE = - process.env.CLAUDE_PROJECT_DIR || - process.env.OLDPWD || - process.cwd(); +const RUNTIME = detectRuntime(); +const RUNTIME_INFO = runtimeInfo(RUNTIME); +const PLUGIN_ROOT = resolvePluginRoot(); +// WORKSPACE = user's project dir. The launcher exports CLAWCODE_WORKSPACE +// when Codex/Claude start the MCP server from the plugin directory. +const WORKSPACE = resolveWorkspaceRoot(PLUGIN_ROOT); const MEMORY_DIR = path.join(WORKSPACE, "memory"); const DREAMS_DIR = path.join(MEMORY_DIR, ".dreams"); @@ -459,12 +462,10 @@ function _loadBootstrapFilesInner(): string { // -- Runtime adaptation sections.push("## Runtime\n"); - sections.push("You are running inside Claude Code."); - sections.push( - "Use Claude Code tools: Bash, Read, Write, Edit, Grep, Glob, Agent, WebSearch, WebFetch." - ); + sections.push(`You are running inside ${RUNTIME_INFO.displayName}.`); + sections.push(runtimeToolInstruction(RUNTIME)); sections.push( - "Some workspaces include skill files (e.g. SOUL.md, AGENTS.md) that reference tools from a different agent system — names like `message`, `sessions_spawn`, `browser tool`, `gateway`, `cron tool`, `nodes`, `canvas`. Those are NOT available here. If you encounter them in skill instructions, treat them as descriptive intent and substitute with the closest Claude Code equivalent (e.g. `Agent` for sub-agents, messaging plugin `reply` for `message`)." + "Some workspaces include skill files (e.g. SOUL.md, AGENTS.md) that reference tools from a different agent system — names like `message`, `sessions_spawn`, `browser tool`, `gateway`, `cron tool`, `nodes`, `canvas`. Those are NOT available here. If you encounter them in skill instructions, treat them as descriptive intent and substitute with the closest runtime equivalent." ); sections.push( "Ignore tokens like HEARTBEAT_OK, NO_REPLY, ANNOUNCE_SKIP, SILENT_REPLY — they do not apply here." @@ -496,7 +497,7 @@ function _loadBootstrapFilesInner(): string { // -- Memory instructions (MUST use MCP tools, not native Claude Code tools) sections.push("## Memory — CRITICAL RULES\n"); - sections.push("You have MCP memory tools. You MUST use them instead of Claude Code's native tools:"); + sections.push(`You have MCP memory tools. You MUST use them instead of ${RUNTIME_INFO.displayName}'s native or ad-hoc file search tools for agent memory:`); sections.push("- To SEARCH memory: use `memory_search` (MCP tool), NOT Read or Grep"); sections.push("- To READ memory details: use `memory_get` (MCP tool), NOT Read"); sections.push("- To RUN dreaming: use `dream` (MCP tool)"); @@ -510,7 +511,7 @@ function _loadBootstrapFilesInner(): string { sections.push("Citations: include Source: path#Lstart-Lend when it helps verify."); sections.push(""); sections.push("To SAVE information to memory: write to memory/YYYY-MM-DD.md (today's date) using Write or Edit tool. APPEND only."); - sections.push("Do NOT use Claude Code's auto-memory (~/.claude/projects/.../memory/). Use the memory/ directory in this workspace ONLY."); + sections.push(`Do NOT use ${RUNTIME_INFO.displayName}'s native memory store for ClawCode agent memory. Use the memory/ directory in this workspace ONLY.`); sections.push("For long-term curated memory, update memory/MEMORY.md."); sections.push(""); @@ -580,7 +581,9 @@ function _loadBootstrapFilesInner(): string { // -- Dreaming sections.push("## Dreaming\n"); sections.push( - "You have a `dream` tool for memory consolidation. It runs automatically via nightly cron (3 AM)." + RUNTIME === "claude" + ? "You have a `dream` tool for memory consolidation. It runs automatically via nightly cron (3 AM)." + : "You have a `dream` tool for memory consolidation. Under Codex it can run through the ClawCode Codex runner after `/agent:service install`, or manually with the `dream` tool." ); sections.push( "Dreaming promotes frequently-recalled memories to MEMORY.md using weighted scoring." @@ -592,15 +595,24 @@ function _loadBootstrapFilesInner(): string { // -- Scheduled tasks (registry-based persistence; see docs/crons.md) sections.push("## Scheduled Tasks\n"); - sections.push( - "This workspace maintains a cron registry at `memory/crons.json` — the source of truth for every scheduled task the user wants alive across sessions." - ); - sections.push( - "On session start you may receive a reconcile envelope from `[clawcode]`. Follow it exactly: ToolSearch → CronList → CronCreate for missing entries → writeback.sh set-alive → adopt-unknown → print summary → remove the `memory/.reconciling` marker." - ); - sections.push( - "Do not create default crons on your own — the registry is the source of truth, and hooks keep it in sync. User-facing management: `/agent:crons list|add|delete|pause|reconcile` (alias `/agent:reminders`)." - ); + if (RUNTIME === "claude") { + sections.push( + "This workspace maintains a cron registry at `memory/crons.json` — the source of truth for every scheduled task the user wants alive across sessions." + ); + sections.push( + "On session start you may receive a reconcile envelope from `[clawcode]`. Follow it exactly: ToolSearch → CronList → CronCreate for missing entries → writeback.sh set-alive → adopt-unknown → print summary → remove the `memory/.reconciling` marker." + ); + sections.push( + "Do not create default crons on your own — the registry is the source of truth, and hooks keep it in sync. User-facing management: `/agent:crons list|add|delete|pause|reconcile` (alias `/agent:reminders`)." + ); + } else { + sections.push( + "This workspace maintains a registry at `memory/crons.json`. Under Codex, `/agent:crons` writes that registry and `/agent:service install` installs a launchd/systemd timer that runs the local ClawCode Codex runner with `codex exec`." + ); + sections.push( + "Claude Code's native CronCreate/CronList/CronDelete tools are not available in Codex, so the registry runner is the scheduling adapter. Do not claim reminders are active until the service plan has been installed and status/logs have been checked." + ); + } sections.push(""); // -- Heartbeat behavior @@ -767,15 +779,16 @@ export interface WatchdogPingResponse { let cachedPluginVersion: string | null = null; function readPluginVersion(): string { if (cachedPluginVersion !== null) return cachedPluginVersion; - try { - const raw = fs.readFileSync( - path.join(PLUGIN_ROOT, ".claude-plugin", "plugin.json"), - "utf-8" - ); - cachedPluginVersion = String(JSON.parse(raw).version || "unknown"); - } catch { - cachedPluginVersion = "unknown"; + for (const manifestPath of pluginManifestPaths(PLUGIN_ROOT, RUNTIME)) { + try { + const raw = fs.readFileSync(manifestPath, "utf-8"); + cachedPluginVersion = String(JSON.parse(raw).version || "unknown"); + return cachedPluginVersion; + } catch { + // Try the next runtime manifest. + } } + cachedPluginVersion = "unknown"; return cachedPluginVersion; } @@ -894,7 +907,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: "agent_config", description: - "View or update agent settings (memory backend, QMD, active hours, dreaming). Use action='get' to view current config, action='set' with key and value to change a setting. After changes, remind user to run /mcp reconnect clawcode.", + "View or update agent settings (memory backend, QMD, active hours, dreaming). Use action='get' to view current config, action='set' with key and value to change a setting. After critical changes, tell the user to reload the ClawCode MCP server for their runtime.", inputSchema: { type: "object" as const, properties: { @@ -972,14 +985,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: "channels_detect", description: - "Inspect messaging channel plugins (WhatsApp, Telegram, Discord, iMessage, Slack, Fakechat) and return installed / authenticated / active state per channel, plus a ready-to-use launch command. Read-only and safe — does not install, authenticate, or restart Claude Code.", + "Inspect messaging surfaces for this runtime. Claude Code reports channel plugin state and launch commands. Codex reports native ClawCode surfaces such as WebChat, webhooks, and voice, and marks Claude channel plugins as Claude-only.", inputSchema: { type: "object" as const, properties: { format: { type: "string", enum: ["table", "json", "launch"], - description: "'table' (default) human-readable card; 'json' structured data; 'launch' only the claude launch command", + description: "'table' (default) human-readable card; 'json' structured data; 'launch' only the Claude launch command", }, includeInstalledOnly: { type: "boolean", @@ -995,7 +1008,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: "service_plan", description: - "Plan an always-on service install/uninstall/status/logs for this agent. Returns file content (plist on macOS or systemd unit on Linux), file path, log path, and a list of shell commands to execute. The skill runs the commands after getting user confirmation. This tool does NOT touch the filesystem or invoke launchctl/systemctl — it only computes the plan.", + "Plan an always-on install/uninstall/status/logs flow for this agent. Claude Code uses the historical long-running REPL service. Codex uses a launchd/systemd timer that runs the local ClawCode Codex cron adapter with `codex exec`.", inputSchema: { type: "object" as const, properties: { @@ -1008,6 +1021,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ type: "string", description: "Absolute path to the `claude` binary (e.g. /usr/local/bin/claude). Default: 'claude' (uses PATH resolution at runtime)", }, + codexBin: { + type: "string", + description: "Absolute path to the `codex` binary for Codex runtime service plans. Default: 'codex' (uses PATH resolution at runtime)", + }, extraArgs: { type: "array", items: { type: "string" }, @@ -1019,7 +1036,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ }, resumeOnRestart: { type: "boolean", - description: "Emit a wrapper that runs `claude --continue` so the service rehydrates the prior session on restart. Default: true. Set false for a plain `claude` invocation with no context preservation.", + description: "Claude runtime only: emit a wrapper that runs `claude --continue` so the service rehydrates the prior session on restart. Default: true.", }, selfHeal: { type: "boolean", @@ -1098,7 +1115,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: "list_commands", description: - "Discover all user-invocable commands — skills in ./skills/, .claude/skills/, ~/.claude/skills/, and (by default) the agent's own MCP tools. Returns each command's name, description, triggers (parsed from the description), scope, and argument hint. Use this to answer \"what can I do?\" or to render a live /help. Preferred over hardcoded lists because it picks up skills installed after boot.", + `Discover all user-invocable commands — skills in ./skills/, ${RUNTIME_INFO.projectSkillsDirName}/skills/, ${RUNTIME_INFO.homeDir}/skills/, and (by default) the agent's own MCP tools. Returns each command's name, description, triggers (parsed from the description), scope, and argument hint. Use this to answer "what can I do?" or to render a live /help. Preferred over hardcoded lists because it picks up skills installed after boot.`, inputSchema: { type: "object" as const, properties: { @@ -1126,7 +1143,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: "skill_install", description: - "Install a skill from a source into the agent. Accepts GitHub shorthand (owner/repo), full URLs, optional branch via @ and subdir via #, or a local directory path. Detects OpenClaw-flavored skills and refuses them (pointing the user at /agent:import-skill). Rejects OS/node mismatches; warns on missing binaries or env vars. Scope: plugin (default, ./skills/), project (.claude/skills/), user (~/.claude/skills/).", + `Install a skill from a source into the agent. Accepts GitHub shorthand (owner/repo), full URLs, optional branch via @ and subdir via #, or a local directory path. Detects OpenClaw-flavored skills and refuses them (pointing the user at /agent:import-skill). Rejects OS/node mismatches; warns on missing binaries or env vars. Scope: plugin (default, ./skills/), project (${RUNTIME_INFO.projectSkillsDirName}/skills/), user (${RUNTIME_INFO.homeDir}/skills/).`, inputSchema: { type: "object" as const, properties: { @@ -1421,7 +1438,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [ { type: "text", - text: `## Current Configuration\n\n\`\`\`json\n${JSON.stringify(current, null, 2)}\n\`\`\`\n\nTo change: \`agent_config(action='set', key='memory.backend', value='qmd')\`\nAfter changes: \`/mcp reconnect clawcode\``, + text: `## Current Configuration\n\nRuntime: ${RUNTIME_INFO.displayName}\nWorkspace: ${WORKSPACE}\n\n\`\`\`json\n${JSON.stringify(current, null, 2)}\n\`\`\`\n\nTo change: \`agent_config(action='set', key='memory.backend', value='qmd')\`\nAfter critical changes: ${RUNTIME_INFO.reloadInstruction}`, }, ], }; @@ -1531,7 +1548,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [ { type: "text", - text: `Set \`${key}\` = \`${JSON.stringify(parsedValue)}\`\n\nRun \`/mcp reconnect clawcode\` to apply.`, + text: `Set \`${key}\` = \`${JSON.stringify(parsedValue)}\`\n\n${RUNTIME_INFO.reloadInstruction}`, }, ], }; @@ -1678,6 +1695,95 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { if (name === "channels_detect") { const format = String(params.format || "table"); + if (RUNTIME === "codex") { + const liveConfig = getLiveConfig(); + const liveHttp = { + enabled: liveConfig.http?.enabled ?? httpConfig.enabled, + host: liveConfig.http?.host ?? httpConfig.host, + port: liveConfig.http?.port ?? httpConfig.port, + }; + const webUrl = `http://${liveHttp.host}:${liveHttp.port}`; + const payload = { + ok: true, + runtime: RUNTIME, + surfaces: [ + { + name: "webchat", + label: "WebChat", + kind: "codex-native", + installed: true, + configured: Boolean(liveHttp.enabled), + active: Boolean(httpBridge), + url: liveHttp.enabled ? webUrl : null, + detail: liveHttp.enabled + ? httpBridge + ? `serving at ${webUrl}` + : "enabled in config; reconnect the MCP server to start the HTTP bridge" + : "disabled; set http.enabled=true to expose the browser chat", + }, + { + name: "webhooks", + label: "HTTP webhooks", + kind: "codex-native", + installed: true, + configured: Boolean(liveHttp.enabled), + active: Boolean(httpBridge), + url: liveHttp.enabled ? `${webUrl}/webhook` : null, + detail: liveHttp.enabled + ? "POST events through the ClawCode HTTP bridge" + : "disabled because the HTTP bridge is off", + }, + { + name: "voice", + label: "Voice tools", + kind: "codex-native", + installed: true, + configured: liveConfig.voice?.enabled === true, + active: liveConfig.voice?.enabled === true, + url: null, + detail: liveConfig.voice?.enabled === true + ? "voice_speak and voice_transcribe are available through MCP" + : "disabled; set voice.enabled=true and configure a backend", + }, + ], + claudeOnly: [ + "WhatsApp/Telegram/Discord/iMessage/Fakechat channel plugins", + "Claude Code channel launch flags", + ], + }; + if (format === "json") { + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + }; + } + if (format === "launch") { + return { + content: [ + { + type: "text", + text: + "Codex does not use Claude Code channel launch flags. Enable ClawCode WebChat/webhooks with `http.enabled=true`, then reconnect MCP; use `/agent:service install` only for the Codex reminder/dream runner. Claude channel plugins still require Claude Code.", + }, + ], + }; + } + const surfaceLines = payload.surfaces + .map((surface) => { + const state = surface.active ? "✅ active" : surface.configured ? "⚠️ configured" : "❌ off"; + const url = surface.url ? ` · ${surface.url}` : ""; + return `- ${surface.label}: ${state}${url} — ${surface.detail}`; + }) + .join("\n"); + return { + content: [ + { + type: "text", + text: + `📡 Codex messaging surfaces\n\n${surfaceLines}\n\nClaude Code channel plugins (WhatsApp, Telegram, Discord, iMessage, Fakechat) and Claude launch flags are Claude-only; Codex uses the native WebChat/webhook/voice surfaces above.`, + }, + ], + }; + } const channels = detectChannels(); if (format === "json") { @@ -1724,6 +1830,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } const claudeBin = String(params.claudeBin || "claude"); + const codexBin = String(params.codexBin || "codex"); const extraArgs = Array.isArray(params.extraArgs) ? params.extraArgs.map(String) : undefined; @@ -1735,7 +1842,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const plan = buildServicePlan(action, { workspace: WORKSPACE, + runtime: RUNTIME, claudeBin, + codexBin, + pluginRoot: PLUGIN_ROOT, extraArgs, logPath, resumeOnRestart, @@ -2118,7 +2228,7 @@ const transport = new StdioServerTransport(); await server.connect(transport); // Watch agent-config.json — non-critical changes apply live; critical changes -// surface via a logging notification telling the user to run /mcp. +// surface via a logging notification telling the user to reload the MCP server. startConfigWatcher(WORKSPACE, async (changes: CriticalChange[]) => { const keys = changes.map((c) => c.key).join(", "); try { @@ -2129,7 +2239,7 @@ startConfigWatcher(WORKSPACE, async (changes: CriticalChange[]) => { logger: "clawcode.config", data: { source: "live-config", - message: `Config change to ${keys} requires /mcp to apply. Other changes (if any) applied live.`, + message: `Config change to ${keys} requires a ClawCode MCP reload to apply. ${RUNTIME_INFO.reloadInstruction} Other changes (if any) applied live.`, changes, }, }, diff --git a/skills/channels/SKILL.md b/skills/channels/SKILL.md index 7943c87..e829966 100644 --- a/skills/channels/SKILL.md +++ b/skills/channels/SKILL.md @@ -11,6 +11,14 @@ Diagnose messaging channel plugins and give the user the exact command to load t This is a CORE feature. See `docs/channels.md` for details. +## Codex runtime path + +If `CLAWCODE_RUNTIME=codex` or the runtime is OpenAI Codex, do not present Claude launch commands as usable. Call `channels_detect` normally: under Codex it reports ClawCode-native surfaces such as WebChat, webhooks, and voice, and marks Claude Code channel plugins as Claude-only. + +For `launch`, print the tool response verbatim. Codex does not use Claude Code `--channels` or `--dangerously-load-development-channels` flags. + +For normal Codex use, guide the user to enable the HTTP bridge (`http.enabled=true`) for WebChat/webhooks, then reconnect MCP. Use `/agent:service install` only for the Codex reminder/dream runner, not for Claude channel plugin launch flags. + ## Dispatch | User says | Action | diff --git a/skills/create/SKILL.md b/skills/create/SKILL.md index 7491d01..8046786 100644 --- a/skills/create/SKILL.md +++ b/skills/create/SKILL.md @@ -15,18 +15,24 @@ The plugin is already installed — this skill just copies the template files to 1. **Copy templates** to the current directory as the agent's initial files: ```bash - cp ${CLAUDE_PLUGIN_ROOT}/templates/SOUL.md ./ - cp ${CLAUDE_PLUGIN_ROOT}/templates/IDENTITY.md ./ - cp ${CLAUDE_PLUGIN_ROOT}/templates/USER.md ./ - cp ${CLAUDE_PLUGIN_ROOT}/templates/AGENTS.md ./ - cp ${CLAUDE_PLUGIN_ROOT}/templates/TOOLS.md ./ - cp ${CLAUDE_PLUGIN_ROOT}/templates/HEARTBEAT.md ./ - cp ${CLAUDE_PLUGIN_ROOT}/templates/CLAUDE.md ./ + PLUGIN_ROOT="${CLAWCODE_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" + cp "$PLUGIN_ROOT"/templates/SOUL.md ./ + cp "$PLUGIN_ROOT"/templates/IDENTITY.md ./ + cp "$PLUGIN_ROOT"/templates/USER.md ./ + cp "$PLUGIN_ROOT"/templates/AGENTS.md ./ + cp "$PLUGIN_ROOT"/templates/TOOLS.md ./ + cp "$PLUGIN_ROOT"/templates/HEARTBEAT.md ./ + if [ "${CLAWCODE_RUNTIME:-claude}" = "codex" ]; then + cp "$PLUGIN_ROOT"/templates/CODEX.md ./ + else + cp "$PLUGIN_ROOT"/templates/CLAUDE.md ./ + fi ``` 2. **Copy the bootstrap file** (the birth certificate): ```bash - cp ${CLAUDE_PLUGIN_ROOT}/templates/BOOTSTRAP.md ./ + PLUGIN_ROOT="${CLAWCODE_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" + cp "$PLUGIN_ROOT"/templates/BOOTSTRAP.md ./ ``` 3. **Create memory directory:** @@ -42,12 +48,12 @@ The plugin is already installed — this skill just copies the template files to Drive the ritual across as many turns as needed: name → creature → vibe → emoji → human's name/timezone. One question per turn. 5. **At the very end of the ritual** (after IDENTITY.md + USER.md are written and BOOTSTRAP.md is deleted), tell the user: - > "Run `/mcp` so my new identity and memory config take effect." + > "Reload the ClawCode MCP server so my new identity and memory config take effect." ## Important -- Files are created in the **current directory** (where you launched Claude Code) +- Files are created in the **current directory** (where you launched the agent runtime) - BOOTSTRAP.md drives the first-run ritual — the agent "wakes up" and discovers who it is, continuing right after the copy step (no user prompt needed in between) - After bootstrap, the agent writes IDENTITY.md, USER.md, adjusts SOUL.md, then deletes BOOTSTRAP.md -- `/mcp` runs **once, at the end** of the ritual — the bootstrap conversation itself does not need an MCP reload to happen +- Reload happens **once, at the end** of the ritual — the bootstrap conversation itself does not need an MCP reload to happen - Do NOT fill in IDENTITY.md or USER.md manually — the bootstrap conversation does that diff --git a/skills/crons/SKILL.md b/skills/crons/SKILL.md index 075521b..e94e3cd 100644 --- a/skills/crons/SKILL.md +++ b/skills/crons/SKILL.md @@ -12,6 +12,30 @@ All writes to the registry go through one script: `bash ${CLAUDE_PLUGIN_ROOT}/sk **`CronCreate` / `CronList` / `CronDelete` are deferred tools** — call `ToolSearch(query="select:CronList,CronCreate,CronDelete")` once per session before invoking them. The parameter name is `cron`, not `schedule`. Always pass `durable: true` (forward-compat for when the upstream flag is fixed). +## Codex runtime path + +If `CLAWCODE_RUNTIME=codex` or the runtime is OpenAI Codex, do not call `ToolSearch`, `CronCreate`, `CronList`, or `CronDelete`. Codex uses the local registry plus the ClawCode Codex cron runner installed by `/agent:service install`. + +Codex flows: + +- **LIST**: run `bash "$CLAWCODE_PLUGIN_ROOT/skills/crons/writeback.sh" seed-defaults`, then render `memory/crons.json`. Status is registry-based: active entries fire when the Codex runner service/timer is installed. Do not run `prune-expired` under Codex before rendering; one-shot entries are tombstoned by the runner only after successful execution. +- **ADD**: run `bash "$CLAWCODE_PLUGIN_ROOT/bin/cron-from.sh" ...` first. Then upsert directly: + ```bash + KEY="codex-$(date +%s)-$$" + bash "$CLAWCODE_PLUGIN_ROOT/skills/crons/writeback.sh" upsert \ + --key "$KEY" \ + --source ad-hoc \ + --cron "" \ + --prompt "" \ + --recurring "" \ + --target-epoch "" + ``` + Omit `--target-epoch` when the helper returns `null`. Tell the user the reminder is registered; if `/agent:service install` has not been installed for Codex yet, tell them to install it so reminders fire automatically. +- **DELETE**: `writeback.sh tombstone --key `. +- **PAUSE**: `writeback.sh pause --key `. +- **RESUME**: `writeback.sh resume --key `. +- **RECONCILE**: `seed-defaults`. There is no Claude harness to audit under Codex; the runner reads the registry directly every minute and tombstones one-shot entries after successful execution. + --- ## ⛔ FORBIDDEN — read before touching anything diff --git a/skills/messaging/SKILL.md b/skills/messaging/SKILL.md index d4dddd9..d4935e5 100644 --- a/skills/messaging/SKILL.md +++ b/skills/messaging/SKILL.md @@ -11,6 +11,16 @@ Guide the user through installing a messaging plugin so they can reach this agen **IMPORTANT — architectural limitation**: The agent CANNOT execute `/plugin marketplace add` or `/plugin install` — these are REPL-only commands. This skill SHOWS the user the exact commands to run, and guides them through the flow. +## Codex runtime path + +If `CLAWCODE_RUNTIME=codex` or the runtime is OpenAI Codex, do not show the Claude Code `/plugin` install commands as Codex commands. Use the Codex-native surfaces instead: + +- WebChat and webhooks: enable the ClawCode HTTP bridge (`http.enabled=true`) and reconnect MCP. +- Voice: enable `voice.enabled=true` and configure a supported backend. +- Status: call `channels_detect({ format: "table" })`. + +Claude Code channel plugins such as WhatsApp, Telegram, Discord, iMessage, and Fakechat still require Claude Code because their install flow and launch flags are Claude-specific. Say that plainly if the user asks for one of those platforms from Codex. + ## Available platforms | # | Platform | Marketplace | Launch flag | Notes | diff --git a/skills/scope/SKILL.md b/skills/scope/SKILL.md index ca00d48..50d8c2c 100644 --- a/skills/scope/SKILL.md +++ b/skills/scope/SKILL.md @@ -13,6 +13,12 @@ This is an OPTIONAL feature — see `docs/channel-scope-compat.md`. Enforcement Talk to the user in the language they've been using on this turn — never default to a hard-coded language. +## Codex runtime path + +If `CLAWCODE_RUNTIME=codex` or the runtime is OpenAI Codex, do not enable Claude channel-scope adapters or create Claude scope trust files. This flow depends on Claude Code messaging plugin envelopes, especially `claude-whatsapp` request envelopes and Claude Code PreToolUse hooks. + +For Codex, read-only `agent_config(action='get')` is fine if the user only wants to inspect raw config, but do not create trust files, enable enforcement, or run the Claude channel wizard. WebChat sessions are handled through the ClawCode HTTP bridge and do not use the Claude channel-scope envelope contract. + ## When to use - After installing claude-whatsapp + pairing it: `/agent:scope status` confirms the adapter sees `access.json`. diff --git a/skills/service/SKILL.md b/skills/service/SKILL.md index 4d4deda..dad98a4 100644 --- a/skills/service/SKILL.md +++ b/skills/service/SKILL.md @@ -7,16 +7,28 @@ argument-hint: install|status|uninstall|logs # Always-on service -Wrap Claude Code (with ClawCode) in the OS's service manager so the agent keeps running after the terminal closes. This is what makes the HTTP bridge, WebChat, webhooks, and crons work 24/7. +Wrap the active runtime in the OS's service manager so ClawCode background work can continue after the terminal closes. Claude Code gets the historical long-running REPL service. Codex gets a one-minute runner for reminders, heartbeat, and dreaming. This is an OPTIONAL feature. See `docs/service.md` for the full reference, risks, and how to add messaging-channel flags. +## Codex runtime path + +If `CLAWCODE_RUNTIME=codex` or the runtime is OpenAI Codex, use `service_plan` normally. It returns a Codex-specific launchd/systemd plan that installs a one-minute runner for `bin/clawcode-codex-cron-runner.mjs`. + +Safety copy for Codex install: + +> This installs a background runner that uses `codex exec` for due ClawCode reminders, heartbeat, and dreaming. It runs with `--ask-for-approval never` and `--dangerously-bypass-approvals-and-sandbox`, so only install it for workspaces you trust. + +For Codex, find the binary with `which codex`, pass it as `codexBin`, and do not pass Claude channel flags in `extraArgs`. + +For Codex install/status/uninstall/logs, use the same confirmation and plan execution shape below, but skip Claude-specific prechecks such as `which claude` and `~/.claude/settings.json`. + ## ⚠️ Safety — read before install -Installing the service runs Claude Code with **`--dangerously-skip-permissions`** in the background. That flag: +Installing the Claude Code service runs Claude Code with **`--dangerously-skip-permissions`** in the background. Installing the Codex runner runs `codex exec` with **`--ask-for-approval never`** and **`--dangerously-bypass-approvals-and-sandbox`**. These flags: - Pre-approves every tool call (Bash, Write, Edit, network requests) -- Cannot be undone per-request — the running service has full permissions over the agent's workspace for its whole lifetime +- Cannot be undone per-request — the running service/runner has full permissions over the agent's workspace for its whole lifetime - Is necessary because a daemon cannot answer interactive tool-approval prompts This is an **irrevocable trust decision for this workspace**. Only run `/agent:service install` if you understand that. @@ -39,7 +51,7 @@ Parse the action and call `service_plan` with it. 1. Find the `claude` binary: `Bash(which claude)`. Trim output. If empty, abort with: *"Can't find `claude` in PATH. Install Claude Code or point me at it manually."* 2. Call `service_plan({ action: "install", claudeBin: })` 3. If the plan has `error` (unsupported OS), print the error and stop. -4. **Show the user the warning** (see Safety section). Ask explicitly: *"This will install a background service that runs with --dangerously-skip-permissions. Confirm? [y/N]"* +4. **Show the user the warning** (see Safety section). Ask explicitly: *"This will install a background service/runner with pre-approved tool execution. Confirm? [y/N]"* 5. If the user says no, stop with a neutral acknowledgement. 6. If the user confirms: - **Pre-check `~/.claude/settings.json`** (prevents the most common install hang — see `docs/service.md` "Heads-up" note): diff --git a/templates/CODEX.md b/templates/CODEX.md new file mode 100644 index 0000000..78ab9e8 --- /dev/null +++ b/templates/CODEX.md @@ -0,0 +1,39 @@ +# Codex Runtime Notes + +This workspace is using ClawCode through OpenAI Codex. + +## Identity + +Use `SOUL.md`, `IDENTITY.md`, `USER.md`, `AGENTS.md`, `TOOLS.md`, and `HEARTBEAT.md` +as the agent's local identity and operating context. + +Never claim that Claude-specific features are available in Codex unless they +have been installed and verified in this workspace. + +## Memory + +Use the ClawCode MCP memory tools when available: + +- `memory_search` for searching agent memory +- `memory_get` for reading cited memory lines +- `memory_context` for turn-start context +- `dream` for manual consolidation + +Save durable agent memory under `memory/` in this workspace. + +## Codex Compatibility + +The Codex compatibility layer supports the MCP server, memory, identity files, +registry-backed reminders, the Codex service runner, voice tooling, WebChat, +doctor, and skill management. + +These Claude Code features are intentionally treated as Claude-only under +Codex: + +- Claude Code channel launch flags +- `CronCreate`, `CronList`, `CronDelete` +- Claude Code service mode using `claude --continue` +- Claude-specific messaging plugins such as `claude-whatsapp` + +Use `/agent:service install` to install the Codex runner before claiming that +reminders or automatic dreaming are active in this workspace. diff --git a/tests/scope-runtime.test.ts b/tests/scope-runtime.test.ts index 1063435..96f0989 100644 --- a/tests/scope-runtime.test.ts +++ b/tests/scope-runtime.test.ts @@ -14,12 +14,26 @@ * Run: `npx tsx tests/scope-runtime.test.ts` */ +import path from "node:path"; import { detectScopeRuntime, isChannelDerivedPath, applyPreventivePromoteGuard, type ScopeRuntimeState, } from "../lib/scope/runtime.ts"; +import { + detectRuntime, + pluginManifestPaths, + projectSkillsDir, + resolvePluginRoot, + resolveWorkspaceRoot, + runtimeInfo, +} from "../lib/runtime.ts"; +import { scopeDir } from "../lib/skill-manager.ts"; +import { + cronMatchesDate, + dueEntries, +} from "../bin/clawcode-codex-cron-runner.mjs"; const results: Array<{ name: string; pass: boolean; msg?: string }> = []; @@ -192,6 +206,91 @@ check("guard handles missing entry.path defensively", () => { assert(kept.length === 2, `expected 2 kept on undefined path, got ${kept.length}`); }); +// --------------------------------------------------------------------------- +// ClawCode host runtime detection — Claude Code vs Codex +// --------------------------------------------------------------------------- + +check("detectRuntime honors explicit override", () => { + assert(detectRuntime({ CLAWCODE_RUNTIME: "codex" } as NodeJS.ProcessEnv) === "codex", "codex override failed"); + assert( + detectRuntime({ CLAWCODE_RUNTIME: "claude", CODEX_HOME: "/tmp/codex" } as NodeJS.ProcessEnv) === "claude", + "claude override failed" + ); +}); + +check("detectRuntime falls back to CODEX_HOME", () => { + assert(detectRuntime({ CODEX_HOME: "/tmp/codex" } as NodeJS.ProcessEnv) === "codex", "CODEX_HOME did not select codex"); + assert(detectRuntime({} as NodeJS.ProcessEnv) === "claude", "empty env should default to claude"); +}); + +check("resolvePluginRoot prefers ClawCode env before Claude env", () => { + const root = resolvePluginRoot({ + CLAWCODE_PLUGIN_ROOT: "/tmp/claw", + CLAUDE_PLUGIN_ROOT: "/tmp/claude", + } as NodeJS.ProcessEnv, "/tmp/cwd"); + assert(root === path.resolve("/tmp/claw"), `wrong plugin root: ${root}`); +}); + +check("resolveWorkspaceRoot prefers explicit Codex workspace", () => { + const root = resolveWorkspaceRoot("/tmp/plugin", { + CODEX_PROJECT_DIR: "/tmp/project", + OLDPWD: "/tmp/old", + } as NodeJS.ProcessEnv, "/tmp/plugin"); + assert(root === path.resolve("/tmp/project"), `wrong workspace: ${root}`); +}); + +check("resolveWorkspaceRoot uses OLDPWD when cwd is plugin root", () => { + const root = resolveWorkspaceRoot("/tmp/plugin", { + OLDPWD: "/tmp/workspace", + } as NodeJS.ProcessEnv, "/tmp/plugin"); + assert(root === path.resolve("/tmp/workspace"), `wrong workspace: ${root}`); +}); + +check("runtimeInfo uses Codex paths", () => { + const info = runtimeInfo("codex", { CODEX_HOME: "/tmp/codex-home" } as NodeJS.ProcessEnv); + assert(info.projectSkillsDirName === ".codex", "wrong project skills dir name"); + assert(info.userSkillsDir === path.join("/tmp/codex-home", "skills"), "wrong user skills dir"); + assert(info.reloadInstruction.includes("Restart Codex"), "reload instruction should mention Codex"); +}); + +check("scopeDir is runtime aware", () => { + assert(scopeDir("/tmp/work", "project", "codex") === path.join("/tmp/work", ".codex", "skills"), "wrong codex project scope"); + assert(scopeDir("/tmp/work", "project", "claude") === path.join("/tmp/work", ".claude", "skills"), "wrong claude project scope"); +}); + +check("projectSkillsDir and manifest order are runtime aware", () => { + assert(projectSkillsDir("/tmp/work", "codex") === path.join("/tmp/work", ".codex", "skills"), "wrong codex project skills path"); + const manifests = pluginManifestPaths("/tmp/plugin", "codex"); + assert(manifests[0].endsWith(path.join(".codex-plugin", "plugin.json")), "codex manifest should be first"); + assert(manifests[1].endsWith(path.join(".claude-plugin", "plugin.json")), "claude manifest should be fallback"); +}); + +check("Codex cron runner matches generated cron expressions", () => { + const date = new Date(2026, 0, 5, 9, 30, 0); + assert(cronMatchesDate("30 9 * * *", date), "daily cron should match"); + assert(cronMatchesDate("*/15 * * * *", date), "step cron should match"); + assert(cronMatchesDate("30 9 * * 1", date), "weekly cron should match Monday"); + assert(!cronMatchesDate("31 9 * * *", date), "wrong minute should not match"); +}); + +check("Codex cron runner identifies due registry entries", () => { + const now = new Date(2026, 0, 5, 9, 30, 0); + const registry = { + entries: [ + { key: "daily", cron: "30 9 * * *", prompt: "daily", recurring: true, paused: false, tombstone: null }, + { key: "paused", cron: "30 9 * * *", prompt: "paused", recurring: true, paused: true, tombstone: null }, + { key: "done", cron: "30 9 * * *", prompt: "done", recurring: true, paused: false, tombstone: null }, + { key: "oneshot", cron: "0 0 1 1 *", prompt: "one", recurring: false, targetEpoch: 1, paused: false, tombstone: null }, + ], + }; + const state = { fired: { done: Math.floor(now.getTime() / 60000) } }; + const due = dueEntries(registry, state, now).map((e: { key: string }) => e.key); + assert(due.includes("daily"), "daily entry should be due"); + assert(due.includes("oneshot"), "expired one-shot should be due once"); + assert(!due.includes("paused"), "paused entry should not be due"); + assert(!due.includes("done"), "already-fired minute should not be due"); +}); + // --------------------------------------------------------------------------- // Run summary // --------------------------------------------------------------------------- diff --git a/tests/service-generator-smoke.test.ts b/tests/service-generator-smoke.test.ts index 583e8dc..bd2fa89 100644 --- a/tests/service-generator-smoke.test.ts +++ b/tests/service-generator-smoke.test.ts @@ -20,6 +20,9 @@ import { generateHealSystemdService, generateHealSystemdTimer, generateHealLaunchdPlist, + generateCodexLaunchdPlist, + generateCodexSystemdService, + generateCodexSystemdTimer, generateSystemdUnit, generatePlist, versionStampPathExpr, @@ -218,6 +221,60 @@ check("buildPlan install (resumeOnRestart=false) disables sidecar by default", ( assert(!filePaths.includes(resumeWrapperPath(slug)), "wrapper should not install when disabled"); }); +check("buildPlan install (codex/linux) emits runner service + timer", () => { + const plan = buildPlan("install", { + ...linuxOpts, + runtime: "codex", + codexBin: "/usr/local/bin/codex", + pluginRoot: "/opt/clawcode", + }); + const filePaths = (plan.extraFiles ?? []).map((f) => f.path); + assert(plan.label === "clawcode-codex-my-agent", "wrong codex linux label"); + assert(plan.filePath.endsWith("clawcode-codex-my-agent.service"), "wrong codex service path"); + assert(filePaths.some((p) => p.endsWith("clawcode-codex-my-agent.timer")), "missing codex timer"); + assert(plan.fileContent.includes("clawcode-codex-cron-runner.mjs"), "service does not call codex runner"); + assert(plan.fileContent.includes("/usr/local/bin/codex"), "service does not pass codex binary"); + assert(!plan.fileContent.includes("claude --continue"), "codex service leaked claude resume"); + assert(!plan.fileContent.includes("--dangerously-skip-permissions"), "codex service leaked claude flag"); + assert(plan.commands.map((c) => c.cmd).join("\n").includes("clawcode-codex-my-agent.timer"), "install does not enable codex timer"); +}); + +check("buildPlan install (codex/darwin) emits launchd interval runner", () => { + const plan = buildPlan("install", { + ...macOpts, + runtime: "codex", + codexBin: "/opt/homebrew/bin/codex", + pluginRoot: "/opt/clawcode", + }); + assert(plan.label === "com.clawcode.codex.my-agent", "wrong codex mac label"); + assert(plan.filePath.endsWith("com.clawcode.codex.my-agent.plist"), "wrong codex plist path"); + assert(plan.fileContent.includes("StartInterval"), "codex plist missing interval"); + assert(plan.fileContent.includes("60"), "codex plist not 60s"); + assert(plan.fileContent.includes("clawcode-codex-cron-runner.mjs"), "plist does not call codex runner"); + assert(!plan.fileContent.includes("claude --continue"), "codex plist leaked claude resume"); +}); + +check("codex service generators are syntax/stamp sane", () => { + const service = generateCodexSystemdService({ + slug, + workspace: "/home/tester/my-agent", + pluginRoot: "/opt/clawcode", + codexBin: "/usr/local/bin/codex", + logPath: "/home/tester/.clawcode/logs/codex-my-agent.log", + }); + const timer = generateCodexSystemdTimer({ slug }); + const plist = generateCodexLaunchdPlist({ + label: "com.clawcode.codex.my-agent", + workspace: "/Users/tester/my-agent", + pluginRoot: "/opt/clawcode", + codexBin: "/opt/homebrew/bin/codex", + logPath: "/Users/tester/.clawcode/logs/codex-my-agent.log", + }); + assert(service.includes("Type=oneshot"), "codex service should be oneshot"); + assert(timer.includes("OnUnitActiveSec=1min"), "codex timer cadence wrong"); + assert(plist.includes("CLAWCODE_RUNTIME"), "codex plist missing runtime env"); +}); + check("buildPlan install (selfHeal=false explicit) suppresses sidecar", () => { const plan = buildPlan("install", { ...linuxOpts, selfHeal: false }); const filePaths = (plan.extraFiles ?? []).map((f) => f.path);