Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"agent",
"automation"
],
"skills": "./skills/",
"hooks": "./hooks/codex-hooks.json",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Clone Loop",
Expand All @@ -25,14 +27,15 @@
"developerName": "Clone",
"category": "Productivity",
"capabilities": [
"Interactive",
"Hooks",
"Skills",
"MCP"
],
"websiteURL": "https://clone.is/you",
"defaultPrompt": [
"Predict the next prompt for this coding session.",
"Help me keep this task moving until tests pass.",
"Use Clone MCP context to suggest the next step."
"Set up Clone Loop for Codex.",
"Start a Clone Loop to keep working until tests pass.",
"Check my Clone API key status."
],
"brandColor": "#6F42C1"
}
Expand Down
6 changes: 3 additions & 3 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"mcpServers": {
"mcp_servers": {
"clone": {
"url": "https://api.clone.is/mcp",
"headers": {
"X-Clone-API-Key": "${CLONE_API_TOKEN:-clone_yc-reviewer-public-demo-2026}"
"env_http_headers": {
"X-Clone-API-Key": "CLONE_API_TOKEN"
}
}
}
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,34 @@ Tags: see [Releases](https://github.com/cloneisyou/clone-loop/releases).

## Codex plugin

This repo also publishes a Codex plugin manifest:
This repo also publishes a Codex plugin with skills, hooks, and Clone MCP
configuration:

- `.codex-plugin/plugin.json` declares the `clone-loop` Codex plugin.
- `.agents/plugins/marketplace.json` exposes it through the repo-local
`clone-loop` marketplace.
- `.mcp.json` is shared so Codex can connect to Clone MCP with
`CLONE_API_TOKEN` or the public demo fallback.
`CLONE_API_TOKEN`.
- `hooks/codex-hooks.json` runs the Codex Stop hook that injects confident
Clone-predicted next prompts.

First run `clone-setup` in Codex. It enables `[features].plugin_hooks = true`
in `~/.codex/config.toml`, reports the effective Clone API key source, and
leaves a one-time `config.toml.clone-loop.bak` backup before changing an
existing config.

Then start a loop:

```text
clone-loop "Run tests and fix any failures" --max-iterations 5
```

Codex plugin hooks require trust review. If Codex warns about untrusted hooks,
open `/hooks`, trust the Clone Loop plugin hooks, and retry.

Codex v1 reproduces the Stop-hook loop behavior. Claude's AskUserQuestion
auto-answer hook is not available in Codex v1 because Codex does not expose an
equivalent AskUserQuestion tool hook event.

## Requirements

Expand Down
30 changes: 30 additions & 0 deletions hooks/codex-hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"description": "Clone plugin hooks for Codex Clone Loops",
"hooks": {
"PostToolUse": [
{
"matcher": "apply_patch|Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/scripts/capture-tool-use.mjs\"",
"timeout": 3,
"statusMessage": "Capturing Clone Loop tool context"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/stop-hook.mjs\"",
"timeout": 30,
"statusMessage": "Predicting the next Clone Loop prompt"
}
]
}
]
}
}
9 changes: 7 additions & 2 deletions hooks/stop-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import {
validateActiveLoopState,
} from '../scripts/loop-state-guard.mjs'

const LOOP_STATE_FILE = resolve(process.cwd(), '.claude', 'clone-loop.local.md')
const LOOP_HISTORY_FILE = resolve(process.cwd(), '.claude', 'clone-loop.history.local.jsonl')
let LOOP_STATE_FILE = resolve(process.cwd(), '.claude', 'clone-loop.local.md')
let LOOP_HISTORY_FILE = resolve(process.cwd(), '.claude', 'clone-loop.history.local.jsonl')
const CLIENT_VERSION = '0.14.2'
const ANSI_BOLD = '\u001b[1m'
const ANSI_PURPLE = '\u001b[35m'
Expand Down Expand Up @@ -200,6 +200,11 @@ function findLastContinueTs(historyPath) {

async function main() {
const hookInput = parseJson(await readStdin())
if (hookInput.stop_hook_active === true) return

const root = hookInput.cwd ? resolve(String(hookInput.cwd)) : process.cwd()
LOOP_STATE_FILE = resolve(root, '.claude', 'clone-loop.local.md')
LOOP_HISTORY_FILE = resolve(root, '.claude', 'clone-loop.history.local.jsonl')
const hookSession = hookInput.session_id ? String(hookInput.session_id) : ''

const validation = validateActiveLoopState({
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"test": "node --test tests/api-key-manager.test.mjs tests/setup-clone-loop.test.mjs tests/post-tool-use-capture.test.mjs tests/repo-identity.test.mjs tests/codex-plugin.test.mjs tests/stop-hook-v2.test.mjs tests/ask-user-question-hook.test.mjs tests/release-automation.test.mjs",
"test": "node --test tests/api-key-manager.test.mjs tests/setup-clone-loop.test.mjs tests/post-tool-use-capture.test.mjs tests/repo-identity.test.mjs tests/codex-plugin.test.mjs tests/codex-setup.test.mjs tests/codex-post-tool-use.test.mjs tests/codex-stop-hook.test.mjs tests/stop-hook-v2.test.mjs tests/ask-user-question-hook.test.mjs tests/release-automation.test.mjs",
"test:mcp:e2e": "node --test tests/remote-mcp-e2e.test.mjs"
}
}
19 changes: 16 additions & 3 deletions scripts/capture-tool-use.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import { appendLoopHistory, validateActiveLoopState } from './loop-state-guard.mjs'
import { resolve } from 'node:path'

const LOOP_STATE_FILE = resolve(process.cwd(), '.claude', 'clone-loop.local.md')
const LOOP_HISTORY_FILE = resolve(process.cwd(), '.claude', 'clone-loop.history.local.jsonl')
let LOOP_STATE_FILE = resolve(process.cwd(), '.claude', 'clone-loop.local.md')
let LOOP_HISTORY_FILE = resolve(process.cwd(), '.claude', 'clone-loop.history.local.jsonl')
const CAP = 240

function readStdin() {
Expand Down Expand Up @@ -37,6 +37,7 @@ function extractFilePath(toolInput) {
if (!toolInput || typeof toolInput !== 'object') return ''
if (toolInput.file_path) return stringValue(toolInput.file_path)
if (toolInput.path) return stringValue(toolInput.path)
if (toolInput.patch) return extractApplyPatchPaths(toolInput.patch).join(', ')
if (Array.isArray(toolInput.edits)) {
const paths = toolInput.edits
.map((edit) => stringValue(edit?.file_path || edit?.path))
Expand All @@ -46,6 +47,15 @@ function extractFilePath(toolInput) {
return ''
}

function extractApplyPatchPaths(patch) {
const paths = []
for (const line of String(patch || '').split(/\r?\n/)) {
const match = line.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/)
if (match) paths.push(match[1].trim())
}
return [...new Set(paths.filter(Boolean))]
}

function extractSuccess(toolResponse) {
if (!toolResponse || typeof toolResponse !== 'object') return null
if (typeof toolResponse.success === 'boolean') return toolResponse.success
Expand All @@ -69,6 +79,9 @@ function appendHistory(record) {

async function main() {
const hookInput = parseJson(await readStdin())
const root = hookInput.cwd ? resolve(String(hookInput.cwd)) : process.cwd()
LOOP_STATE_FILE = resolve(root, '.claude', 'clone-loop.local.md')
LOOP_HISTORY_FILE = resolve(root, '.claude', 'clone-loop.history.local.jsonl')
const hookSession = hookInput.session_id ? stringValue(hookInput.session_id) : ''
const validation = validateActiveLoopState({
statePath: LOOP_STATE_FILE,
Expand All @@ -80,7 +93,7 @@ async function main() {
const { state } = validation

const toolName = stringValue(hookInput.tool_name || hookInput.toolName)
if (!/^(Write|Edit|MultiEdit)$/.test(toolName)) return
if (!/^(Write|Edit|MultiEdit|apply_patch)$/.test(toolName)) return

const toolInput = hookInput.tool_input && typeof hookInput.tool_input === 'object'
? hookInput.tool_input
Expand Down
4 changes: 3 additions & 1 deletion scripts/clone-auth.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ export function defaultPluginDataDir() {
}

export function pluginDataDir(env = process.env) {
const codexValue = String(env.PLUGIN_DATA || '').trim()
if (codexValue) return codexValue
const value = String(env.CLAUDE_PLUGIN_DATA || '').trim()
return value || defaultPluginDataDir()
}

export function isPluginDataDirInjected(env = process.env) {
return Boolean(String(env.CLAUDE_PLUGIN_DATA || '').trim())
return Boolean(String(env.PLUGIN_DATA || env.CLAUDE_PLUGIN_DATA || '').trim())
}

export function authFilePath(env = process.env) {
Expand Down
80 changes: 73 additions & 7 deletions scripts/conversation-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,37 @@ function detectTimestampField(records) {
return null
}

function compareTimestamp(left, right) {
const leftMs = Date.parse(String(left || ''))
const rightMs = Date.parse(String(right || ''))
if (Number.isFinite(leftMs) && Number.isFinite(rightMs)) {
return leftMs - rightMs
}
if (left < right) return -1
if (left > right) return 1
return 0
}

function extractAssistantTexts(record) {
if (record?.message?.role !== 'assistant') return []
const content = record.message?.content
if (record?.type === 'event_msg' && record?.payload?.type === 'agent_message') {
const message = String(record.payload.message || '').trim()
return message ? [message] : []
}

const message = record?.message || (
record?.type === 'response_item' && record?.payload?.type === 'message'
? record.payload
: null
)
if (message?.role !== 'assistant') return []
const content = message.content
if (!Array.isArray(content)) return []
const texts = []
for (const block of content) {
if (block?.type === 'text' && typeof block.text === 'string' && block.text) {
texts.push(block.text)
} else if (block?.type === 'output_text' && typeof block.text === 'string' && block.text) {
texts.push(block.text)
}
}
return texts
Expand Down Expand Up @@ -165,7 +188,7 @@ export function assistantTextsThisIteration(transcriptPath, sinceTs) {
for (const record of records) {
if (useFilter) {
const recordTs = record?.[timestampField]
if (typeof recordTs !== 'string' || recordTs <= sinceTs) continue
if (typeof recordTs !== 'string' || compareTimestamp(recordTs, sinceTs) <= 0) continue
}
for (const text of extractAssistantTexts(record)) {
texts.push(text)
Expand All @@ -192,14 +215,41 @@ function toolResultBlockText(block) {
}

function extractIterationBlocks(record) {
const role = record?.message?.role
const content = record?.message?.content
if (record?.type === 'response_item' && record?.payload?.type === 'function_call') {
return [{
kind: 'tool_use',
id: String(record.payload.call_id || ''),
name: String(record.payload.name || ''),
input: safeJsonObject(record.payload.arguments),
}]
}
if (record?.type === 'response_item' && record?.payload?.type === 'function_call_output') {
return [{
kind: 'tool_result',
toolUseId: String(record.payload.call_id || ''),
text: String(record.payload.output || ''),
}]
}
if (record?.type === 'event_msg' && record?.payload?.type === 'agent_message') {
const text = String(record.payload.message || '').trim()
return text ? [{ kind: 'text', text }] : []
}

const message = record?.message || (
record?.type === 'response_item' && record?.payload?.type === 'message'
? record.payload
: null
)
const role = message?.role
const content = message?.content
if (!role || !Array.isArray(content)) return []
const blocks = []
for (const block of content) {
if (!block || typeof block !== 'object') continue
if (role === 'assistant' && block.type === 'text' && block.text) {
blocks.push({ kind: 'text', text: String(block.text) })
} else if (role === 'assistant' && block.type === 'output_text' && block.text) {
blocks.push({ kind: 'text', text: String(block.text) })
} else if (role === 'assistant' && block.type === 'tool_use') {
blocks.push({ kind: 'tool_use', name: String(block.name || ''), input: block.input ?? {} })
} else if (role === 'user' && block.type === 'tool_result') {
Expand All @@ -213,6 +263,17 @@ function extractIterationBlocks(record) {
return blocks
}

function safeJsonObject(value) {
if (!value) return {}
if (typeof value === 'object') return value
try {
const parsed = JSON.parse(String(value))
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return { arguments: String(value) }
}
}

/**
* Reads the Claude Code transcript JSONL and collects every chronologically-
* ordered block from the current iteration: assistant text, assistant
Expand All @@ -239,6 +300,11 @@ function readTranscriptRecords(transcriptPath) {
function buildToolNameMap(records) {
const toolNameById = new Map()
for (const record of records) {
if (record?.type === 'response_item' && record?.payload?.type === 'function_call') {
const id = record.payload.call_id || record.payload.id
if (id) toolNameById.set(String(id), String(record.payload.name || ''))
continue
}
const content = record?.message?.content
if (!Array.isArray(content)) continue
for (const block of content) {
Expand All @@ -256,8 +322,8 @@ function collectBlocksInRange({ records, timestampField, startExclusive, endIncl
if (timestampField) {
const ts = record?.[timestampField]
if (typeof ts !== 'string') continue
if (startExclusive && ts <= startExclusive) continue
if (endInclusive && ts > endInclusive) continue
if (startExclusive && compareTimestamp(ts, startExclusive) <= 0) continue
if (endInclusive && compareTimestamp(ts, endInclusive) > 0) continue
}
for (const block of extractIterationBlocks(record)) {
if (block.kind === 'tool_result') {
Expand Down
6 changes: 3 additions & 3 deletions scripts/manage-api-key.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ USAGE:

TOKEN PRIORITY:
1. CLONE_API_TOKEN environment variable
2. Plugin config in CLAUDE_PLUGIN_DATA/auth.local.json
2. Plugin config in PLUGIN_DATA/auth.local.json or CLAUDE_PLUGIN_DATA/auth.local.json
3. Public demo fallback token`)
}

Expand All @@ -43,7 +43,7 @@ function printResolvedToken(prefix = '') {
if (injected) {
console.log(`Plugin config: ${authFilePath()}`)
} else {
console.log(`Plugin config: ${authFilePath()} (fallback path — CLAUDE_PLUGIN_DATA not injected)`)
console.log(`Plugin config: ${authFilePath()} (fallback path — PLUGIN_DATA/CLAUDE_PLUGIN_DATA not injected)`)
}

if (resolved.isDemo) {
Expand All @@ -64,7 +64,7 @@ if (command === 'status') {
if (command === 'import-env') {
if (args.length !== 1) fail('Usage: /clone:api-key import-env')
const token = String(process.env.CLONE_API_TOKEN || '').trim()
if (!token) fail('CLONE_API_TOKEN is not set in this Claude Code process.')
if (!token) fail('CLONE_API_TOKEN is not set in this agent process.')

writePluginConfigToken(token)
console.log('Stored Clone API key from CLONE_API_TOKEN.')
Expand Down
Loading