Skip to content
Open
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
5 changes: 5 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
"name": "ralph-wiggum",
"source": "./plugins/ralph-wiggum"
},
{
"name": "claude-loop",
"description": "Recurring loop & reminder plugin for Claude Code. Works on any model provider with three-tier fallback (CronCreate → Background Sleep Chain → Execute Once).",
"source": "./plugins/claude-loop"
},
{
"name": "google-workspace",
"description": "Comprehensive Google Workspace meta-skill covering all 47+ services (Calendar, Gmail, Drive, Docs, Sheets, Slides, Chat, Meet, Tasks, Forms, People, Workflows, and more)",
Expand Down
9 changes: 9 additions & 0 deletions plugins/claude-loop/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "claude-loop",
"version": "0.1.0",
"description": "Recurring loop & reminder scheduling for Claude Code. Works on any model provider with three-tier fallback (CronCreate → Background Sleep Chain → Execute Once).",
"author": {
"name": "Tommy Nguyen",
"url": "https://github.com/tuannvm"
}
}
36 changes: 36 additions & 0 deletions plugins/claude-loop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# claude-loop

Recurring loop & reminder plugin for Claude Code. Works on **any model provider** — not just Anthropic.

## Commands

| Command | Description |
|---------|-------------|
| `/claude-loop:loop [interval] <prompt>` | Schedule a recurring prompt |
| `/claude-loop:list` | List active loops |
| `/claude-loop:stop [id \| all]` | Stop loops |
| `/claude-loop:help` | Show usage and examples |

## Features

- Schedule recurring prompts: `/claude-loop:loop 5m check the deploy`
- One-time reminders: `/claude-loop:loop remind me at 3pm to review PRs`
- Natural language: "check the deploy every 5 minutes"
- Intervals: `Ns`, `Nm`, `Nh`, `Nd` (default `10m`, minimum `1m`)

## Three-Tier Execution

1. **Tier 1: CronCreate** — Loads deferred Cron tools via ToolSearch. Full-featured, identical to built-in `/loop`.
2. **Tier 2: Background Sleep Chain** — When Cron tools are unavailable, uses `Bash run_in_background` sleep notifications for real recurring execution. Output appears inline in the conversation.
3. **Tier 3: Execute Once** — Last resort fallback if neither Tier 1 nor Tier 2 work.

## Why This Exists

The built-in `/loop` command relies on `CronCreate`, which is a deferred tool that may not be discovered automatically on custom model providers. This plugin explicitly loads Cron tools via `ToolSearch` first, and provides a working fallback (Tier 2) for providers where Cron tools are genuinely unavailable.

## Installation

```bash
/plugin marketplace add tuannvm/plugins
/plugin install claude-loop@plugins
```
43 changes: 43 additions & 0 deletions plugins/claude-loop/commands/help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
description: "Show claude-loop usage and examples"
---

# claude-loop Help

Show the user the following:

## Usage

```
/claude-loop:loop [interval] <prompt or /command>
/claude-loop:loop <prompt> every <interval>
/claude-loop:list
/claude-loop:stop [job_id | all]
```

## Examples

| Command | Effect |
|---------|--------|
| `/claude-loop:loop 5m check the deploy` | Check deploy every 5 minutes |
| `/claude-loop:loop 1m print hello world` | Print "hello world" every minute |
| `/claude-loop:loop check CI status every 10m` | Check CI every 10 minutes |
| `/claude-loop:loop remind me at 3pm to review PRs` | One-time reminder at 3pm |
| `/claude-loop:loop 30s /babysit-prs` | Run /babysit-prs every minute (30s rounds up) |
| `/claude-loop:list` | Show active loops |
| `/claude-loop:stop all` | Cancel all loops |
| `/claude-loop:stop a1b2c3d4` | Cancel specific loop by ID |

## Intervals

`Ns` (seconds, rounds up to 1m minimum), `Nm` (minutes), `Nh` (hours), `Nd` (days). Default: `10m`.

## How It Works

Works on **any model provider** via three-tier fallback:

1. **Tier 1:** Loads CronCreate via ToolSearch — may be available on any provider
2. **Tier 2:** Background sleep chain — real recurring via `Bash run_in_background` notifications
3. **Tier 3:** Execute once — if neither tier works

Jobs are session-scoped and stop when Claude Code exits.
25 changes: 25 additions & 0 deletions plugins/claude-loop/commands/list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
description: "List all active recurring loops and reminders"
---

# List Active Loops

Show all active jobs scheduled in this session.

## Method Selection

1. Call `ToolSearch("select:CronList")` to load the tool
2. **If loaded** → call `CronList` and display results
3. **If unavailable** → Glob `/tmp/claude-loop-*.state`, read each file, show active ones

### Stale Detection (Tier 2)

For each state file, check if `session_pid` matches a running process:
```bash
kill -0 <session_pid> 2>/dev/null
```
If not running, the job is stale — delete the file and skip it.

### Output Format

Show a table with: Job ID, Prompt, Interval, Created time.
177 changes: 177 additions & 0 deletions plugins/claude-loop/commands/loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
---
description: "Run a prompt on a recurring interval or set a one-time reminder. Works on any model provider."
argument-hint: "[interval] <prompt or /command>"
---

# Recurring Loop & Reminder

Schedule a prompt to run on a recurring interval, or set a one-time reminder, within this session.

## Input

Parse `$ARGUMENTS` into `[interval] <prompt…>`:

1. **No args / "help"** → show usage and examples, then stop
2. **"list"** → list active jobs (see list command logic below)
3. **"stop" / "cancel"** → cancel jobs (see stop command logic below)
4. **Leading token** matches `^\d+[smhd]$` (e.g. `5m`, `2h`) → that's the interval; rest is prompt
5. **Trailing "every" clause** ends with `every <N><unit>` → extract interval, strip from prompt. Only match when followed by a time expression — `check every PR` has no interval.
6. **One-shot** — "remind me at/in", "at Xpm", "in N minutes" → schedule once (`recurring: false`)
7. **Default** — interval is `10m`, entire input is the prompt

If resulting prompt is empty, show usage `/claude-loop:loop [interval] <prompt>` and stop.

## Method Selection (Three-Tier)

**ALWAYS try Tier 1 first.** Do NOT skip to lower tiers without attempting.

### Tier 1: Load CronCreate via ToolSearch

CronCreate is a **deferred tool** in Claude Code — it exists in the infrastructure, not the model. It may be available on any provider but must be loaded first.

1. Call `ToolSearch("select:CronCreate,CronList,CronDelete")` to load the tools
2. **If loaded** → use the Cron Method below
3. **If ToolSearch fails or tools don't exist** → proceed to Tier 2

### Tier 2: Background Sleep Chain

Use when CronCreate is genuinely unavailable.

**How it works:** Run `sleep <seconds>` via Bash with `run_in_background: true`. When the sleep completes, Claude receives an automatic notification. Claude then executes the prompt inline and starts the next sleep cycle.

**Detection:** If the first Bash `run_in_background` call errors, fall to Tier 3.

### Tier 3: Execute Once (last resort)

Only if both Tier 1 and Tier 2 fail:
1. Execute the prompt once immediately in the conversation
2. Explain the limitation and offer alternatives (system cron, `watch`, separate terminal)

---

## Cron Method (Tier 1)

### Interval-to-Cron Conversion

| Interval | Cron | Notes |
|----------|------|-------|
| `Ns` | `*/ceil(N/60) * * * *` | Round up to nearest minute, warn user |
| `1m` | `* * * * *` | |
| `Nm` (2-59) | `*/N * * * *` | |
| `Nm` (60+) | `0 */H * * *` where H=round(N/60) | Must divide 24 evenly; tell user what was picked |
| `Nh` (1-23) | `M */N * * *` | M ∈ 1-59, avoid 0 and 30 |
| `Nh` (24+) | Convert to days | e.g., `48h` → `2d` |
| `Nd` (1-31) | `M H */N * *` | M ∈ 1-59, H ∈ 7-21 |
Comment on lines +58 to +64
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In standard 5-field cron, does 0 */2 * * *represent every 90 minutes, and does7 */5 * * * maintain exact 5-hour spacing across day boundaries? Please include example next-run timestamps.

💡 Result:

No.

0 */2 * * *

This means: at minute 0 of every 2nd hour (hours 0,2,4,…,22)—i.e., every 2 hours on the clock, not “every 90 minutes.” (cronviz.com)

Example (assume “now” is 2026-03-08 13:26 local time):

  • Next runs: 2026-03-08 14:00, 16:00, 18:00, 20:00, 22:00, 2026-03-09 00:00, …

7 */5 * * *

This means: at minute 7 of every 5th hour (hours 0,5,10,15,20) each day. It does not preserve exact 5-hour spacing across the midnight boundary because the schedule “wraps” to the next day’s matching hours (cron is a pattern matcher, not an interval timer). (en.wikipedia.org)

Example (assume “now” is 2026-03-08 13:26):

  • Next runs: 2026-03-08 15:07, 20:07, 2026-03-09 00:07, 05:07, 10:07, …
  • Notice the gap 20:07 → 00:07 is 4 hours, not 5. (en.wikipedia.org)

Citations:


Don't silently translate unsupported intervals into a different cadence.

In standard 5-field cron, 0 */2 * * * runs every 2 hours at minute 0 (hours 0,2,4,…,22), not every 90 minutes. Similarly, 7 */5 * * * does not preserve exact 5-hour spacing across day boundaries; the gap from 20:07 to 00:07 the next day is 4 hours, not 5. These mappings drift and create uneven gaps. For intervals that cannot be represented exactly in 5-field cron, fall back to Tier 2 or reject them instead of rounding to a different schedule.

🛠️ Suggested doc fix
-| `Nm` (60+) | `0 */H * * *` where H=round(N/60) | Must divide 24 evenly; tell user what was picked |
-| `Nh` (1-23) | `M */N * * *` | M ∈ 1-59, avoid 0 and 30 |
-| `Nd` (1-31) | `M H */N * *` | M ∈ 1-59, H ∈ 7-21 |
+| `Nm` (60+) | Only if exactly representable in cron | Otherwise fall back to Tier 2 or reject |
+| `Nh` (1-23) | Only if exactly representable in cron | Otherwise fall back to Tier 2 or reject |
+| `Nd` (1-31) | Only if exact calendar-day semantics are acceptable | Otherwise fall back to Tier 2 or reject |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/claude-loop/commands/loop.md` around lines 58 - 64, The docs
currently show automatic translations for unsupported intervals (see table
entries like `Ns`, `Nm (60+)`, `Nh (24+)`, `Nd`) which silently round or convert
intervals into different cron cadences; update the text and the table to stop
suggesting these silent translations and instead state that intervals not
representable exactly in 5-field cron should either fall back to Tier 2
scheduling or be rejected with an explicit error message to the user;
specifically remove or mark as unsupported the mappings for `Nm (60+)` → `0 */H
* * *`, `Nh (24+)` → Convert to days, and any rounding behavior under `Ns`/`Nm`
that implies drift, and add a short note that exact-interval requirements must
use Tier 2 or be declined.

| `Nd` (32+) | Reject | Tell user max is 31d |

**Anti-spike:** For hourly+, never use minute 0 or 30 unless user requests exact time.

### How CronCreate Works

The `prompt` parameter is a **Claude prompt**, not a bash command. When the job fires, the prompt is fed back into the current Claude Code session on the next idle turn. Claude then interprets and executes it inline — output appears directly in the conversation.

### Execution

1. Parse interval and prompt
2. Convert to cron expression
3. `CronCreate`: `cron`, `prompt`, `recurring: true` (or `false` for one-shot)
4. Confirm: prompt, interval, cron, job ID, 3-day expiry note

### One-Time Reminders (Cron)

Parse target time → pin to cron fields → `CronCreate` with `recurring: false`.

### Managing Jobs (Cron)

- **List:** `CronList`
- **Cancel by ID:** `CronDelete` (8-char ID)
- **Cancel all:** `CronList` → `CronDelete` each

Max 50 tasks per session.

### Runtime Behavior

- **Session-scoped** — gone when Claude Code exits
- **Fires between turns** — only when idle, not mid-response
- **No catch-up** — missed fires execute once when idle
- **Local timezone** — not UTC
- **3-day expiry** — recurring auto-expire
- **Jitter** — recurring: up to 10% late (max 15 min); one-shot on :00/:30: up to 90s early

### Cron Reference

Standard 5-field: `minute hour day-of-month month day-of-week`. Supports `*`, values, `*/N` steps, ranges, lists. Day-of-week: 0 or 7 = Sunday.

---

## Background Sleep Chain (Tier 2)

Use when CronCreate is genuinely unavailable after ToolSearch attempt.

### State File

Each job creates `/tmp/claude-loop-<id>.state` (JSON):
```json
{"id": "<8-char-hex>", "prompt": "<prompt>", "interval_sec": 60, "recurring": true, "created": "<ISO>", "session_pid": <PID>}
```

- Generate `id`: `openssl rand -hex 4`
- **Escape prompt** for JSON: replace `\` → `\\`, `"` → `\"`, newlines → `\n`
- `session_pid`: `echo $PPID` (Claude Code's PID — used for staleness detection)
- `recurring`: `false` for one-shot reminders, `true` for loops
Comment on lines +113 to +121
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use a private per-session state directory instead of shared /tmp files.

Storing executable prompts in predictable /tmp/claude-loop-<id>.state files lets other local processes discover, delete, or overwrite another session's jobs. Because the fire path later reads that file and executes its prompt, this is both a session-isolation bug and a prompt-injection risk. Put Tier 2 state under a per-session temp directory created with restrictive permissions, and have list/stop only inspect that directory.

🔒 Suggested doc fix
-Each job creates `/tmp/claude-loop-<id>.state` (JSON):
+Create a private session directory first, e.g. `${TMPDIR:-/tmp}/claude-loop.<session-id>/` with mode `0700`.
+Each job creates `<session_dir>/<id>.state` (JSON) with mode `0600`:
-- Generate `id`: `openssl rand -hex 4`
+- Generate `id`: `openssl rand -hex 4`
+- Create state files with restrictive permissions and never glob outside the current session directory
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/claude-loop/commands/loop.md` around lines 113 - 121, The current
design writes job state to predictable global files like
/tmp/claude-loop-<id>.state which allows other local processes to enumerate or
tamper with prompts; change to create a private per-session temp directory with
restrictive permissions (owner-only) and store state files inside it (e.g.,
session-specific temp dir created at session start), update the logic that
generates state files (the code that writes `/tmp/claude-loop-<id>.state` and
sets "session_pid") to write into that session directory, and update the
list/stop and the fire path to only read/inspect files in that per-session
directory so only the owning session can discover, delete, or execute its jobs.
Ensure the session dir is created atomically with secure perms and cleaned up
when the session ends.


### One-Time Reminders (Tier 2)

For absolute times ("at 3pm", "remind me at 14:30"): compute seconds until target time and use as `interval_sec` with `recurring: false`.
- **macOS:** `$(( $(date -j -f '%H:%M' '15:00' '+%s') - $(date '+%s') ))`
- **Linux:** `$(( $(date -d '15:00' '+%s') - $(date '+%s') ))`
- If result is negative, target is tomorrow — add 86400.

### Starting a Loop

1. Parse interval and prompt (same rules as Cron method)
2. Convert interval to seconds (`1m`→60, `5m`→300, `1h`→3600). Enforce minimum 60s.
3. Create the state file
4. Run Bash: `sleep <interval_sec> && echo 'CLAUDE_LOOP_FIRE <id>'` with `run_in_background: true`
- The echo embeds the job ID so the notification is self-describing
5. Confirm to user: prompt, interval, job ID

### On Sleep Completion (notification received)

When you see a background task notification containing `CLAUDE_LOOP_FIRE <id>`:

**CRITICAL behavioral rules:**
- **MUST output ONLY the result of executing the stored prompt.** Nothing else.
- **MUST NOT output any meta-commentary**, reflection, or speculation about the loop, the user's intent, or what might happen next. NEVER emit phrases like "Loop fired", "reading state file", "next cycle started", "the loop is working", "the user might want to...", or similar.
- **Treat each fire as atomic and isolated.** Do not reflect on previous fires or accumulated loop history.
- If the prompt produces no visible output, output nothing at all.

**Steps:**

1. Read `/tmp/claude-loop-<id>.state` (no commentary)
2. If file exists:
- **Execute the prompt** — user sees only the prompt's output, nothing else
- If `recurring: true`: re-arm with `sleep <interval_sec> && echo 'CLAUDE_LOOP_FIRE <id>'` (`run_in_background: true`) — no confirmation text
- If `recurring: false`: delete the state file — no confirmation text
3. If file missing → do nothing (loop was cancelled)

**After executing the prompt and re-arming, STOP. Do not add any trailing commentary or thoughts.**

### Managing Jobs (Sleep Chain)

- **List:** Glob `/tmp/claude-loop-*.state`, read each, show active ones. Filter stale: if `session_pid` doesn't match a running process (`kill -0 <pid> 2>/dev/null`), skip and delete.
- **Stop by ID:** Delete `/tmp/claude-loop-<id>.state`. The next sleep notification will find no file and stop.
- **Stop by prompt substring:** Glob all state files, match prompt substring, delete. If multiple match, list and ask user to clarify.
- **Stop all:** Glob and delete all `/tmp/claude-loop-*.state`.

Max 20 concurrent jobs per session (check count before creating).

### Runtime Behavior

- **Session-scoped** — state files go stale when Claude exits; detected via `session_pid`
- **Fires between turns** — notification arrives when idle
- **Output inline** — Claude executes the prompt in the conversation, identical to Cron behavior
- **Minimum interval** — 1 minute (60 seconds)
- **Self-describing notifications** — `CLAUDE_LOOP_FIRE <id>` in background output identifies the job
- **No 3-day expiry** — runs until stopped or session ends
- **On prompt error** — log the error inline, continue the loop (don't stop on transient failures)
20 changes: 20 additions & 0 deletions plugins/claude-loop/commands/stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
description: "Stop active loops by ID, prompt substring, or all"
argument-hint: "[job_id | prompt_substring | all]"
---

# Stop Loops

Cancel active jobs. Parse `$ARGUMENTS`:

- **No args** → list active jobs and ask which to stop
- **"all"** → stop all active jobs
- **Otherwise** → match by: exact ID → ID prefix → prompt substring. If multiple match, list them and ask user to clarify.

## Method Selection

1. Call `ToolSearch("select:CronList,CronDelete")` to load the tools
2. **If loaded** → use `CronList` to find jobs, `CronDelete` to cancel
3. **If unavailable** → Glob `/tmp/claude-loop-*.state`, match against argument, delete matching files

Confirm what was stopped: job ID, prompt, interval.