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
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<br/><br/>
<strong>Keep Agent working, with predicted next prompts.</strong>
<br/>
<sub>A Claude Code plugin for self-driving coding loops powered by Clone MCP.</sub>
<sub>An agent plugin for self-driving coding loops powered by Clone MCP.</sub>
</p>

<p align="center">
Expand Down Expand Up @@ -35,9 +35,9 @@
<a href="#pinning-a-version">Versions</a>
</p>

Clone is a Claude Code plugin that turns any task into a self-driving loop.
When Claude tries to stop, Clone predicts your most likely next prompt and
hands it back to Claude so the work continues — without you having to type
Clone Loop turns any agent task into a self-driving loop for Claude Code and
Codex. When the agent tries to stop, Clone predicts your most likely next prompt
and hands it back so the work continues — without you having to type
the same nudge ten times.

```text
Expand All @@ -53,9 +53,9 @@ Then walk away.
you. Threshold-gated so it only auto-continues when it's confident.
- **Rich context, not a one-line snapshot.** Each prediction sees your
original task, every prior iteration's user turn, and a full timeline of
what Claude did this iteration — text, tool calls, and tool results.
- **AskUserQuestion popups answered automatically** during an active loop.
- **One session, one Claude.** No subprocesses, no daemons, no parallel
what the agent did this iteration — text, tool calls, and tool results.
- **AskUserQuestion popups answered automatically in Claude Code** during an active loop.
- **One session, one agent.** No subprocesses, no daemons, no parallel
agents to herd.

## Quick start
Expand Down Expand Up @@ -144,22 +144,22 @@ generation: Clone does not generate the interview questions yet.

## How it works

1. `/clone:loop` writes a state file and Claude starts the task.
2. When Claude tries to stop, the Stop hook intercepts.
1. `/clone:loop` or `clone-loop` writes a state file and the agent starts the task.
2. When the agent tries to stop, the Stop hook intercepts.
3. The hook asks Clone MCP `predict_next_prompt` for what you'd most likely
say next.
4. **Above threshold** → prediction injected, Claude continues.
4. **Above threshold** → prediction injected, the agent continues.
**Above threshold + satisfaction signal** → loop exits, asks for input.
**Below threshold** → loop exits, asks for input.
5. Mid-loop `AskUserQuestion` popups are auto-answered the same way.
5. In Claude Code, mid-loop `AskUserQuestion` popups are auto-answered the same way.

### What Clone actually sees

- **Your original task prompt** — always preserved verbatim.
- **The conversation so far** — every prior iteration's injected user turn
plus the assistant timeline that produced it (text + tool calls +
summarized tool results).
- **What Claude just did** — this iteration's full timeline.
- **What the agent just did** — this iteration's full timeline.

Sane caps: 20-turn rolling window on user history, oldest prior-iter
timelines drop first when the combined size exceeds budget, and long tool
Expand Down Expand Up @@ -287,8 +287,8 @@ equivalent AskUserQuestion tool hook event.

## Requirements

Claude Code with plugin support, Node.js on `PATH`. Windows: PowerShell
or cmd, no Git Bash needed.
Claude Code or Codex with plugin support, plus Node.js on `PATH`. Windows:
PowerShell or cmd, no Git Bash needed.

## Development

Expand Down
27 changes: 14 additions & 13 deletions commands/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,27 @@ Please explain the following to the user:

## What is Clone Loop?

Clone Loop is a Clone-backed automation loop for Claude Code. It runs inside
the current session and uses Clone MCP to predict the user's likely next prompt
whenever Claude tries to stop.
Clone Loop is a Clone-backed automation loop for Claude Code and Codex. It runs
inside the current agent session and uses Clone MCP to predict the user's likely
next prompt whenever the agent tries to stop.

**Core concept:**

1. Claude receives the user's initial task.
2. Claude works on the task, modifying files.
3. Claude tries to stop.
1. The agent receives the user's initial task.
2. The agent works on the task, modifying files.
3. The agent tries to stop.
4. The Stop hook intercepts the stop.
5. The Stop hook calls Clone MCP `predict_next_prompt` directly.
6. If confidence clears the configured threshold, the hook passes the
prediction payload to Claude.
7. Claude evaluates the prediction in context and continues as if the user had
provided the predicted prompt.
prediction payload to the agent.
7. The agent evaluates the prediction in context and continues as if the user
had provided the predicted prompt.
8. The loop continues until max iterations are reached, confidence fails, MCP
fails, or the user runs `/clone:cancel-loop`.

During an active loop, Clone also watches `AskUserQuestion` and answers with a
predicted response so the user does not have to handle the popup manually.
In Claude Code, Clone also watches `AskUserQuestion` during an active loop and
answers with a predicted response so the user does not have to handle the popup
manually. Codex v1 does not expose an equivalent question hook.

## Available Commands

Expand Down Expand Up @@ -97,7 +98,7 @@ Cancel an active Clone Loop.

Clone Loop resolves API keys in this order:

1. Nonblank `CLONE_API_TOKEN` from the Claude Code process environment.
1. Nonblank `CLONE_API_TOKEN` from the agent process environment.
2. Saved plugin config in `${CLAUDE_PLUGIN_DATA}/auth.local.json`.
3. The public demo fallback token.

Expand All @@ -106,7 +107,7 @@ Full tokens are never shown in command output.
### Human Escalation

If Clone MCP returns low confidence or fails, the hook removes
`.claude/clone-loop.local.md`. Claude should explain that Clone was not
`.claude/clone-loop.local.md`. The agent should explain that Clone was not
confident enough and wait for human input.

## Learn More
Expand Down
131 changes: 94 additions & 37 deletions hooks/ask-user-question-hook.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
#!/usr/bin/env node

import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { loopHistoryPath, loopStatePath } from '../scripts/clone-paths.mjs'
import { resolveCloneToken } from '../scripts/clone-auth.mjs'
import {
HISTORY_WINDOW_TURNS,
assistantTextsThisIteration,
Expand All @@ -16,7 +15,7 @@ import {
appendLoopHistory,
validateActiveLoopState,
} from '../scripts/loop-state-guard.mjs'
import { cloneMcpClientInfo, cloneMcpEndpoint, cloneMcpRpc } from '../scripts/clone-mcp-client.mjs'
import { clonePredictNextPrompt, recordAgentPrompt, submitFeedback } from '../scripts/clone-mcp.mjs'
import { numeric, parseJson, readStdin } from '../scripts/clone-utils.mjs'
import { mapPredictionToOption, rankedPredictionCandidates } from '../scripts/interview-answer-utils.mjs'

Expand All @@ -27,39 +26,6 @@ function appendHistory(record) {
appendLoopHistory(LOOP_HISTORY_FILE, record)
}

async function clonePredictNextPrompt({ agent, agentInput, threshold, sessionId }) {
const endpoint = cloneMcpEndpoint()
const { token } = resolveCloneToken()

const init = await cloneMcpRpc(endpoint, token, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: cloneMcpClientInfo(),
})

const args = {
agent,
agent_input: agentInput,
k: 1,
threshold: Number(threshold || '0.6'),
}
if (sessionId) args.session_id = sessionId

const prediction = await cloneMcpRpc(
endpoint,
token,
'tools/call',
{ name: 'predict_next_prompt', arguments: args },
init.sessionId,
)

const content = prediction.payload?.result?.content?.[0]
if (!content || content.type !== 'text') {
throw new Error('Clone MCP returned no text prediction content.')
}
return JSON.parse(content.text)
}

function numericConfidence(value, fallback = 0) {
return numeric(value, fallback)
}
Expand Down Expand Up @@ -226,6 +192,57 @@ function deferQuestion({ decision, question, threshold, prediction, confidence,
})
}

function updateFrontmatter(content, key, value) {
const escaped = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
const replacement = `${key}: "${escaped}"`
const pattern = new RegExp(`^${key}: .*$`, 'm')
if (pattern.test(content)) {
return content.replace(pattern, replacement)
}
return content.replace(/^---\r?\n([\s\S]*?)\r?\n---/, (_, body) => `---\n${body}\n${replacement}\n---`)
}

async function safeFeedback({ predictionId, status, mcpSessionId }) {
if (!predictionId) return
try {
await submitFeedback({ predictionId, status, mcpSessionId })
appendHistory({ event: 'feedback-sent', source: 'ask-user-question', prediction_id: predictionId, status })
} catch (error) {
appendHistory({ event: 'feedback-sent', source: 'ask-user-question', prediction_id: predictionId, status, error: error?.message || String(error) })
}
}

async function safeReject({ predictionId, mcpSessionId }) {
await safeFeedback({ predictionId, status: 'rejected', mcpSessionId })
}

async function safeRecordAnswer({ cloneSessionId, mcpSessionId, agent, question, answer, iteration }) {
if (!cloneSessionId || !answer) return null
try {
const result = await recordAgentPrompt({
cloneSessionId,
mcpSessionId,
agent,
prompt: `Q: ${question}\nA: ${answer}`,
source: 'auto-answer',
sourceDetail: `clone-loop:ask-user-question:iteration-${iteration || 'unknown'}`,
})
appendHistory({
event: 'record-prompt',
source: 'ask-user-question',
event_id: result?.eventId || null,
})
return result
} catch (error) {
appendHistory({
event: 'record-prompt',
source: 'ask-user-question',
error: error?.message || String(error),
})
return null
}
}

async function main() {
const hookInput = parseJson(await readStdin())
if (hookInput.tool_name && hookInput.tool_name !== 'AskUserQuestion') return
Expand All @@ -247,6 +264,7 @@ async function main() {
clone_agent: cloneAgentRaw,
clone_session_id: cloneSessionId,
mcp_session_id: mcpSessionIdInitial,
iteration,
} = state.frontmatter

const toolInput = hookInput.tool_input || {}
Expand All @@ -256,7 +274,9 @@ async function main() {
const cloneThreshold = cloneThresholdRaw || '0.6'
const cloneAgent = cloneAgentRaw || 'Claude Code Clone Loop'
const answers = {}
const acceptedPredictions = []
const confidenceValues = []
let activeMcpSessionId = mcpSessionIdInitial || ''

for (const [questionIndex, question] of questions.entries()) {
if (question?.multiSelect) {
Expand Down Expand Up @@ -286,7 +306,7 @@ async function main() {
}),
threshold: cloneThreshold,
sessionId: cloneSessionId || undefined,
mcpSessionId: mcpSessionIdInitial,
mcpSessionId: activeMcpSessionId,
})
} catch (error) {
deferQuestion({
Expand All @@ -306,9 +326,16 @@ async function main() {
prediction,
confidence: prediction.confidence,
})
await safeReject({ predictionId: prediction.id, mcpSessionId: activeMcpSessionId })
return
}

if (prediction.mcp_session_id && prediction.mcp_session_id !== activeMcpSessionId) {
activeMcpSessionId = prediction.mcp_session_id
const updated = updateFrontmatter(readFileSync(LOOP_STATE_FILE, 'utf8'), 'mcp_session_id', activeMcpSessionId)
writeFileSync(LOOP_STATE_FILE, updated)
}

const predictedResponse = String(prediction?.predicted_response || '').trim()
const selection = predictedResponse
? {
Expand All @@ -327,6 +354,7 @@ async function main() {
prediction,
confidence: prediction?.confidence,
})
await safeReject({ predictionId: prediction?.id, mcpSessionId: activeMcpSessionId })
return
}

Expand All @@ -338,10 +366,16 @@ async function main() {
prediction,
confidence: selection.confidence,
})
await safeReject({ predictionId: prediction.id, mcpSessionId: activeMcpSessionId })
return
}

answers[questionText] = selection.answer
acceptedPredictions.push({
predictionId: prediction.id || '',
question: questionText,
answer: selection.answer,
})
confidenceValues.push(selection.confidence)
}

Expand All @@ -353,6 +387,29 @@ async function main() {
threshold: Number(cloneThreshold),
answers,
})
for (const accepted of acceptedPredictions) {
await safeFeedback({
predictionId: accepted.predictionId,
status: 'accepted',
mcpSessionId: activeMcpSessionId,
})
const recorded = await safeRecordAnswer({
cloneSessionId,
mcpSessionId: activeMcpSessionId,
agent: cloneAgent,
question: accepted.question,
answer: accepted.answer,
iteration,
})
if (recorded?.eventId) {
const updated = updateFrontmatter(
readFileSync(LOOP_STATE_FILE, 'utf8'),
'last_prompt_event_id',
recorded.eventId,
)
writeFileSync(LOOP_STATE_FILE, updated)
}
}
allowAnswer({ toolInput, answers, confidence, threshold: cloneThreshold })
}

Expand Down
Loading