claude-marathon 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/bin/marathon +690 -0
- package/hooks/marathon-hooks.js +197 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 marathon contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# marathon
|
|
2
|
+
|
|
3
|
+
**Infinite context sessions for Claude Code.**
|
|
4
|
+
|
|
5
|
+
Two modes. Same result: Claude never loses context.
|
|
6
|
+
|
|
7
|
+
## Auto mode (recommended)
|
|
8
|
+
|
|
9
|
+
Install once, then use `claude` normally. Marathon activates only when context gets large.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
marathon install # one-time setup
|
|
13
|
+
claude # use normally — marathon kicks in when needed
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Zero overhead for small tasks. When context triggers compaction, Claude is automatically told to write a handoff file. Next session picks up where it left off.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
marathon status # check if hooks are installed & any active handoffs
|
|
20
|
+
marathon uninstall # remove hooks
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Explicit mode
|
|
24
|
+
|
|
25
|
+
Wrap a task in marathon for guaranteed multi-session execution.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
marathon "Refactor the entire auth module to use JWT tokens"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
marathon v0.2.0
|
|
33
|
+
Infinite context sessions for Claude Code
|
|
34
|
+
|
|
35
|
+
Task: Refactor the entire auth module to use JWT tokens
|
|
36
|
+
Max turns: 200 per leg
|
|
37
|
+
Max legs: 50
|
|
38
|
+
|
|
39
|
+
[marathon leg 1/50] Starting...
|
|
40
|
+
[marathon] Leg 1 complete (tokens: 45000in/12000out, stop: end_turn)
|
|
41
|
+
[marathon] Handoff found. Continuing to next leg...
|
|
42
|
+
|
|
43
|
+
[marathon leg 2/50] Starting...
|
|
44
|
+
[marathon] Leg 2 complete (tokens: 38000in/9000out, stop: end_turn)
|
|
45
|
+
[marathon] Task complete after 2 leg(s)!
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## How it works
|
|
49
|
+
|
|
50
|
+
### Auto mode (`marathon install`)
|
|
51
|
+
|
|
52
|
+
1. Adds hooks to Claude Code's `~/.claude/settings.json`
|
|
53
|
+
2. **PreCompact hook**: When context is about to be compacted (signal that it's getting large), Claude is told to write `.marathon/handoff.md` with current progress
|
|
54
|
+
3. **SessionStart hook**: When a new session starts and a handoff file exists, Claude is given the context and picks up where it left off
|
|
55
|
+
4. **Stop hook**: When a task is marked complete, the handoff is archived
|
|
56
|
+
|
|
57
|
+
No wrapper, no extra commands. Just `claude`.
|
|
58
|
+
|
|
59
|
+
### Explicit mode (`marathon "task"`)
|
|
60
|
+
|
|
61
|
+
1. Marathon runs Claude Code with `--print` and a system prompt teaching the handoff protocol
|
|
62
|
+
2. Claude works normally, writing/updating `.marathon/handoff.md` as it goes
|
|
63
|
+
3. When the session ends (max turns or completion), marathon checks the handoff
|
|
64
|
+
4. If work remains, a new "leg" starts with the handoff context
|
|
65
|
+
5. Repeats until `## Status: COMPLETE` or max legs reached
|
|
66
|
+
|
|
67
|
+
Each "leg" is a fresh session with full context budget.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Install globally via npm
|
|
73
|
+
npm install -g claude-marathon
|
|
74
|
+
|
|
75
|
+
# Enable auto mode (hooks into Claude Code)
|
|
76
|
+
marathon install
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or without npm:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/josephtandle/claude-marathon.git
|
|
83
|
+
ln -s $(pwd)/claude-marathon/bin/marathon /usr/local/bin/marathon
|
|
84
|
+
marathon install
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Requirements:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` CLI), `node`, and `jq`
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Auto mode — install once, forget about it
|
|
93
|
+
marathon install
|
|
94
|
+
|
|
95
|
+
# Explicit mode — for specific tasks
|
|
96
|
+
marathon "Build a REST API for the user management system"
|
|
97
|
+
marathon -f tasks/migration-plan.md
|
|
98
|
+
marathon -m opus "Design and implement the caching layer"
|
|
99
|
+
marathon -t 100 -l 10 "Fix all TypeScript errors"
|
|
100
|
+
marathon -a "Bash,Read,Edit,Write,Glob,Grep" "Fix all lint errors"
|
|
101
|
+
marathon -p auto "Migrate database schema"
|
|
102
|
+
|
|
103
|
+
# Continue from an existing handoff
|
|
104
|
+
marathon continue
|
|
105
|
+
|
|
106
|
+
# Management
|
|
107
|
+
marathon status
|
|
108
|
+
marathon uninstall
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Options (explicit mode)
|
|
112
|
+
|
|
113
|
+
| Flag | Description | Default |
|
|
114
|
+
|------|-------------|---------|
|
|
115
|
+
| `-t, --max-turns <n>` | Max turns per leg | 200 |
|
|
116
|
+
| `-l, --max-legs <n>` | Max session legs | 50 |
|
|
117
|
+
| `-b, --max-budget <usd>` | Budget per leg (USD) | unlimited |
|
|
118
|
+
| `-m, --model <model>` | Model (opus, sonnet, etc.) | default |
|
|
119
|
+
| `-p, --permission-mode <mode>` | Permission mode | default |
|
|
120
|
+
| `-a, --allowed-tools <tools>` | Pre-approved tools | none |
|
|
121
|
+
| `-d, --work-dir <path>` | Working directory | current |
|
|
122
|
+
| `-f, --file <path>` | Read task from file | - |
|
|
123
|
+
| `-q, --quiet` | Minimal output | false |
|
|
124
|
+
| `--dry-run` | Preview without running | false |
|
|
125
|
+
|
|
126
|
+
## Environment variables
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
export MARATHON_MAX_TURNS=150
|
|
130
|
+
export MARATHON_MAX_LEGS=20
|
|
131
|
+
export MARATHON_MAX_BUDGET=5.00
|
|
132
|
+
export MARATHON_MODEL=opus
|
|
133
|
+
export MARATHON_PERMISSION_MODE=auto
|
|
134
|
+
export MARATHON_ALLOWED_TOOLS="Bash,Read,Edit,Write,Glob,Grep"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## The handoff file
|
|
138
|
+
|
|
139
|
+
Claude writes `.marathon/handoff.md` with this structure:
|
|
140
|
+
|
|
141
|
+
```markdown
|
|
142
|
+
## Task
|
|
143
|
+
What we're trying to achieve
|
|
144
|
+
|
|
145
|
+
## Progress
|
|
146
|
+
What's been completed so far
|
|
147
|
+
|
|
148
|
+
## Current State
|
|
149
|
+
Where things stand right now
|
|
150
|
+
|
|
151
|
+
## Next Steps
|
|
152
|
+
1. Specific next actions
|
|
153
|
+
2. In order
|
|
154
|
+
|
|
155
|
+
## Key Context
|
|
156
|
+
File paths, decisions made, gotchas found
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
When the task is done, Claude adds `## Status: COMPLETE` at the top, and the handoff is archived automatically.
|
|
160
|
+
|
|
161
|
+
## Output files
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
.marathon/
|
|
165
|
+
handoff.md # Current handoff (auto mode + explicit mode)
|
|
166
|
+
run.json # Run metadata (explicit mode)
|
|
167
|
+
summary.json # Final summary (explicit mode)
|
|
168
|
+
leg-1-result.json # Claude output per leg (explicit mode)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Add `.marathon/` to your `.gitignore`.
|
|
172
|
+
|
|
173
|
+
## Recovery
|
|
174
|
+
|
|
175
|
+
If Claude hits max turns before writing a handoff, marathon creates a recovery handoff that tells the next session to check `git status`/`git diff` and continue.
|
|
176
|
+
|
|
177
|
+
## FAQ
|
|
178
|
+
|
|
179
|
+
**Does auto mode slow down normal sessions?**
|
|
180
|
+
No. The hooks only fire on specific events (PreCompact, SessionStart, Stop). PreCompact only fires when context is large enough to need compaction — small sessions never trigger it.
|
|
181
|
+
|
|
182
|
+
**Can I use both modes?**
|
|
183
|
+
Yes. Auto mode handles interactive sessions. Explicit mode is better for headless/CI automation where you want the full loop.
|
|
184
|
+
|
|
185
|
+
**What if I want to stop auto-continuation?**
|
|
186
|
+
Delete `.marathon/handoff.md` in your project, or run `marathon uninstall` to remove hooks entirely.
|
|
187
|
+
|
|
188
|
+
**Does it work with subagents?**
|
|
189
|
+
The hooks only fire for the main session, not subagents. This is intentional — subagents have their own context management.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
package/bin/marathon
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# marathon - Infinite context sessions for Claude Code
|
|
5
|
+
# https://github.com/anthropics/marathon
|
|
6
|
+
#
|
|
7
|
+
# When context gets large, Claude writes a handoff file, the session ends,
|
|
8
|
+
# and a new session picks up right where it left off. Automatically.
|
|
9
|
+
|
|
10
|
+
VERSION="0.2.0"
|
|
11
|
+
# Resolve symlinks to find the actual install directory
|
|
12
|
+
_resolve_script() {
|
|
13
|
+
local src="${BASH_SOURCE[0]}"
|
|
14
|
+
while [[ -L "$src" ]]; do
|
|
15
|
+
local dir="$(cd "$(dirname "$src")" && pwd)"
|
|
16
|
+
src="$(readlink "$src")"
|
|
17
|
+
[[ "$src" != /* ]] && src="$dir/$src"
|
|
18
|
+
done
|
|
19
|
+
cd "$(dirname "$src")/.." && pwd
|
|
20
|
+
}
|
|
21
|
+
SCRIPT_DIR="$(_resolve_script)"
|
|
22
|
+
MARATHON_DIR=".marathon"
|
|
23
|
+
CLAUDE_SETTINGS="${HOME}/.claude/settings.json"
|
|
24
|
+
HOOKS_DIR="${HOME}/.claude/hooks"
|
|
25
|
+
MAX_TURNS="${MARATHON_MAX_TURNS:-200}"
|
|
26
|
+
MAX_LEGS="${MARATHON_MAX_LEGS:-50}"
|
|
27
|
+
MAX_BUDGET="${MARATHON_MAX_BUDGET:-}"
|
|
28
|
+
MODEL="${MARATHON_MODEL:-}"
|
|
29
|
+
PERMISSION_MODE="${MARATHON_PERMISSION_MODE:-}"
|
|
30
|
+
ALLOWED_TOOLS="${MARATHON_ALLOWED_TOOLS:-}"
|
|
31
|
+
WORK_DIR="${MARATHON_WORK_DIR:-.}"
|
|
32
|
+
QUIET=false
|
|
33
|
+
DRY_RUN=false
|
|
34
|
+
|
|
35
|
+
# Colors
|
|
36
|
+
RED='\033[0;31m'
|
|
37
|
+
GREEN='\033[0;32m'
|
|
38
|
+
YELLOW='\033[1;33m'
|
|
39
|
+
BLUE='\033[0;34m'
|
|
40
|
+
DIM='\033[2m'
|
|
41
|
+
BOLD='\033[1m'
|
|
42
|
+
NC='\033[0m'
|
|
43
|
+
|
|
44
|
+
usage() {
|
|
45
|
+
cat <<'EOF'
|
|
46
|
+
marathon - Infinite context sessions for Claude Code
|
|
47
|
+
|
|
48
|
+
USAGE:
|
|
49
|
+
marathon [options] "your task description"
|
|
50
|
+
marathon [options] -f task.md
|
|
51
|
+
marathon install Install hooks for automatic mode
|
|
52
|
+
marathon uninstall Remove marathon hooks
|
|
53
|
+
marathon status Show if hooks are installed & any active handoffs
|
|
54
|
+
marathon continue Continue from an existing handoff file
|
|
55
|
+
|
|
56
|
+
OPTIONS:
|
|
57
|
+
-t, --max-turns <n> Max turns per leg (default: 200)
|
|
58
|
+
-l, --max-legs <n> Max session legs before stopping (default: 50)
|
|
59
|
+
-b, --max-budget <usd> Max budget in USD per leg
|
|
60
|
+
-m, --model <model> Model to use (e.g., opus, sonnet)
|
|
61
|
+
-p, --permission-mode <mode> Permission mode (default, plan, auto, etc.)
|
|
62
|
+
-a, --allowed-tools <tools> Comma-separated tools to pre-approve
|
|
63
|
+
-d, --work-dir <path> Working directory (default: current)
|
|
64
|
+
-f, --file <path> Read task from file instead of argument
|
|
65
|
+
-q, --quiet Minimal output (no banner, less logging)
|
|
66
|
+
--dry-run Show what would run without executing
|
|
67
|
+
-v, --version Show version
|
|
68
|
+
-h, --help Show this help
|
|
69
|
+
|
|
70
|
+
ENVIRONMENT VARIABLES:
|
|
71
|
+
MARATHON_MAX_TURNS Default max turns per leg
|
|
72
|
+
MARATHON_MAX_LEGS Default max legs
|
|
73
|
+
MARATHON_MAX_BUDGET Default budget per leg
|
|
74
|
+
MARATHON_MODEL Default model
|
|
75
|
+
MARATHON_PERMISSION_MODE Default permission mode
|
|
76
|
+
MARATHON_ALLOWED_TOOLS Default allowed tools
|
|
77
|
+
MARATHON_WORK_DIR Default working directory
|
|
78
|
+
|
|
79
|
+
EXAMPLES:
|
|
80
|
+
# Explicit mode - wrap a task in marathon
|
|
81
|
+
marathon "Refactor the auth module to use JWT"
|
|
82
|
+
marathon -t 100 -l 10 -m opus "Build the entire test suite"
|
|
83
|
+
marathon -f tasks/migration.md --permission-mode auto
|
|
84
|
+
|
|
85
|
+
# Auto mode - install hooks, then use claude normally
|
|
86
|
+
marathon install
|
|
87
|
+
claude # marathon activates automatically when context gets large
|
|
88
|
+
|
|
89
|
+
HOW IT WORKS:
|
|
90
|
+
|
|
91
|
+
Explicit mode (marathon "task"):
|
|
92
|
+
Marathon runs Claude in a loop. Each "leg" is a fresh session.
|
|
93
|
+
Claude writes a handoff file, marathon reads it, starts a new session.
|
|
94
|
+
|
|
95
|
+
Auto mode (marathon install):
|
|
96
|
+
Hooks are added to Claude Code that fire automatically:
|
|
97
|
+
- PreCompact: When context is about to be compacted, Claude is told
|
|
98
|
+
to write a handoff file. Zero overhead until this point.
|
|
99
|
+
- SessionStart: If a handoff file exists from a previous session,
|
|
100
|
+
Claude is given the context and picks up where it left off.
|
|
101
|
+
No wrapper needed. Just use claude normally.
|
|
102
|
+
|
|
103
|
+
EOF
|
|
104
|
+
exit 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# ─── Subcommands ──────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
cmd_install() {
|
|
110
|
+
echo -e "${BOLD}marathon install${NC}"
|
|
111
|
+
echo ""
|
|
112
|
+
|
|
113
|
+
# Ensure hooks dir exists
|
|
114
|
+
mkdir -p "$HOOKS_DIR"
|
|
115
|
+
|
|
116
|
+
# Copy hooks script
|
|
117
|
+
local hook_src="$SCRIPT_DIR/hooks/marathon-hooks.js"
|
|
118
|
+
local hook_dst="$HOOKS_DIR/marathon-hooks.js"
|
|
119
|
+
|
|
120
|
+
if [[ ! -f "$hook_src" ]]; then
|
|
121
|
+
log_error "Hook script not found at $hook_src"
|
|
122
|
+
log_error "Make sure you're running from the marathon directory or it's properly installed."
|
|
123
|
+
exit 1
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
cp "$hook_src" "$hook_dst"
|
|
127
|
+
echo -e " ${GREEN}+${NC} Copied hooks to $hook_dst"
|
|
128
|
+
|
|
129
|
+
# Update settings.json
|
|
130
|
+
if [[ ! -f "$CLAUDE_SETTINGS" ]]; then
|
|
131
|
+
echo '{}' > "$CLAUDE_SETTINGS"
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# Read current settings
|
|
135
|
+
local settings
|
|
136
|
+
settings=$(cat "$CLAUDE_SETTINGS")
|
|
137
|
+
|
|
138
|
+
# Check if marathon hooks already installed
|
|
139
|
+
if echo "$settings" | jq -e '.hooks.PreCompact[]?.hooks[]? | select(.command | contains("marathon-hooks.js"))' &>/dev/null; then
|
|
140
|
+
echo -e " ${DIM}Hooks already installed, updating...${NC}"
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
# Remove any existing marathon hooks first (idempotent install)
|
|
144
|
+
# Use a node one-liner since jq gets tricky with nested hook structures
|
|
145
|
+
settings=$(node -e "
|
|
146
|
+
const s = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
|
|
147
|
+
if (s.hooks) {
|
|
148
|
+
for (const [event, matchers] of Object.entries(s.hooks)) {
|
|
149
|
+
if (!Array.isArray(matchers)) continue;
|
|
150
|
+
s.hooks[event] = matchers.filter(m => {
|
|
151
|
+
if (Array.isArray(m.hooks)) {
|
|
152
|
+
m.hooks = m.hooks.filter(h => !(h.command || '').includes('marathon-hooks.js'));
|
|
153
|
+
return m.hooks.length > 0;
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
if (s.hooks[event].length === 0) delete s.hooks[event];
|
|
158
|
+
}
|
|
159
|
+
if (Object.keys(s.hooks).length === 0) delete s.hooks;
|
|
160
|
+
}
|
|
161
|
+
process.stdout.write(JSON.stringify(s, null, 2));
|
|
162
|
+
" <<< "$settings")
|
|
163
|
+
|
|
164
|
+
# Add marathon hooks for PreCompact, SessionStart, and Stop
|
|
165
|
+
local hook_cmd="node \"$hook_dst\""
|
|
166
|
+
|
|
167
|
+
settings=$(echo "$settings" | jq --arg cmd "$hook_cmd" '
|
|
168
|
+
.hooks //= {} |
|
|
169
|
+
|
|
170
|
+
.hooks.PreCompact //= [] |
|
|
171
|
+
.hooks.PreCompact += [{
|
|
172
|
+
"hooks": [{
|
|
173
|
+
"type": "command",
|
|
174
|
+
"command": ($cmd + " PreCompact")
|
|
175
|
+
}]
|
|
176
|
+
}] |
|
|
177
|
+
|
|
178
|
+
.hooks.SessionStart //= [] |
|
|
179
|
+
.hooks.SessionStart += [{
|
|
180
|
+
"hooks": [{
|
|
181
|
+
"type": "command",
|
|
182
|
+
"command": ($cmd + " SessionStart")
|
|
183
|
+
}]
|
|
184
|
+
}] |
|
|
185
|
+
|
|
186
|
+
.hooks.Stop //= [] |
|
|
187
|
+
.hooks.Stop += [{
|
|
188
|
+
"hooks": [{
|
|
189
|
+
"type": "command",
|
|
190
|
+
"command": ($cmd + " Stop")
|
|
191
|
+
}]
|
|
192
|
+
}]
|
|
193
|
+
')
|
|
194
|
+
|
|
195
|
+
echo "$settings" | jq '.' > "$CLAUDE_SETTINGS"
|
|
196
|
+
echo -e " ${GREEN}+${NC} Added hooks to $CLAUDE_SETTINGS"
|
|
197
|
+
|
|
198
|
+
echo ""
|
|
199
|
+
echo -e "${GREEN}${BOLD}Marathon auto mode installed.${NC}"
|
|
200
|
+
echo ""
|
|
201
|
+
echo "How it works:"
|
|
202
|
+
echo " 1. Use claude normally — zero overhead for small tasks"
|
|
203
|
+
echo " 2. When context gets large enough to trigger compaction,"
|
|
204
|
+
echo " Claude is told to write a handoff file (.marathon/handoff.md)"
|
|
205
|
+
echo " 3. Next time you start claude in the same directory,"
|
|
206
|
+
echo " it automatically picks up from the handoff"
|
|
207
|
+
echo ""
|
|
208
|
+
echo "That's it. No wrapper needed."
|
|
209
|
+
echo ""
|
|
210
|
+
echo "To uninstall: marathon uninstall"
|
|
211
|
+
exit 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
cmd_uninstall() {
|
|
215
|
+
echo -e "${BOLD}marathon uninstall${NC}"
|
|
216
|
+
echo ""
|
|
217
|
+
|
|
218
|
+
# Remove hooks from settings
|
|
219
|
+
if [[ -f "$CLAUDE_SETTINGS" ]]; then
|
|
220
|
+
local settings
|
|
221
|
+
settings=$(cat "$CLAUDE_SETTINGS")
|
|
222
|
+
|
|
223
|
+
settings=$(node -e "
|
|
224
|
+
const s = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
|
|
225
|
+
if (s.hooks) {
|
|
226
|
+
for (const [event, matchers] of Object.entries(s.hooks)) {
|
|
227
|
+
if (!Array.isArray(matchers)) continue;
|
|
228
|
+
s.hooks[event] = matchers.filter(m => {
|
|
229
|
+
if (Array.isArray(m.hooks)) {
|
|
230
|
+
m.hooks = m.hooks.filter(h => !(h.command || '').includes('marathon-hooks.js'));
|
|
231
|
+
return m.hooks.length > 0;
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
});
|
|
235
|
+
if (s.hooks[event].length === 0) delete s.hooks[event];
|
|
236
|
+
}
|
|
237
|
+
if (Object.keys(s.hooks).length === 0) delete s.hooks;
|
|
238
|
+
}
|
|
239
|
+
process.stdout.write(JSON.stringify(s, null, 2));
|
|
240
|
+
" <<< "$settings")
|
|
241
|
+
|
|
242
|
+
echo "$settings" | jq '.' > "$CLAUDE_SETTINGS"
|
|
243
|
+
echo -e " ${GREEN}-${NC} Removed hooks from $CLAUDE_SETTINGS"
|
|
244
|
+
fi
|
|
245
|
+
|
|
246
|
+
# Remove hook script
|
|
247
|
+
local hook_dst="$HOOKS_DIR/marathon-hooks.js"
|
|
248
|
+
if [[ -f "$hook_dst" ]]; then
|
|
249
|
+
rm "$hook_dst"
|
|
250
|
+
echo -e " ${GREEN}-${NC} Removed $hook_dst"
|
|
251
|
+
fi
|
|
252
|
+
|
|
253
|
+
echo ""
|
|
254
|
+
echo -e "${GREEN}Marathon auto mode uninstalled.${NC}"
|
|
255
|
+
echo "You can still use marathon explicitly: marathon \"your task\""
|
|
256
|
+
exit 0
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
cmd_status() {
|
|
260
|
+
echo -e "${BOLD}marathon status${NC}"
|
|
261
|
+
echo ""
|
|
262
|
+
|
|
263
|
+
# Check hooks
|
|
264
|
+
local hooks_installed=false
|
|
265
|
+
if [[ -f "$CLAUDE_SETTINGS" ]] && jq -e '.hooks.PreCompact[]?.hooks[]? | select(.command | contains("marathon-hooks.js"))' "$CLAUDE_SETTINGS" &>/dev/null; then
|
|
266
|
+
hooks_installed=true
|
|
267
|
+
fi
|
|
268
|
+
|
|
269
|
+
if [[ "$hooks_installed" == true ]]; then
|
|
270
|
+
echo -e " Auto mode: ${GREEN}installed${NC}"
|
|
271
|
+
else
|
|
272
|
+
echo -e " Auto mode: ${DIM}not installed${NC} (run 'marathon install')"
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
# Check for active handoff
|
|
276
|
+
if [[ -f "$MARATHON_DIR/handoff.md" ]]; then
|
|
277
|
+
local first_line
|
|
278
|
+
first_line=$(head -5 "$MARATHON_DIR/handoff.md" | tr '[:upper:]' '[:lower:]')
|
|
279
|
+
if echo "$first_line" | grep -q "status:.*complete"; then
|
|
280
|
+
echo -e " Handoff: ${GREEN}complete${NC} (.marathon/handoff.md)"
|
|
281
|
+
else
|
|
282
|
+
echo -e " Handoff: ${YELLOW}active${NC} (.marathon/handoff.md)"
|
|
283
|
+
echo ""
|
|
284
|
+
echo -e " ${DIM}Next 'claude' session will auto-continue from this handoff.${NC}"
|
|
285
|
+
echo -e " ${DIM}Or run: marathon continue${NC}"
|
|
286
|
+
fi
|
|
287
|
+
else
|
|
288
|
+
echo -e " Handoff: ${DIM}none${NC}"
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
# Check for run history
|
|
292
|
+
if [[ -f "$MARATHON_DIR/summary.json" ]]; then
|
|
293
|
+
local legs total_in total_out
|
|
294
|
+
legs=$(jq -r '.legs // "?"' "$MARATHON_DIR/summary.json" 2>/dev/null)
|
|
295
|
+
total_in=$(jq -r '.total_input_tokens // 0' "$MARATHON_DIR/summary.json" 2>/dev/null)
|
|
296
|
+
total_out=$(jq -r '.total_output_tokens // 0' "$MARATHON_DIR/summary.json" 2>/dev/null)
|
|
297
|
+
echo -e " Last run: ${legs} legs, ${total_in} in / ${total_out} out tokens"
|
|
298
|
+
fi
|
|
299
|
+
|
|
300
|
+
echo ""
|
|
301
|
+
exit 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
cmd_continue() {
|
|
305
|
+
if [[ ! -f "$MARATHON_DIR/handoff.md" ]]; then
|
|
306
|
+
log_error "No handoff file found at $MARATHON_DIR/handoff.md"
|
|
307
|
+
log_error "Nothing to continue."
|
|
308
|
+
exit 1
|
|
309
|
+
fi
|
|
310
|
+
|
|
311
|
+
# Read handoff as the task
|
|
312
|
+
local handoff_content
|
|
313
|
+
handoff_content=$(cat "$MARATHON_DIR/handoff.md")
|
|
314
|
+
|
|
315
|
+
TASK=$(cat <<EOF
|
|
316
|
+
You are continuing a task from a previous session. Here is the handoff:
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
$handoff_content
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
Pick up where the previous session left off. Follow the "Next Steps" above.
|
|
323
|
+
Update .marathon/handoff.md as you make progress.
|
|
324
|
+
When the task is fully complete, add "## Status: COMPLETE" at the top.
|
|
325
|
+
EOF
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Fall through to the main marathon loop below
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
log() {
|
|
332
|
+
[[ "$QUIET" == true ]] && return
|
|
333
|
+
echo -e "${DIM}[marathon]${NC} $*"
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
log_leg() {
|
|
337
|
+
echo -e "${BLUE}${BOLD}[marathon leg $1/$MAX_LEGS]${NC} $2"
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
log_done() {
|
|
341
|
+
echo -e "${GREEN}${BOLD}[marathon]${NC} $1"
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
log_warn() {
|
|
345
|
+
echo -e "${YELLOW}[marathon]${NC} $1"
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
log_error() {
|
|
349
|
+
echo -e "${RED}[marathon]${NC} $1" >&2
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# The system prompt injected into each session
|
|
353
|
+
marathon_system_prompt() {
|
|
354
|
+
cat <<'PROMPT'
|
|
355
|
+
## Marathon Auto-Continuation Protocol
|
|
356
|
+
|
|
357
|
+
You are running inside **marathon**, an auto-continuation wrapper. This means when your context gets large, you can hand off to a fresh session that will continue your work.
|
|
358
|
+
|
|
359
|
+
### How it works:
|
|
360
|
+
1. You do your work normally
|
|
361
|
+
2. When you sense context is getting large (many tool calls, large file reads, deep into a task), proactively write a handoff file
|
|
362
|
+
3. Your session will end (via max-turns or natural completion), and marathon will start a new session with your handoff
|
|
363
|
+
|
|
364
|
+
### Writing a handoff:
|
|
365
|
+
When you need to hand off, write the file `.marathon/handoff.md` with this structure:
|
|
366
|
+
|
|
367
|
+
```markdown
|
|
368
|
+
## Task
|
|
369
|
+
[Original task / goal - what are we trying to achieve]
|
|
370
|
+
|
|
371
|
+
## Progress
|
|
372
|
+
[What has been completed so far - be specific about files changed, decisions made]
|
|
373
|
+
|
|
374
|
+
## Current State
|
|
375
|
+
[Where things stand right now - what was the last thing done]
|
|
376
|
+
|
|
377
|
+
## Next Steps
|
|
378
|
+
[Exactly what needs to happen next - ordered list, be specific]
|
|
379
|
+
|
|
380
|
+
## Key Context
|
|
381
|
+
[Important details the next session needs to know - file paths, patterns found, gotchas discovered, architectural decisions]
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Rules:
|
|
385
|
+
- Write the handoff **proactively** - don't wait until you're forced to stop
|
|
386
|
+
- Be specific in the handoff - the next session has NO memory of this one
|
|
387
|
+
- Include file paths, function names, error messages - anything concrete
|
|
388
|
+
- If the task is **fully complete**, write the handoff with `## Status: COMPLETE` at the top and summarize what was done
|
|
389
|
+
- Update the handoff file as you make progress, so it's always current even if the session ends unexpectedly
|
|
390
|
+
- The handoff file is your ONLY way to communicate with the next session
|
|
391
|
+
|
|
392
|
+
### Important:
|
|
393
|
+
- You are leg MARATHON_LEG_NUMBER of up to MARATHON_MAX_LEGS legs
|
|
394
|
+
- Each leg has up to MARATHON_MAX_TURNS turns
|
|
395
|
+
- Don't rush - do quality work. Marathon gives you unlimited context.
|
|
396
|
+
- After ~70% of your turns, start writing/updating the handoff file
|
|
397
|
+
PROMPT
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
# Handle subcommands first
|
|
401
|
+
case "${1:-}" in
|
|
402
|
+
install) cmd_install ;;
|
|
403
|
+
uninstall) cmd_uninstall ;;
|
|
404
|
+
status) cmd_status ;;
|
|
405
|
+
continue) cmd_continue ;; # sets TASK, falls through to main loop
|
|
406
|
+
esac
|
|
407
|
+
|
|
408
|
+
# Parse arguments
|
|
409
|
+
TASK="${TASK:-}"
|
|
410
|
+
TASK_FILE=""
|
|
411
|
+
|
|
412
|
+
while [[ $# -gt 0 ]]; do
|
|
413
|
+
case $1 in
|
|
414
|
+
-t|--max-turns) MAX_TURNS="$2"; shift 2 ;;
|
|
415
|
+
-l|--max-legs) MAX_LEGS="$2"; shift 2 ;;
|
|
416
|
+
-b|--max-budget) MAX_BUDGET="$2"; shift 2 ;;
|
|
417
|
+
-m|--model) MODEL="$2"; shift 2 ;;
|
|
418
|
+
-p|--permission-mode) PERMISSION_MODE="$2"; shift 2 ;;
|
|
419
|
+
-a|--allowed-tools) ALLOWED_TOOLS="$2"; shift 2 ;;
|
|
420
|
+
-d|--work-dir) WORK_DIR="$2"; shift 2 ;;
|
|
421
|
+
-f|--file) TASK_FILE="$2"; shift 2 ;;
|
|
422
|
+
-q|--quiet) QUIET=true; shift ;;
|
|
423
|
+
--dry-run) DRY_RUN=true; shift ;;
|
|
424
|
+
-v|--version) echo "marathon $VERSION"; exit 0 ;;
|
|
425
|
+
-h|--help) usage ;;
|
|
426
|
+
-*) log_error "Unknown option: $1"; exit 1 ;;
|
|
427
|
+
*) TASK="$1"; shift ;;
|
|
428
|
+
esac
|
|
429
|
+
done
|
|
430
|
+
|
|
431
|
+
# Read task from file if specified
|
|
432
|
+
if [[ -n "$TASK_FILE" ]]; then
|
|
433
|
+
if [[ ! -f "$TASK_FILE" ]]; then
|
|
434
|
+
log_error "Task file not found: $TASK_FILE"
|
|
435
|
+
exit 1
|
|
436
|
+
fi
|
|
437
|
+
TASK=$(cat "$TASK_FILE")
|
|
438
|
+
fi
|
|
439
|
+
|
|
440
|
+
if [[ -z "$TASK" ]]; then
|
|
441
|
+
log_error "No task provided. Usage: marathon \"your task\""
|
|
442
|
+
echo "Run 'marathon --help' for options."
|
|
443
|
+
exit 1
|
|
444
|
+
fi
|
|
445
|
+
|
|
446
|
+
# Check dependencies
|
|
447
|
+
if ! command -v claude &>/dev/null; then
|
|
448
|
+
log_error "claude CLI not found. Install Claude Code first:"
|
|
449
|
+
log_error " https://docs.anthropic.com/en/docs/claude-code"
|
|
450
|
+
exit 1
|
|
451
|
+
fi
|
|
452
|
+
|
|
453
|
+
if ! command -v jq &>/dev/null; then
|
|
454
|
+
log_error "jq not found. Install it:"
|
|
455
|
+
log_error " brew install jq (macOS)"
|
|
456
|
+
log_error " apt install jq (Linux)"
|
|
457
|
+
exit 1
|
|
458
|
+
fi
|
|
459
|
+
|
|
460
|
+
# Setup
|
|
461
|
+
cd "$WORK_DIR"
|
|
462
|
+
mkdir -p "$MARATHON_DIR"
|
|
463
|
+
|
|
464
|
+
# Clean previous handoff
|
|
465
|
+
rm -f "$MARATHON_DIR/handoff.md"
|
|
466
|
+
|
|
467
|
+
# Write run metadata
|
|
468
|
+
cat > "$MARATHON_DIR/run.json" <<EOF
|
|
469
|
+
{
|
|
470
|
+
"task": $(echo "$TASK" | jq -Rs .),
|
|
471
|
+
"started_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
472
|
+
"max_turns": $MAX_TURNS,
|
|
473
|
+
"max_legs": $MAX_LEGS,
|
|
474
|
+
"version": "$VERSION"
|
|
475
|
+
}
|
|
476
|
+
EOF
|
|
477
|
+
|
|
478
|
+
# Banner
|
|
479
|
+
if [[ "$QUIET" != true ]]; then
|
|
480
|
+
echo ""
|
|
481
|
+
echo -e "${BOLD} marathon v${VERSION}${NC}"
|
|
482
|
+
echo -e " ${DIM}Infinite context sessions for Claude Code${NC}"
|
|
483
|
+
echo ""
|
|
484
|
+
echo -e " Task: ${TASK:0:80}$([ ${#TASK} -gt 80 ] && echo '...')"
|
|
485
|
+
echo -e " Max turns: $MAX_TURNS per leg"
|
|
486
|
+
echo -e " Max legs: $MAX_LEGS"
|
|
487
|
+
[[ -n "$MODEL" ]] && echo -e " Model: $MODEL"
|
|
488
|
+
echo ""
|
|
489
|
+
fi
|
|
490
|
+
|
|
491
|
+
# Build base claude command
|
|
492
|
+
build_claude_cmd() {
|
|
493
|
+
local leg_num=$1
|
|
494
|
+
local prompt="$2"
|
|
495
|
+
local cmd="claude -p"
|
|
496
|
+
|
|
497
|
+
# System prompt with leg number substituted
|
|
498
|
+
local sys_prompt
|
|
499
|
+
sys_prompt=$(marathon_system_prompt)
|
|
500
|
+
sys_prompt="${sys_prompt//MARATHON_LEG_NUMBER/$leg_num}"
|
|
501
|
+
sys_prompt="${sys_prompt//MARATHON_MAX_LEGS/$MAX_LEGS}"
|
|
502
|
+
sys_prompt="${sys_prompt//MARATHON_MAX_TURNS/$MAX_TURNS}"
|
|
503
|
+
|
|
504
|
+
cmd+=" --append-system-prompt $(printf '%q' "$sys_prompt")"
|
|
505
|
+
cmd+=" --max-turns $MAX_TURNS"
|
|
506
|
+
cmd+=" --output-format json"
|
|
507
|
+
|
|
508
|
+
[[ -n "$MODEL" ]] && cmd+=" --model $MODEL"
|
|
509
|
+
[[ -n "$MAX_BUDGET" ]] && cmd+=" --max-budget-usd $MAX_BUDGET"
|
|
510
|
+
[[ -n "$PERMISSION_MODE" ]] && cmd+=" --permission-mode $PERMISSION_MODE"
|
|
511
|
+
[[ -n "$ALLOWED_TOOLS" ]] && cmd+=" --allowedTools $ALLOWED_TOOLS"
|
|
512
|
+
|
|
513
|
+
cmd+=" $(printf '%q' "$prompt")"
|
|
514
|
+
echo "$cmd"
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# Build the prompt for leg 1 (initial task)
|
|
518
|
+
build_initial_prompt() {
|
|
519
|
+
echo "$TASK"
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# Build the prompt for continuation legs
|
|
523
|
+
build_continuation_prompt() {
|
|
524
|
+
local handoff_content
|
|
525
|
+
handoff_content=$(cat "$MARATHON_DIR/handoff.md")
|
|
526
|
+
cat <<EOF
|
|
527
|
+
You are continuing a task from a previous marathon session. Here is the handoff from the previous leg:
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
$handoff_content
|
|
531
|
+
---
|
|
532
|
+
|
|
533
|
+
Continue the work described above. Pick up exactly where the previous session left off.
|
|
534
|
+
Remember to update .marathon/handoff.md as you make progress.
|
|
535
|
+
EOF
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
# Check if the task is complete
|
|
539
|
+
is_task_complete() {
|
|
540
|
+
if [[ ! -f "$MARATHON_DIR/handoff.md" ]]; then
|
|
541
|
+
return 1
|
|
542
|
+
fi
|
|
543
|
+
# Check for COMPLETE status in handoff
|
|
544
|
+
if head -5 "$MARATHON_DIR/handoff.md" | grep -qi "status:.*complete"; then
|
|
545
|
+
return 0
|
|
546
|
+
fi
|
|
547
|
+
return 1
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
# Main loop
|
|
551
|
+
total_input_tokens=0
|
|
552
|
+
total_output_tokens=0
|
|
553
|
+
leg=1
|
|
554
|
+
|
|
555
|
+
while [[ $leg -le $MAX_LEGS ]]; do
|
|
556
|
+
log_leg "$leg" "Starting..."
|
|
557
|
+
|
|
558
|
+
# Build prompt
|
|
559
|
+
if [[ $leg -eq 1 ]]; then
|
|
560
|
+
prompt=$(build_initial_prompt)
|
|
561
|
+
else
|
|
562
|
+
if [[ ! -f "$MARATHON_DIR/handoff.md" ]]; then
|
|
563
|
+
log_warn "No handoff file found. Assuming task is complete."
|
|
564
|
+
break
|
|
565
|
+
fi
|
|
566
|
+
prompt=$(build_continuation_prompt)
|
|
567
|
+
fi
|
|
568
|
+
|
|
569
|
+
# Build command
|
|
570
|
+
cmd=$(build_claude_cmd "$leg" "$prompt")
|
|
571
|
+
|
|
572
|
+
if [[ "$DRY_RUN" == true ]]; then
|
|
573
|
+
echo -e "${DIM}Would run:${NC}"
|
|
574
|
+
echo "$cmd"
|
|
575
|
+
exit 0
|
|
576
|
+
fi
|
|
577
|
+
|
|
578
|
+
# Run claude and capture output
|
|
579
|
+
# We use a temp file because the output can be large
|
|
580
|
+
output_file=$(mktemp)
|
|
581
|
+
trap "rm -f $output_file" EXIT
|
|
582
|
+
|
|
583
|
+
log "Running claude..."
|
|
584
|
+
set +e
|
|
585
|
+
eval "$cmd" > "$output_file" 2>"$MARATHON_DIR/leg-${leg}-stderr.log"
|
|
586
|
+
exit_code=$?
|
|
587
|
+
set -e
|
|
588
|
+
|
|
589
|
+
# Parse output
|
|
590
|
+
if [[ -s "$output_file" ]]; then
|
|
591
|
+
# Extract the last valid JSON object (claude may output multiple)
|
|
592
|
+
result=$(tail -1 "$output_file")
|
|
593
|
+
|
|
594
|
+
# Try to parse usage
|
|
595
|
+
input_tokens=$(echo "$result" | jq -r '.usage.input_tokens // 0' 2>/dev/null || echo 0)
|
|
596
|
+
output_tokens=$(echo "$result" | jq -r '.usage.output_tokens // 0' 2>/dev/null || echo 0)
|
|
597
|
+
session_id=$(echo "$result" | jq -r '.session_id // "unknown"' 2>/dev/null || echo "unknown")
|
|
598
|
+
stop_reason=$(echo "$result" | jq -r '.stop_reason // "unknown"' 2>/dev/null || echo "unknown")
|
|
599
|
+
response_text=$(echo "$result" | jq -r '.result // ""' 2>/dev/null || echo "")
|
|
600
|
+
|
|
601
|
+
total_input_tokens=$((total_input_tokens + input_tokens))
|
|
602
|
+
total_output_tokens=$((total_output_tokens + output_tokens))
|
|
603
|
+
|
|
604
|
+
# Save leg result
|
|
605
|
+
echo "$result" > "$MARATHON_DIR/leg-${leg}-result.json"
|
|
606
|
+
|
|
607
|
+
log "Leg $leg complete (tokens: ${input_tokens}in/${output_tokens}out, stop: ${stop_reason})"
|
|
608
|
+
else
|
|
609
|
+
log_warn "No output from claude (exit code: $exit_code)"
|
|
610
|
+
if [[ -s "$MARATHON_DIR/leg-${leg}-stderr.log" ]]; then
|
|
611
|
+
log_error "stderr: $(cat "$MARATHON_DIR/leg-${leg}-stderr.log")"
|
|
612
|
+
fi
|
|
613
|
+
fi
|
|
614
|
+
|
|
615
|
+
rm -f "$output_file"
|
|
616
|
+
|
|
617
|
+
# Check if task is complete
|
|
618
|
+
if is_task_complete; then
|
|
619
|
+
log_done "Task complete after $leg leg(s)!"
|
|
620
|
+
log "Total tokens: ${total_input_tokens} in / ${total_output_tokens} out"
|
|
621
|
+
|
|
622
|
+
# Show completion summary from handoff
|
|
623
|
+
if [[ -f "$MARATHON_DIR/handoff.md" ]]; then
|
|
624
|
+
echo ""
|
|
625
|
+
echo -e "${GREEN}${BOLD}--- Completion Summary ---${NC}"
|
|
626
|
+
cat "$MARATHON_DIR/handoff.md"
|
|
627
|
+
echo ""
|
|
628
|
+
fi
|
|
629
|
+
break
|
|
630
|
+
fi
|
|
631
|
+
|
|
632
|
+
# Check if we've hit max legs
|
|
633
|
+
if [[ $leg -ge $MAX_LEGS ]]; then
|
|
634
|
+
log_warn "Reached maximum legs ($MAX_LEGS). Task may be incomplete."
|
|
635
|
+
log_warn "Check $MARATHON_DIR/handoff.md for current state."
|
|
636
|
+
break
|
|
637
|
+
fi
|
|
638
|
+
|
|
639
|
+
# Check if handoff exists for continuation
|
|
640
|
+
if [[ ! -f "$MARATHON_DIR/handoff.md" ]]; then
|
|
641
|
+
# No handoff and not complete - Claude didn't write one
|
|
642
|
+
# This could mean the task finished without explicit COMPLETE status
|
|
643
|
+
# or Claude ran out of turns before writing handoff
|
|
644
|
+
if [[ "$stop_reason" == "max_turns" ]]; then
|
|
645
|
+
log_warn "Hit max turns without handoff. Starting recovery leg..."
|
|
646
|
+
# Create a minimal handoff for recovery
|
|
647
|
+
cat > "$MARATHON_DIR/handoff.md" <<RECOVERY
|
|
648
|
+
## Task
|
|
649
|
+
$TASK
|
|
650
|
+
|
|
651
|
+
## Progress
|
|
652
|
+
Previous session (leg $leg) ran out of turns before writing a handoff.
|
|
653
|
+
|
|
654
|
+
## Current State
|
|
655
|
+
Unknown - the previous session ended abruptly.
|
|
656
|
+
|
|
657
|
+
## Next Steps
|
|
658
|
+
1. Assess what has been done by checking recent file changes (git status/diff)
|
|
659
|
+
2. Continue the original task
|
|
660
|
+
3. Write a proper handoff file early this time
|
|
661
|
+
|
|
662
|
+
## Key Context
|
|
663
|
+
This is a recovery leg. Check the codebase state to understand progress.
|
|
664
|
+
RECOVERY
|
|
665
|
+
else
|
|
666
|
+
log_done "Session ended naturally without handoff. Assuming complete."
|
|
667
|
+
log "Total tokens: ${total_input_tokens} in / ${total_output_tokens} out"
|
|
668
|
+
break
|
|
669
|
+
fi
|
|
670
|
+
fi
|
|
671
|
+
|
|
672
|
+
log "Handoff found. Continuing to next leg..."
|
|
673
|
+
echo ""
|
|
674
|
+
leg=$((leg + 1))
|
|
675
|
+
done
|
|
676
|
+
|
|
677
|
+
# Write final run summary
|
|
678
|
+
cat > "$MARATHON_DIR/summary.json" <<EOF
|
|
679
|
+
{
|
|
680
|
+
"task": $(echo "$TASK" | jq -Rs .),
|
|
681
|
+
"started_at": $(jq -r '.started_at' "$MARATHON_DIR/run.json" | jq -Rs .),
|
|
682
|
+
"finished_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
683
|
+
"legs": $leg,
|
|
684
|
+
"total_input_tokens": $total_input_tokens,
|
|
685
|
+
"total_output_tokens": $total_output_tokens,
|
|
686
|
+
"version": "$VERSION"
|
|
687
|
+
}
|
|
688
|
+
EOF
|
|
689
|
+
|
|
690
|
+
log "Run summary saved to $MARATHON_DIR/summary.json"
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// marathon-hooks.js — Auto-continuation hooks for Claude Code
|
|
3
|
+
//
|
|
4
|
+
// Handles multiple hook events:
|
|
5
|
+
// PreCompact → Inject marathon protocol, tell Claude to write handoff
|
|
6
|
+
// SessionStart → Detect existing handoff, inject continuation context
|
|
7
|
+
// Stop → After Claude responds, check if handoff says COMPLETE
|
|
8
|
+
//
|
|
9
|
+
// Install via: marathon install
|
|
10
|
+
// Zero overhead for normal sessions — only activates when needed.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const MARATHON_DIR = '.marathon';
|
|
17
|
+
const HANDOFF_FILE = path.join(MARATHON_DIR, 'handoff.md');
|
|
18
|
+
|
|
19
|
+
let input = '';
|
|
20
|
+
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
|
21
|
+
process.stdin.setEncoding('utf8');
|
|
22
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
23
|
+
process.stdin.on('end', () => {
|
|
24
|
+
clearTimeout(stdinTimeout);
|
|
25
|
+
try {
|
|
26
|
+
const data = JSON.parse(input);
|
|
27
|
+
const cwd = data.cwd || process.cwd();
|
|
28
|
+
const hookEvent = data.hook_event_name || process.argv[2] || '';
|
|
29
|
+
|
|
30
|
+
switch (hookEvent) {
|
|
31
|
+
case 'PreCompact':
|
|
32
|
+
handlePreCompact(data, cwd);
|
|
33
|
+
break;
|
|
34
|
+
case 'SessionStart':
|
|
35
|
+
handleSessionStart(data, cwd);
|
|
36
|
+
break;
|
|
37
|
+
case 'Stop':
|
|
38
|
+
handleStop(data, cwd);
|
|
39
|
+
break;
|
|
40
|
+
default:
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ─── PreCompact ──────────────────────────────────────────────────────────────
|
|
49
|
+
// Context is about to be compacted. This is our signal that the session is big.
|
|
50
|
+
// Inject the marathon protocol and tell Claude to write a handoff.
|
|
51
|
+
function handlePreCompact(data, cwd) {
|
|
52
|
+
// Create .marathon dir if it doesn't exist
|
|
53
|
+
const marathonPath = path.join(cwd, MARATHON_DIR);
|
|
54
|
+
try { fs.mkdirSync(marathonPath, { recursive: true }); } catch (e) {}
|
|
55
|
+
|
|
56
|
+
// Check if handoff already exists (Claude already knows about marathon)
|
|
57
|
+
const handoffPath = path.join(cwd, HANDOFF_FILE);
|
|
58
|
+
const handoffExists = fs.existsSync(handoffPath);
|
|
59
|
+
|
|
60
|
+
let message;
|
|
61
|
+
if (handoffExists) {
|
|
62
|
+
message = [
|
|
63
|
+
'MARATHON: Context is being compacted again. UPDATE your handoff file at .marathon/handoff.md',
|
|
64
|
+
'with current progress, state, and next steps. This ensures nothing is lost during compaction.',
|
|
65
|
+
'Keep working after updating — this is not the end of the session.'
|
|
66
|
+
].join(' ');
|
|
67
|
+
} else {
|
|
68
|
+
message = [
|
|
69
|
+
'MARATHON AUTO-CONTINUATION ACTIVATED: Context is large enough to trigger compaction.',
|
|
70
|
+
'Write a handoff file NOW at .marathon/handoff.md so your progress survives if the session ends.',
|
|
71
|
+
'',
|
|
72
|
+
'Format:',
|
|
73
|
+
'```',
|
|
74
|
+
'## Task',
|
|
75
|
+
'[What we\'re trying to achieve]',
|
|
76
|
+
'',
|
|
77
|
+
'## Progress',
|
|
78
|
+
'[What\'s been completed - files changed, decisions made]',
|
|
79
|
+
'',
|
|
80
|
+
'## Current State',
|
|
81
|
+
'[Where things stand right now]',
|
|
82
|
+
'',
|
|
83
|
+
'## Next Steps',
|
|
84
|
+
'[Ordered list of what needs to happen next]',
|
|
85
|
+
'',
|
|
86
|
+
'## Key Context',
|
|
87
|
+
'[File paths, patterns, gotchas, architectural decisions]',
|
|
88
|
+
'```',
|
|
89
|
+
'',
|
|
90
|
+
'Rules:',
|
|
91
|
+
'- Update this file as you make progress',
|
|
92
|
+
'- Be specific — the next session has NO memory of this one',
|
|
93
|
+
'- When task is fully complete, add "## Status: COMPLETE" at the top',
|
|
94
|
+
'- Keep working after writing the handoff — this is a checkpoint, not a stop signal',
|
|
95
|
+
'',
|
|
96
|
+
'If this session ends, marathon will start a fresh session with your handoff.'
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const output = {
|
|
101
|
+
hookSpecificOutput: {
|
|
102
|
+
hookEventName: 'PreCompact',
|
|
103
|
+
additionalContext: message
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
process.stdout.write(JSON.stringify(output));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── SessionStart ────────────────────────────────────────────────────────────
|
|
110
|
+
// Check if there's a handoff file from a previous session.
|
|
111
|
+
// If so, inject continuation context so Claude picks up where it left off.
|
|
112
|
+
function handleSessionStart(data, cwd) {
|
|
113
|
+
const handoffPath = path.join(cwd, HANDOFF_FILE);
|
|
114
|
+
|
|
115
|
+
if (!fs.existsSync(handoffPath)) {
|
|
116
|
+
process.exit(0);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let handoff;
|
|
121
|
+
try {
|
|
122
|
+
handoff = fs.readFileSync(handoffPath, 'utf8').trim();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
process.exit(0);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!handoff) {
|
|
129
|
+
process.exit(0);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check if it's a completed task (don't auto-continue completed work)
|
|
134
|
+
const firstLines = handoff.split('\n').slice(0, 5).join('\n').toLowerCase();
|
|
135
|
+
if (firstLines.includes('status: complete')) {
|
|
136
|
+
// Rename to archive instead of continuing
|
|
137
|
+
const archiveName = `handoff-complete-${Date.now()}.md`;
|
|
138
|
+
try {
|
|
139
|
+
fs.renameSync(handoffPath, path.join(cwd, MARATHON_DIR, archiveName));
|
|
140
|
+
} catch (e) {}
|
|
141
|
+
process.exit(0);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Active handoff found — inject continuation context
|
|
146
|
+
const message = [
|
|
147
|
+
'MARATHON CONTINUATION: A previous session left a handoff file. Here is the context:',
|
|
148
|
+
'',
|
|
149
|
+
'---',
|
|
150
|
+
handoff,
|
|
151
|
+
'---',
|
|
152
|
+
'',
|
|
153
|
+
'Pick up where the previous session left off. Follow the "Next Steps" above.',
|
|
154
|
+
'Update .marathon/handoff.md as you make progress.',
|
|
155
|
+
'When the task is fully complete, add "## Status: COMPLETE" at the top of the handoff.'
|
|
156
|
+
].join('\n');
|
|
157
|
+
|
|
158
|
+
const output = {
|
|
159
|
+
hookSpecificOutput: {
|
|
160
|
+
hookEventName: 'SessionStart',
|
|
161
|
+
additionalContext: message
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
process.stdout.write(JSON.stringify(output));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Stop ────────────────────────────────────────────────────────────────────
|
|
168
|
+
// After Claude finishes responding, check if the handoff says COMPLETE.
|
|
169
|
+
// If so, archive it so the next session starts fresh.
|
|
170
|
+
function handleStop(data, cwd) {
|
|
171
|
+
const handoffPath = path.join(cwd, HANDOFF_FILE);
|
|
172
|
+
|
|
173
|
+
if (!fs.existsSync(handoffPath)) {
|
|
174
|
+
process.exit(0);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let handoff;
|
|
179
|
+
try {
|
|
180
|
+
handoff = fs.readFileSync(handoffPath, 'utf8').trim();
|
|
181
|
+
} catch (e) {
|
|
182
|
+
process.exit(0);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const firstLines = handoff.split('\n').slice(0, 5).join('\n').toLowerCase();
|
|
187
|
+
if (firstLines.includes('status: complete')) {
|
|
188
|
+
// Archive the completed handoff
|
|
189
|
+
const archiveName = `handoff-complete-${Date.now()}.md`;
|
|
190
|
+
const marathonPath = path.join(cwd, MARATHON_DIR);
|
|
191
|
+
try {
|
|
192
|
+
fs.renameSync(handoffPath, path.join(marathonPath, archiveName));
|
|
193
|
+
} catch (e) {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-marathon",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Infinite context sessions for Claude Code. Auto-continues when context gets large.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"marathon": "./bin/marathon"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"hooks/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "echo \"Tests coming soon\" && exit 0"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"context",
|
|
21
|
+
"continuation",
|
|
22
|
+
"handoff",
|
|
23
|
+
"ai",
|
|
24
|
+
"cli",
|
|
25
|
+
"anthropic",
|
|
26
|
+
"infinite-context",
|
|
27
|
+
"session-management",
|
|
28
|
+
"developer-tools"
|
|
29
|
+
],
|
|
30
|
+
"author": "",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/josephtandle/claude-marathon.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/josephtandle/claude-marathon#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/josephtandle/claude-marathon/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=16.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|